1 Star 0 Fork 0

Prim. / mit6.S086

Create your Gitee Account
Explore and code with more than 12 million developers,Free private repositories !:)
Sign up
Clone or Download
chapter4.md 21.99 KB
Copy Edit Raw Blame History

陷入和系统调用 Traps and system calls

有三种事件会引起CPU将正在执行的普通指令暂时搁置并强制将控制权转换到一些特定的处理这些事件的代码中。一种情况是一个系统调用,当一个用户应用程序执行ecall指令让内核为它做某些事情。另一种情况就是一个异常(exception):一个(用户态或内核)指令做了一些非法的事情,例如:除零或者使用了一个非法的虚拟地址。第三中情况是一个硬件中断(device interrupt),当一个硬件信号需要内核注意到的时候,例如:当磁盘硬件完成了读或者写请求。

这本书使用陷入trap作为通用的术语来描述这种情况。在发生陷入时,无论什么样的正在执行的代码随后都会被恢复【在中断发生之后】。换而言之,我们总是希望陷入是透明的;这对中断来说特别重要,因为这些中断的代码各种各样,不能预测。通常的顺序是一个陷入将CPU的控制权强制转换到内核;内核保存正在运行的程序的寄存器和其他的状态,所以中断之前执行的操作可以被恢复;内核执行对应的处理代码(例如:一个系统调用实现或者一个硬件驱动);内核恢复保存的状态并从陷入的位置返回用户态;并且原来的代码也会在它停止的地方被恢复。

xv6内核处理所有的陷入。这个对系统调用来说是很正常的。这对中断来说是非常有意义的,因为内核的隔离机制要求用户进程不能直接使用硬件,还因为只有内核保存了硬件执行时需要的状态。这对异常来说也十分重要,因为xv6从用户空间中响应通过杀死非法进程来响应所有的异常。

xv6陷入处理分四个阶段进行:CUP执行硬件的操作,一个为内核C代码做准备的汇编向量(中断向量),一个决定在陷入时做什么事情的C陷入控制程序,和一个系统调用或者一个硬件驱动服务例程。当这三种陷入的共同点是内核可以使用一段单一的代码处理所有的陷入,这使得可以很容易在三种不同的情况下将汇编向量和C陷入程序分离:从用户空间陷入,从内核空间陷入,和时钟中断。

4.1 RISC-V 的陷入机制 trap machinery

每一个RISC-V的CPU 都有一系列控制寄存器,内核通过向这些寄存器中写入信息来告诉这样处理陷入,并且内核可以读取这些寄存器的信息来找出一个已经出现的中断。RISC-V文档包含了所有这些事情。riscv.h(kernel/riscv.h:1)包含xv6使用的所用的定义。这是大部分寄存器的概述:

  • stvec:内核将陷入处理程序的地址写到这个寄存器上;然后RISC-V就会跳转到这里执行程序并处理陷入。
  • sepc:当一个陷入出现,RISC-V将保存程序计数器(program counter)在这里(因为pc之后会被stvec中的内容覆写)【因为程序计数器是指向了当前CPU下一个将要执行的指令的位置】。sret(从陷入中返回)指令将sepc复制到pc中。内核可以向sepc中写入信息来控制sret要跳转到哪里。
  • scause:内核从这里拿出一个数字,这个数字描述了陷入的原因。
  • sscratch:内核将一个值放到这里,这个值在内核每个开始一个陷入处理程序的时候会派上用场。
  • sstatussstatus中的SIE位控制是否允许硬件中断。如果内核清楚了SIE位,那么RISC-V将忽视硬件中断知道内核设置SIE。SPP位表示一个陷入来自用户态还是来自内核态,并且控制着sret返回到什么模式中

上面的寄存器和特权模式下的陷入处理程序有关,并且它们在用户态下不能被读写。在机器模式下,这对于陷入程序来说是一系列等效的控制寄存器;xv6只有在时钟中断这种特殊的情况发生时才能使用它们。

每一个CPU在一个多核芯片下都有它们自己的一系列寄存器,并且在任意一个给定的时刻下,可能会有不止一个CPU在处理陷入。

