跳至主要內容

Chapter 4 陷阱和系统调用 (Traps and system calls)

Plus大约 23 分钟操作系统MITXV6riscv

Chapter 4 陷阱和系统调用 (Traps and system calls)

有三种事件会导致CPU暂停普通指令的执行,并且强制将控制权转换到处理该事件的特殊代码上。第一种情况是系统调用,当用户程序执行ecall指令时请求内核为其执行某些操作时。第二种情况是是一种异常,指令(用户或者内核)执行了非法操作,例如除以零或者使用了无效的虚拟地址。第三种情况是设备中断,当设备发出需要关注的信号时,例如当硬盘完成了读写请求。

这本书使用trap陷阱作为这些情况的通用术语。通常,任何在陷阱发生时正在执行的代码在之后都要被恢复,并且不该意识到发生了任何特殊事件。换句话说,我们通常希望陷阱是透明的;这对于中断尤其重要,因为被中断的代码通常不会预料到中断的发生。通常的顺序是一个陷阱强制把控制权转入内核;内核保存寄存器和其他状态这样正在执行的进程才能够被恢复;内核执行相应的处理程序代码(例如,系统调用实现或设备驱动程序);内核恢复保存的状态并从陷阱返回;原始代码在中断发生的地方恢复执行。

xv6内核处理所有陷阱。这对于系统调用来说是自然的。对于中断来说是有道理的,因为隔离要求用户进程不能直接使用设备,并且只有内核具有设备处理所需的状态。对于异常也是有道理的,因为xv6对来自用户空间的所有异常都会通过终止有问题的程序进行响应。

xv6的陷阱处理分为四个阶段:RISC-V CPU采取的硬件操作,一个准备内核C代码的汇编“向量”,一个决定如何处理陷阱的C陷阱处理程序,以及系统调用或设备驱动程序服务例程。虽然三种陷阱类型之间的共性表明内核可以使用单个代码路径处理所有陷阱,但实际上,为三种不同情况(来自用户空间的陷阱、来自内核空间的陷阱和定时器中断)分别拥有独立的汇编向量和 C 陷阱处理程序更为方便。

4.1 RISC-V陷阱机制(RISC-V trap machinery)

个 RISC-V CPU 都有一组控制寄存器,内核通过写入这些寄存器来告知 CPU 如何处理陷阱,并且内核可以读取这些寄存器以了解已发生的陷阱。RISC-V 文档中包含了完整的详细说明[1]。riscv.hkernel/riscv.h:1)包含了 xv6 使用的定义。以下是最重要寄存器的概述:

  • stvec:内核在这里写入陷阱处理程序的地址;RISC-V处理器跳入该地址处理陷阱。

  • sepc:当陷阱发生时,RISC-V处理器在这里保存程序计数器中的地址(因为此时程序计数器被 stvec 覆盖)。sret(从陷阱返回)指令将 sepc 的值复制到程序计数器。内核可以通过写入 sepc 来控制 sret 返回的位置。

  • scause: RISC-V 会在此处存放一个数字用于描述发生陷阱的原因。

  • sscrad'wtch:内核会在此处放置一个值,在陷阱处理程序开始时非常有用。

  • sstatus:sstatus中的SIE位控制设备中断是否有效。如果内核清理了SIE位,RISC-V处理器会推迟设备中断,直到内核重新设置该位。SPP位指示陷阱是来自用户模式还是主管模式,并控制sret返回到哪种模式。

上面的几个寄存器与监督模式下的陷阱处理有关,这些寄存在用户模式下不能被读写。有一个相当于在机器模式下处理的陷阱的控制寄存器集合;xv6只用于计时器中断的特殊情况。

每个多核芯片上的每个 CPU 都有自己的一组这些寄存器,而且在任何给定时间可能有多个 CPU 处理一个陷阱。

当需要强制一个陷阱时,RISC-V 硬件对于所有陷阱类型(除了计时器中断)会执行以下操作:

  1. 如果陷阱是设备中断,并且 sstatus 中的 SIE 位被清除,则不执行以下任何操作。
  2. 通过清除 SIE 来禁用中断。
  3. 将 pc 的值复制到 sepc。
  4. 将当前模式(用户模式或监管者模式)保存到 sstatus 的 SPP 位中。
  5. 设置 scause 以反映陷阱的原因。
  6. 将模式设置为监管者模式。
  7. 将 stvec 的值复制到 pc。
  8. 开始执行新的 pc。

