MIT6828 学习笔记 012 (book::c4)

MIT6828 学习笔记 012 (book::c4)

RayAlto OP

Trap 和系统调用

有三种事件会使 CPU 先停下正在执行的普通指令,强制将控制权转移到处理这种事件的特殊代码:

  1. 系统调用,用户程序执行 ecall 指令请求内核为它做事
  2. exception (异常),一条指令(用户指令或内核指令)进行了非法操作(如用一个数除以 0 或使用一个无效的虚拟地址)
  3. interrupt (设备中断),某个设备表示自己需要被注意(如某个磁盘硬件完成了一个读/写请求)

本书用 trap 统一表示这三种事件。通常情况下,不管 trap 时执行的是什么代码,之后都需要继续执行,且不应该感知到期间发生了什么,也就是说 trap 应该是透明的,这对于设备中断来说特别重要,中断代码通常不会注意到这一点。通常情况下 trap 强制把控制权转移进内核,内核把寄存器的内容和其他数值保存好以便后面恢复运行,然后执行对应的处理代码(比如系统调用实现或设备驱动),最后恢复寄存器和其他数值的内容后继续执行原来的代码。

Xv6 在内核里处理所有 trap ,且不会交给用户代码。系统调用自然而然要在内核里处理;设备中断因为操作系统对隔离的需求以及内核提供的在多个进程间共享设备的手段也可以在内核里处理;因为 Xv6 会终结所有引起异常的用户进程,所以异常也可以在内核里处理。

Xv6 处理 trap 分 4 步: RISC-V CPU 进行一些硬件操作;一些为内核的 C 代码做准备的汇编指令;决定 trap 如何处理的 C 函数;系统调用/设备代码。内核可以通过同一个代码路径处理这三种 trap 类型更加说明了这三种的共通性,把这些归为三种情况处理更加方便:

  1. 来自用户空间的 trap
  2. 来自内核空间的 trap
  3. timer interrupt (计时器中断)

内核里处理 trap 的代码(汇编或 C )通常称作 handler ,其第一个指令通常由汇编编写(而不是 C ),这个指令有时称作 vector 。

1. RISC-V trap 机制

每个 RISC-V CPU 都有一套控制寄存器,内核通过写入这些寄存器来告诉 CPU 如何处理 trap ,或读取这些寄存器来找出发生了的 trap ,具体可以看 RISC-V 文档, kernel/riscv.h 包含了所有 Xv6 用到的寄存器:

  • stvec: 内核把它的 trap handler 地址写在这里, RISC-V 就会跳到这里面的地址来处理 trap
  • sepc: 发生 trap 时 RISC-V 会把 PC 保存在这里(因为 pc 随后就会被 stvec 的值覆盖),处理完成后 sret 指令(从 trap 返回)把 sepc 复制进 pc ,内核可以通过这个寄存器控制 sret 指令的返回位置
  • scause: RISC-V 在这里放一个数表示 trap 的原因
  • sscratch: trap handler 使用这个寄存器辅助自己避免在保存用户寄存器之前覆盖掉它们
  • sstatus: SIE 位控制是否启用设备中断,如果内核清除了 SIE 位, RISC-V 会把设备中断推迟到内核设置了 SIE 位。 SPP 位表示 trap 来自 user mode 还是 supervisor mode 并控制 sret 指令返回到什么 mode

这些 trap 相关的寄存器都在 supervisor mode 下被处理,且不能在 user mode 下被读/写,还有一套类似的用于处理 trap 的控制寄存器在 machine mode 下被处理, Xv6 只在 timer interrupt 的特殊情况下使用这套寄存器。

多核芯片的每个 CPU 都有自己的这几套寄存器,在某个时间下,可能多个 CPU 在处理 trap 。

当需要强制设置 trap 时, RISC-V 硬件对除了 timer interrupt 的所有类型的 trap 执行如下操作:

  1. 如果 trap 是设备中断,且 sstatus 的 SIE 位没有被清掉(启用设备中断),不继续执行下面的操作。
  2. 清掉 sstatus 的 SIE 位来禁用中断。
  3. pc 复制给 sepc
  4. 把当前的 mode (user/supervisor) 保存到 sstatus 的 SPP 位。
  5. 设置 scause 表示 trap 原因。
  6. 切换到 supervisor 模式。
  7. stvec 复制进 pc
  8. pc 开始继续执行。