当它需要陷入时,RISC-V的硬件会对于所有的陷入都会执行下面的类型(除了时钟中断):

  1. 如果陷入是一个硬件中断并且sstatus的SIE位被清除了,将不执行下面的任何操作。
  2. 通过将sstatus的SIE位清除来禁止中断。
  3. pc拷贝到sepc
  4. sstatus的SPP位上保存当前进程所处的模式
  5. 设置scause以指出陷入的原因
  6. 将模式设置为特权模式
  7. 复制stvecpc
  8. 开始执行新的pc指向的程序

现在CUP不会切换内核页表,不会切换一个内核中的栈【我觉得是内核栈】,并且不会保存任何一个除了程序计数器以外的寄存器。内核软件必须执行这些任务。一个原因是CPU在陷入做尽量少的事情能够为软件提供灵活性;例如:一些操作系统在某些情况下不切换页表,这样可以提供性能。

你可能想知道CPU硬件的陷入处理情况是否可以进一步地进行简化。例如:支持CPU不切换程序计数器。然后一个陷入当它仍然在用户态执行的时候就能够切换到内核态。那些用户指令可以破坏内核的隔离机制,例如:通过修改satp寄存器,让他执行一个允许访问物理内存的页表。因此,CPU切换到一个内核指定的指令空间是很重要的,(即stevc)。

4.2 从用户空间陷入 Traps form user space

当我们正在用户空间中运行的时候,如果我们的程序发起了一个系统调用ecall指令,或者执行了某些非法操作,或者出现一个硬件中断的时候,一个陷入就有可能出现。一个从用户空间开始的陷入的高层路径是:uservec(kernle/trampoline.S:16),然后是usertrap(kernel/trap.c:37);而返回的是usertrapret(kernel/trap.c:90)然后是userret(kernel/trampoline.S:16)

从用户代码陷入比从内核中陷入更有难度,因为satp指向一个用户页表,这个用户页表并不能被映射在内核【应该不能被映射到该进程的内核栈中】,并且堆栈指针可能指向一个无效或者甚至是一个恶意的值。

因为RISC-V硬件在陷入的过程中不会切换页表,用户页表必须包含一个uservec的向量,即stvec指向的陷入向量指令。uservec必须切换satp让它指向内核页表;为了在切换之后能够继续执行指令,uservec必须将内核页表映射到和用户页表相同的地址上。

xv6使用一个包含uservectrampoline page满足了这些限制条件。xv6将trampoline page映射到内核页表和每一个用户页表上的相同的虚拟地址。这个虚拟地址是TRAMPOLINE(就像我们在图2.3和3.3看到的那样)。这个trampoline的内容都设置在trampoline.S上,并且(当正在执行用户代码的时候)stvec设置为uservec(kernel/trampoline.S:16)

uservec开始,所有的32位寄存器包含的值都是中断代码所有的。但是uservec需要能够修改一些寄存器以便设置satp并产生一些需要保存在寄存器中的地址。RISC-V提供了一种以sscratch寄存器形式的辅助手段。csrrw指令在uservec开始的时候会交换a0sscratch中的内容。现在用户代码的a0已经被保存了;uservec有一个寄存器a0来处理;然后a0包含了先前内核放在寄存器sscratch中的值。

uservec的下一个任务是保存用户寄存器。在进程用户空间之前,内核先前设置的sscratch会指向每一个进程的trapframe,它有空间保存了所有用户寄存器(kernel/proc.h:44)。因为satp仍然指向用户页表uservec需要trapframe被映射到用户空间。当创建每一个进程的时候,xv6为每一个进程的trapfame分配一个页,这个页在TRAMPOLINE的下面【TRAMPOLINE就是trampoline所在的页】。进程中的p->trapframe也是指向这个trapframe,虽然只是指向它的物理地址,但是内核可以通过内核页表使用它。

因此在交换了a0sscratch之后,a0保存一个指向当前进行的trapframe的指针。uservec现在保存所有的用户寄存器,包括用户的从sscratch中读取的a0

trapframe包含一个指向当前进程内核栈的指针,当前CPU的hartid【hard thread id 硬件线程id】,usertrap的地址,和内核页表的地址。uservec恢复这些值,切换satp到内核页表并调用usersatp

