4.1 RISC-V trap machinery

  • 陷入 (trap) 是一个广泛的术语, 包括了:

    • 系统调用 (由 ecall 指令触发);
    • 异常 (包括除零异常 (divide by zero) 和缺页异常等);
    • 设备中断 (包括来自外部设备的硬件中断 (例如硬盘读写事件) 和来自内部设备的软件中断 (例如手动设置 SIP 寄存器所触发的软件中断)).
  • RISC-V 下与陷入有关的几个重要寄存器:

    • stvec: 用于保存当触发内核模式陷入时程序执行流将会跳转到的地址.
    • sepc: 触发内核模式陷入时 PC 的值将被自动保存至此.
    • scause: 用于保存陷入的触发原因 (一个整数).
    • sscratch: 用于保存一些提前准备好的数据和上下文信息, 以便在处理陷入时使用.
    • sstatus: 包含一些重要的标志位. 例如 SIE 指示设备中断是否启用, SPP 指示陷入来自用户空间还是内核空间等等.
  • 当陷入被触发时, RISC-V 硬件将自动执行如下操作:

    • 如果本次陷入为设备中断并且 SIE 被置零, 则什么也不做.
    • 否则将 SIE 置零 (表示禁用中断).
      • 注意这里并不意味着本次陷入也是中断, 实际上本次陷入可以是系统调用, 异常, 以及设备中断中的任意一种.
    • 将 PC 复制到 sepc.
    • 将当前的模式 (用户模式或内核模式) 保存至 SPP 中.
    • 设置 scause 用来指示本次陷入的原因.
    • 将当前模式切换为内核模式.
    • stvec 复制到 PC 中.
    • 跳转到 stvec 所指示的陷入处理函数的入口处.

    注意上述操作并没有包括切换内核页表, 切换内核栈, 以及保存任何其他寄存器等等, 这些工作需要由内核亲自来完成.

  • 陷入触发时强制跳转至 stvec 的操作具有重要意义. 考虑不强制跳转的情况, 此时的模式为内核模式, 但程序执行流仍然停留在用户代码上, 而这些代码完全可以是恶意代码, 从而轻易击破内核的安全性.

4.2 Traps from user space

  • 由于 RISC-V 硬件并不会在陷入被触发时切换页表, 为了能够使其成功跳转至 stvec 寄存器所指向的处理函数的入口 (在 xv6 即 uservec 函数), 用户的页表中就必须含有该处理函数的入口所在的物理页面的映射; 同时 uservec 函数内部还要确保将 satp 切换为内核页表; 为了能够在切换页表后继续执行 uservec 函数, 内核页表中也必须含有 uservec 所在的物理页面的映射, 并且映射到的虚拟页面地址也必须相同. 在 xv6 中, uservec 所在的物理页面由文件 kernel/trampoline.S 中的符号 .section trampsec 和文件 kernel/kernel.ld 中的布局所指定, 而其虚拟页面地址正是文件 kernel/memlayout.h 中的 TRAMPOLINE (蹦床) 宏所指定的地址 (即虚拟地址空间中的最高位页面).
  • uservec 函数中还需要对陷入之前的所有寄存器中的值进行备份, 这些备份将被存储到每个进程各自独立的陷入栈帧 (trapframe) 中. 由于对寄存器的备份发生在页表切换之前, 因此同样需要在用户页表中维护陷入栈帧页面的映射. 在 xv6 中陷入栈帧的物理地址由 allocproc 函数 (其定义位于文件 kernel/proc.c 中) 在初始化进程时进行分配, 而其虚拟页面地址正是文件 kernel/memlayout.h 中的 TRAPFRAME (陷入栈帧) 宏所指定的地址 (即虚拟地址空间中的高位页面).

4.3 Code: Calling system calls

第二章讲到 initcode.S 调用了 exec 系统调用, 下面详细介绍一下该过程.

  1. initcode.S 负责将 exec 所需要的两个参数放在寄存器 a0a1 中, 然后将代表 exec 的系统调用代码 SYS_exec 放在寄存器 a7 中, 最后调用 ecall 指令.
  2. ecall 指令将触发一次陷入, 进程切换至内核模式并跳转至 uservec 函数 (位于文件 kernel/trampoline.S 中), 然后调用 usertrap 函数并最终调用 syscall 函数.
  3. syscall 使用 a7 中的系统调用代码来索引系统调用表 syscalls 得到系统调用的实际实现代码的入口地址 (本例中即 sys_exec) 并执行. 当 sys_exec 返回时, syscall 再将其返回值放置在寄存器 a0 中, 这也就是系统调用 exec 的最终返回值.

4.4 Code: System call arguments