注意 CPU 不会切换到内核分页表、不会切换到内核里的栈、也不会保存 pc 以外的寄存器,这些任务必须由内核完成, CPU 如此摆烂的一个原因是这样给软件提供了更大的灵活性,比如一些操作系统忽略分页表的切换来提高 trap 性能。

2. 来自用户空间的 trap

Xv6 对于执行内核代码或执行用户代码时发生的 trap 有不同的处理方式,这一节介绍来自用户代码的 trap 。

在用户空间运行时,用户程序调用系统调用(通过 ecall 指令)、进行了非法操作或发生了设备中断都有可能发生 trap ,处理的过程是: uservec (kernel/trapmoline.S:21) → usertrap (kernel/trap.c:37) → usertrapret (kernel/trap.c:90) → userret (kernel/trampoline.S:101) 。

Xv6 对 trap 处理的设计的一个主要限制是 RISC-V 硬件在强制执行 trap 时不会切换分页表,这意味着 stvec 中的 trap 处理程序地址必须在用户分页表中有一个有效的映射,因为这个分页表是 trap 处理代码开始执行时需要使用的;此外, Xv6 的 trap 处理代码需要切换到内核分页表,为了能在切换后继续运行,内核分页表也需要有 stvec 指向的 trap 处理程序的映射。

Xv6 通过 trampoline 分页满足这些要求,这个分页包含 stvec 指向的 Xv6 处理 trap 的代码 uservec ,且被映射到所有进程(包括内核)的分页表的 TRAMPOLINE 地址(虚拟地址的顶部,程序自己使用的内存之上)。因为 trampoline 在用户分页表上的映射没有 PTE_U flag ,所以 trap 可以先以 supervisor mode 在那里运行,切换到内核后因为内核分页表在同一地址上也映射了 trampoline ,所以 trap 可以继续运行。

trap 处理代码 uservec (kernel/trampoline.S:21) 开始执行后,所有 32 个寄存器里归用户代码所有的值都需要被保存到内存中的某个位置以便于 trap 返回到用户空间时恢复,这时还需要一个寄存器来记录保存到的内存地址,但已经没有通用寄存器可以用了( 32 个通用寄存器的值都需要保存起来)!幸运的是 RISC-V 提供 sscratch 寄存器来解决这个问题, uservec 开头 csrw 指令把 a0 寄存器的值保存到 sscratch 寄存器,这样 uservec 就可以用 a0 寄存器实现目的。

uservec 接下来需要保存 32 个用户寄存器,内核给每个进程都分配了一个 trapframe 分页来存放一个 struct trapframe (kernel/proc.h:43) ,可以保存 32 个用户寄存器,目前 satp 寄存器(存放根分页表地址,设为零以禁用虚拟地址)仍然指向用户分页表, uservec 需要用户地址空间也有 trapframe 映射,而 Xv6 给每个进程都在分页表的 TRAPFRAME 虚拟地址映射了自己的 trapframe ,就在 TRAMPOLINE 下面, p->trapframe 也指向这个 trapframe 的物理地址,这样内核就可以通过内核分页表使用进程的 trapframe 。

uservec 先把 TRAPFRAME 放进 a0 (kernel/trampoline.S:36) ,然后把所有用户寄存器的值放进去(也就是放进了 struct trapframe 里, a0 可以在 sscratch 里读出来)。

trapframe 包含了当前进程的内核栈地址、当前 CPU 的 hartid 、 usertrap 函数的地址和内核分页表的地址, uservec 存好这些值后使 satp 指向内核分页表,然后调用 usertrap (进入了 kernel/trap.c:36 )。

usertrap 需要判断 trap 的原因、处理然后返回。首先让 stvec 指向 kernelvec (kernel/kernelvec.S:12) 使接下来的 trap 直接由内核处理(因为现在已经进入了内核,不需要再用 uservec 了),然后保存 sepc 寄存器(记录用户 PC ),因为 usertrap 可能会调用 yield 来切换到另一个进程的内核线程。如果 trap 是系统调用, usertrap 会调用 syscall 来处理这个 trap ; devintr 返回非零表示是设备中断;否则这个 trap 是异常,内核会终止发生异常的进程。因为 RISC-V 在系统调用的情况下会放着用户 PC 指向 ecall 指令不管,而当系统调用返回后应该回到 ecall 下面的一条指令才能继续运行用户进程,所以 uservec 会给用户进程的 PC 加 4 。返回时, usertrap 会检查进程是否已经被终止或 timer interrupt 情况下需要调用 yield

