MIT6.S081 Chap4:陷入与系统调用

430 阅读25分钟

三种事件会让CPU停止指令的正常执行,并强制将控制权转移给处理事件的特殊代码。下面是这三种事件:

  • 系统调用,这时用户程序执行ecall指令,来让内核为它做一些事情
  • 异常,一个指令(用户指令或内核指令)做了一些非法的事情,比如说除以0或者使用无效的虚拟地址
  • 设备中断,当设备发出信号表明该设备需要关注时,比如当磁盘硬件完成了读或写请求时

之后用trap作为这些情况的通用术语。通常,不管trap发生时在执行什么代码,稍后都需要恢复,并且不需要知道发生了什么特别的事。就是说,我们通常需要trap是透明的:这对于设备中断尤其重要,中断的代码通常不希望中断发生。

通常的执行顺序是,trap强制将控制转移到内核内核保存寄存器和其他状态,以便之后可以继续执行;内核执行合适的处理代码(比如,系统调用或设备驱动程序);内核恢复保存的状态,从trap中返回;原始代码从它停止的地方继续执行。

xv6内核处理所有的traptrap不会传递给用户代码。在内核中处理trap对于系统调用来说是很自然的。这对于中断也有意义,因为隔离要求用户进程不直接使用设备,只允许内核使用设备,而且内核拥有设备处理所需要的状态,并且内核是一种在多个进程之间共享设备的便利机制。 对于异常同样也是有意义的,因为xv6对所有来自用户空间的异常都是同样的回应:kill掉产生异常的程序。

xv6的trap处理分四个阶段进行:

  1. RISC-V CPU采取的硬件动作
  2. 为内核C代码做准备的一些汇编指令
  3. 通过C语言函数trap handler决定怎么处理这个trap
  4. 系统调用或设备驱动程序服务例程

虽然这三种trap类型的共同点说明内核可以用一条代码路径处理所有的trap,但是对于三种不同的情况使用不同的代码会更方便:来自用户空间的traps、来自内核空间的traps和定时器中断

处理trap的内核代码(汇编或C)通常被称为处理程序(handler);第一个handler指令通常用汇编(而不是C)编写,通常被称为vector

1. RISC-V陷入机制

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

  • stvec:内核将traphandler的地址写在这,RISC-V跳转到stvec中的地址来处理trap
  • sepc:当trap发生时,RISC-V将程序计数器保存在这里(因为程序计数器pc随后会被stvec中的值覆盖)。sret(从trap返回)指令将sepc复制到pc。内核可以通过写入sepc来控制sret返回到哪里
  • scause:RISC-V将引发trap的原因编号保存在这里
  • sscratch:内核在这里放置了一个值,这个值在trap handler开始时很有用
  • sstatussstatus中的SIE位(SIE bit)控制了是否启用设备中断。如果内核清除了SIE(不设置SIE),RISC-V将推迟设备中断,直到内核设置SIE。SPP位表示trap来自用户模式还是管理员模式,并控制sret返回到哪种模式

上面的寄存器与管理员模式下处理的trap有关,它们不能在用户模式下读写。对于在机器模式下处理的trap,有一组类似的控制寄存器;xv6只在特殊的定时器中断下使用它们

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

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

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

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

值得思考的是,上面列出的任何步骤是否可以省略。虽然在某些情况下,一个更简单的执行序列是可以工作的,但是在一般情况下,一些步骤被忽略是危险的。比如,假设CPU没有切换程序计数器pc,那么来自用户空间的trap可以切换到管理员模式,却仍然运行用户指令。这些用户指令将破坏用户、内核之间的隔离,举例来说,通过修改satp寄存器来指向一个允许访问所有物理内存的页表。因此,CPU切换到内核指定的指令地址(即stvec)非常重要。

2. 来自用户空间的Traps

xv6处理trap的方式不同,这取决于它是在内核中执行还是在用户代码中执行。

如果用户程序进行系统调用(ecall指令),或做了非法的事情,或者设备中断,那么在用户空间执行时可能会发生trap

来自用户空间的trap的高层路径是:uservec(kernel/trampoline.S),然后是usertrap(kernel/trap.c);返回时,usertrapret(kernel/trap.c),然后userret(kernel/trampoline.S)

来自用户代码的trap比来自内核的更复杂

