Skip to content

Latest commit

 

History

History
134 lines (73 loc) · 19 KB

第4章 陷入与系统调用.md

File metadata and controls

134 lines (73 loc) · 19 KB

有三种类型的事件会让CPU停止正常执行的指令,强制切换到指定的代码,处理这些事件。一种是系统调用,当用户程序执行ecall指令来让内核为它做一些事。另一种是异常:一个指令(用户或内核)做了一些非法的事,比方说除以0或者使用无效的虚拟地址。第三种是设备中断,当设备发信号说它需要关注时,比方说当磁盘硬件完成了读或写的请求。

本书使用trap作为这些情况的通用术语。通常,不管trap发生时在执行什么代码,后面都需要恢复,这些代码不需要知道发生了什么不同的事。就是说,我们通常需要trap是透明的:这对中断来说尤其重要,被中断掉的代码不希望中断发生。通常的执行顺序是,trap强制将控制转移到内核;内核保存寄存器和其他的状态,以便之后执行能恢复;内核执行合适的处理代码(例如系统调用实现或设备驱动);内核重载保存的状态,从trap中返回;最初的代码从它离开的位置恢复。

xv6内核处理所有的trap。这对系统调用来说自然而然。对中断也有意义,因为隔离要求用户进程不直接使用设备,而且内核拥有设备处理所需要的状态。对异常同样有意义,因为xv6对所有来自用户空间的异常都是同样的回应:干掉产生异常的程序。

xv6的trap处理分四步进行:1. RISC-V CPU采取的硬件动作,2. 一个汇编写的向量表通向内核的C代码,3. C语言trap handler决定怎么处理这个trap,4. 系统调用或设备驱动服务例行程序。虽然三种trap类型的共同点说明内核能够通过一条代码路径处理所有的trap,事实证明,对三种不同的实例分别使用不同的汇编向量表更方便:用户空间trap,内核空间trap,计时器中断。

distinct

4.1 RISC-V 陷入机制

每个RISC-V CPU都有一组控制寄存器,内核通过写入这些寄存器告诉CPU怎样处理trap,内核也可以读取这些寄存器来知道trap的发生。RISC-V文件详述了整个过程。riscv.h(kernel/riscv.h:1)包含xv6使用的定义。下面是大部分主要寄存器的说明:

  • stvec:内核将trap handler的地址写在这;RISC-V跳到这里来处理一个trap。
  • sepc:当trap发生时,RISC-V将程序计数器保存在这(因为pc会被stvec覆盖掉)。sret(从trap返回)指令将sepc拷贝到pc。内核可以通过写入sepc来控制sret返回到哪里。
  • scause:RISC-V将引发trap的原因编号放在这。
  • sscratch:内核把一个值放在这,它将在trap handler最开始派上用场。
  • sstatus:sstatus中的SIE位控制了设配中断是否被允许。如果内核清空了SIE,RISC-V将推迟设备中断,直到内核设置SIE位。SPP位表示trap是来自用户模式还是管理者模式,也控制了sret返回到什么模式。

上面的寄存器与管理者模式下处理的trap有关,它们不能在用户模式下被读写。在机器模式下也有一组等价的寄存器,xv6只在特殊的定时器中断下使用它们。

多核芯片上的每个CPU都有它自己的一组寄存器,在任意给定的时间可能不止一个CPU在处理一个trap。

当需要强制陷入时,RISC-V硬件对所有trap类型(除了定时器中断)执行下列步骤:

  1. 如果trap是设备中断,且sstatus中的SIE位被清除,则不再向下执行
  2. 通过清除SIE禁止中断
  3. 将pc拷贝到sepc中
  4. 在sstatus中的SPP位保存当前模式(用户或管理者)
  5. 设置scause来反映trap的触发原因
  6. 将模式设置为管理者
  7. 将stvec拷贝到pc
  8. 在新的pc处开始执行

请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc外的任何寄存器。内核软件必须完成这些工作。CPU在trap过程中只做最少工作的一个原因,是为软件提供灵活性;比方说,有些操作系统不在某些情况下不需要页表切换,这能够提高性能。

你可能会好奇,是否还能进一步简化CPU硬件的trap处理过程。比方说,假设CPU不切换程序计数器。然后,trap可能切换到管理者模式,却仍然运行用户指令。这些用户指令将破坏用户、内核之间的隔离,举例来说,通过修改satp寄存器来指向一个允许访问所有物理内存的页表。因此,CPU切换到内核指定的指令地址即stvec非常重要。

comes in handy defer

4.2 来自用户空间的trap