请注意,CPU 在陷阱期间不会切换到内核页表,不会切换到内核中的堆栈,并且不会保存除 pc 之外的任何寄存器。这些任务必须由内核软件执行。CPU 在陷阱期间执行最小的工作的一个原因是为了提供软件的灵活性;例如,某些操作系统在某些情况下不需要进行页表切换,这可以提高性能。

您可能想知道 CPU 硬件的陷阱处理顺序是否可以进一步简化。例如,假设 CPU 不切换程序计数器。那么,陷阱可能在运行用户指令的同时切换到监管者模式。这些用户指令可能会破坏用户/内核隔离,例如通过修改 satp 寄存器以指向一个允许访问所有物理内存的页表。因此,CPU 切换到内核指定的指令地址,即 stvec,是很重要的。

4.2 用户空间陷阱(Traps from user space)

陷阱有可能发生在用户空间执行期间,例如用户程序发出系统调用(ecall 指令),或执行非法操作,或者设备中断。从用户空间发生陷阱的高级路径是 uservec(kernel/trampoline.S:16),然后是 usertrap(kernel/trap.c:37);返回时,是 usertrapret(kernel/trap.c:90),然后是 userret(kernel/trampoline.S:16)。

从用户代码发生的陷阱比从内核发生的更具有挑战性,因为用户页表的stap指针并不映射内核,并且栈指针也许包含无效甚至是恶意值。

因为RISC-V硬件并不会在陷阱期间切换页表,所以用户页表必须包含uservec的映射,stvec指向陷阱向量指令。uservec 必须切换stap指向内核页表,为了在切换后继续执行指令,uservec 必须在内核页表中的与用户页表相同的地址处进行映射。

Xv6通过包含uservec映射的trampoline页实现这些约束。Xv6在以内和页表和用户页表中映射trampoline页相同的虚拟地址。TRAMPOLINE虚拟地址相关的信息在图2.3和图2.4中可以看到。trampoline的内容被定义在在代码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,不过是通过物理地址,因此内核可以通过内核页表使用它。

之后,交换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 处理;否则就是异常,内核会终止出错的进程。在系统调用路径中,它会在保存的用户程序计数器上加上四,因为在系统调用的情况下,RISC-V 将程序指针留在 ecall 指令上。出trap时,usertrap 会检查进程是否已经被杀死或者是否应该让出 CPU(如果这个trap是定时器中断)。

返回用户空间的第一步是调用 usertrapret(kernel/trap.c:90)。此函数设置 RISC-V 控制寄存器,以准备将来从用户空间触发的 trap。这涉及将 stvec 更改为指向 uservec,准备 uservec 依赖的 trapframe 字段,并将 sepc 设置为先前保存的用户程序计数器。最后,usertrapret 调用映射在用户和内核页表中的 trampoline 页上的 userret;原因是 userret 中的汇编代码将切换页表。

usertrapret 调用 userret 时,会将指向进程用户页表的指针传递给 a0,将 TRAPFRAME 传递给 a1(kernel/trampoline.S:88)。userret 将 satp 切换到进程的用

户页表。请记住,用户页表映射了 trampoline 页和 TRAPFRAME,但没有映射内核的其他内容。同样,trampoline 页在用户和内核页表中映射在相同的虚拟地址这一事实,使得 uservec 能在更改 satp 后继续执行。userret 将 trapframe 中保存的用户 a0 复制到 sscratch,以准备稍后的与 TRAPFRAME 的交换。从此时起,userret 只能使用寄存器内容和 trapframe 的内容。接下来,userret 从 trapframe 恢复保存的用户寄存器,最终交换 a0 和 sscratch 以恢复用户 a0 并保存 TRAPFRAME 以备下次 trap 使用,然后使用 sret 返回用户空间。

