前言
這個星期睡眠和精神狀態一直比較差,6.824很多論文沒時間回顧,15-445的Lab2又耗費了我巨大的精力,實在寫不動代碼了。只能寫點回顧總結之類的東西。我很久之前就想總結一下xv6中關於進程的知識,后來發現這涉及的范圍實在是太大了(廢話,這在哪本OS教材里都能占完整的一章),而且無論如何組織結構,trap永遠都是一個繞不過去的地方,不講trap,那么進程的許多內容都將無從談起。本blog算是對xv6中trap機制的一點粗淺的個人筆記。
請注意:
(1)很多教材要區分trap和interrupt,當初看的我雲里霧里,相關內容一圈讀下來感覺就是讀了個分類學。這里我將它們統稱為trap,並按照不同於xv6 book的分類方式(xv6 book將trap分為來自用戶的中斷、來自內核的中斷、來自設備的中斷)對trap分類並進行了討論。
(2)討論trap必然涉及到硬件,而riscv的硬件相關細節夠把blog碼到⑨⑩年之后。因此本blog雖然會討論一點硬件細節,但也點到為止,不會長篇大論去討論什么M Mode、S Mode等。如果希望看到這方面討論的可以點右上角的X了。
trap就是修改了PC
trap對我來說算是學OS的時候感覺最為迷惑的概念之一。
作為曾經408的受害者,我對trap的理解一直是《他改變了PC》,正常情況下PC應當隨着指令的逐條執行發生變化。如果這條指令不是跳轉指令,那么PC += 1。如果是跳轉指令,那么就向那里發生跳轉。整個程序的執行中,如果不發生中斷,PC就像是已經被徹底安排好了一樣,只能在程序划定好的范圍內反復橫跳。
但如果引入了trap,一切就不一樣了。用戶執行指令時,如果指令執行出現故障就會觸發trap,如果使用了特殊的指令(ecall、越權指令等)也會觸發trap。更要命的是,哪怕指令正常執行,也可能出現trap——設備trap是完全異步的,這類trap在任意時期都可能出現。無論發生了哪種trap,它都打斷了現有程序的執行流。
trap的觸發既和軟件有關也和硬件有關,如果要思考它們的具體情景是一個很要命很迷惑的問題。我個人的建議是不妨把trap想象的簡單些——把trap定義為是PC值被強行修改的行為。這種行為既可能來源於用戶代碼的執行,也可能來自於外部。但它們都做過相同的事情:強行修改了當前的PC值,以及為了能強行修改PC值,也做了一些預備的工作。
trap的引入對於計算機來意義非凡。嚴格的說,OS的幾大功能,進程管理、內存管理、虛擬內存、設備管理、文件管理幾乎都需要trap。甚至可以說,如果沒有trap,那么OS連基礎的輸入輸出都沒辦法處理。我曾看到過一種較為民科的說法,它認為計算機可以看做一台狀態機,狀態隨着指令的執行改變。如果沒有輸入和輸出,那么計算機內的一切狀態都是可以確定的,而輸入輸出恰恰打亂了計算機的狀態,為計算機注入了活力。這種說法和我前面舉的例子有點契合,也只是博人一笑的民科觀點而已,但這也足以讓我們了解到,trap對於計算機來說到底是多么強大的一項基礎功能。
下面我們將仔細討論trap的細節。
trap的硬件級支持以及中斷隱指令
首先思考一個問題,在trap發生到修改PC值之前,這段時間內到底要完成哪些工作呢?
這個疑問在xv6 book中解釋的十分清楚:
When it needs to force a trap, the RISC-V hardware does the following for all trap types (other than timer interrupts):
1. If the trap is a device interrupt, and the sstatus SIE bit is clear, don’t do any of the following. 2. Disable interrupts by clearing SIE. 3. Copy the pc to sepc. 4. Save the current mode (user or supervisor) in the SPP bit in sstatus. 5. Set scause to reflect the interrupt’s cause. 6. Set the mode to supervisor. 7. Copy stvec to the pc. 8. Start executing at the new pc.
非常值得注意的是,這些工作都沒有對應的代碼,都是由硬件完成的,也就是許多OS課本中所說的“中斷隱指令”。
如果這個trap是由設備引起的,而此時SIE(設備使能中斷位)為0,則此時不允許響應設備的trap。
在響應這個trap之前,我們必須保存好那個被打斷的程序的現場,在保存現場之前,必須首先關設備中斷,避免現場被其他中斷打斷破壞。注意到進程切換的中斷也依賴於設備中斷(Device Trap),因此在開中斷之前,所執行的代碼必定都是原子的。
riscv為處理trap提供了sstatuts、scause、stvec、sepc、sscratch等寄存器,這些寄存器都會被中斷隱指令所使用到:
(1)stvec寄存器存放着trap wrapper的地址。當trap發生時,stvec的值將被讀入到pc中
(2)scause記錄這個trap的發生原因。當trap發生時,這個寄存器被設置
(3)sepc保存pc的值。在PC的值被更新為stvec之前,PC值被保存在這個寄存器中
(4)sstatuts的SPP位記錄着當前CPU所處的mode。
(5)sscratch記錄着這個進程的trapframe地址。保存現場時,需要將寄存器保存在trapframe上。
總而言之,當trap發生時,首先會“執行中斷隱指令”,完成關中斷和設定trap相關寄存器等操作,而保護現場等操作,是由相應的指令執行的。
trap wrapper與stvec寄存器
stvec:trap處理入口函數
當“中斷隱指令”執行完畢后,PC的值已經被修改為了stvec寄存器中的值。在xv6中,stvec的值只可能是uservec或者kernelvec。這兩個vec我稱之為wrapper,即“外殼函數”。usertrap、keneltrap這兩個函數都是直接由這兩個vec調用的。
stvec記錄着這些wrapper的入口,因此意義非凡。在xv6中對stvec寄存器的修改是通過調用w_stvec來實現的。我們不妨查看一下w_stvec在哪里被調用了:
void trapinithart(void) { w_stvec((uint64)kernelvec); }
這個函數是內核初始化的時候調用的,將中斷處理入口設置成了kernelvec。
void usertrapret(void) { struct proc *p = myproc(); // turn off interrupts, since we're switching // now from kerneltrap() to usertrap(). intr_off(); // send syscalls, interrupts, and exceptions to trampoline.S w_stvec(TRAMPOLINE + (uservec - trampoline)); ...... }
void usertrap(void) { int which_dev = 0; if((r_sstatus() & SSTATUS_SPP) != 0) panic("usertrap: not from user mode"); // send interrupts and exceptions to kerneltrap(), // since we're now in the kernel. w_stvec((uint64)kernelvec); ........ }
誒等等,你不是前面說過“在xv6中,stvec的值只可能是uservec或者kernelvec”么,那么這個 TRAMPOLINE + (uservec - trampoline) 又是什么東西?
不要慌,readelf,永遠的神!
ms@ubuntu:~/public/MIT 6.S081/Lab5 cow/xv6-riscv-fall19$ readelf -a kernel/kernel | grep trampoline 94: 0000000080008000 0 NOTYPE GLOBAL DEFAULT 1 trampoline ms@ubuntu:~/public/MIT 6.S081/Lab5 cow/xv6-riscv-fall19$ readelf -a kernel/kernel | grep uservec 227: 0000000080008000 0 NOTYPE GLOBAL DEFAULT 1 uservec ms@ubuntu:~/public/MIT 6.S081/Lab5 cow/xv6-riscv-fall19$ readelf -a kernel/kernel | grep userret 131: 0000000080008090 0 NOTYPE GLOBAL DEFAULT 1 userret
得了,uservec - trampoline正好為0,即 TRAMPOLINE + (uservec - trampoline) 的值,正好就是uservec。簡要總結一下:
當進程觸發trap后,進入內核代碼。由於內核代碼的執行也可能會觸發trap,因此在usertrap中,要設定stval值為內核的trap wrapper,即kernelvec。這樣進程進入內核后,如果再次觸發trap,負責處理的是kernelvec和kerneltrap
當進程要返回用戶態時,要重新設定stval的值為TRAMPOLINE。這樣當用戶再次進入內核態時,PC的值會被更新為TRAMPOLINE,即uservec,負責處理這個trap的是usertrap。
wrapper函數與多重中斷
wrapper函數的功能就是保存與恢復現場。對於來自用戶的trap,現場被保存在了trapframe中;而對於來自內核的trap,現場被保存在了內核棧上:
kernelvec: // make room to save registers. addi sp, sp, -256 // save the registers. sd ra, 0(sp) sd sp, 8(sp) sd gp, 16(sp) sd tp, 24(sp) sd t0, 32(sp) sd t1, 40(sp) sd t2, 48(sp) ......
保存現場到內核棧這個操作同樣具有巨大的意義。由於只要發生trap,必定會進入內核態,因此不會存在多重的usertrap,也正是因此在kernel/proc.h中,進程只有一個trapframe。
而內核中可能發生多重中斷(內核代碼的執行也可能出錯),處理多重中斷必須要有能存放多個trapframe的空間。把內核的trapframe放到內核棧上,就可以實現這一點。如果執行內核代碼的過程中發生trap,那就在內核棧上多加一層trapframe就行了。此外,usertrap如果發現trap的原因是系統調用,會執行intr_on,即允許設備中斷:
if(r_scause() == 8){ // system call if(p->killed) exit(-1); // sepc points to the ecall instruction, // but we want to return to the next instruction. p->tf->epc += 4; // an interrupt will change sstatus &c registers, // so don't enable until done with those registers. intr_on(); // 系統調用下可以允許設備中斷 syscall();
此外,群友RedemptionC還提出了trapframe存放在內核棧上的另一個重大意義:
上述的保存寄存器是保存在該進程的內核棧上(這一點很重要,因為如果在kerneltrap中切換到其他進程,如kerneltrap中調用yield,這樣做就能保證重新切換回來時,能夠從該進程的內核棧上恢復寄存器)
源博客鏈接:https://blog.csdn.net/RedemptionC/article/details/108718347
在xv6 book中,也提示我們:
It’s worth thinking through how the trap return happens if kerneltrap called yield due to a timer interrupt.
這里我們可以得出一個沒什么卵用,但xv6 book中沒有提及的結論:xv6是可以實現多重中斷的!
xv6中的trap
Timer Trap
Timer Trap是由定時器觸發的trap,這個trap既可以由usertrap處理,也可以由kerneltrap處理。即進程無論是處於S Mode還是U Mode,都應該能處理Timer Trap。
不過對於riscv(注意是riscv,不是xv6)來說,Timer Trap是一種非常特殊的trap,具體表現在以下幾個方面:
(1)Timer Trap是由CPU中的定時器周期觸發的,
(2)處理Timer Trap時,CPU處於M Mode,而非S Mode
(3)Timer Trap不可被屏蔽
(4)處理Timer Trap的代碼不涉及虛地址的映射轉換
我們前文中又提到,保存現場的操作必須是原子的,而Timer Trap無法通過關中斷屏蔽掉,因此Timer Trap可能破壞當前CPU的現場。這樣處理Timer Trap就成了一個相當棘手的問題。
但與之相應的,riscv也為Timer Trap提供了許多專用的寄存器。xv6采用了較為巧妙的辦法來解決上述問題,即產生一個軟中斷,將Timer Trap交給kernelvec來處理。我們查看一下kernel/kernelvec.S下處理Timer Trap的wrapper:timervec
timervec: # start.c has set up the memory that mscratch points to: # scratch[0,8,16] : register save area. # scratch[32] : address of CLINT's MTIMECMP register. # scratch[40] : desired interval between interrupts. csrrw a0, mscratch, a0 sd a1, 0(a0) sd a2, 8(a0) sd a3, 16(a0) # schedule the next timer interrupt # by adding interval to mtimecmp. ld a1, 32(a0) # CLINT_MTIMECMP(hart) ld a2, 40(a0) # interval ld a3, 0(a1) add a3, a3, a2 sd a3, 0(a1) # raise a supervisor software interrupt. li a1, 2 csrw sip, a1 ld a3, 16(a0) ld a2, 8(a0) ld a1, 0(a0) csrrw a0, mscratch, a0 mret
當Timer Trap發生后,處理流程如下:
(1)PC的值將被替換為timervec的值,而原PC的值被保存在了另一個寄存器中(是啥我實在找不到了)
(2)將寄存器a0的值與mscratch置換
(3)將a1、a2、a3三個寄存器存放到內存的某處(是哪兒我也找不到了)
(4)為了讓kerneltrap/usertrap發現這是一個設備中斷,修改a1、a2、a3的值,並做一些其他操作
(5)修改sip為2,觸發軟中斷(大概)
(6)kerneltrap/usertrap執行,調用devintr(),該函數返回2,於是判斷這是一個設備中斷,調用yield(),切換到新的進程
(7)本進程得到調度,從kerneltrap/usertrap中返回,回到timervec中
(8)恢復a0 — a3的值,從timervec中返回。
這樣就實現了在不破壞現場的情況下處理Timer Trap。
Device Trap
Device Trap既可以被usertrap處理,也可以被kerneltrap處理,但其處理的過程比Timer Trap簡單不少。
一個典型的Device Trap是由Console Driver觸發的trap(見xv6 book P47)。Console Driver是UART的驅動設備,這個設備是通過qemu模擬的,負責處理從鍵盤上獲得的輸入字符串。當UART讀取到字符時,會觸發Device Trap,控制台將自己的設備id告知給內核,內核調用devinter,確認這是由控制台觸發的trap,進入到處理輸入的函數uartintr中:
// check if it's an external interrupt or software interrupt, // and handle it. // returns 2 if timer interrupt, // 1 if other device, // 0 if not recognized. int devintr() { uint64 scause = r_scause(); if((scause & 0x8000000000000000L) && (scause & 0xff) == 9){ // this is a supervisor external interrupt, via PLIC. // irq indicates which device interrupted. int irq = plic_claim(); if(irq == UART0_IRQ){ uartintr(); } else if(irq == VIRTIO0_IRQ || irq == VIRTIO1_IRQ ){ virtio_disk_intr(irq - VIRTIO0_IRQ); } plic_complete(irq); return 1; } else if(scause == 0x8000000000000001L){ // software interrupt from a machine-mode timer interrupt, // forwarded by timervec in kernelvec.S. if(cpuid() == 0){ clockintr(); } // acknowledge the software interrupt by clearing // the SSIP bit in sip. w_sip(r_sip() & ~2); return 2; } else { return 0; } }
查看一下uartintr的代碼:
void uartintr(void) { while(1){ int c = uartgetc(); if(c == -1) break; consoleintr(c); } }
consoleintr是向控制台打印字符的函數。
總的來說,UART處理輸入是一個字符一個字符的處理的。每鍵入一個字符,就會觸發一次Device Trap,進入到UART的驅動代碼中。UART驅動程序從UART設備中讀取一個字符,並將它打印到控制台上。在鍵入換行鍵之前,這些字符並不是讀完就丟棄掉的,而是存放在了console.buf中。當鍵入換行鍵后,內核會從console.buf中將這行輸入的字符串拷貝到用戶態。
指令異常Trap
指令的執行異常同樣會觸發trap。最耳熟能詳的例子就是“除零異常”。如果一條除法指令發現被除數為0,那么就會觸發trap;另一個在做Lab經常遇到的trap就是page fault。當訪問了某個虛地址p時。xv6會查看本進程的頁表,核查相應的權限后,將這個地址翻譯為實地址。如果訪問權限發生錯誤,將會觸發page fault trap。
系統調用
這個我想大家已經非常熟悉了。當然系統調用與前面的幾種情況不同,在xv6中,進程是在用戶態主動執行ecall指令觸發trap的,這也是唯一一個主動觸發trap的例子。觸發trap時的機制與其他的trap大差不差,唯一的區別在於系統調用的編號為8,而且系統調用下可以繼續允許設備中斷(見前文中usertrap.c的代碼)。
總結
xv6中的所有trap到目前為止已經總結完畢。前文中提到,xv6 book將trap分為三類,現在我們可以歸納到xv6 book的分類上來了:
(1)內核態下的trap。除了系統調用引發的trap,其他trap均可能發生
(2)用戶態下的trap。與內核態下的trap相比,加上一個系統調用
(3)設備引發的trap。就是前文中所提到的Timer Trap和Device Trap。
sscratch的初始化
前文中提到,發生trap時,中斷隱指令邏輯從sscratch中讀取到trapframe應當存放的地址,這個值應該等於TRAPFRAME。那么,sscratch是什么時候被初始化為TRAPFRAME的呢?
如果想要理解sscratch的初始化,就需要對進程的分配代碼有所了解。
當創建一個新進程時,會調用allocproc,allocproc的代碼如下:
static struct proc* allocproc(void) { struct proc *p; for(p = proc; p < &proc[NPROC]; p++) { acquire(&p->lock); if(p->state == UNUSED) { goto found; } else { release(&p->lock); } } return 0; found: p->pid = allocpid(); // Allocate a trapframe page. if((p->tf = (struct trapframe *)kalloc()) == 0){ release(&p->lock); return 0; } // An empty user page table. p->pagetable = proc_pagetable(p); // Set up new context to start executing at forkret, // which returns to user space. memset(&p->context, 0, sizeof p->context); p->context.ra = (uint64)forkret; p->context.sp = p->kstack + PGSIZE; return p; }
allocproc成功返回后,進程的trapframe已經分配成功。注意后續的p->context.ra = (uint64)forkret。這意味着當進程被分配完成后,並第一次獲得時間片時,會首先執行forkret。我們繼續查看forkret的代碼:
void forkret(void) { static int first = 1; // Still holding p->lock from scheduler. release(&myproc()->lock); if (first) { // File system initialization must be run in the context of a // regular process (e.g., because it calls sleep), and thus cannot // be run from main(). first = 0; fsinit(minor(ROOTDEV)); } usertrapret(); }
隨后這個進程繼續執行usertrapret:
void usertrapret(void) { struct proc *p = myproc(); // turn off interrupts, since we're switching // now from kerneltrap() to usertrap(). intr_off(); // send syscalls, interrupts, and exceptions to trampoline.S w_stvec(TRAMPOLINE + (uservec - trampoline)); // set up trapframe values that uservec will need when // the process next re-enters the kernel. p->tf->kernel_satp = r_satp(); // kernel page table p->tf->kernel_sp = p->kstack + PGSIZE; // process's kernel stack p->tf->kernel_trap = (uint64)usertrap; p->tf->kernel_hartid = r_tp(); // hartid for cpuid() // set up the registers that trampoline.S's sret will use // to get to user space. // set S Previous Privilege mode to User. unsigned long x = r_sstatus(); x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode x |= SSTATUS_SPIE; // enable interrupts in user mode w_sstatus(x); // set S Exception Program Counter to the saved user pc. w_sepc(p->tf->epc); // tell trampoline.S the user page table to switch to. uint64 satp = MAKE_SATP(p->pagetable); // jump to trampoline.S at the top of memory, which // switches to the user page table, restores user registers, // and switches to user mode with sret. uint64 fn = TRAMPOLINE + (userret - trampoline); ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); }
注意最后一行那個稀奇古怪的函數。TRAPFRAME + (userret - trampoline)的地址,其實就是uservec下的userret。注意這個userret函數還會接受兩個參數,其中第一個參數就是TRAPFRAME,trapframe的虛地址。這個參數存放到了a0寄存器中。
userret: # userret(TRAPFRAME, pagetable) # switch from kernel to user. # usertrapret() calls here. # a0: TRAPFRAME, in user page table. # a1: user page table, for satp. # switch to the user page table. csrw satp, a1 sfence.vma zero, zero # put the saved user a0 in sscratch, so we # can swap it with our a0 (TRAPFRAME) in the last step. ld t0, 112(a0) csrw sscratch, t0 # restore all but a0 from TRAPFRAME ld ra, 40(a0) ld sp, 48(a0) ld gp, 56(a0) ld tp, 64(a0) ld t0, 72(a0) ld t1, 80(a0) ld t2, 88(a0) ld s0, 96(a0) ld s1, 104(a0) ld a1, 120(a0) ld a2, 128(a0) ld a3, 136(a0) ld a4, 144(a0) ld a5, 152(a0) ld a6, 160(a0) ld a7, 168(a0) ld s2, 176(a0) ld s3, 184(a0) ld s4, 192(a0) ld s5, 200(a0) ld s6, 208(a0) ld s7, 216(a0) ld s8, 224(a0) ld s9, 232(a0) ld s10, 240(a0) ld s11, 248(a0) ld t3, 256(a0) ld t4, 264(a0) ld t5, 272(a0) ld t6, 280(a0) # restore user a0, and save TRAPFRAME in sscratch csrrw a0, sscratch, a0 # return to user mode and user pc. # usertrapret() set up sstatus and sepc. sret
在最后幾行指令里,a0的值被存放在了sscratch中。雖然此時這個進程已經跑完了forkret、usertrapret,但它的具體程序代碼還沒有執行。這個程序的入口地址此時被存放在了ra中,而最后的sret指令就是將PC的值更新為ra的值!這樣在用戶代碼執行之前,sscratch寄存器就已經完成了初始化,隨后才開始執行了用戶代碼。
抱歉我沒有仔細查看指令的相應手冊。在做Lab6(https://www.cnblogs.com/KatyuMarisaBlog/p/13948455.html)時經過調試我發現我弄混了sret指令和ret指令。ret指令會將PC值更新為ra的值,一般來說是U Mode下使用的指令。而sret指令一般是在S Mode下使用的指令,它的作用是利用epc寄存器的值來更新PC的值。執行sret后會開始執行用戶代碼的原因是我們在usertrapret中使用了w_sepc函數,該函數利用了p->tf->epc的值更新了epc寄存器。執行sret指令后,pc的值就被更新為了p->tf->epc的值。
后記
碼到現在已經大半夜了,幾個小時后開組會,沒啥東西可以報的,現在信誰可能都救不了我了.....