trap可能发生在用户空间,当用户程序发起系统调用(ecall指令),或做了非法的事情,或者设备中断。来自用户空间的trap的高层路径是:uservec(kernel/trampoline.S:16),然后usertrap(kernel/trap.c:37);返回时,usertrapret(kernel/trap.c:90),然后userret(kernel/trampoline.S:16)。

来自用户代码的的traps比来自内核的更复杂,因为satp指向用户页表,而不映射内核,而且栈指针可能包含一个无效甚至恶意的值。

因为RISC-V硬件在trap中不切换页表,用户页表必须包含一个指向uservec的映射,uservec是stvec指向的trap向量表的所有指令。uservec切换satp使其指向内核页表;为了在切换之后继续执行指令,uservec在内核页表与用户页表中必须位于同一个地址。

xv6使用一个包含uservec的跳板页来满足这些限制条件。xv6在内核页表中与每个用户页表中都将跳板页应设在一个相同的虚拟地址上。这个虚拟地址就是TRAMPOLINE(正如我们在图2.3与图3.3中看到的)。跳板内容在trampoline.S中设置,而且,(当执行用户代码时)stvec被设置为uservec(kernel/trampoline.S:16)。

当uservec开始执行时,所有32个寄存器中包含的都是被打断的代码所拥有的值。但是uservec需要修改一些寄存器来设置satp,并生成存放寄存器的地址。RISC-V以sscratch寄存器的形式提供了帮助。uservec中最开始的指令csrrw交换了a0与sscratch寄存器的内容。现在用户代码中的a0得到了保存,uservec也有了一个寄存器a0可以使用,而且a0包含了内核之前放在sscratch寄存器中的值。

uservec接下来的任务是保存用户寄存器。在进入用户空间之前,内核设置sscratch,指向一个每个进程的trapframe,这个帧有空间保存所有的用户寄存器(kernel/proc.h:44)。因为satp仍然指向用户页表,uservec需要这个trapframe在用户页表中可以映射。当创建每个进程时,xv6为这个进程的trapframe分配一个页,然后把它跟用户虚拟地址TRAPFRAME建立映射关系,这个虚拟地址紧邻TRAMPOLINE,在其下面。进程的p->trapframe也指向trapframe,它指向trapframe的物理地址,这样内核就能通过内核页表使用trapframe。

因此,在交换a0和sscratch后,a0保存了一个指向当前进程trapframe的指针。uservec现在将所有的用户寄存器保存在那里,包括从sscratch中读到的用户的a0。

trapframe包含了指向当前进程内核栈的指针,当前CPU的hartid,usertrap的地址,以及内核页表的地址。uservec检索这些信息,将satp切换到内核页表,然后调用usertrap。

usertrap的工作是确定trap的触发原因,执行它,然后返回(kernel/trap.c:37)。正如上面所提到的,它首先修改stvec使得trap将被kernelvec处理。它再次保存sepc(被保存永和程序计数器),因为在usertrap中可能有进程切换,这会导致sepc被覆盖。如果trap是一个系统调用,syscall处理它;如果是用户中断,devintr;如果是异常,内核直接杀掉出错的进程。系统调用路线会在保存的用户pc上加4,因为RISC-V在系统调用时,将程序指针留在了ecall指令处。在离开时,usertrap会检查是否进程已经被杀掉,或者是否应该CPU让权(如果trap是计时器中断)。

返回用户空间的第一步是调用usertrapret(kernel/trap.c:90)。这个函数设置好RISC-V的控制寄存器,准备好迎接下一个来自用户空间的trap。这包括修改stvec让它指向uservec,准备uservec依赖的trapframe域,以及设置sepc为之前保存的用户程序计数器。最后,usertrapret调用用户或内核页表跳板页上的userret;这么说的原因是userret中的汇编代码会切换页表。

usertrapret对userret的调用在a0中传递了指向进程用户页表的指针,在a1中传递了指向TRAPFRAME的指针(kernel/trampoline.S:88)。userret将satp切换到进程的用户页表。回忆一下,用户页表除了trampoline页和TRAPFRAME外,没有映射内核的任何东西。再一次,trampoline页在用户页表和内核页表被映射到同一虚拟地址的事实,使得uservec能够在修改satp后继续执行。userret将trapframe所保存的用户a0复制到sscratch来为后面与TAPFRAME的交换做准备。从此开始,userret能使用的数据只有寄存器内容和trapframe中的内容。下一个userret从trapframe中恢复保存的用户寄存器,最后交换一次a0和sscratch来恢复用户a0,为下一个trap保存TRAPFRAME,然后调用sret返回用户空间。

retrieve

4.3 代码:使用系统调用

第二章截止于initcode.S调用exec系统调用(user/initcode.S:11)。我们来看一下,用户的调用是怎样调到内核中exec系统调用的实现的。