sequenceDiagram
    participant 用户空间
    participant uservec
    participant usertrap
    participant usertrapret
    participant userret

    用户空间->>uservec: 触发陷阱 (ecall, 中断, 非法操作)
    uservec->>uservec: csrrw a0, sscratch (交换内容)
    uservec->>uservec: 保存用户寄存器到trapframe
    uservec->>uservec: 获取内核栈指针、hartid、usertrap地址、内核页表地址
    uservec->>uservec: 切换satp到内核页表
    uservec->>usertrap: 调用usertrap
    usertrap->>usertrap: 保存sepc (用户程序计数器)
    usertrap->>usertrap: 确定trap原因并处理
    usertrap->>usertrap: 更新sepc (如果是系统调用)
    usertrap->>usertrapret: 调用usertrapret
    usertrapret->>usertrapret: 设置stvec指向uservec
    usertrapret->>usertrapret: 准备trapframe字段
    usertrapret->>usertrapret: 恢复用户程序计数器 (sepc)
    usertrapret->>userret: 调用userret (传递用户页表指针到a0, TRAPFRAME到a1)
    userret->>userret: 切换satp到用户页表
    userret->>userret: 从trapframe恢复用户寄存器
    userret->>userret: 交换a0和sscratch
    userret->>用户空间: sret返回用户空间

4.3 代码:调用系统调用(Code: Calling system calls)

第二章以initcode.S调用exec系统调用(user/initcode.S:11)结束。我们看看用户调用如何传递到内核中 exec 系统调用的实现。

用户代码将传递给exec的参数放在寄存器a0a1中,将系统调用号存放在a7中。系统调用号与在syscalls数组中的入口匹配,这个syscalls数组是一个方法指针表(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()会返回该值,因为C语言在RISC-V中约定将返回值放在a0中。系统调用约定返回负数指示错误,成功返回0或者正数。如果系统调用号无效,syscall会打印错误并返回-1。

4.4 代码:系统调用参数(Code: System call arguments)

内核中的系统调用实现需要查找用户代码传递的参数。由于用户代码调用系统调用的包装函数,所以参数初始化在RISC-V C约定调用的寄存器中。内核陷阱代码将用户寄存器种的值保存在当前进程中的trapframe中,内核代码能在这里找到他们。函数 argint,argaddr,argfd将系统调用参数的第n位作为,整数,指针或者文件描述符从trapframe中恢复。它们都调用 argraw 来检索适当的已保存用户寄存器(kernel/syscall.c:35)。

某些系统调用会传递指针作为参数,内核必须使用这些指针来操作用户内存。例如,exec系统调用传递给内核一组指向字符串参数的指针数组。这些指针面临两个挑战。第一个是,用户程序可能会有bug或者存在恶意代码,传递给内核的可能是无效指针或者一个试图欺骗内核访问内核内存而非用户内存的指针。第二个是,xv6的内核页表映射与用户页表映射不相同,因此内核无法使用普通指令从用户提供的地址加载或存储数据。

内核实现了安全传输数据到用户提供的地址以及从用户提供的地址传输数据的函数fetchstr 是一个示例(kernel/syscall.c:25)。像 exec 这样的文件系统调用使用 fetchstr 从用户空间检索字符串文件名参数。fetchstr 调用了 copyinstr 来完成艰苦的工作。

copyinstr(kernel/vm.c:406)将最多 max 字节从用户页表 pagetable 中的虚拟地址 srcva 复制到 dst它使用 walkaddr(调用 walk)在软件中遍历页表以确定 srcva 的物理地址 pa0。由于内核将所有物理 RAM 地址映射到相同的内核虚拟地址,因此 copyinstr 可以直接从 pa0 复制字符串字节到 dst。walkaddr(kernel/vm.c:95)检查用户提供的虚拟地址是否属于进程的用户地址空间,因此程序无法欺骗内核读取其他内存。一个类似的函数 copyout 将数据从内核复制到用户提供的地址。

重要

copyinstr 函数中,dst 是目标地址,用于存储从用户空间复制过来的字符串数据。具体来说,copyinstr 将从用户空间的虚拟地址 srcva 读取的字符串字节复制到内核空间的 dst 位置。

这个过程的目的是从用户空间安全地读取字符串数据并将其存储在内核空间,以便内核能够安全地处理这些数据。例如,当用户进程请求打开一个文件时,文件名作为字符串会被传递给内核,内核需要将这个字符串从用户空间复制到内核空间进行处理。

假设有以下情况:

  • srcva 是用户空间的虚拟地址,指向用户传递的字符串 "filename"。
  • dst 是内核空间的缓冲区地址,用于存储从用户空间复制过来的字符串。

copyinstr 函数将执行以下操作:

  1. 遍历用户页表,找到 srcva 对应的物理地址 pa0
  2. pa0 开始复制字符串字节到 dst
  3. 如果字符串长度超过 max,则只复制 max 个字节。
  4. 确保在复制过程中不会访问非法的内存地址。

通过这种方式,内核可以安全地从用户空间获取所需的字符串数据。

4.5 从内核空间发生的陷阱(Traps from kernel space)

Xv6 对CPU陷阱寄存器的设置在用户或者内核代码执行的过程有一点不同。当内核在 CPU 上执行时,内核将stvec指向kernelvec的汇编代码 (kernel/kernelvec.S:10)

因为xv6已经在内核当中,kernelvec可以依赖于被设置成内核页表的stap,并且栈指针指向的是有效的内核栈。kernelvec 保存所有寄存器,以便被中断的代码最终可以不受干扰地恢复执行。

重要

  1. stvec:

    stvec(Supervisor Trap-Vector Base Address Register)是一个控制寄存器,用于存储发生陷阱(trap)时,处理器应跳转到的地址。当系统在特权模式下运行,并且发生中断或异常时,处理器会使用 stvec 寄存器中的地址作为入口点来执行相应的陷阱处理程序。

  2. kernelvec:

    kernelvec 是一个汇编程序入口点,用于处理内核模式下的陷阱。当 CPU 在内核模式下运行时,stvec 寄存器会指向 kernelvec,以便在发生陷阱时跳转到此入口点。kernelvec 负责保存所有寄存器的状态,确保中断处理完成后,系统能恢复到中断之前的状态。

  3. satp:

    satp(Supervisor Address Translation and Protection Register)是一个控制寄存器,用于管理地址转换和保护。 satp 寄存器用于设置页表的基地址,并且控制地址转换机制。当 CPU 运行不同的进程时,通过更改 satp 的值,内核可以切换到该进程的页表,从而提供进程间的内存隔离。

kernelvec 将寄存器保存在被中断的内核线程的堆栈上,这是有道理的,因为这些寄存器的值属于该线程。如果陷阱导致切换到另一个线程,这一点尤为重要——在这种情况下,陷阱实际上会在新线程的堆栈上返回,从而使被中断线程的安全地保存寄存器在其堆栈上。

kernelvec在保存寄存器后会跳转到kerneltrap (kernel/trap.c:134)kerneltrap为两种陷阱准备:设备中断和异常。kerneltrap调用devintr (kernel/-trap.c:177)检查和处理前者。如果陷阱不是设备中断,那么它一定是异常,并且如果它发生在xv6内核当中往往是致命错误;内核会调用panic并终止执行。

如果 kerneltrap 是由于定时器中断而被调用,并且一个进程的内核线程正在运行(而不是调度程序线程),kerneltrap 会调用 yield 以让其他线程有机会运行。在某个时刻,其中一个线程会让出,从而允许我们的线程和其 kerneltrap 再次恢复运行。第 7 章解释了 yield 中发生的事情。

kerneltrap 的工作完成时,它需要返回到被陷阱中断的代码。由于 yield 可能已经干扰了保存的 sepcsstatus 中保存的先前模式,kerneltrap 会在开始时保存它们。现在,它恢复那些控制寄存器并返回到 kernelveckernel/kernelvec.S:48)。kernelvec 从堆栈中弹出保存的寄存器并执行 sret,将 sepc 复制到 pc,然后恢复被中断的内核代码的执行。