xv6的trap处理设计的一个主要限制是:RISC-V硬件在强制trap时不会切换页表。这意味着stvec中的trap handler程序地址必须在用户页表中有一个有效的映射。再者,xv6的trap handler代码需要切换到内核页表,为了能够在切换后继续执行,内核页表还必须有stvec指向的处理程序的映射

即用户页表必须包含一个指向uservec的映射,uservecstvec指向的trap向量表的所有指令,uservec切换satp使其指向内核页表;为了在切换后继续执行指令,uservec在内核页表与用户页表中必须位于同一个地址

xv6使用一个trampoline页面来满足这些需求。trampoline页面包含uservecuservecstvec指向的xv6trap处理代码。xv6在内核页表与每个用户页表中都将trampoline页面映射在一个相同的虚拟地址上。这个虚拟地址就是TRAMPOLINE,它位于虚拟地址空间的末端,因此它将位于程序自己使用的内存之上。trampoline页面内容在trampoline.S中设置,而且,当执行用户代码时,stvec被设置为uservec(kernel/trampoline.S)

因为trampoline页面被映射在用户页表中,使用PTE_U标志,使得trap可以在管理员模式下执行。因为trampoline页面被映射到内核地址空间中的相同地址,所以trap handler在切换到内核页表后可以继续执行。

uservec的代码(trap handler)在trampoline.S中。当uservec开始执行时,所有32个寄存器中包含的都是被中断用户代码所拥有的值。但是uservec需要修改一些寄存器来设置satp,并生成存放寄存器的地址。即这32个值需要保存在内存中的某个地方,以便在trap返回到用户空间时可以恢复它们。存储到内存需要使用一个寄存器来保存地址,但目前还没有通用寄存器可用!

注意,执行uservec之前,sscratch寄存器中存放的是指向进程trapframe的指针。

幸运的是,RISC-V以sscratch寄存器的形式提供了帮助。uservec开头的csrrw指令交换a0sscratch的内容。现在用户代码的a0保存在sscratch中;从而uservec有一个寄存器a0可以使用;a0包含内核先前放在sscratch中的值。

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

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

trapframe包含当前进程的内核堆栈的地址,当前CPU的 hartid,usertrap函数的地址,以及内核页表的地址。uservec检索这些信息,将satp切换到内核页表,并调用usertrap

usertrap的工作是确定trap的触发原因,处理它,然后返回(kernel/trap.c)。它首先改变stvec,这样在内核中的trap将由kernelvec而不是uservec处理。它再次保存sepc寄存器(保存的用户程序计数器),因为usertrap中可能有进程切换,而那个进程可能会返回到用户空间,这会导致sepc被覆盖。如果trap是一个系统调用,usertrap调用syscall处理它;如果是设备中断,devintr;如果是异常,内核会终止出错的进程。系统调用路径将保存的用户程序计数器加4,因为RISC-V在系统调用时,将程序指针指向ecall指令,但用户代码需要在后续指令处继续执行。在退出时,usertrap会检查进程是否已经被终止或者是否应该让出CPU(如果这个trap是一个定时器中断)

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

usertrapretuserret的调用,在a0中传递了TRAPFRAME,在a1中传递了指向进程用户页表的指针(kernel/trampoline.S)。userretsatp切换到进程的用户页表。用户页表映射了trampoline页面和TRAPFRAME,但没有映射内核中的其他内容。事实上,trampoline页面在用户页表和内核页表中被映射到相同的虚拟地址,这使得uservec能够在修改satp后继续执行。 userrettrapframe保存的用户a0复制到sscratch,为以后与TRAPFRAME的交换做准备。从此开始,userret可以使用的数据只有寄存器内容和trapframe中的内容。接下来,userrettrapframe中恢复保存的所有用户寄存器,最后一次交换a0sscratch来恢复用户a0,并为下一个trap保存TRAPFRAME(保存在sscratch中),然后执行sret以返回用户空间。

3. 代码:调用系统调用

Chap2以initcode.S调用exec系统调用结束(user/initcode.S)。让我们看看,用户调用是怎样在内核中实现exec系统调用的。

initcode.Sexec的参数放在寄存器a0a1,并将系统调用号放在a7中。系统调用号与syscalls数组中的条目相匹配,syscalls是一个函数指针表(kernel/syscall.c)。ecall指令陷入内核,然后执行uservecusertrapsyscall

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

