第四章 陷阱与系统调用
有三种事件会导致 CPU 暂时中断普通指令的执行,并强制将控制权转移到处理该事件的特殊内核代码中。一种情况是系统调用:当用户程序执行 ecall 指令,请求内核为其执行某些操作。另一种情况是异常:某条指令(无论是在用户态还是内核态)执行了非法操作,例如从一个无效的虚拟地址加载数据。第三种情况是设备中断:当某个设备发出需要处理的信号时,例如磁盘硬件完成了一次读或写请求。
本书将这些情况统称为“陷阱”(trap)。通常,在陷阱发生时正在执行的代码之后仍需要继续运行,并且不应该意识到发生了任何特殊事件。也就是说,我们通常希望陷阱是透明的;这对于设备中断尤其重要,因为被中断的代码通常并不期望发生中断。陷阱会强制将控制权转移到内核中;内核会保存寄存器和其他状态,以便之后能够恢复执行;内核执行相应的处理代码(例如系统调用的实现或设备驱动程序);内核恢复之前保存的状态并从陷阱返回;原来的代码从中断处继续执行。
Xv6 在内核中处理所有陷阱;陷阱不会被传递给用户代码。对于系统调用来说,在内核中处理陷阱是很自然的选择。对于中断来说也是合理的,因为隔离性要求只有内核才能使用设备,而且内核能够在多个进程之间共享设备。对于异常来说同样如此,因为内核可能能够处理来自用户空间的异常(例如第 5 章中的一个示例),或者通过终止违规程序来响应。
Xv6 的陷阱处理分为四个阶段:RISC-V CPU 执行的硬件动作;一些用于为内核 C 代码做准备的汇编指令;一个决定如何处理该陷阱的 C 函数;以及系统调用或设备驱动的服务例程。尽管三种陷阱类型之间的共性表明内核可以使用单一路径来处理所有陷阱,但事实证明,将两种不同情况分开处理更为方便:来自用户空间的陷阱,以及来自内核空间的陷阱。处理陷阱的内核代码(汇编或 C)通常称为处理程序(handler);最开始的处理指令通常用汇编语言编写(而不是 C),有时也称为向量(vector)。
在继续之前,请阅读 kernel/trampoline.S,以及 kernel/trap.c 中的 usertrap() 和 prepare_return()。
一、RISC-V 的陷阱机制
每个 RISC-V CPU 都有一组硬件控制寄存器,内核通过写这些寄存器来告诉 CPU 如何处理陷阱,并且内核可以读取这些寄存器以了解已经发生的陷阱情况。RISC-V 的官方文档给出了完整说明。xv6 在 riscv.h(0500)中包含了相关定义。下面概述最重要的几个寄存器:
- stvec:内核在此写入其陷阱处理代码的地址;当发生陷阱时,RISC-V 会跳转到 stvec 中的地址来处理陷阱。
- sepc:当陷阱发生时,RISC-V 会将程序计数器(pc)保存在这里(因为 pc 随后会被 stvec 的值覆盖)。
sret(从陷阱返回)指令会将 sepc 的值复制回 pc。内核可以通过写 sepc 来控制sret返回的位置。 - scause:RISC-V 在这里放置一个数值,用于描述陷阱发生的原因。
- sscratch:内核的陷阱处理代码使用 sscratch 来帮助其在保存用户寄存器之前避免覆盖这些寄存器。
- sstatus:sstatus 中的 SIE 位控制是否启用设备中断。如果内核清除 SIE,RISC-V 会推迟设备中断,直到内核重新设置 SIE。SPP 位指示陷阱是来自用户模式还是监督者模式,并控制
sret返回到哪种模式。
上述寄存器只能在监督者模式(即内核态)下访问;CPU 会阻止用户代码对它们进行读写。
在多核芯片中,每个 CPU 都有自己的一组这些寄存器,并且在任意给定时间,可能有不止一个 CPU 正在处理陷阱。
当 RISC-V 硬件强制触发一个陷阱时,会执行以下操作:
- 如果陷阱是设备中断,并且 sstatus 中的 SIE 位被清除,则不执行下面任何步骤。
- 通过清除 sstatus 中的 SIE 位来禁用中断。
- 将 pc 复制到 sepc。
- 将当前模式(用户态或监督者态)保存在 sstatus 的 SPP 位中。
- 将 scause 设置为一个表示陷阱原因的数值。
- 将模式设置为监督者模式。
- 将 stvec 的值复制到 pc。
- 从新的 pc 开始执行。
图 4.1:来自用户代码的陷阱是如何被处理的概述