返回用户空间的第一步是调用 usertrapret (kernel/trap.c:89) , usertrapret 会设置好 RISC-V 控制寄存器来准备处理后面来自用户空间的 trap ,具体步骤有使 stvec 重新指向 uservec ,准备好 uservec 需要用到的 trapframe 字段,把 sepc 的值恢复到之前保存的用户 PC ,最后调用在用户分页表和内核分页表都有映射的 trampoline 分页上的 userret (kernel/trampoline.S:101) 来切换到用户分页表。

usertrapret 调用 userret 时会把进程的用户分页表以指针的形式通过 a0 传过去, userretsatp 切换回进程的用户分页表,这里需要注意的是因为内核分页表和用户分页表都在同一个虚拟地址上映射了 trampoline ,所以 userret 才可以在 satp 被修改后继续运行,然后把 trapframe 地址放进 a0 ,恢复用户寄存器的值,最后 sret 回用户空间。

总结起来,程序调用系统调用后,内核需要:保存 32 个用户寄存器和 PC ;切换到 supervisor mode ;切换到内核分页表;切换到内核栈;跳转到内核的 C 代码。

问:为啥要有 supervisor mode 和 user mode ,这种设计在保护什么?答: supervisor mode 可以读写 CPU 的控制寄存器 satp (分页表的物理地址)、 stvececall 跳转到的内核地点,指向 trampoline )、 sepcecall 指令把用户 PC 保存到这里)、 sscratch (用于暂存 a0 ),且可以使用没有 PTE_U flag 的 PTE 。

3. 代码:调用系统调用

之前讲到 user/initcode.S 调用 exec ,现在可以展开这个过程: initcode.Sexecargc 参数放进 a0argv 参数放进 a1 ,系统调用号放进 a7 ,然后调用 ecall ,经过上面讲的过程进入内核。

这一节有好多内容都在之前就已经明白了,记在了之前的笔记里,这里就不记录重复内容了。

4. 代码:系统调用参数

用户代码调用系统调用的包装,这个包装遵循 RISC-V 调用约定,把参数放进参数寄存器里,内核的 trap 代码把这些寄存器放进用户进程的 trapframe 里方便内核通过 argraw 函数以及其封装来获取对应位置的系统调用参数。

也有好多重复内容,这里不记录了。看来之前读代码的时候用力过猛了,这里解释的很清楚,当时没必要那么死磕的。

5. GDB: 观察 Xv6 Shell 的 write 系统调用

Xv6 Shell 会输出 "$ " (user/sh.c:137) 后读取用户输入。 user/sh.asm 里可以看到 write 的地址为 0xe2c ,打开 GDB ,打个断点 b *0xe2cc 开始运行, x/3i 0xe2c 可以看到系统调用的前后:

1
2
3
=> 0xe2c <write>:       li      a7,16
0xe2e <write+2>: ecall
0xe32 <write+6>: ret

info reg 查看寄存器的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ra             0x24     0x24 <getcmd+36>
sp 0x4f80 0x4f80
gp 0x505050505050505 0x505050505050505
tp 0x505050505050505 0x505050505050505
t0 0x505050505050505 361700864190383365
t1 0x505050505050505 361700864190383365
t2 0x505050505050505 361700864190383365
fp 0x4fa0 0x4fa0
s1 0x2020 8224
a0 0x2 2
a1 0x1330 4912
a2 0x2 2
a3 0x505050505050505 361700864190383365
a4 0x505050505050505 361700864190383365
a5 0x2 2
a6 0x505050505050505 361700864190383365
a7 0x15 21
s2 0x64 100
s3 0x20 32
s4 0x505050505050505 361700864190383365
s5 0x505050505050505 361700864190383365
s6 0x505050505050505 361700864190383365
s7 0x505050505050505 361700864190383365
s8 0x505050505050505 361700864190383365
s9 0x505050505050505 361700864190383365
s10 0x505050505050505 361700864190383365
s11 0x505050505050505 361700864190383365
t3 0x505050505050505 361700864190383365
t4 0x505050505050505 361700864190383365
t5 0x505050505050505 361700864190383365
t6 0x505050505050505 361700864190383365
pc 0xe2c 0xe2c <write>