重要

sret 是 RISC-V 指令集中的一条指令,其主要用途是从陷阱(即异常或中断)中返回到先前的执行状态。具体来说,sret 将以下内容恢复到陷阱发生前的状态:

  1. 程序计数器(pc)sretsepc(保存的异常程序计数器)寄存器的值复制到 pc,使得处理器能够从陷阱发生时的位置继续执行代码。
  2. 处理器模式sret 还会恢复处理器模式(用户模式或监督模式),使其回到陷阱发生前的模式。这通常涉及恢复 sstatus(监督状态寄存器)中的某些位,例如 SPP(监督先前模式)位。

值得思考的是,如果 kerneltrap 因为定时器中断调用了 yield,陷阱返回将如何发生。

当 CPU 从用户空间进入内核时,xv6 将该 CPU 的 stvec 设置为 kernelvec;你可以在 usertrap(kernel/trap.c:29)中看到这一点。在内核执行但 stvec 设置为 uservec 的时间窗口内,至关重要的是设备中断必须被禁用。幸运的是,RISC-V 在开始处理陷阱时总是禁用中断,并且 xv6 直到设置完 stvec 之后才会重新启用它们。

4.6 Page-fault异常 (Page-fault exceptions)

xv6 对异常的响应相当简单:如果异常发生在用户空间,内核会终止故障进程。如果异常发生在内核中,内核会崩溃。真实的操作系统通常会以更有趣的方式做出响应。

