Lec6 System Call Entry/Exit

trap 处理,trampoline 和 trapframe。
Published

2024-03-19

Modified

2024-10-23

1 课前准备

Read Chapter 4, except 4.6 and kernel/riscv.h, kernel/trampoline.S, and kernel/trap.c.

有三种事件会使 CPU 暂停普通指令的执行并强制转换到处理该事件的特殊代码:系统调用、异常(exception)、设备中断(interrupt)。我们使用 trap 作为这些情况的通用术语。处理 trap 后需要恢复代码,并且要有无事发生的感觉,这需要 trap 是透明的(到这里终于明白第一章所说的在进程间透明地切换是什么意思了)。trap 在内核中处理是自然的,这样保证了隔离性。

xv6 的 trap 处理分四个阶段:RISC-V CPU 采取硬件措施、汇编指令为内核 C 代码准备、C 函数决定如何处置 trap、进入系统调用或硬件中断的常规处理流程。三种不同情况对应三种处理代码,其中处理 trap 的内核代码(汇编或 C)称为 handler;第一个handler 的指令通常用汇编程序(而不是 C)编写,有时称为 vector 。

1.1 RISC-V trap 机制

每个 RISC-V CPU 都有一组控制寄存器,内核写入这些控制寄存器来告诉 CPU 如何处理 trap,并且内核可以读取这些寄存器来找出已发生的 trap。以下是最重要的寄存器的概述:

  • stvec(Supervisor Trap Vector Base Address Register):内核将其 trap 处理程序的地址写入此处;RISC-V 跳转到 stvec 中的地址来处理 trap。

  • sepc(Supervisor Exception Program Counter):当 trap 发生时,RISC-V 将 pc 保存在这里(因为 pc 随后会被 stvec 中的值覆盖)。sret (从 trap 返回)指令将 sepc 复制到 pc 。内核可以写 sepc 来控制 sret 的去向。

  • scause:trap 的原因,用一个数字表示。

  • sscratch(Supervisor Scratch Register):trap handler 用这个来避免用户寄存器在保存之前被覆写。

  • sstatus:这个寄存器的 SIE 位控制设备中断是否启用。如果内核清除了 SIE,RISC-V 将推迟设备中断,直到 SIE 重新设置。SPP 位指示 trap 是来自用户模式还是监督模式,并控制 sret 返回的模式。

以上寄存器只能在监督模式下使用,对于机器模式下处理的 trap,有一组类似的控制寄存器;xv6 仅将它们用于定时器中断的特殊情况。当需要强制 trap 时,RISC-V 硬件会对所有 trap 类型(定时器中断除外)执行以下操作:

  1. 如果是设备中断,并且 sstatus SIE 位清零,则不要执行以下任何操作。
  2. 通过清除 sstatus 中的 SIE 位来禁用中断。
  3. pc 复制到 sepc
  4. 将当前模式(用户或管理员)保存在 sstatus 的 SPP 位中。
  5. 调整为监督模式。
  6. stvec 复制到 pc
  7. 在新的 pc 下开始执行。

剩下的未决事务,如切换内核页表与堆栈等,为保持灵活性,交给内核软件处理。

1.2 暂停一下

读到下一节的时候,越发感觉语焉不详,所以这次调整一下,先看 lecture,剩下的回来处理。

2 Lecture

以 write 系统调用为例,追踪内核的执行进程。

write 通过 ecall 执行系统调用,ecall 切换到监督模式的内核中。在这个过程中,内核中执行的第一个指令是一个由汇编语言写的函数,叫做 uservec。之后,在这个函数中,代码跳转到了由 C 实现的 usertrap 中。在这个函数中执行了一个 syscall 的函数,这个函数会在一个表单中,根据传入的代表系统调用的数字进行查找,并在内核中执行具体实现了系统调用功能的函数。对于我们来说,这个函数就是 sys_writesys_write 会将要显示数据输出到 console 上,当它完成了之后,它会返回给 syscall 函数。在syscall 函数中,会调用一个函数叫做 usertrapret,这个函数完成了部分方便在 C 代码中实现的返回到用户空间的工作。除此之外,最终还有一些工作只能在汇编语言中完成。这部分工作通过汇编语言实现,并且存在于 userret 函数中。最终,在这个汇编函数中会调用机器指令返回到用户空间,并且恢复 ecall 之后的用户程序的执行。

用户空间的 trampoline page 负责执行最初的处理,但是此时已在监督模式下的内核中,因为 ecall 并没有切换页表。更详细地,ecall 只做了三件事(其实就是上面“RISC-V trap 机制”一节提到的过程)。但我们实际离执行内核中的C代码还差的很远。接下来:

  1. 我们需要保存 32 个用户寄存器的内容,这样当我们想要恢复用户代码执行时,我们才能恢复这些寄存器的内容。
  2. 因为现在我们还在用户地址空间,我们需要切换到内核地址空间。
  3. 我们需要创建或者找到一个内核栈,并将 sp 寄存器的内容指向它。
  4. 我们还需要跳转到内核中 C 代码的某些合理的位置。

首先解决第一个问题,每个用户页表都有一块 trapframe 页,里面有对应 32 个寄存器的空槽位,我们可以将寄存器储存进去。同时借助 sscratch 寄存器,可以先将 a0 寄存器转移走,从而借助 a0 作为桥梁将寄存器全部储存完毕。sscratch 中有 trapframe 页的地址,这是在上一次从内核返回到用户空间的 usertrapret 函数中设置的。同理,trapframe 也有来自上一次返回设置好的内核栈 sp 寄存器、表示 CPU 核的标号的 tp 寄存器、usertrap 函数的指针、内核页表的指针,分别对应了后三步的解决方法。

usertrap 中,首先将 stvec 指向了 kernelvec ,这是处理内核中出现的中断和异常的函数(而非现在的从用户空间来的 trap)。后面打开了对应的 sstauts 的 SIE 位。并调用了对应的 sys_write 函数。返回后,进入 usertrapret

usertrapret 主要是为返回用户空间之前做恢复,包括设置一系列上面所说的 trapframe 中存贮的关于内核的数据,这是一个对称的过程。usertrapret 里也关闭了中断。最后跳转回 userret 函数。

userret 函数仍然位于 trampoline 页,因为映射了用户与内核间地址空间的恒等映射,因此在这里完成最后的交接工作,包括所有 32 个寄存器以及 sret 指令(程序切换回用户模式、sepc 拷贝到 pc、重新打开中断)。此时一次系统调用完满结束。

3 课前准备:RE

3.1 用户空间的 trap

lecture 正好对应了整个这一节,看完 lecture 后这里就很清楚了。

3.2 内核空间的 trap

由于发生 trap 时已经处于内核中,kernelvec 会直接将 32 个寄存器压进栈里,并待稍后恢复。之后跳转到 kerneltrap 里,kerneltrap 会为设备中断和异常分别实现不同的处理。如果是异常,则是致命错误,直接 panic。对于设备中断和 yield 在 Chapter 7 有更详细的描述。

3.3 Real World

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

产品级操作系统实现了 copy-on-write fork、延迟分配 (lazy allocation)、按需分页 (demand paging)、页面置换到磁盘 (paging to disk)、内存映射文件 (memory-mapped files) 等功能。此外,产品级操作系统会尽量利用所有物理内存用于应用程序或缓存。相比之下,xv6 在这方面相对简单:你希望你的操作系统利用你支付的物理内存,但 xv6 没有这样做。此外,如果 xv6 内存不足,它会向正在运行的应用返回错误或终止它,而不是逐出另一个应用的页。