pcsp 都在低地址,因为用户内存从 0 开始,系统调用的参数在参数寄存器里 a0 值为 2 ,是参数 fda2 值为 2 ,是参数 nx/2c $a1 可以打印 a1 的内容:

1
0x1330: 36 '$'  32 ' '

是参数 buf 。现在的分页表是什么样的? print/x $satp 打印分页表地址好像没啥用,切到 qemu console info mem 可以看到 8 个分页:

1
2
3
4
5
6
7
8
9
10
vaddr            paddr            size             attr
---------------- ---------------- ---------------- -------
0000000000000000 0000000087f58000 0000000000001000 r-xu-a-
0000000000001000 0000000087f55000 0000000000001000 r-xu-a-
0000000000002000 0000000087f54000 0000000000001000 rw-u---
0000000000003000 0000000087f53000 0000000000001000 rw-----
0000000000004000 0000000087f52000 0000000000001000 rw-u-ad
0000003fffffd000 0000000087f59000 0000000000001000 r--u---
0000003fffffe000 0000000087f6c000 0000000000001000 rw---ad
0000003ffffff000 0000000080007000 0000000000001000 r-x--a-

从上到下分别是:两个指令分页、数据分页、保护分页(没有 U flag )、栈分页、未知、 trapframe 、 trampoline ,没有内核内存、设备、物理内存的映射。 si 走一步, print $pc 发现代码走到了很高的地址, x/6i $pc 可以发现走到了 trampoline.S:31 , info reg 里除了 pc 以外没什么变化, qemu shell info mem 发现这时仍然使用用户分页表,而当前 pc 的地址正好是在最后一个分页的开头,也就是说当前正在从 trampoline 开始执行代码,这个分页没有 U flag ,内核仍在正常运行说明了这时已经切换进了 supervisor mode 。程序怎么走到这里的: ecall 干的,从 user mode 切换到 supervisor mode 、把 pc 保存到 epc 、把 pc 设为 stvec 来跳转到 stvec

我的 ArchLinux 提供的 riscv64-linux-gnu-gdb stepi 会直接跳过 ecall 指令,可以 gdb shell print/x $stvec 拿到 trap 处理代码的起点地址,直接 b *$stvec 在这个地址设一个断点,然后就可以 c 进去

现在需要做的:保存 32 个用户寄存器的值(为了后面“透明地”恢复运行)、切换到内核分页表、给内核 C 代码设置好栈、跳转到内核的 C 代码。为什么 RISC-V 不能自动完成这些操作:把这些的控制权交给操作系统提高灵活性、可能内核和用户进程都映射进了同一个分页表(就不需要切换分页表了)、一些寄存器可能不需要被保存、可能有些简单的系统调用不需要一个栈。如何保存那些寄存器: Xv6 在 trampoline 下面还映射了 trapframe 用来保存这些寄存器的值、 RISC-V 提供了仅在 supervisor mode 下可用的 sscratch 寄存器(用户代码不能访问,所以不需要算在需要保存的寄存器里)

kernel/trampoline.S:75 把之前内核准备好的栈(放在了 p->trapframe->kernel_sp )放在 sp 里, :81 把 usertrap (kernel/trap.c) 的地址放进了 t0 , :85 把内核分页表的地址放进了 t1 ,随后发起了 sfence 清理 TLB , :92 切换了分页表, si 还能继续运行的原因是 trampoline 分页在用户分页表和内核分页表映射在同一个虚拟地址,这时用 qemu shell info mem 可以发现现在用的是内核分页表,现在可以使用内核里的函数和数据了。 :98 跳转进 t0 (usertrap) 。 usertrap 会调用 syscall 把对应系统调用的实现的返回值放进 p->trapframe->a0 ,处理好之后会进入 usertrapret ,快要返回到用户程序了,这时需要为下次用户到内核的切换做准备:把 uservec (kernel/trampoline.S) 的地址填进 stvec 、重新给 p->trapframe 的一些字段赋值使进程准备好下一次 trap ,最后 a0 带着返回值返回到用户代码。