本小节主要介绍系统调用的参数传递过程和内核如何安全获取由用户传入的指针类型参数所指向的数据.

  • 用户代码执行系统调用之前会先将参数存储在 C 语言调用规则约定的地方——寄存器中. 触发陷入后, uservec 函数会将寄存器中的参数备份至当前进程的陷入栈帧中, 这样后续调用的内核代码便能够获取到用户程序所传入的参数.

    • 函数 argraw (位于文件 kernel/syscall.c 中) 用于从陷入栈帧中获取并返回寄存器参数.
    • 函数 argint, argaddrargfd 通过调用 argraw 函数分别以整型, 指针或文件描述符形式获取并返回系统调用的第 n 个参数.
  • 一些系统调用要求用户以指针的形式传入所需数据, 内核必须负责从这些用户指针指向的内存中读取或写入数据. 为了能够做到这一点, 内核有两个问题需要解决:

    1. 解决用户页表映射与内核页表映射不一致的问题.
    2. 确保用户指针是合法且安全的 (即不会指向例如内核地址空间等非法区域);

    内核使用某些专门编写的函数来安全地从用户给出的地址中读取或向其中写入数据. 一个例子便是 fetchstr 函数 (位于文件 kernel/syscall.c 中). 顾名思义, fetchstr 函数的作用是从用户地址空间中读取字符串. fetchstr 函数在内部调用了 copyinstr 函数 (位于文件 kernel/vm.c 中). copyinstr 函数从用户页表 pagetable 下的虚拟地址 srcva 处的位置复制至多 max 字节至 dst 处. 由于用户页表与内核页表并不相同, copyinstr 进一步调用 walkaddr (位于文件 kernel/vm.c 中) 来从用户页表 pagetable 中获取 srcva 对应的物理地址 pa0, 而在内核模式下由于全局内核页表维护的正是到物理内存的恒等映射, 因此物理地址在内核模式下关于全局内核页表恰等于虚拟地址, 此时用户的地址空间中的虚拟地址就被转换到了内核的地址空间, 这就解决了第一个问题.

    至于第二个问题, 由于 walkaddr 同时还负责检查用户传入的指针是否位于进程的用户地址空间, 因此第二个问题也随之解决.

4.5 Traps from kernel space

  • 当从用户模式经由陷入切换至内核模式时, uservec 处理函数负责将当前进程的上下文保存至陷入栈帧中, 并将执行流从用户栈 (此时由 sp 指定, 并将会保存至 p->trapframe->sp 中) 切换至内核栈 (即 p->trapframe->kernel_sp); 而当该进程在内核模式下再次被中断时, kernelvec 处理函数负责将当前进程的上下文就地保存至内核栈中, 然后递减栈顶指针 sp, 就像一次普通的函数调用一样.
  • 如果进程在内核模式下被中断时执行了 yield 函数让出了执行权, 该进程实际上将依次调用 yield 函数, sched 函数, 以及 swtch 函数 (其定义位于文件 kernel/swtch.S 中). swtch 函数的作用是切换执行上下文至另一个不同的进程 (而不是像陷入一样仅在同一个进程内部切换上下文至不同模式), 用在此处即为将上下文切换至调度器函数 (进程) scheduler.
  • 用户空间的陷入总是发生在一个具体的用户空间进程中, 但内核空间的陷入不是, 因为至少调度器函数 (进程) scheduler 必须在内核模式下执行, 仅仅因为这一反例 xv6 中就必须在执行 yield 前判断当前是否是用户进程 (myproc() != 0). 而至于执行 yield 的具体地点——无论是 usertrap 中执行 yield 还是 kerneltrap 中执行 yield——实际上完全可以看作是一回事, 因为不论哪种情况当前进程均位于其内核线程中, 区别仅仅只是递归深度而已.
  • 在内核模式下 stvec 的值应被设置为内核陷入的处理函数的入口地址, 即 kernelvec, 但在陷入刚刚被触发时存在一个短暂的窗口, 其中执行模式已经被切换至内核模式, 但 stvec 寄存器的值却仍然是用户陷入的处理函数的入口地址 uservec. 幸运的是 RISC-V 在陷入被触发时总是会自动禁用设备中断, 令内核代码能够自主选择何时启用, 在此期间可以处理好 stvec 的切换工作.

4.6 Page-fault exceptions

  • 对于异常, xv6 的处理逻辑十分简单粗暴——如果是用户空间触发异常则直接杀掉该进程, 而如果是内核空间触发则报错并死机.
  • 缺页异常可以用于实现写时复制 (copy-on-write, COW) fork. xv6 中实现的 fork 同样是简单粗暴的——直接将父进程的完整内存映像 (即所有物理页面) 一次性复制给子进程, 这里面包括一次性分配与父进程的内存映像大小相同的物理空间.
  • RISC-V 中包含三种不同的缺页异常, 分别是读操作引发的缺页异常, 写操作引发的缺页异常, 以及取指令操作引发的缺页异常. 当缺页异常被触发时, scause 寄存器中包含有异常的触发原因, 而 stval 寄存器中则包含有转换失败的虚拟地址.
  • 缺页异常还可以用于实现另一种有趣的机制, 即内存的惰性分配 (lazy allocation). 用户应用经常使用 sbrk 分配一大片内存, 但却并不保证利用率, 这就会导致极大的内存浪费. 采用惰性分配之后, 每当应用要求更多内存时, 内核仅仅在逻辑上增加应用的内存大小, 真正的内存分配则推迟到由进程通过缺页异常来进行.
  • 还有一种重要机制可以由缺页异常实现, 即硬盘换页 (paging from disk). 如果物理内存不够用, 那么内核可以将内存中使用不活跃的那部分页面弹出 (evict) 至硬盘, 以便腾出空间给当前进程. 如果其他进程此后使用到了这些被弹出的页面, 那么就会触发一次缺页异常, 内核便会重新将其载入至内存中.
  • 其他可由缺页异常实现的机制包括自动拓展栈空间和内存映射文件 (memory-mapped files) 等等.

4.7 Real world

  • 之所以在 xv6 中存在蹦床页面, 页表切换, 以及手动虚拟地址转换等等繁琐的工作, 是因为 xv6 中的进程页表和内核页表是分离的两个实体. 如果将内核空间和用户空间合并为同一个页表, 那么就不再需要蹦床页面 (因为此时并不需要切换页表), 也能够直接对用户指针进行解引用. 但实现这一设计需要复杂的安全性检查, 同时也需要确保用户与内核空间不发生重叠, 因此为了简化实现 xv6 选择了将二者进行分离的方案.