CPU 不会切换到内核页表,不会切换到内核栈,也不会保存除 pc 之外的任何寄存器。这些工作必须由内核软件来完成。CPU 在陷阱期间只执行最少工作的一个原因是为了给软件提供灵活性;例如,一些操作系统在某些情况下会省略页表切换,以提高陷阱处理性能。
值得思考的是,上述步骤中是否有某些可以被省略,以期获得更快的陷阱处理。尽管在某些场景下,更简单的序列可能可行,但在一般情况下省略其中许多步骤都是危险的。例如,假设 CPU 不切换程序计数器,那么来自用户空间的陷阱可能在仍然执行用户指令的同时切换到监督者模式。这些用户指令就可能破坏用户/内核隔离,例如通过修改 satp 寄存器,使其指向一个允许访问所有物理内存的页表。因此,CPU 切换到由内核指定的指令地址(即 stvec)是至关重要的。
二、来自用户空间的陷阱
Xv6 会根据陷阱是在内核中执行时发生,还是在用户代码中执行时发生,而采用不同的处理方式。本节介绍来自用户代码的陷阱;第 4.5 节描述来自内核代码的陷阱。
当用户程序执行系统调用(ecall 指令)、执行了非法操作,或发生设备中断时,就可能在用户空间触发陷阱。如图 4.1 所示,来自用户空间的陷阱在高层次上的处理路径是:先进入 uservec(3071),然后进入 usertrap(3337);当内核准备返回时,usertrap 返回到 userret(3151),由它执行 sret 返回到用户空间。
xv6 陷阱处理设计中的一个主要约束是:RISC-V 硬件在强制陷阱时不会切换页表。这意味着,stvec 中的陷阱处理程序地址必须在用户页表中有一个有效映射,因为在陷阱处理代码开始执行时,仍然使用的是用户页表。此外,xv6 的陷阱处理代码需要切换到内核页表;为了在切换之后还能继续执行,内核页表中也必须包含对 stvec 所指向处理程序的映射。
xv6 使用一个“蹦床页”(trampoline page)来满足这些要求。该页面包含 uservec,也就是 stvec 指向的 xv6 陷阱处理代码。蹦床页在每个进程的页表中都被映射到虚拟地址 0x3ffffff000(称为 TRAMPOLINE),这是虚拟地址空间中的最后一个页面,因此位于程序自身使用的内存之上。蹦床页在内核页表中也映射到相同的虚拟地址。参见图 2.3 和图 3.3。由于蹦床页映射在用户页表中,陷阱可以在监督者模式下从该页面开始执行;又由于蹦床页在内核地址空间中映射到相同地址,陷阱处理程序在切换到内核页表后仍可继续执行。
uservec 陷阱处理程序的代码位于 trampoline.S(3071)。当 uservec 开始执行时,所有 32 个寄存器都包含被中断的用户代码所拥有的值。这 32 个值需要被保存到内存中的某个地方,以便内核在返回用户空间之前能够恢复它们。向内存存储数据需要使用一个寄存器来保存存储目标地址,但此时并没有可用的通用寄存器!幸运的是,RISC-V 提供了 sscratch 寄存器来帮忙。uservec 开头的 csrw 指令将 a0 保存到 sscratch 中,这样 uservec 就有了一个可以使用的寄存器(a0)。
uservec 的下一个任务是保存这 32 个用户寄存器。内核为每个进程分配一页内存,用于存放一个 trapframe 结构,该结构(以及其他内容)中有空间保存 32 个用户寄存器(1992)。由于此时 satp 仍然指向用户页表,uservec 需要 trapframe 被映射在用户地址空间中。xv6 将每个进程的 trapframe 映射到该进程用户页表中的虚拟地址 TRAPFRAME(0x3fffffe000),即 TRAMPOLINE 下方的一页。每个进程的 p->trapframe 中还包含该进程 trapframe 的一个内核虚拟地址。
uservec 将寄存器 a0 设置为 TRAPFRAME 的地址,并将所有用户寄存器保存到那里。随后,它从 sscratch 中取回用户的 a0,并将其保存到 trapframe 中。
内核事先已初始化 trapframe,使其包含一些对 uservec 有用的值:当前进程的内核栈地址、当前 CPU 的 hartid、usertrap 函数的地址,以及内核页表的地址。uservec 取出这些值,切换 satp 到内核页表,并跳转到 usertrap(一个 C 函数)。
usertrap 的工作是确定陷阱的原因、进行处理并返回(3337)。它首先修改 stvec,使得在内核中发生的陷阱由 kernelvec 而不是 uservec 处理。它保存 sepc 寄存器(即保存的用户程序计数器),以便将来返回用户空间时使用。如果陷阱是系统调用,usertrap 调用 syscall 处理;如果是设备中断,则调用 devintr;如果是缺页异常,则调用 vmfault;否则就是异常(例如使用了无效地址),内核会杀死出错的进程。系统调用路径会将保存的用户程序计数器加 4,因为在系统调用的情况下,RISC-V 会让程序计数器仍然指向 ecall 指令,而用户代码需要从下一条指令继续执行。usertrap 还会检查进程是否已被杀死,或者是否应该让出 CPU(如果该陷阱是定时器中断)。
返回用户空间的第一步是调用 prepare_return(3404)。该函数设置 RISC-V 控制寄存器,以为将来来自用户空间的陷阱做好准备:将 stvec 设置为 uservec,并准备 trapframe 中 uservec 依赖的字段。prepare_return 将 sepc 设置为之前保存的用户程序计数器。最后,usertrap 返回到蹦床页中的 userret(3151),并在 a0 中传回用户页表的指针。
userret 将 satp 切换为进程的用户页表。回想一下,用户页表同时映射了蹦床页和 TRAPFRAME,但不映射内核的其他内容。蹦床页在用户和内核页表中位于相同的虚拟地址,这使得 userret 在改变 satp 后仍能继续执行。从这一点开始,userret 唯一可以使用的数据就是寄存器内容以及 trapframe 中的内容。userret 将 TRAPFRAME 的地址加载到 a0,通过 a0 从 trapframe 中恢复保存的用户寄存器,恢复保存的用户 a0,并执行 sret 返回到用户空间。
uservec 和 userret 都是用汇编语言编写的,因为用 C 语言来保存或恢复所有寄存器,或在页表切换过程中保持正确执行,是非常困难的。
三、代码:调用系统调用
用户程序通过调用库函数来发起系统调用。例如,shell 使用下面的函数调用来显示提示符(位于 user/sh.c):
write(2, "$ ", 2);
对应的库函数位于 user/usys.S 中:
write:
li a7, SYS_write
ecall
ret
C 编译器为该函数调用生成的代码会将三个参数加载到寄存器 a0、a1 和 a2 中。随后,write() 函数将系统调用号 SYS_write(16)加载到 a7 中。内核会查看这些寄存器,以确定请求的是哪个系统调用,以及它的参数是什么。ecall 指令会从用户空间陷入内核,并依次导致 uservec、usertrap,以及最终的 syscall 被执行。
此时,请阅读 kernel/syscall.c,kernel/sysfile.c 中的 sys_write(),以及 kernel/vm.c 中的 copyout()、copyin() 和 copyinstr()。
syscall(3731)从 trapframe 中保存的 a7 里取出系统调用号,并用它作为索引访问 syscalls(3706)。在本例中,a7 包含 SYS_write(3566),因此会调用对应的系统调用实现函数 sys_write。
当 sys_write 返回时,syscall 会将其返回值记录到 p->trapframe->a0 中。这样,原本用户空间对 write() 的调用就会返回该值,因为在 RISC-V 的 C 调用约定中,返回值存放在 a0 中。系统调用通常使用负数表示错误,用 0 或正数表示成功。
四、代码:系统调用参数
系统调用参数最初位于用户寄存器中,随后由内核的陷阱处理代码移动到陷阱帧(trapframe)中。内核函数 argint、argaddr 和 argfd 分别以整数、指针或文件描述符的形式,从陷阱帧中取出第 n 个系统调用参数。
有些系统调用以指针作为参数,内核必须使用这些指针来读取或写入用户内存。例如,write 系统调用会向内核传递一个指向要写入数据的用户空间指针。这类指针带来两个挑战。第一,用户程序可能存在缺陷或具有恶意行为,可能向内核传递一个无效指针,或者试图诱使内核访问内核内存而不是用户内存。第二,xv6 的内核页表映射与用户页表映射并不相同,因此内核不能使用普通指令直接从用户提供的地址加载或存储数据。
内核实现了一些函数,用于在内核与用户提供的地址之间安全地传输数据。fetchstr 就是一个例子(3624)。诸如 exec 这样的文件系统调用使用 fetchstr 从用户空间获取字符串形式的文件名参数。fetchstr 调用 copyinstr 来完成主要工作。
copyinstr(1833)从用户页表 pagetable 中的虚拟地址 srcva 复制最多 max 字节到目标地址 dst。由于 pagetable 并不是当前使用的页表,copyinstr 使用 walkaddr(它会调用 walk)在 pagetable 中查找 srcva,得到物理地址 pa0。内核的页表将所有物理 RAM 映射到与其物理地址相同的虚拟地址上,这使得 copyinstr 可以直接将字符串字节从 pa0 复制到 dst。walkaddr(1520)会检查用户提供的虚拟地址是否属于该进程的用户地址空间,从而防止程序诱使内核读取其他内存。一个类似的函数 copyout 用于将数据从内核复制到用户提供的地址。
五、来自内核空间的陷阱
请阅读 kernel/kernelvec.S,以及 kernel/trap.c 中的 kerneltrap()。
Xv6 对来自内核代码的陷阱采用了与来自用户代码的陷阱不同的处理方式。当进入内核时,usertrap 会将 stvec 指向位于 kernelvec(3211)的汇编代码。由于 kernelvec 只会在 xv6 已经处于内核态时执行,kernelvec 可以假定 satp 已经设置为内核页表,并且栈指针指向一个有效的内核栈。kernelvec 会将全部 32 个寄存器压入当前栈中,稍后再从栈中恢复它们,以便被中断的内核代码能够不受影响地继续执行。
kernelvec 将寄存器保存在被中断的内核线程的栈上,这是合理的,因为这些寄存器值属于该线程。这在陷阱导致切换到不同线程时尤为重要——在这种情况下,陷阱实际上会从新线程的栈中返回,而被中断线程的寄存器值则安全地保存在它自己的栈上。
kernelvec 在保存寄存器后跳转到 kerneltrap(3453)。kerneltrap 只准备处理一种类型的陷阱:设备中断。它调用 devintr(3506)来处理这些中断。如果该陷阱不是设备中断,那它一定是异常,例如内核代码试图使用一个无效指针。这只能由内核代码中的错误引起。内核在这种情况下无法恢复,因此会调用 panic(),打印错误信息并随后停止运行。
如果 kerneltrap 是由于定时器中断而被调用,并且正在运行的是某个进程的内核线程(而不是调度器线程),kerneltrap 会调用 yield,让其他线程有机会运行。在某个时刻,其中一个线程会让出 CPU,使得当前线程及其 kerneltrap 得以继续执行。第 8 章将解释 yield 中发生的事情。
当 kerneltrap 的工作完成后,需要返回到被陷阱中断的代码处。由于一次 yield 可能扰乱 sepc 以及 sstatus 中之前的模式位,kerneltrap 在开始时会保存它们。现在它会恢复这些控制寄存器,并返回到 kernelvec(3237)。kernelvec 从栈中弹出保存的寄存器并执行 sret,该指令会将 sepc 复制到 pc,从而恢复执行被中断的内核代码。
当 CPU 从用户空间进入内核时,xv6 会将该 CPU 的 stvec 设置为 kernelvec;你可以在 usertrap(3346)中看到这一点。不过,在内核刚开始执行但 stvec 仍然指向 uservec 的这段时间窗口内,绝不能发生设备中断。幸运的是,RISC-V 在开始处理陷阱时总是会禁用中断,而 usertrap 在设置好 stvec 之前也不会重新启用中断。
六、现实世界
如果将内核内存映射到每个进程的用户页表中(并将 PTE_U 清零),就可以消除对蹦床页的需求。这样也可以避免在从用户空间陷入内核时进行页表切换。进一步地,这将允许内核中的系统调用实现利用当前进程的用户内存映射,使内核代码能够直接解引用用户指针。许多操作系统都曾采用这些思想来提高效率。
xv6 避免使用这些做法,是为了减少由于内核中意外使用用户指针而导致安全漏洞的可能性,并降低为确保用户虚拟地址与内核虚拟地址不重叠而需要引入的复杂性。