6. 来自内核空间的 trap

Xv6 根据发生 trap 时执行的是用户代码还是内核代码采取有点不同的方式配置 CPU 的 trap 寄存器。当 CPU 正在运行内核,内核会使 stvec 指向 kernelvec (kernel/kernelvec.S:12) ,因为 Xv6 已经在内核里了, kernelvec 可以使用表示内核分页表的 satp 以及一个有效的内核栈,把 32 个寄存器的值放进栈里,以便于之后继续执行之前的代码。

kernelvec 把寄存器保存在被打断的内核线程的栈上(因为这些寄存器的值属于这个线程,所以这样做很合理,比如切换线程后这些寄存器的值还在),然后跳转到 kerneltrap (kernel/trap.c:134) ,它可以处理设备中断和异常两种 trap :调用 devintr (kernel/trap.c:178) 检查并处理设备中断,如果不是设备中断则一定是异常,且这个发生在 Xv6 内核的异常大多是一个致命的错误,这时内核调用 panic 并停止运行。

kerneltrap 完成自己的工作后需要回到被 trap 打断前的代码,从栈里恢复控制寄存器的值(如果调用了 yield 会导致 sepcsstatus 值被覆盖)并执行 sret 指令回到被打断的内核代码。

思考:如果 kerneltrap 因为 timer interrupt 调用了 yield , trap 如何返回

当 CPU 从用户空间进入内核时, Xv6 把 stvec 设置成 kernelvec ,这期间有一定的间隔,在这个间隔中内核开始运行了而 stvec 仍然指向 uservec ,如果在这个间隔中发生了设备中断会导致严重问题,幸运的是 RISC-V 会在开始捕获 trap 时禁用中断,也就是说在 Xv6 重新设置 stvec 前不会接收到新的中断

7. 分页错误异常 (page-fault exceptions)

Xv6 对异常的处理十分平淡:如果是用户空间发生的异常,则终止这个进程;如果是内核发生的异常,则 panic 。现实生活中的操作系统的处理方式会更有趣,比如许多内核使用分页异常来实现 copy-on-write ( COW 写时复制)的 fork , Xv6 这边会使用 (kernel/proc.c:310) uvmcopy (kernel/vm.c:305) 给子进程复制一个与父进程完全相同的分页表(会 kalloc 一块一样大的物理内存),而 fork 后一般会接 exec 再创建(并分配空间)一个新得分页表替换掉刚刚从父进程复制(并分配空间)过来的分页表,这样会造成性能损失,可以让子进程先用父进程的分页表避免损失,但直接这么写是行不通的。然而可以利用父子进程每次写入共享的堆和栈时产生的异常,通过给分页表设置合适的权限,来安全地使用共享的物理内存。当尝试使用在分页表中没有映射或者映射没有 V flag 又或者 R, W, X, U 等权限 flag 与操作不符的虚拟地址时, CPU 会发起分页错误异常。 RISC-V 区分三种分页错误:加载分页错误( load page fault 加载指令的虚拟地址转换失败)、存储分页错误( store page fault 存储指令的虚拟地址转换失败)和指令分页异常( PC 里的虚拟地址转换失败), scause 寄存器会指示分页错误的类型, stval 寄存器存储转换失败的虚拟地址。

COW fork 的基本计划是:父子进程最初共享所有物理分页,但都采用只读映射(去掉 W flag ),父子进程都可以读取这块共享的虚拟内存,但当一方想要写入其中一个分页, RISC-V CPU 会发起分页错误异常,内核的 trap handler 分配一个新的物理内存分页并把发生错误的地址映射的物理分页的内容复制进去来响应这个异常,然后内核使发生错误的进程的对应 PTE 指向这个新分配的物理内存并设置 W flag 来允许进程读写这个地址,这样重新执行之前发生错误的指令就不会再发生错误了。因为这样做会导致一个分页可能被多个进程的分页表映射,且这个数目还在变化( fork 、分页错误、 execexit 引起的),所以 COW 需要一个记录来确定物理分页是否可以被释放,这个记录提供了一个重要的优化:如果进程引起了存储分页错误且这个物理分页只被这个进程的分页表引用,则不需要复制。