用户代码将exec的参数放在a0和a1中,将系统调用号放在a7中。系统调用号与systemcalls数组中的项匹配,systemcalls是一个函数指针表(kernel/syscall.c:108)。ecall指令陷入内核,然后执行uservec,usertrap,然后是syscall,正如我们上面所看到的。

syscall(kernel/syscall.c:133)从trapframe所保存的a7中找到系统调用号,用它索引对应的系统调用。对第一个系统调用来说,a7包含了SYS_exec(kernel/syscall.h:8),转到对系统调用实现函数sys_exec的调用。

当系统调用实现函数返回时,syscall将它的返回值记录在p->trapframe->a0中。这将使得最初用户空间调用的exec()函数返回这个值,因为RISC-V中C语言函数调用的惯例是将返回值放在a0寄存器中。系统调用返回值的惯例是用负值表示错误,0或正数表示成功。如果系统调用号是无效的,syscall打印错误并返回-1。

convention

4.4 代码:系统调用参数

内核中的系统调用实现需要找到用户代码传来的参数。因为用户代码调用的是系统调用包装程序,参数最初位于RISC-V C调用通常放置参数的地方:寄存器中。内核trap代码将用户寄存器保存到当前进程的trapframe中,这样内核代码就能找到它们。函数argint,argaddr和argfd从trapframe中找回第n个系统调用参数,作为整数,指针或是文件描述符。它们都调用argraw来找回保存的对应用户寄存器(kernel/syscall.c:35)。

有些系统调用传递指针作为参数,然后内核必须使用这些指针读写用户内存。举例来说,exec系统调用将一个指向用户空间字符串参数的指针数组传给内核。这些指针带来了两个挑战。首先,用户程序可能有bug或是恶意的,可能传给内核无效的指针,也可能欺骗内核,使用这个指针访问内核内存而不是用户内存。第二,xv6内核页表映射与用户页表不同,所以内核不能使用普通指令来从用户提供的地址加载或存储内容。

内核实现了能够安全地从/向用户提供的地址拷贝数据的函数。fetchstr是一个例子(kernel/syscall.c:25)。exec之类的文件系统调用使用fetchstr来从用户空间取回字符串类型的文件名参数。fetchstr调用copyinstr来完成困难的工作。

copyinstr(kernel/vm.c:406)从虚拟地址srcva向dst拷贝最多max字节的数据,srcva是来自用户页表的虚拟地址。它使用walkaddr(会调用walk)在软件中查找页表,确定srcva对应了物理地址pa0。因为内核将所有物理RAM地址映射在相同的内核虚拟地址上,copyinstr能够直接从pa0往dst拷贝字符串数据。walkaddr(kernel/vm.c:95)检查用户提供的虚拟地址,保证它是这个进程的用户地址空间的一部分,从而避免程序欺骗内核读取别处的内存。copyout与之相似,从内核向用户提供的地址拷贝数据。

implementation retrieve pose

4.5 来自内核空间的trap

根据trap发生时执行的是用户还是内核代码,xv6注册CPU trap寄存器的过程有一定的不同。当内核运行在CPU上时,内核将stvec指向kernelvec的汇编代码(kernel/kernel.S:10)。因为xv6已经在内核中,kernelvec能够使用已经指向内核页表的satp,也能使用已经指向有效内核栈的栈指针。kernelvec保存了所有的寄存器,以便被打断的代码最后能够不受干扰地恢复执行。

kernelvec将寄存器保存在被打断的内核线程的栈上,这是有效的因为寄存器的值属于那个线程。这一点特别重要,因为trap造成线程的切换 - 这时trap实际上将在新线程的栈上返回,让被打断进程中的寄存器安全地留在它的线程上。

kernel在保存好寄存器后,跳转到kerneltrap(kernel/trap.c:134)。kerneltrap用于处理两种类型的trap:设备中断和异常。它调用devintr(kernel/trap.c:177)来检查并处理前者。如果trap不是设备中断,它一定是异常,在xv6内核中出现异常时通常是一个严重错误,内核会调用panic函数并停止执行。

如果kerneltrap是因为计时器中断被调用,而且一个进程的内核线程正在执行(不是调度线程),kerneltrap会调用yield,把运行机会让给其他线程。到某些时候,那些线程将会让步,让我们的线程和它的kerneltrap再次恢复运行。第7章解释了yield函数中发生了什么。

当kerneltrap的工作完成后,它需要返回被trap打断的代码。因为yield函数可能会扰乱保存的sepc和sstatus中保存的之前模式,kerneltrap在刚开始时就保存了它们。它现在恢复这些控制寄存器,然后返回到kernelvec(kernel/kernelvec.S:48)。kernelvec将保存的寄存器从线程栈弹出,然后执行sret。sret会将sepc拷贝到pc,恢复被打断的内核代码。