usersatp的工作就是决定引起trap的原因,执行它,然后返回kernel/trap.c:37。正如上文所说,它的第一个改变的就是stvec所以一个陷入当在内核的时候将会被kernelvec处理。它再一次保存sepcsepc保存了程序计数器),因为可能会有一个进程在usertrap中切换,这会导致sepc被覆写。如果陷入是一个系统调用,syscall会处理它;如果这是一个硬件中断,devintr会进行处理;否则就是一个异常,内核会将非法的进程杀死。系统调用路径+4,来保存用户的pc因为RISC-V一个系统调用的情况下,会将程序计数器指向ecall指令。在即将离开的时候,usertrap会检查进程是否被杀死了或者应该让出CPU【发生的是一个时钟中断】。

返回用户空间的第一步是调用usertrapret(kernel/trap.c:90)。这个函数设置RISC-V的控制寄存器来为一个将来来自用户空间的陷入做准备。这个行为改变了stvec,使他指向了uservec,准备uservec所依赖的trapframe字段,并且将sepc设置为先前保存的用户程序计数器。最后,usertrapret调用在trampline page上的userret,这个page被映射在用户和内核页表上;原因就是userret中的汇编代码将会切换页表。

usertrapretuserret的调用通过一个指向进程用户页表的指针,这个指针在寄存器a0中,并且TRAPFRAMEa1(kernel/trampoline.S:88)中。userretsatp切换到用户页表。回忆一下用户页表映射了trampoline pageTRAPFRAME,但是并没有其他的东西来自内核。再说一遍,实际上是trampoline page被映射到了用户页表和内核页表上的相同的虚拟地址,这就是为什么允许uservec在改变了satp之后还能一直执行。userret复制保存的用户寄存器a0以便以后和TRAPFRAM的交换。从这个点看,userret只能够使用的数据是寄存器的内容和trapframe中的内容。之后,userrettrapframe中恢复保存到用户寄存器,做最后一个a0sscratch的交换来恢复用户的a0寄存器并保存TRAPFRAME以便下一次陷入,并使用sret返回用户空间。

4.5 代码:调用系系统调用 Code: Calling system calls

第二章以执行系统调用exec(user/initcode.S:11)initcode.S结束。让我们看看用户调用如何在内核中实现exec系统调用。用户代码将exec中的参数放到寄存器a0a1中,并把系统调用号放在寄存器a7中。系统调用号匹配对应的syscalls列表中的条目(entry),这是一个函数指针列表(kernel/syscall.c:108)ecall指令陷入内核并执行uservecusertrap然后执行syscall,正如我们看到的那样。

syscall(kernel/syscall.c:133)会取回保存在trapframe a7中的系统调用号并使用它来在syscalls中索引。对于第一个系统调用,a7包含了SYS_exec(kernel/syscall.h:8),将导致一个对sys_exec的系统调用实现函数进行调用。

当系统调用实现函数返回,syscall将它的返回值记录在p->trapframe->a0。这将使原先的用户空间中的exec()调用能够返回这个值,因为C调用在RISC-V上都是将返回放在a0这个寄存器中。系统调用一般会返回负数来表示调用出错,而0或者整数表示调用成功。如果系统调用号是无效的,syscall会打印一个错误并返回0.

4.4 代码:系统调用参数 Code: System call argument

在内核中,系统调用的实现需要找到用户代码传递的参数。因为用户代码调用系统调用包装的函数,参数最开始是被放在寄存器中的,RISC-V中C调用一般将它们放在这里。内核陷入调用将用户寄存器保存到当前进程的trap frame中,内核可以在这里找到他们。【用户代码将参数放在当前进程的寄存器中,而内核又将当前进程的上下文(寄存器和相关的状态)保存在trap frame中】。argint函数,argaddr函数和argfd函数可以从trap frame中取回系统调用的第n个参数参数,并把它们转化为int pointer file descriptor。它们调用argraw来取回被保存在寄存器中的目标值(kernel/syscall.c:35)

一些系统调用传递指针作为参数,并且讷河必须使用这些指针来读写用户内存。exec系统调用,例如传递给内核一个指针数组指向用户空间中的字符串参数。这些指针面临两个挑战。首先,用户程序可能是有bug的或者是恶意的,并且可能传递给内核一个无效的指针或一个故意绕开内核访问内核空间而不是用户空间的指针。第二,xv6内核页表映射与用户页表的映射不同,所以内核不能够使用普通的指令来加载或保存来自用户空间的地址。

