重读 xv6(IV)

xv6 Trap 与系统调用

#

现在我们讨论 Trap 机制,用于三个方面:

  1. 系统调用
  2. 异常中断
  3. 设备中断

透明操作 #

Trap 是由 riscv 硬件提供的服务,触发时的工作流程:

  1. 保存当前的程序计数器 %pc%sepc
  2. 记录当前的权限模式
  3. 设置 %scause,记录 Trap 原因
  4. 进入 kernel mode
  5. 根据预先设置好的中断向量 %stvec,跳转到中断服务程序

中断结束时,根据 %sepc 回到原先的程序位置
而在中断服务期间,我们可以选择 关中断,暂时屏蔽其他信号源

硬件只做最少量的工作,以保证内核软件的灵活性

中转代码 #

源码文件:trampoline.S

III 中,我们提到过,无论是内核还是用户,都会将虚拟空间的顶部映射为 TRAMPOLINE,实际就是用于 trap 的代码

而在 TRAMPOLINE 下面则是记录寄存器信息的 trapframe

从用户空间触发 trap 时,硬件会将中断向量指向 trampoline.S/uservec,运行内核软件的处理程序

uservec 会将 用户态的上下文保存到 trapframe 中,同时从 trapframe 中读取内核所需的信息

需要保存的信息是 32 个寄存器的内容,但是我们需要占用至少一个寄存器来运行程序,怎么办呢?

解决方案是提供一个 %sscratch 寄存器,用于临时保存 %a0 的内容,而我们则征用 %a0 存储 trapframe 的地址

需要读取的信息:

  1. trap.c/usertrap() 的地址
  2. kernel stack 的地址
  3. kernel pagetable 的地址
  4. 内核的 hartid

最后,uservec 会修改 %satp,转为使用内核页表,然后 jmpusertrap,正式进入处理逻辑

这里我们修改了页表,达成了与 III 中,kvmhartinit 无缝启用页表机制相同的效果,这是因为切换前后,trampoline 的地址映射没有改变,因此程序计数器不受影响,程序流不会改变

但是需要注意 trapframe 并不是这样,每个程序的 trapframe 具有独立的物理地址,在 proc.c/allocproc 中通过 kalloc 获取,在 proc_pagetable 中初始化地址映射

而在 userret 中,我们复原 trapframe 中的用户数据,并重定向 ret 到原位置即可

Trap 处理 #

源码文件:trap.c

安排好寄存器这些繁杂的细节之后,我们才进入正题,检查中断类型,并进行对应处理

usertrap 的流程其实很简单:

  1. 将中断向量重定向为 kerneltrap
  2. 检查 %scause,判断中断类型
  3. do something
  4. 调用 usertrapret,返回到用户空间

这里引出了一个问题:userkernel 的中断处理是不同的,为什么呢?

这里的 do something,就是处理的具体内容:时钟中断就让出 CPU,系统调用就去调用,设备中断就动设备,等等

此处是 trap 机制中最可扩展的部分,开发者可以实现自己想要的机制

copy-on-write 举例,我们将 复制页 设置为只读,写入时引发 page fault,此时硬件会触发异常中断,陷入到 usertrap ,我们检查 %scause 便可得知异常类型,再动态分配页面即可实现该机制

usertrapret 调整好权限与 trapframe,重定向返回地址 sepc ,再调用 userret 即可

读源码可以发现,这一系列流程都是通过函数调用作为连接的,然而最后 userret 却直接回到了用户空间,让人不禁想问,这么做不会导致栈溢出吗,为什么没有释放栈上的调用信息呢?

担心是多余的,trapframe 中我们会单独保存 kernel stack 的起始地址,每次从 usertrap 进入时,都会重置 %sp 指向内核栈底,之前的调用信息也就成为了垃圾信息,而用户栈被保存在 trapframe->sp

内核中断 #

源码文件:kernelvec.S

即便是内核程序,也有可能被设备与异常所中断,因此要另起一套内核 trap 的逻辑

不过 kernelveckerneltrap 的行为与用户 trap 相差不大,区别在于 kernel 会将寄存器内容保护在 kernel stack 上,而不是 trapframe

用户 trap 只会发生一次,并且明确属于某个 proc,但内核 trap 可能会嵌套进行,所以用栈保护寄存器

重新启动 #

现在我们看一下 main.c 中对 trap 做了怎样的初始化

  • trapinit:初始化 time lock,用来管理时钟中断
  • trapinithart:将 %stvec 设置为 kernelvec,因为起初代码处于内核态

除此之外没有其他动作,trap 的初始化就是这样

再检查 II 中提到的 forkret,只是调用 usertrapret 进入用户态

这里 usertrapret 是混沌状态下的操作,我们看看它做了什么:

  • 将中断向量设置为 uservec
  • 将内核状态保存到 trapframe
  • trapframe 中读取寄存器内容
  • 重定向到用户线程

这引出一个问题,如何保证从 trapframe 中读取的寄存器内容具备合理性(reasonable)?

溯源可以发现:

  1. 可用寄存器的内容在初始化时无意义,因此是垃圾也无所谓
  2. 内核状态是从 CPU 中直接读取的
  3. %sp%epc的内容在 exec 中已经设置好
  4. pagetableallocproc 中完成映射

因此这里 forkret 直接调用 usertrapret 是没问题的

系统调用 #

源码文件:syscall.c

基于 Trap 机制,我们可以从系统调用陷入到内核态,运行 kernel 代码

整个 syscall 的实现逻辑很简单:

  1. 约定参数在 trapframe 中的位置
  2. 预定义调用表
  3. 运行 syscall function,从 trapframe 读取调用号与参数
  4. 将返回值覆盖到 trapframe
  5. %pc 指针向后移动一位,表示完成 syscall
  6. trap 中返回用户空间,用 trapframe 覆盖寄存器

用户视角下,%pc后移一条指令,返回值放在约定的寄存器%a0,就像一瞬间完成了系统调用一般,从而完成了系统调用的透明化

kernel 可能与用户内存进行读写交互,但二者的页表并不一致,因此我们需要编写在 kernel 与 user 间进行内存交互的辅助函数,在 xv6 中则是 vm.c 中的 copyin/copyout 逻辑

到这里,我们发现了矛盾,kernel 与 user 的页表是隔离的,才只能通过 trapframe 这样的中介进行通信,如果我们在彼此中插入对方的代码映射呢?

在 user 中插入 kernel 的映射,就不需要大费周章地更换页表,直接换权限,运行kernel代码就好了
在 kernel 中插入 user 的映射,就不需要两头跑 copy 信息,直接读出 user 的内存即可

这种设计明显可行,因为虚拟内存是无限的,我们稍微加一点限制,将 user 和 kernel 的映射强制分区,便可以在同一张页表里映射两态的空间


本节,我们回答了以下问题:

  1. 硬件如何响应中断信号
  2. 中断服务如何切换线程的上下文
  3. 连接中断源与中断处理的胶水代码是怎样的
  4. trampolinetrapframe 映射的设计目的是什么
  5. 调用 syscall 时,xv6 内部发生了什么
  6. trap 机制如何作用于其他模块