有一个问题值得认真思考,如果因为定时器中断,kerneltrap调用了yield,trap将如何返回?

当CPU从用户空间进入到内核,xv6把CPU的stvec设置为kernelvec;你可以在usertrap(kernel/trap.c:29)中看到这个过程。在这之前有一个时间窗口,内核仍在执行,但stvec还是uservec,这时,禁止设备中断至关紧要。幸运的是RISC-V开始处理trap时都会禁掉中断,xv6会在stvec设置完成之后才开启它们。

eventually thinking through

4.6 页错误异常

xv6对异常的响应非常无聊:如果用户空间发生了异常,内核会干掉出错的进程。如果内核发生了异常,内核会panic。真实地操作系统会有更多有趣的响应方式。

举个例子,很多内核都使用页错误来实现写时拷贝(copy-on-write,COW)fork。为了解释写时拷贝fork,我们来考虑下第3章中所讲的xv6的fork。fork调用uvmcopy(kernel/vm.c:309)为子进程分配物理内存,然后即将父进程的内存拷贝过去,使子进程与父进程拥有相同的内存内容。如果子进程能与父进程共享物理内存,这个过程将更高效。然而,直接这样实现是不行的,因为当它们向共享的栈或者堆写入时,双方的执行都会被打乱。

父子进程之间可以使用写时拷贝fork共享物理内存,这是有页错误驱动的。当CPU无法将虚拟内存翻译成物理内存时,会产生一个页错误异常。RISC-V有三种类型的页错误:加载页错误(当load指令不能翻译虚拟地址),存储页错误(当store指令不能翻译虚拟地址),指令页错误(当指令的地址无法翻译时)。scause寄存器中的值表示页错误的类型,stval寄存器中包含了无法翻译的地址。

COW fork的基本计划是,父子进程最初共享所有的物理页表,但将它们映射为只读。因此,当子进程或父进程执行store指令时,RISC-V CPU将产生一个页错误异常。在响应这个异常时,内核将包含错误的地址拷贝了一份。它将一份可读可写的拷贝映射到子进程地址空间,另一份可读可写拷贝映射给福进程地址空间。更新完页表后,内核在引起错误的指令处恢复出错的进程。因为内核已经将对应的PTE更新为可写,出错的指令现在就不会报错了。

COW计划在fork中运行良好,因为通常子进程在fork之后会马上调用exec,将它的地址空间替换为新的地址空间。在这种一般情况下,子进程只需经历几个页错误,内核就能避免完整的拷贝。此外,COW是透明的:应用程序不需要改任何东西就能受益。

页表和页错误的结合为一大批有趣的除COW fork以外的用法打开了大门。另一个广泛应用的特性叫做懒分配,包含两部分。首先,当应用调用sbrk,内核增加了地址空间,但将页表中新加的地址标记为不可用。第二,在新加地址的一个页错误中,内核才分配物理内存,然后将它映射到页表中。因为应用程序经常申请比实际需求更多的内存,懒分配非常有效:内核只在应用真的用到内存时才给它分配。像COW fork一样,内核可以实现这项特性,而对应用完全透明。

但另一项利用页错误的广泛使用的特性是从硬盘分页。如果应用需要比可用物理RAM更多的内存,内核能够驱逐一些页:把它们写到硬盘之类的存储设备上,并将他们的PTE标记为不可用。如果应用读或写一个被驱逐的页,CPU将产生页错误。内核能够检查出错的地址。如果地址属于硬盘上的一个页,内核会分配一个物理内存页,从硬盘把页读到内存中,将PTE更新为可用,并指向那块内存,然后恢复应用。为了给那个页腾出空间,内核可能必须驱逐另一个页。这个特性不需要应用做改动,在应用有引用位置时工作良好(即,它们在运行时只使用他们内存的一部分)。

其他结合页表和页错误异常的特性还包括自动扩展栈和内存映射文件。

evict inspect

4.7 真实世界

如果内核内存被映射到每个进程的用户页表中(用合适的权限标记),特殊的跳板页的就不需要了。从用户空间陷入到内核时的页表切换也不再需要。这反过来使得内核中的系统调用实现能够使用当前进程映射的用户空间内存,从而使内核代码能够直接解引用用户指针。很多操作系统都使用这种思想来提高效率。xv6没有采用这种思想,从而减少了随意使用用户指针带来的安全性bug出现的可能,同时也减少了保证用户和内核虚拟地址不重叠带来的复杂性。

inadvertent