内核实现了能够安全将来自或传递到用户空间的数据进行转换的函数。fetchstr是一个例子(kernel/syscall.c:25)。文件系统调用例如:exec使用fetchstr来取回用户空间中的字符串文件名参数。fetchstr调用copyinstr来完成这个困难的工作。

copyinstr(kernel/vm.406)从在用户页表中的虚拟地址srcva中拷贝max个字节的数据到dst中。它使用walkaddr(这个函数会调用walk)在软件中对页表进行遍历从而找出srcva对应的物理地址。因为内核将所有的物理RAM地址映射到相同的内核虚拟地址,copyinstr能够直接将字符串字节从pa0拷贝到dstwalkaddr(kernel/vm.c:95)检查用户支持的虚拟地址【用户空间中的虚拟地址】是否是当前进程用户地址空间的一部分,所以程序不能绕开内核进入其他空间。一个相似的函数叫copyout,它将数据从内核拷贝到用户空间。

4.5 从内核空间陷入 Code : Traps from kernel space

xv6对CPU陷入寄存器的配置有一些不同,这取决于是在用户空间陷入还是在内核空间陷入。当内核在一个CPU上执行时,内核将stvec执行kernelvec(kernel/kernelvec.S:10)中的汇编代码。因为xv6已经处在内核中,kernelvec能够依赖satp来设置内核页表,并且在栈上,堆栈指针指向的是一个有效的内核栈。kernelvec保存所有寄存器,以便被中断代码最终能够没有干扰地被恢复。

kernelvec将寄存器保存在中断内核线程的栈上,这是很有意义的,因为寄存器的值属于这个线程。这个非常重要,如果陷入引起一个一个不同的线程进行切换--在这种情况下陷入实际上将返回新线程的栈,安全地将被中断的线程保存的寄存器留在了它的线程栈上。

在保存了寄存器后,kernelvec跳转到kerneltrap(kernel/trap.c:134)kerneltrap是为两种类型的陷入准备的:硬件中断和异常。它调用devintr(kernel/trap.c:177)来检查并处理请求。如果陷入不是硬件中断引起的,那么肯定是一个异常,并且如果出现在内核的话,这通常是一个致命的错误;内核调用panic并停止执行。

如果kerneltrap由于时钟中断被调用,并且一个进程的内核线程正在运行(而不是一个被调度的线程),kerneltrap调用yield来给其他线程一个运行的机会。在某些时间点上,一个其他的线程将会让出CPU,并且让我们的线程和它的kerneltrap再次恢复执行。第七章将会解释yeild发生了什么。

