現在你的操作系統內核已經具備一定的異常處理能力了,在這部分實驗中,我們將會進一步完善它,使它能夠處理不同類型的中斷/異常。
Handling Page Fault
缺頁中斷是一個非常重要的中斷,因為我們在后續的實驗中,非常依賴於能夠處理缺頁中斷的能力。當缺頁中斷發生時,系統會把引起中斷的線性地址存放到控制寄存器 CR2 中。在trap.c 中,已經提供了一個能夠處理這種缺頁異常的函數page_fault_handler()。
Question 5:
修改一下 trap_dispatch 函數,使系統能夠把缺頁異常引導到 page_fault_handler() 上執行。在修改完成后,運行 make grade,出現的結果應該是你修改后的 JOS 可以成功運行 faultread,faultreadkernel,faultwrite,faultwritekernel 測試程序。
答:
根據 trapentry.S 文件中的 TRAPHANDLER 函數可知,這個函數會把當前中斷的中斷碼壓入堆棧中,再根據 inc/trap.h 文件中的 Trapframe 結構體我們可以知道,Trapframe 中的 tf_trapno 成員代表這個中斷的中斷碼。所以在 trap_dispatch 函數中我們需要根據輸入的 Trapframe 指針 tf 中的 tf_trapno 成員來判斷到來的中斷是什么中斷,這里我們需要判斷是否是缺頁中斷,如果是則執行 page_fault_handler 函數,所以我們可以這么修改代碼:
static void trap_dispatch(struct Trapframe *tf) { int32_t ret_code; // Handle processor exceptions. // LAB 3: Your code here. switch(tf->tf_trapno) { case (T_PGFLT): page_fault_handler(tf); break; default: // Unexpected trap: The user process or the kernel has a bug. print_trapframe(tf); if (tf->tf_cs == GD_KT) panic("unhandled trap in kernel"); else { env_destroy(curenv); return; } } }
我們之后還會對這個中斷處理函數進一步改進。
Breaking Points Exception
斷點異常,異常號為3,這個異常可以讓調試器能夠給程序加上斷點。加斷點的基本原理就是把要加斷點的語句用一個 INT3 指令替換,執行到INT3時,會觸發軟中斷。在JOS中,我們將通過把這個異常轉換成一個偽系統調用,這樣的話任何用戶環境都可以使用這個偽系統調用來觸發JOS kernel monitor。
Exercise 6:
修改trap_dispatch()使斷點異常發生時,能夠觸發kernel monitor。修改完成后運行 make grade,運行結果應該是你修改后的 JOS 能夠正確運行 breakpoint 測試程序。
答:
這個練習其實和上一個練習是類似的,只不過是在這里我們需要處理斷點中斷 (T_BRKPT),kernel monitor 就是定義在 kern/monitor.c 文件中的 monitor 函數,所以修改后的程序如下
static void trap_dispatch(struct Trapframe *tf) { int32_t ret_code; // Handle processor exceptions. // LAB 3: Your code here. switch(tf->tf_trapno) { case (T_PGFLT): page_fault_handler(tf); break; case (T_BRKPT): monitor(tf); break; default: // Unexpected trap: The user process or the kernel has a bug. print_trapframe(tf); if (tf->tf_cs == GD_KT) panic("unhandled trap in kernel"); else { env_destroy(curenv); return; } } }
Question
3. 在上面的break point exception測試程序中,如果你在設置IDT時,對break point exception采用不同的方式進行設置,可能會產生觸發不同的異常,有可能是break point exception,有可能是 general protection exception。這是為什么?你應該怎么做才能得到一個我們想要的breakpoint exception,而不是general protection exception?
答:
通過實驗發現出現這個現象的問題就是在設置IDT表中的breakpoint exception的表項時,如果我們把表項中的DPL字段設置為3,則會觸發break point exception,如果設置為0,則會觸發general protection exception。DPL字段代表的含義是段描述符優先級(Descriptor Privileged Level),如果我們想要當前執行的程序能夠跳轉到這個描述符所指向的程序哪里繼續執行的話,有個要求,就是要求當前運行程序的CPL,RPL的最大值需要小於等於DPL,否則就會出現優先級低的代碼試圖去訪問優先級高的代碼的情況,就會觸發general protection exception。那么我們的測試程序首先運行於用戶態,它的CPL為3,當異常發生時,它希望去執行 int 3指令,這是一個系統級別的指令,用戶態命令的CPL一定大於 int 3 的DPL,所以就會觸發general protection exception,但是如果把IDT這個表項的DPL設置為3時,就不會出現這樣的現象了,這時如果再出現異常,肯定是因為我們還沒有編寫處理break point exception的程序所引起的,所以是break point exception。
System Calls
用戶程序會要求內核幫助它完成系統調用。當用戶程序觸發系統調用,系統進入內核態。處理器和操作系統將保存該用戶程序當前的上下文狀態,然后由內核將執行正確的代碼完成系統調用,然后回到用戶程序繼續執行。而用戶程序到底是如何得到操作系統的注意,以及它如何說明它希望操作系統做什么事情的方法是有很多不同的實現方式的。
在JOS中,我們會采用int指令,這個指令會觸發一個處理器的中斷。特別的,我們用int $0x30來代表系統調用中斷。注意,中斷0x30不是通過硬件產生的。
應用程序會把系統調用號以及系統調用的參數放到寄存器中。通過這種方法,內核就不需要去查詢用戶程序的堆棧了。系統調用號存放到 %eax 中,參數則存放在 %edx, %ecx, %ebx, %edi, 和 %esi 中。內核會把返回值送到 %eax中。在lib/syscall.c中已經寫好了觸發一個系統調用的代碼。
Exercise 7:
給中斷向量T_SYSCALL編寫一個中斷處理函數。你需要去編輯kern/trapentry.S和kern/trap.c中的trap_init()函數。你也需要去修改trap_dispatch()函數,使他能夠通過調用syscall()(在kern/syscall.c中定義的)函數處理系統調用中斷。最終你需要去實現kern/syscall.c中的syscall()函數。確保這個函數會在系統調用號為非法值時返回-E_INVAL。你應該充分理解lib/syscall.c文件。我們要處理在inc/syscall.h文件中定義的所有系統調用。
通過make run-hello指令來運行 user/hello 程序,它應該在控制台上輸出 "hello, world" 然后出發一個頁中斷。如果沒有發生的話,代表你編寫的系統調用處理函數是不正確的。
答:
我們需要了解一下系統調用的整個流程,如果現在運行的是內核態的程序的話,此時調用了一個系統調用,比如 sys_cputs 函數時,此時不會觸發中斷,那么系統會直接執行定義在 lib/syscall.c 文件中的 sys_cputs,我們可以看一下這個文件,可以發現這個文件中定義了幾個比較常用的系統調用,包括 sys_cputs, sys_cgetc 等等。我們還會發現他們都是統一調用一個 syscall 函數,通過這個函數的代碼發現其實它是執行了一個匯編指令。所以最終是這個函數完成了系統調用。
以上是運行在內核態下的程序,調用系統調用時的流程。
但是如果是用戶態程序呢?這個練習就是讓我們編寫程序使我們的用戶程序在調用系統調用時,最終也能經過一系列的處理最終去執行 lib/syscall.c 中的 syscall 指令。
讓我們看一下這個過程,當用戶程序中要調用系統調用時,比如 sys_cputs,從它的匯編代碼中我們會發現,它會執行一個 int $0x30 指令,這個指令就是軟件中斷指令,這個中斷的中斷號就是 0x30,即 T_SYSCALL,所以題目中讓我們首先為這個中斷號編寫一個中斷處理函數,我們首先就要在 kern/trapentry.S 文件中為它聲明它的中斷處理函數,即TRAPHANDLER_NOEC,就像我們為其他中斷號所做的那樣。
kern/trapentry.S
.....
TRAPHANDLER_NOEC(t_fperr, T_FPERR)
TRAPHANDLER(t_align, T_ALIGN)
TRAPHANDLER_NOEC(t_mchk, T_MCHK)
TRAPHANDLER_NOEC(t_simderr, T_SIMDERR)
TRAPHANDLER_NOEC(t_syscall, T_SYSCALL)
_alltraps
....
然后在trap.c 文件中聲明 t_syscall() 函數。並且在 trap_init() 函數中為它注冊
kern/trap.c .... void t_fperr(); void t_align(); void t_mchk(); void t_simderr(); void t_syscall(); ..... void trap_init(void) { extern struct Segdesc gdt[]; ..... SETGATE(idt[T_ALIGN], 0, GD_KT, t_align, 0); SETGATE(idt[T_MCHK], 0, GD_KT, t_mchk, 0); SETGATE(idt[T_SIMDERR], 0, GD_KT, t_simderr, 0); SETGATE(idt[T_SYSCALL], 0, GD_KT, t_syscall, 3); // Per-CPU setup trap_init_percpu(); }
此時當系統調用中斷發生時,系統就可以捕捉到這個中斷了,中斷發生時,系統會調用 _alltraps 代碼塊,並且最終來到 trap() 函數處,進入trap函數后,經過一系列處理進入 trap_dispatch 函數。題目中要求此時我們需要去調用 kern/syscall.c 中的syscall函數,這里注意,這個函數可不是 lib/syscall.c 中的 syscall 函數,但是通過閱讀 kern/syscall.c 中的 syscall 程序我們發現,它的輸入和 lib/syscall.c 中的 syscall 很像,如下
kern/syscall.c 中的 syscall :
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
lib/syscall.c 中的 syscall :
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
所以我們可以假象一下,是不是 kern/syscall.c 中的 syscall 就是一個外殼函數,它的存在就是為了能夠調用 lib/syscall 的呢? 所以我們按照這個思路繼續進行下去,我們再繼續觀察 kern/syscall.c 中的其他函數,會驚人的發現,kern/syscall.c 中的所有函數居然和 lib/syscall.c 中的所有函數都是一樣的!!比如 在這兩個文件中都有 sys_cputs 函數,但是我們仔細觀察可以發現這兩個同名的函數,實現方式卻不一樣。拿 sys_cputs 函數舉例
在 kern/syscall.c 中的 sys_cputs 是這樣的:
static void sys_cputs(const char *s, size_t len) { // Check that the user has permission to read memory [s, s+len). // Destroy the environment if not:. // LAB 3: Your code here. user_mem_assert(curenv, s, len, 0); // Print the string supplied by the user. cprintf("%.*s", len, s); }
而在 lib/syscall.c 中的 sys_cputs 是這樣的
void sys_cputs(const char *s, size_t len) { syscall(SYS_cputs, 0, (uint32_t)s, len, 0, 0, 0); }
可見在 lib/syscall.c 中,是直接調用 syscall 的,但是注意觀察 kern/syscall.c 中的 sys_cputs,它調用了 cprintf,這個調用其實就是為了完成輸出的功能,但是我們要注意,當我們程序運行到這里時,系統已經工作在內核態了,而cprintf函數其實就是通過調用 lib/syscall.c 中的 sys_cputs 來實現的,由於此時系統已經處於內核態了,所以這個 sys_cputs 可以被執行了!所以 kern/syscall.c 中的 sys_cputs 函數通過調用 cprintf 實現了調用 lib/syscall.c 中的 syscall !這正是我們一開始要實現的目標!
所以剩下的就是我們如何在 kern/syscall.c 中的 syscall() 函數中正確的調用 sys_cputs 函數了,當然 kern/syscall.c 中其他的函數也能完成這個功能。所以我們必須根據觸發這個系統調用的指令到底想調用哪個系統調用來確定我們該調用哪個函數。
那么怎么知道這個指令是要調用哪個系統調用呢?答案是根據 syscall 函數中的第一個參數,syscallno,那么這個值其實要我們手動傳遞進去的,這個值存在哪里?通過閱讀 lib/syscall.c 中的syscall函數我們可以知道它存放在 eax寄存器中,大家可以自己思考下這個是為什么。所以我們來最后完成 trap_dispatch 和 kern/syscall.c 中的 syscall 函數的代碼。
trap_dispatch:
static void trap_dispatch(struct Trapframe *tf) { int32_t ret_code; // Handle processor exceptions. // LAB 3: Your code here. switch(tf->tf_trapno) { case (T_PGFLT): page_fault_handler(tf); break; case (T_BRKPT): monitor(tf); break; case (T_SYSCALL): // print_trapframe(tf); ret_code = syscall( tf->tf_regs.reg_eax, tf->tf_regs.reg_edx, tf->tf_regs.reg_ecx, tf->tf_regs.reg_ebx, tf->tf_regs.reg_edi, tf->tf_regs.reg_esi); tf->tf_regs.reg_eax = ret_code; break; default: // Unexpected trap: The user process or the kernel has a bug. print_trapframe(tf); if (tf->tf_cs == GD_KT) panic("unhandled trap in kernel"); else { env_destroy(curenv); return; } } }
kern/syscall.c 中的 syscall()
int32_t syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5) { // Call the function corresponding to the 'syscallno' parameter. // Return any appropriate return value. // LAB 3: Your code here. // panic("syscall not implemented"); switch (syscallno) { case (SYS_cputs): sys_cputs((const char *)a1, a2); return 0; case (SYS_cgetc): return sys_cgetc(); case (SYS_getenvid): return sys_getenvid(); case (SYS_env_destroy): return sys_env_destroy(a1); default: return -E_INVAL; } }
User-mode startup
用戶程序真正開始運行的地方是在lib/entry.S文件中。該文件中,首先會進行一些設置,然后就會調用lib/libmain.c 文件中的 libmain() 函數。你首先要修改一下 libmain() 函數,使它能夠初始化全局指針 thisenv ,讓它指向當前用戶環境的 Env 結構體。
然后 libmain() 函數就會調用 umain,這個 umain 程序恰好是 user/hello.c 中被調用的函數。在之前的實驗中我們發現,hello.c程序只會打印 "hello, world" 這句話,然后就會報出 page fault 異常,原因就是 thisenv->env_id 這條語句。現在你已經正確初始化了這個 thisenv的值,再次運行就應該不會報錯了。
Exercise 8.
把我們剛剛提到的應該補全的代碼補全,然后重新啟動內核,此時你應該看到 user/hello 程序會打印 "hello, world", 然后在打印出來 "i am environment 00001000"。user/hello 然后就會嘗試退出,通過調用 sys_env_destroy()。由於內核目前僅僅支持一個用戶運行環境,所以它應該匯報 “已經銷毀用戶環境”的消息,然后退回內核監控器(kernel monitor)。
答:
其實這個練習就是讓你通過程序獲得當前正在運行的用戶環境的 env_id , 以及這個用戶環境所對應的 Env 結構體的指針。 env_id 我們可以通過調用 sys_getenvid() 這個函數來獲得。那么如何獲得它對應的 Env結構體指針呢?
通過閱讀 lib/env.h 文件我們知道,env_id的值包含三部分,第31位被固定為0;第10~30這21位是標識符,標示這個用戶環境;第0~9位代表這個用戶環境所采用的 Env 結構體,在envs數組中的索引。所以我們只需知道 env_id 的低 0~9 位,我們就可以獲得這個用戶環境對應的 Env 結構體了。
lib/libmain.c
void libmain(int argc, char **argv) { // set thisenv to point at our Env structure in envs[]. // LAB 3: Your code here. thisenv = &envs[ENVX(sys_getenvid())]; // save the name of the program so that panic() can use it if (argc > 0) binaryname = argv[0]; // call user main routine umain(argc, argv); // exit gracefully exit(); }
Page faults and memory protection
內存保護是操作系統的非常重要的一項功能,它可以防止由於用戶程序崩潰對操作系統帶來的破壞與影響。
操作系統通常依賴於硬件的支持來實現內存保護。操作系統可以讓硬件能夠始終知曉哪些虛擬地址是有效的,哪些是無效的。當程序嘗試去訪問一個無效地址,或者嘗試去訪問一個超出它訪問權限的地址時,處理器會在這個指令處終止,並且觸發異常,陷入內核態,與此同時把錯誤的信息報告給內核。如果這個異常是可以被修復的,那么內核會修復這個異常,然后程序繼續運行。如果異常無法被修復,則程序永遠不會繼續運行。
作為一個可修復異常的例子,讓我們考慮一下可自動擴展的堆棧。在許多系統中,內核在初始情況下只會分配一個內核堆棧頁,如果程序想要訪問這個內核堆棧頁之外的堆棧空間的話,就會觸發異常,此時內核會自動再分配一些頁給這個程序,程序就可以繼續運行了。
系統調用也為內存保護帶來了問題。大部分系統調用接口讓用戶程序傳遞一個指針參數給內核。這些指針指向的是用戶緩沖區。通過這種方式,系統調用在執行時就可以解引用這些指針。但是這里有兩個問題:
1. 在內核中的page fault要比在用戶程序中的page fault更嚴重。如果內核在操作自己的數據結構時出現 page faults,這是一個內核的bug,而且異常處理程序會中斷整個內核。但是當內核在解引用由用戶程序傳遞來的指針時,它需要一種方法去記錄此時出現的任何page faults都是由用戶程序帶來的。
2. 內核通常比用戶程序有着更高的內存訪問權限。用戶程序很有可能要傳遞一個指針給系統調用,這個指針指向的內存區域是內核可以進行讀寫的,但是用戶程序不能。此時內核必須小心不要去解析這個指針,否則的話內核的重要信息很有可能被泄露。
現在你需要通過仔細檢查所有由用戶傳遞來指針所指向的空間來解決上述兩個問題。當一個程序傳遞給內核一個指針時,內核會檢查這個地址是在整個地址空間的用戶地址空間部分,而且頁表也運行進行內存的操作。
Exercise 9
修改kern/trap.c文件,使其能夠實現:當在內核模式下發現頁錯,trap.c 文件會panic。
提示:
為了能夠判斷這個page fault是出現在內核模式下還是用戶模式下,我們應該檢查 tf_cs 的低幾位。
閱讀 user_mem_assert (在 kern/pmap.c),並且實現 user_mem_check;
修改一下 kern/syscall.c 去檢查輸入參數。
啟動內核后,運行 user/buggyhello 程序,用戶環境可以被銷毀,內核不可以panic,你應該看到:
[00001000] user_mem_check assertion failure for va 00000001
[00001000] free env 00001000
Destroyed the only environment - nothing more to do!
答:
首先我們應該根據什么來判斷當前運行的程序時處在內核態下還是用戶態下?答案是根據 CS 段寄存器的低2位,這兩位的名稱叫做 CPL 位,表示當前運行的代碼的訪問權限級別,0代表是內核態,3代表是用戶態。
題目要求我們在檢測到這個 page fault 是出現在內核態時,要把這個事件 panic 出來,所以我們把 page_fault_handler 文件修改如下:
void page_fault_handler(struct Trapframe *tf) { uint32_t fault_va; // Read processor's CR2 register to find the faulting address fault_va = rcr2(); // Handle kernel-mode page faults. // LAB 3: Your code here. if(tf->tf_cs && 0x01 == 0) { panic("page_fault in kernel mode, fault address %d\n", fault_va); } // We've already handled kernel-mode exceptions, so if we get here, // the page fault happened in user mode. // Destroy the environment that caused the fault. cprintf("[%08x] user fault va %08x ip %08x\n", curenv->env_id, fault_va, tf->tf_eip); print_trapframe(tf); env_destroy(curenv); }
然后根據題目的要求,我們還要繼續完善 kern/pmap.c 文件中的 user_mem_assert , user_mem_check 函數,通過觀察 user_mem_assert 函數我們發現,它調用了 user_mem_check 函數。而 user_mem_check 函數的功能是檢查一下當前用戶態程序是否有對虛擬地址空間 [va, va+len] 的 perm| PTE_P 訪問權限。
自然我們要做的事情應該是,先找到這個虛擬地址范圍對應於當前用戶態程序的頁表中的頁表項,然后再去看一下這個頁表項中有關訪問權限的字段,是否包含 perm | PTE_P,只要有一個頁表項是不包含的,就代表程序對這個范圍的虛擬地址沒有 perm|PTE_P 的訪問權限。以上就是這段代碼的大致思想。
int user_mem_check(struct Env *env, const void *va, size_t len, int perm) { // LAB 3: Your code here. char * end = NULL; char * start = NULL; start = ROUNDDOWN((char *)va, PGSIZE); end = ROUNDUP((char *)(va + len), PGSIZE); pte_t *cur = NULL; for(; start < end; start += PGSIZE) { cur = pgdir_walk(env->env_pgdir, (void *)start, 0); if((int)start > ULIM || cur == NULL || ((uint32_t)(*cur) & perm) != perm) { if(start == ROUNDDOWN((char *)va, PGSIZE)) { user_mem_check_addr = (uintptr_t)va; } else { user_mem_check_addr = (uintptr_t)start; } return -E_FAULT; } } return 0; }
最后按照題目要求我們還要補全 kern/syscall.c 文件中的一部分內容,即 sys_cputs 函數,這個函數要求檢查用戶程序對虛擬地指空間 [s, s+len] 是否有訪問權限,所以我們恰好可以使用剛剛寫好的函數 user_mem_assert() 來實現。
static void sys_cputs(const char *s, size_t len) { // Check that the user has permission to read memory [s, s+len). // Destroy the environment if not:. // LAB 3: Your code here. user_mem_assert(curenv, s, len, 0); // Print the string supplied by the user. cprintf("%.*s", len, s); }
至此,我們就完成了這個練習,可以運行一下 make run-buggyhello,看一下它是否按照題目的要求輸出了信息。
Exercise 10
重新啟動你的內核,並且運行 user/evilhello。內核應該不能 panic,並且輸出如下信息:
[00000000] new env 00001000
[00001000] user_mem_check assertion failure for va f010000c
[00001000] free env 00001000
答:
其實 Exercise 9 完成后,Exercise 10 其實也完成了,你可以直接運行 make run-evilhello,看一下是否輸出要求的結果。
Lab 3 到現在就全部完成了~
歡迎提問與指錯~
zzqwf12345@163.com