sys_exec返回时,syscall将其返回值记录在p->trapframe->a0中。这将使得最初用户空间调用的exec()函数返回这个值,因为RISC-V上的C调用约定将返回值放在a0中。系统调用通常返回负数表示错误,返回0或正数表示成功。如果系统调用号是无效的,syscall会打印一个错误并返回-1。

4. 代码:系统调用参数

内核中的系统调用实现需要找到用户代码传来的参数。因为用户代码调用的是系统调用包装函数(system call wrapper functions),参数最初放置在RISC-V调用约定中所规定的地方:寄存器中。内核trap代码将用户寄存器保存到当前进程的trapframe中,这样内核代码就能找到它们。内核函数argintargaddrargfdtrapframe中检索第n个系统调用参数,作为整数、指针或文件描述符。它们都调用argraw来检索所保存的适当的用户寄存器(kernel/syscall.c

有些系统调用将指针作为参数传递,然后内核必须使用这些指针来读写用户内存。比如,exec系统调用向内核传递一个指针数组,该数组指向用户空间中的字符串参数。这些指针带来了两个挑战:

  1. 用户程序可能有bug或是恶意的,可能向内核传递无效指针或欺骗内核访问内核空间而不是用户空间
  2. xv6内核页表映射与用户页表映射不同,所以内核不能使用普通指令从用户提供的地址加载或存储内容。

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

copyinstr(kernel/vm.c)从虚拟地址srcva向dst拷贝最多max字节的数据,srcva是来自用户页表的虚拟地址。由于参数pagetable不是当前的页表,所以copyinstr使用walkaddr(调用walk)在pagetable中查找srcva,并得到srcva对应的物理地址pa0。 内核将所有物理RAM地址映射在相应的内核虚拟地址上,因此copyinstr能够直接从pa0向dst拷贝字符串数据。walkaddr(kernel/vm.c)检查用户提供的虚拟地址,保证它是这个进程的用户地址空间的一部分, 从而避免程序欺骗内核读取别处的内存。 copyout与之相似,从内核向用户提供的地址拷贝数据。

5. 来自内核空间的Traps

xv6根据trap发生时,执行的是用户代码还是内核代码,对CPU trap寄存器的配置略有不同。当内核在CPU上执行时,内核将stvec指向kernelvec(kernel/kernel.S)处的汇编代码。由于xv6已经在内核中了,kernelvec可以使用已经指向内核页表的satp,也能使用已经指向有效内核堆栈的栈指针。kernelvec将所有32个寄存器保存到堆栈上,稍后将从堆栈中恢复它们,以便被中断的内核代码可以不受干扰地恢复执行。

kernelvec将寄存器保存在被中断的内核线程的堆栈上,这是因为寄存器的值属于那个线程。这一点是很重要的,如果trap造成线程的切换,在这种情况下,trap实际上将从新线程的堆栈中返回,并将被中断线程所保存的寄存器安全地留在被中断线程的堆栈中

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

如果是由于定时器中断而调用kerneltrap,并且进程的内核线程正在运行(不是调度线程),kerneltrap会调用yield,把运行机会让给其他线程。在某个时刻,那些线程中的一个将会退出,让之前的线程和它的kerneltrap重新开始。Chap7解释了yield中发生了什么。

kerneltrap的工作完成后,它需要返回到被trap中断的代码。因为yield函数可能会扰乱保存的sepcsstatus中保存的之前的模式,所以kerneltrap会在启动时保存它们。它现在恢复这些控制寄存器,然后返回到kernelvec(kernel/kernelvec.S)kernelvec将保存的寄存器从堆栈中弹出,并执行sretsret会将sepc拷贝到pc,并恢复被中断的内核代码。

值得思考的是,如果因为定时器中断,kerneltrap调用yieldtrap将如何返回?

当CPU从用户空间进入内核时,xv6将CPU的stvec设置为kernelvec,你可以在usertrap(kernel/trap.c)中看到这一点。当内核已经开始执行,但stvec仍然设置为uservec时,有一个时间窗口,在该窗口期间,没有设备中断发生是至关重要的。幸运的是,RISC-V总是在开始处理trap时禁用中断,xv6会在设置stvec后才再次启用中断

6. 页错误异常(Page-fault execptions)

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

比如,许多内核使用页错误来实现写时复制(COW)fork。为了解释写时复制fork,考虑Chap3中描述的xv6的forkfork导致子进程的初始内容与父进程在fork时的内容相同。xv6用uvmcopy(kernel/vm.c)实现fork,其为子进程分配物理内存,并将父进程的内存复制到其中。如果子进程能与父进程共享父进程的物理内存,这个过程将更高效。然而,直接这样实现是不行的,因为当它们向共享的栈或者堆写入时,双方的执行都会被打乱

通过恰当使用页表权限和页错误,父进程和子进程可以安全地共享物理内存。当使用的虚拟地址在页表中没有映射,或者映射的PTE_V标志被清除,或者映射的权限位(PTE_RPTE_WPTE_XPTE_U禁止正在尝试的操作时,CPU会引发页错误异常(即当CPU无法将虚拟地址翻译成物理地址时,会产生一个页错误异常)。

RISC-V有三种类型的页错误:加载页面错误(当load指令不能翻译虚拟地址时),存储页面错误(当store指令不能翻译虚拟地址时),指令页面错误(当程序计数器中的地址不能翻译时)。scause寄存器中的值表示页错误的类型,stval寄存器包含了无法翻译的地址。

COW fork 的基本计划是,父子进程最初共享所有的物理页面,但是每个页面都将它们映射为只读(清除PTE_W标志)。父子进程可以从共享的物理内存中读取。如果任何一个进程写了一个给定的页面(执行store指令),RISC-V CPU就会产生一个页面错误异常。内核的trap handler通过分配一个新的物理内存页面,并将出错地址所映射到的物理页面进行拷贝,将其拷贝到新的物理页面中,从而做出响应。内核更改出错进程的页表中的相关PTE,以指向新的物理页面,并允许读写,然后在导致错误的指令处继续执行出错的进程。因为PTE允许写入,所以重新执行的指令现在将无错误地执行。 写时复制需要 book-keeping 来帮助决定何时可以释放物理页面,因为每个页面可以被不同数量的页表引用,这取决于 forks,页错误,execs 和 exits。book-keeping 允许一个重要的优化:如果一个进程发生存储页面错误,并且物理页面只从该进程的页表中引用,则不需要复制。

写时复制使fork更快,因为fork不需要复制内存了。当写入时,一些内存将不得不在以后被复制,但是通常情况下,大部分的内存从来没有被复制过。 一个常见的例子是 fork 后马上调用 exec,将它的地址空间替换为新的地址空间:fork之后可能会写几页,这时候子进程只需经历几个页错误,对几个页面进行拷贝即可,之后子进程的exec会释放从父进程继承的大部分内存,这样内核就能避免完整地拷贝。所以,COW fork消除了复制所有内存的需要。 此外,COW fork 是透明的:不需要对应用程序进行任何修改就可以受益。

除了COW fork 之外,页表和页错误的组合还提供了许多有趣的可能性。 另一个广泛应用的特性叫做惰性分配懒分配),它有两个部分。首先,当应用程序调用sbrk请求更多内存时,内核会注意到进程内存大小的增加,但不会分配物理内存,也不会为新的虚拟地址范围创建PTE(因为PTE包含了物理地址,既然没有分配物理内存,那也就没有物理地址)。第二,当这些新地址中的一个出现页面错误时,内核会分配一个物理内存页面,并将其映射到页表中。 因为应用程序经常申请比实际需求更多的内存,所以懒分配非常有效:内核只在应用真的用到内存时才给它分配,内核不需要为应用程序从不使用的页面做任何工作。 像 COW fork 一样,内核可以对应用透明地实现懒分配。

此外,如果应用程序要求大量增加地址空间,那么没有懒分配的sbrk是十分昂贵的:如果应用程序要求1GB的内存,内核必须分配 262144个4096字节的页面,并将这些页面的内容清零。 懒分配允许这种成本随着时间的推移而分散。 另一方面, 懒分配会导致页面错误的额外开销,这涉及到 内核态/用户态的转换。操作系统可以通过为每个页面错误分配一批连续的页面而不是一个页面,并通过为这种页面错误指定内核进入/退出的代码来降低这一成本。

利用页面错误的另一个广泛使用的功能是按需分页。在exec中, xv6急切地将应用程序的所有文本和数据加载到内存中。由于应用程序可能很大,并且从磁盘读取数据的成本很高,因此用户可能会注意到这种启动成本:当用户从shell启动大型应用程序时,可能需要很长时间才能看到响应。为了改善响应时间,现代内核为用户地址空间创建了页表,但是将页的PTE标记为无效。出现页面错误时,内核从磁盘读取页面内容,并将其映射到用户地址空间。与COW fork 和 懒分配一样,内核可以对应用程序透明地实现这个特性。

计算机上运行的程序可能需要比计算机RAM更多的内存。为了从容应对,操作系统可以实现磁盘分页。其思想是只在RAM中存储一小部分用户页面,而将其余的存储在磁盘的分页区域中。内核将对应于存储在分页区域(不在RAM中)的内存的PTE标记为无效。如果一个应用程序试图使用一个已经被换出到磁盘的页面,该应用程序将导致页面错误,并且该页面必须被换入。内核能够检查出错的地址,如果地址属于硬盘上的一个页,内核的trap handler将分配一个物理内存页,并将该页面从磁盘读入内存,并修改相关的PTE以指向新分配的内存,然后恢复应用。

如果一个页面需要换页,但是没有空闲的物理内存会怎么样?在这种情况下,内核必须首先释放一个物理页面,将它调出到磁盘上的分页区域,并将引用该物理页面的PTE标记为无效。由于调出页面需要很大的开销,所以分页在不频繁的情况下表现最好:如果应用程序只使用它们的内存页面的一个子集和RAM中的子集的组合。这种属性通常被称为具有良好的引用局部性。与许多虚拟内存技术一样,内核通常以对应用程序透明的方式实现磁盘分页。

无论硬件提供多少内存,计算机通常在很少或没有空闲物理内存的情况下运行。例如,云提供商在一台机器上复用许多客户,以便经济高效地使用他们地硬件。作为另一个例子,用户在少量物理内存中运行智能手机上的许多应用程序。在这种设置中,分配页面可能需要先调出现有页面。因此,当空闲物理内存不足时,分配页面是昂贵的。

当空闲内存不足时,懒分配和按需分页特别有优势。在sbrkexec中急切地分配内存会导致额外的内存调出成本。此外,还存在浪费之前的分配工作的风险,因为在应用程序使用该页面之前,操作系统可能已经将其调出。

结合分页和页错误的其他特性包括自动扩展堆栈内存映射文件

7. Real world

trampolinetrapframe可能看起来过于复杂。一个驱动力是RISC-V在强制trap时故意做得尽可能少,以允许非常快速的trap处理的可能性,这被证明是重要的。 因此,内核trap handler程序的前几条指令实际上必须在用户环境中执行:用户页表和用户寄存器内容。 trap handler程序最初不知道一些有用的事实,比如正在运行的进程的身份或者内核页表的地址。 有一种可能的解决方案,因为RISC-V提供了受保护的位置,内核可以在进入用户空间之前将信息隐藏在这些位置:sscratch寄存器和指向内核内存但受PTE_U保护的用户页表条目。xv6的trampolinetrapframe利用了这些RISC-V特性。

如果内核内存被映射到每个进程的用户页表中(用合适的权限标记),特殊的trampoline页面就不需要了。从用户空间陷入到内核时的页表切换也不再需要。这反过来将允许内核中的系统调用实现能够使用当前进程映射的用户空间内存,从而使内核代码能够直接解引用用户指针。 很多操作系统已经使用这些想法来提高效率。 xv6没有采用这种思想,这是为了减少内核中由于无意中使用用户指针而导致的安全错误,并降低确保用户和内核虚拟地址不重叠所需的复杂性

生产操作系统实现了写时复制fork、懒分配、按需分页、磁盘分页、内存映射文件等。此外,生产操作系统将尝试使用所有的物理内存,无论是应用程序还是缓存(比如,文件系统的缓冲区缓存,这将在Chap8中介绍)。xv6在这方面不是这样的,xv6并不希望使用所有的物理内存。此外,如果xv6耗尽了内存,它会向正在运行的应用程序返回一个错误,或者 kill 它,而不是调出另一个应用程序的页面