kerneltrap的工作完成了,它需要返回代码被陷入中断的地方。因为一个yield可能干扰保存在spec寄存器的值和保存在sstatus中的先前的代码的执行的模式【用户模式或者内核模式】,所以kerneltrap必须在开始前保存他们。现在它保存了那些控制寄存器并返回到kernelvec(kernel/kernelvec.S:48中。kernelvec将保存的寄存器从栈中弹出并执行sret,这个指令将sepc中的值复制到pc中并恢复被中断的内核代码。

思考如果kerneltrap由于时钟中断调用yield的时候,陷入返回是怎么发生的,这是很有意义的。

xv6设置一个CPU的stvec设置为kernelvec【指向kernelvec】,当CPU从用户空间进入到内核空间中;你能够在usertrap(kernel/trap.c:29)中看到这个过程。当内核正在执行但是stvec被设置成uservec的时候,会有一个窗口时间【不懂,继续看】,并且在这个窗口时间中禁止硬件中断至关重要。幸运的是,当它开始执行一个陷入的时候RISC-V总是禁止中断,并且xv6不会再打开他们直到在它设置stvec之后。

4.6 缺页异常 Page-fault exception

xv6对异常的处理十分单一:如果一个异常在用户空间中产生,内核就会杀死这个异常的进程。如果一个异常在内核中产生,内核就会调用panic。真正的操作系统经常通过很多有趣的方式来响应。

举个例子:很多内核使用缺页错误来实现copy-on-write(COW)写时复制 fork。为了解释什么是copy-on-write fork,我们考虑xv6的fork,在第三章讲到的。fork可以产生一个与父进程有相同的内存数据的子进程,通过调用uvmcopy(kernle/vm.c:309)来为子继承分配物理内存并且将父进程的内存空间拷贝进去。如果子进程和父进程能够共享父进程的内存,那么将会更有效率。一个简单的实现就是不工作,但是,直到它它们向共享的栈空间或对空间执行写操作时,将导致父进程和子进程相互干扰对方的执行。

父进程和子进程通过page faule驱动copy-one-write fork能够安全共享物理内存,当CPU不能将一个虚拟地址转换成一个物理地址的时候,CPU就会产生一个缺页中断(page-fault exception)。RISC-V有三种类型的缺页中断:加载页错误load page fault(当一个加载指令不能转换它的虚拟地址时),存储页错误stroe page fault(当一个存储指令不能转换它的虚拟地址时),指令页错误instruction page faults(当一条指令的地址不能被正确地转换时)。寄存器scause中的值表示缺页异常的类型,寄存器stval存放了不能被转换的地址。

COW fork的最基本的方案是:父进程和子进程最开始共享所有的物理页,但是只把他们映射为只读的。因此当子进程或父进程执行一个存储指令时,RISC-V的CPU会产生一个缺页异常。为了响应这个异常,内核对这些包含了非法地址的页进行拷贝。它会映射一个副本在子进程中读写,并映射另一个副本在父进程中读写。在更新了页表后,内核会恢复产生异常的进程中的那条产生异常的指令。因为内核已经更新了相关的PTE,这些PTE允许写操作,异常的指令将会顺利地执行。

这种COW方案对fork很好,因为子进程总是在调用了fork之后马上调用exec,用一个新的地址空间代替它原本的地址空间。在那种普遍的情况下,子进程将只经历一些页错误,并且内核能够便面做一个完整的复制。还有就是,COW fork是透明的:它们不需要修改应有程序就能够从中受益。

页表和页错误的结合除了COW fork 之外,还开辟了一系列有趣的可能性。另一种广泛使用的特性是:懒分配lazy allocation,它包括两个部分。第一,当一个应用程序调用sbrk时,内核增加该进行的地址空间,但是它会在页表中把那些新的地址标记为无效。第二,在一个页错误发生在这些地址上的时候,内核为它们分配物理内存并将他们映射到页表上。因为应用程序总是会申请比它们实际需要更多的内存,懒分配是一种很好的策略:内核只有在应用程序实际需要使用到的时候才分配内存。像COW fork一样,内核能够透明地为应用程序实现这个功能。

另一种利用页错误实现的广泛使用的功能是磁盘分页paging from disk。如果应用程序需要比可用的物理RAM更多的内存,内核可以移除一些页:将它们写道一个存储硬件中,例如:一个磁盘,并将它的PTE置为无效PTE_V。如果一个应用程序读写一个被移除的页,CPU将会经历一个页错误。内核然后可以检查这个错误的地址。如果这个地址属于一个在磁盘中的页,那么内核会 分配一个物理页,从磁盘中将相应的页读取到内存中,将对应的PTE更新为有效的,同时指向那块内存,通过恢复应用程序的运行。为了给这个新的页制造空间,内核可能会将其他的页移除。这个功能不需要应用程序的改变,并且如果当应用程序拥有局部引用性的时候可以运行地很好(例如:在任何一个时间它们只是用他们内存的一部分的时候)。

4.7 Real world

如果内核内存被映射到每一个进程的页表(通过对应的PTE权限位)上,进程对特殊的trampoline page的需要能够被消除。同时也能够消除对页表切换的需要,当从用户空间陷入到内核的时候。这反过来又允许系统调用在内核中的实现可以利用当前用户进程内存中被映射的内存【内核内存】,允许内核代码直接使用用户空间中的指针。很多操作系统有使用这些方法来增加系统的效率。xv6没有使用它们是为了减少由于使用用户空间中的指针而导致的安全漏洞出现在内核的机会,并且为了减少一些复杂性,因为那样可以保证需要用户空间中的虚拟地址和内核的虚拟地址不会重叠。

第四章完

time : 2024/3/11 18:04

马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
other
1
https://gitee.com/CZY-3101298914/mit6.-s086.git
git@gitee.com:CZY-3101298914/mit6.-s086.git
CZY-3101298914
mit6.-s086
mit6.S086
master

Search

344bd9b3 5694891 D2dac590 5694891