例如,许多内核使用页面错误来实现写时复制(COW)fork。要解释写时复制fork,可以参考第3章中描述的xv6的fork。fork通过调用uvmcopy(kernel/vm.c:309)为子进程分配物理内存并将父进程的内存复制到子进程中,使子进程与父进程具有相同的内存内容。如果子进程和父进程可以共享父进程的物理内存,那么效率会更高。然而,直接实现这种方法是不可行的,因为它会导致父进程和子进程在共享的栈和堆上的写操作互相干扰。

父进程和子进程可以使用由页面错误驱动的写时复制(COW)fork安全地共享物理内存。当CPU无法将虚拟地址转换为物理地址时,CPU会生成页面错误异常。RISC-V有三种不同类型的页面错误:加载页面错误(当加载指令无法转换其虚拟地址时)、存储页面错误(当存储指令无法转换其虚拟地址时)和指令页面错误(当指令地址无法转换时)。scause寄存器中的值指示页面错误的类型,而stval寄存器包含无法翻译的地址。

写时复制(COW)fork的基本计划是让父进程和子进程最初共享所有物理页面,但将它们映射为只读。因此,当子进程或父进程执行存储指令时,RISC-V CPU会引发页面错误异常。作为对此异常的响应,内核会复制包含故障地址的页面。它在子进程的地址空间中将一个副本映射为读/写,在父进程的地址空间中将另一个副本映射为读/写。更新页表后,内核会在导致错误的指令处恢复发生错误的进程。由于内核已更新相关的页表项(PTE)以允许写入,因此故障指令现在将无故障执行。

这个写时复制(COW)计划对fork来说效果很好,因为通常子进程在fork之后会立即调用exec,用新的地址空间替换其旧的地址空间。在这种常见情况下,子进程只会经历少量的页面错误,内核可以避免进行完整的复制。此外,COW fork是透明的:应用程序不需要进行任何修改就能从中受益。

页表和页面错误的结合除了COW fork外,还可以实现许多有趣的功能。另一个广泛使用的特性叫做延迟分配(lazy allocation),它包括两个部分。首先,当一个应用程序调用sbrk时,内核扩展地址空间,但在页表中将新地址标记为无效。其次,当这些新地址之一发生页面错误时,内核分配物理内存并将其映射到页表中。由于应用程序通常请求的内存比实际需要的多,延迟分配是一种优势:内核仅在应用程序实际使用内存时才进行分配。与COW fork类似,内核可以透明地实现此功能,使应用程序无需修改便可受益。

另一个广泛使用的利用页面错误的特性是从磁盘分页。如果应用程序需要的内存超过了可用的物理RAM,内核可以驱逐一些页面:将它们写入诸如磁盘之类的存储设备,并将它们的PTE标记为无效。如果应用程序读取或写入一个被驱逐的页面,CPU 将会发生页面错误。然后,内核可以检查故障地址。如果地址属于磁盘上的页面,内核将分配一页物理内存,将页面从磁盘读取到该内存中,更新PTE以使其有效并引用该内存,并恢复应用程序。为了为该页面腾出空间,内核可能需要驱逐另一页。这个特性对应用程序不需要进行任何更改,并且在应用程序具有引用局部性(即,在任何给定时间,它们只使用其内存的子集)时效果很好。

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

4.7 Real world

如果将内核内存映射到每个进程的用户页表中(使用适当的PTE权限标志),则可以消除对特殊的trampoline页面的需求。这也将消除从用户空间陷入内核时的页表切换的需要。这反过来将允许内核中的系统调用实现利用当前进程的用户内存被映射的特性,从而允许内核代码直接引用用户指针。许多操作系统已经使用这些想法来提高效率。Xv6避免使用它们,以减少由于无意中使用用户指针而导致的内核安全漏洞的机会,并减少确保用户和内核虚拟地址不重叠所需的一些复杂性。

4.8 练习

  1. 实现copyincopyinstr函数,使得内核页表映射了用户程序,并且copyincopyinstr可以使用memcpy函数将系统调用参数复制到内核空间,依赖硬件进行页表遍历。
  2. 实现惰性内存分配。
  3. 实现写时复制(COW)fork。