在操作系統中,有三種情況會導致CPU的控制流發生轉移:用戶態中通過ecall指令進入內核態;異常發生,如除零、訪問非法地址;設備中斷,如硬盤完成讀寫請求。上面這些情況可以統稱為陷阱(trap)。
陷阱在一般情況下應該是透明的,即當執行完處理程序后能夠恢復之前程序的狀態。這就要求在陷入內核態時,內核要保存之前的寄存器等狀態信息,當執行完處理程序之后再進行恢復。
在XV6中處理陷阱有以下四步:CPU進行硬件操作,匯編向量被設置,C陷阱處理程序決定如何處理,系統調用或設備驅動處理該陷阱。內核中通常分三種情況來分別處理這些陷阱:用戶態陷阱、內核態陷阱、時鍾中斷。
RISC-V CPU有一系列控制寄存器來決定如何處理陷阱,這些寄存器是由內核來設置的。
stvec:陷阱處理程序入口,CPU會跳轉到此處來處理陷阱sepc:保存陷阱發生時的pc,使用sret指令會將pc恢復scause:陷阱原因sscratch:內核保存特定的值,見下文sstatus:sstatus中的SIE位控制中斷是否允許;SPP位表示陷阱來自用戶模式還是監管模式。
當發生陷阱時,硬件會進行以下操作:
- 如果是設備中斷,並且
SIE是清空的,就不響應 - 清空
SIE以關閉中斷 - 保存
pc到sepc - 保存當前模式到
SPP - 設置
scause - 切換到監管模式
- 拷貝
stvec到pc - 開始執行處理程序
硬件不會自動切換內核頁表和內核棧,也不會保存除pc以外的寄存器,處理程序必須完成上述工作。這樣設計可以給軟件更好的靈活性。而設置pc的工作必須由硬件完成,因為當切換到內核態時,用戶指令可能會破壞隔離性。
用戶態陷阱
XV6的用戶態陷阱處理流程如下:uservec -> usertrap -> usertrapret -> userret。
由於CPU不會進行頁表切換,因此用戶頁表必須包含uservec函數(stvec所指向的函數)的映射。該函數要將satp切換為內核頁表,為了切換后的指令能繼續執行,該函數必須在用戶頁表和內核頁表中有相同的地址。為了滿足上述要求,XV6將一個叫trampoline的頁映射到相同的虛擬地址TRAMPOLINE,其中包含了trampoline.S的指令,並設置stvec為uservec。
uservec
在進入uservec函數時,所有的32個寄存器都是被中斷代碼所享有的,而uservec需要使用寄存器來執行指令,因此,RISC-V提供了sscratch寄存器,通過csrrw a0, sscratch, a0指令,保存a0,之后就可以使用a0寄存器了。
之后,函數就需要保存所有用戶寄存器到trapframe結構體中,該結構體的地址在進入用戶模式之前,被保存在sscratch寄存器中,因此經過之前的csrrw操作后,就被保存在a0中。當創建進程時,內核會申請一個頁面保存trapframe,該頁面就位於TRAMPOLINE下方,進程的p->trapframe也指向該頁面。
最后,函數從trapframe中取出內核棧地址、hartid、usertrap的地址、內核頁表地址,切換頁表,跳轉到usertrap函數。
usertrap
usertrap的工作即判斷陷阱類型並處理,最后返回。函數首先將stvec設置為kernelvec的地址,使內核態發生的中斷由kernelvec函數來處理。之后保存sepc寄存器,防止其被覆蓋。然后判斷陷阱類型,如果是系統調用,就將pc指向ecall的下一條指令,然后交給syscall函數處理;如果是設備中斷,就交給devintr;否則就是異常,那么就終止該進程的運行。在最后會判斷進程是否已經被殺死或者當發生時鍾中斷時,讓出處理器。
usertrapret
該函數首先將stvec設置為uservec的地址,之后設置trapframe(這些內容在uservec中會使用到),然后恢復sepc寄存器。最后,調用userret函數。
最后,在userret函數中進行與uservec相反的步驟,將頁表和寄存器進行恢復。
系統調用
以initcode.S中的系統調用為例,將兩個參數分別放在a0 a1寄存器中,將系統調用號放在a7寄存器中,然后執行ecall指令。
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
而在syscall函數中,會取出a7的值,然后查找syscalls數組,找到相應的處理函數即sys_exec,交由該函數進行處理,最后將返回值放在trapframe->a0中。
內核態陷阱
內核態陷阱的處理路徑為:kernelvec -> kerneltrap -> kernelvec
kernelvec
由於陷阱發生在內核態,因此,不需要對satp和棧指針進行處理,只需要保存所有通用寄存器即可。之后跳轉到kerneltrap進行處理,當該函數返回后,再恢復所保存的寄存器。
kerneltrap
kerneltrap只需要處理兩種陷阱:設備中斷和異常。通過調用devintr判斷是否為設備中斷,如果不是設備中斷,那么就是異常,且該異常發生在內核態,內核調用panic函數終止執行。如果是時鍾中斷,那么就讓出處理器。由於yield函數會導致sepc sstatus寄存器被修改,因此在kerneltrap中要對其進行保存和恢復。
缺頁異常
在XV6中,並沒有對異常進行處理,僅僅是簡單地kill或panic。而在真實操作系統中,會對異常進行具體的處理。例如使用缺頁異常來實現COW(copy on write)fork。
在RISC-V中,有三種不同的缺頁異常:load page faults(當load指令轉換虛擬地址時發生),store page faults(當store指令轉換虛擬地址時發生),instruction page faults(當指令的地址轉化時發生)。在scause寄存器中保存了異常原因,stval中保存了轉換失敗的地址。
COW fork使子進程與父進程享有相同的物理頁面,但是設置為只讀的。當子進程或父進程執行store指令時,就會觸發異常,此時再對頁面進行拷貝,然后以讀寫的模式映射到父子進程的地址空間。
另一種技術是lazy allocation,當應用調用sbrk時,增長地址空間,但在頁表中標記新地址為無效的。當在新地址上發生缺頁異常后,才真正地分配物理頁面給進程。
paging from disk即虛擬內存,操作系統選擇一部分保存到磁盤上並標記頁表項為無效,當讀寫該頁面時再從磁盤中取回內存。除此之外,還有如automatically extending stacks 和 memory-mapped files等技術也使用了缺頁異常。
