有三种事件会引起CPU将正在执行的普通指令暂时搁置并强制将控制权转换到一些特定的处理这些事件的代码中。一种情况是一个系统调用,当一个用户应用程序执行ecall
指令让内核为它做某些事情。另一种情况就是一个异常(exception):一个(用户态或内核)指令做了一些非法的事情,例如:除零或者使用了一个非法的虚拟地址。第三中情况是一个硬件中断(device interrupt),当一个硬件信号需要内核注意到的时候,例如:当磁盘硬件完成了读或者写请求。
这本书使用陷入trap
作为通用的术语来描述这种情况。在发生陷入时,无论什么样的正在执行的代码随后都会被恢复【在中断发生之后】。换而言之,我们总是希望陷入是透明的;这对中断来说特别重要,因为这些中断的代码各种各样,不能预测。通常的顺序是一个陷入将CPU的控制权强制转换到内核;内核保存正在运行的程序的寄存器和其他的状态,所以中断之前执行的操作可以被恢复;内核执行对应的处理代码(例如:一个系统调用实现或者一个硬件驱动);内核恢复保存的状态并从陷入的位置返回用户态;并且原来的代码也会在它停止的地方被恢复。
xv6内核处理所有的陷入。这个对系统调用来说是很正常的。这对中断来说是非常有意义的,因为内核的隔离机制要求用户进程不能直接使用硬件,还因为只有内核保存了硬件执行时需要的状态。这对异常来说也十分重要,因为xv6从用户空间中响应通过杀死非法进程来响应所有的异常。
xv6陷入处理分四个阶段进行:CUP执行硬件的操作,一个为内核C代码做准备的汇编向量(中断向量),一个决定在陷入时做什么事情的C陷入控制程序,和一个系统调用或者一个硬件驱动服务例程。当这三种陷入的共同点是内核可以使用一段单一的代码处理所有的陷入,这使得可以很容易在三种不同的情况下将汇编向量和C陷入程序分离:从用户空间陷入,从内核空间陷入,和时钟中断。
每一个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
:内核将一个值放到这里,这个值在内核每个开始一个陷入处理程序的时候会派上用场。sstatus
:sstatus
中的SIE位控制是否允许硬件中断。如果内核清楚了SIE位,那么RISC-V将忽视硬件中断知道内核设置SIE。SPP位表示一个陷入来自用户态还是来自内核态,并且控制着sret
返回到什么模式中上面的寄存器和特权模式下的陷入处理程序有关,并且它们在用户态下不能被读写。在机器模式下,这对于陷入程序来说是一系列等效的控制寄存器;xv6只有在时钟中断这种特殊的情况发生时才能使用它们。
每一个CPU在一个多核芯片下都有它们自己的一系列寄存器,并且在任意一个给定的时刻下,可能会有不止一个CPU在处理陷入。
当它需要陷入时,RISC-V的硬件会对于所有的陷入都会执行下面的类型(除了时钟中断):
sstatus
的SIE位被清除了,将不执行下面的任何操作。sstatus
的SIE位清除来禁止中断。pc
拷贝到sepc
中sstatus
的SPP位上保存当前进程所处的模式scause
以指出陷入的原因stvec
到pc
中pc
指向的程序现在CUP不会切换内核页表,不会切换一个内核中的栈【我觉得是内核栈】,并且不会保存任何一个除了程序计数器以外的寄存器。内核软件必须执行这些任务。一个原因是CPU在陷入做尽量少的事情能够为软件提供灵活性;例如:一些操作系统在某些情况下不切换页表,这样可以提供性能。
你可能想知道CPU硬件的陷入处理情况是否可以进一步地进行简化。例如:支持CPU不切换程序计数器。然后一个陷入当它仍然在用户态执行的时候就能够切换到内核态。那些用户指令可以破坏内核的隔离机制,例如:通过修改satp
寄存器,让他执行一个允许访问物理内存的页表。因此,CPU切换到一个内核指定的指令空间是很重要的,(即stevc
)。
当我们正在用户空间中运行的时候,如果我们的程序发起了一个系统调用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使用一个包含uservec
的trampoline 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
开始的时候会交换a0
和sscratch
中的内容。现在用户代码的a0
已经被保存了;uservec
有一个寄存器a0
来处理;然后a0
包含了先前内核放在寄存器sscratch
中的值。
uservec
的下一个任务是保存用户寄存器。在进程用户空间之前,内核先前设置的sscratch
会指向每一个进程的trapframe
,它有空间保存了所有用户寄存器(kernel/proc.h:44)
。因为satp
仍然指向用户页表uservec
需要trapframe
被映射到用户空间。当创建每一个进程的时候,xv6为每一个进程的trapfame
分配一个页,这个页在TRAMPOLINE
的下面【TRAMPOLINE
就是trampoline
所在的页】。进程中的p->trapframe
也是指向这个trapframe
,虽然只是指向它的物理地址,但是内核可以通过内核页表使用它。
因此在交换了a0
和sscratch
之后,a0
保存一个指向当前进行的trapframe
的指针。uservec
现在保存所有的用户寄存器,包括用户的从sscratch
中读取的a0
。
trapframe
包含一个指向当前进程内核栈的指针,当前CPU的hartid
【hard thread id 硬件线程id】,usertrap
的地址,和内核页表的地址。uservec
恢复这些值,切换satp
到内核页表并调用usersatp
。
usersatp
的工作就是决定引起trap
的原因,执行它,然后返回kernel/trap.c:37
。正如上文所说,它的第一个改变的就是stvec
所以一个陷入当在内核的时候将会被kernelvec
处理。它再一次保存sepc
(sepc
保存了程序计数器),因为可能会有一个进程在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
中的汇编代码将会切换页表。
usertrapret
对userret
的调用通过一个指向进程用户页表的指针,这个指针在寄存器a0
中,并且TRAPFRAME
在a1(kernel/trampoline.S:88)
中。userret
将satp
切换到用户页表。回忆一下用户页表映射了trampoline page
和 TRAPFRAME
,但是并没有其他的东西来自内核。再说一遍,实际上是trampoline page
被映射到了用户页表和内核页表上的相同的虚拟地址,这就是为什么允许uservec
在改变了satp
之后还能一直执行。userret
复制保存的用户寄存器a0
以便以后和TRAPFRAM
的交换。从这个点看,userret
只能够使用的数据是寄存器的内容和trapframe
中的内容。之后,userret
从trapframe
中恢复保存到用户寄存器,做最后一个a0
和sscratch
的交换来恢复用户的a0
寄存器并保存TRAPFRAME
以便下一次陷入,并使用sret
返回用户空间。
第二章以执行系统调用exec(user/initcode.S:11)
的initcode.S
结束。让我们看看用户调用如何在内核中实现exec
系统调用。用户代码将exec
中的参数放到寄存器a0
到a1
中,并把系统调用号放在寄存器a7
中。系统调用号匹配对应的syscalls
列表中的条目(entry),这是一个函数指针列表(kernel/syscall.c:108)
。ecall
指令陷入内核并执行uservec
和usertrap
然后执行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.
在内核中,系统调用的实现需要找到用户代码传递的参数。因为用户代码调用系统调用包装的函数,参数最开始是被放在寄存器中的,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
拷贝到dst
。walkaddr(kernel/vm.c:95)
检查用户支持的虚拟地址【用户空间中的虚拟地址】是否是当前进程用户地址空间的一部分,所以程序不能绕开内核进入其他空间。一个相似的函数叫copyout
,它将数据从内核拷贝到用户空间。
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
之后。
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更新为有效的,同时指向那块内存,通过恢复应用程序的运行。为了给这个新的页制造空间,内核可能会将其他的页移除。这个功能不需要应用程序的改变,并且如果当应用程序拥有局部引用性的时候可以运行地很好(例如:在任何一个时间它们只是用他们内存的一部分的时候)。
如果内核内存被映射到每一个进程的页表(通过对应的PTE权限位)上,进程对特殊的trampoline page
的需要能够被消除。同时也能够消除对页表切换的需要,当从用户空间陷入到内核的时候。这反过来又允许系统调用在内核中的实现可以利用当前用户进程内存中被映射的内存【内核内存】,允许内核代码直接使用用户空间中的指针。很多操作系统有使用这些方法来增加系统的效率。xv6没有使用它们是为了减少由于使用用户空间中的指针而导致的安全漏洞出现在内核的机会,并且为了减少一些复杂性,因为那样可以保证需要用户空间中的虚拟地址和内核的虚拟地址不会重叠。
第四章完
time : 2024/3/11 18:04
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。