COW 使 fork 速度更快,因为现在 fork 不需要分配并复制物理内存,只需要在后面写入时复制必要的分页(但实际上大多数情况下后面会接 exec 所以大多数分页不会需要被复制)。 COW 消除了复制每一个分页的必要,而且 COW fork 是透明的(应用程序不需要重新编写就可以得到优化)。

除了 COW fork ,分页表和分页错误的结合还开拓出了很大范围的可能性,另一个被广泛使用的特性称作 lazy allocation (延迟分配),这个特性分为两个部分:程序通过 sbrk 请求更大的内存空间时内核先记下增长空间的大小但不立即分配物理内存或给新的一段虚拟地址创建 PTE ;当新的一段虚拟地址发生了分页错误,内核才分配物理内存并把它映射到分页表。和 COW fork 一样, lazy allocation 对于应用程序来说也是透明的。因为应用程序通常请求比自己需求更多的内存, lazy allocation 对于这种情况来说是一个优化:内核不会给应用程序分配应用程序用不到的分页。除此之外,如果应用程序一下请求了很大的内存空间, lazy allocation 可以把分配空间的操作按照运行时间平均分摊。但 lazy allocation 同时增加了分配内存时内核-用户的转换成本,操作系统可以使用对这种分页错误特化的内核的入口/退出代码来一次性分配很多分页而不是一个分页来降低这个成本。

还有一个被广泛使用的特性称作 demand paging (按需分页)。 exec 时 Xv6 立即把应用程序的所有代码和数据放进内存里,如果这个应用程序体积很大,磁盘读取可能会有很高的成本。现代的内核会通过给用户的地址空间创建分页表时给 PTE 去掉 V flag 并在发生分页错误时才开始从磁盘中读取分页的内容并把它映射到用户地址空间。和 COW fork 以及 lazy allocation 一样, demand paging 对于应用程序来说也是透明的。

程序有可能需要比计算机能提供的更大的内存空间,操作系统还可能通过 paging to disk (分页到磁盘)来优雅地应对,只在 RAM 里存储一小部分用户分页,把剩下的保存到磁盘的 paging area (分页区),内核把保存到 paging area 的分页的 PTE 的 V flag 去掉,这样应用程序使用这样的分页时会引起分页错误,这时内核会分配一个物理内存分页,把磁盘上对应的分页读进物理内存分页,并使程序对应的 PTE 指向这个物理内存分页,如果分配这个物理内存分页时没有可用内存,内核必须先把一个物理内存分页释放掉或者放进磁盘分页区并去掉对应 PTE 的 V flag ,但放进磁盘分页区的成本很高,所以那种分页不频繁,总是使用一系列 RAM 可以容纳的大小的内存空间的程序的性能最好。和很多虚拟内存技术一样,内核对 paging to disk 的实现通常也是对应用程序透明的。

还有一些特性也是结合分页和分页错误异常来实现的,比如自动扩展的栈和映射在内存的文件。

8. 联系现实

trampoline 和 trapframe 可能看起来过分复杂了,其中一个主要因素是 RISC-V 为了提供灵活性对于 trap 的处理十分摆烂(事实证明这也是有必要的),结果就是内核的 trap handler 起初的一些指令需要在用户环境下(用户的分页表、含有用户的内容的寄存器等)执行,且 trap handler 起初无法得知一系列有用的信息(比如当前运行的进程的实例和内核分页表的地址),但内核提供了一些受保护的空间来在进入用户空间之前存放一些信息: sscratch 寄存器、通过去掉 U flag 的指向内核内存的用户 PTE , Xv6 的 trampoline 和 trapframe 利用了这些 RISC-V 特性。

如果内核内存被映射到每个进程的用户分页表(带上合适的 PTE flags )的话,对于特殊的 trampoline 分页的需求可以被消除,同时还可以消除从用户空间 trap 仅内核时分页表切换的需求,这样还可以允许内核里的系统调用的实现利用当前映射的进程用户内存(直接访问用户指针指向的内容),很多操作系统都使用这个点子来提高性能。 Xv6 为了减少因为无意的使用用户指针导致的内核的安全漏洞和降低确保用户和内核的虚拟地址不重叠所需的复杂度不用这个特性。

生产环境的操作系统一般都有丰富的特性,但 Xv6 没有。