重读 xv6(IV)
xv6 Trap 与系统调用
五 #
现在我们讨论 Trap 机制,用于三个方面:
- 系统调用
- 异常中断
- 设备中断
透明操作 #
Trap 是由 riscv 硬件提供的服务,触发时的工作流程:
- 保存当前的程序计数器
%pc到%sepc - 记录当前的权限模式
- 设置
%scause,记录 Trap 原因 - 进入 kernel mode
- 根据预先设置好的中断向量
%stvec,跳转到中断服务程序
中断结束时,根据 %sepc 回到原先的程序位置
而在中断服务期间,我们可以选择 关中断,暂时屏蔽其他信号源
硬件只做最少量的工作,以保证内核软件的灵活性
中转代码 #
源码文件:trampoline.S
在 III 中,我们提到过,无论是内核还是用户,都会将虚拟空间的顶部映射为 TRAMPOLINE,实际就是用于 trap 的代码
而在 TRAMPOLINE 下面则是记录寄存器信息的 trapframe
从用户空间触发 trap 时,硬件会将中断向量指向 trampoline.S/uservec,运行内核软件的处理程序
而 uservec 会将 用户态的上下文保存到 trapframe 中,同时从 trapframe 中读取内核所需的信息
需要保存的信息是 32 个寄存器的内容,但是我们需要占用至少一个寄存器来运行程序,怎么办呢?
解决方案是提供一个
%sscratch寄存器,用于临时保存%a0的内容,而我们则征用%a0存储trapframe的地址
需要读取的信息:
trap.c/usertrap()的地址kernel stack的地址kernel pagetable的地址- 内核的
hartid
最后,uservec 会修改 %satp,转为使用内核页表,然后 jmp 到 usertrap,正式进入处理逻辑
这里我们修改了页表,达成了与 III 中,
kvmhartinit无缝启用页表机制相同的效果,这是因为切换前后,trampoline的地址映射没有改变,因此程序计数器不受影响,程序流不会改变但是需要注意
trapframe并不是这样,每个程序的trapframe具有独立的物理地址,在proc.c/allocproc中通过kalloc获取,在proc_pagetable中初始化地址映射
而在 userret 中,我们复原 trapframe 中的用户数据,并重定向 ret 到原位置即可
Trap 处理 #
源码文件:trap.c
安排好寄存器这些繁杂的细节之后,我们才进入正题,检查中断类型,并进行对应处理
usertrap 的流程其实很简单:
- 将中断向量重定向为
kerneltrap - 检查
%scause,判断中断类型 - do something
- 调用
usertrapret,返回到用户空间
这里引出了一个问题:
user与kernel的中断处理是不同的,为什么呢?
这里的 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 的逻辑
不过 kernelvec、kerneltrap 的行为与用户 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)?
溯源可以发现:
- 可用寄存器的内容在初始化时无意义,因此是垃圾也无所谓
- 内核状态是从 CPU 中直接读取的
%sp,%epc的内容在exec中已经设置好pagetable在allocproc中完成映射
因此这里 forkret 直接调用 usertrapret 是没问题的
系统调用 #
源码文件:syscall.c
基于 Trap 机制,我们可以从系统调用陷入到内核态,运行 kernel 代码
整个 syscall 的实现逻辑很简单:
- 约定参数在
trapframe中的位置 - 预定义调用表
- 运行
syscall function,从trapframe读取调用号与参数 - 将返回值覆盖到
trapframe中 - 将
%pc指针向后移动一位,表示完成syscall - 从
trap中返回用户空间,用trapframe覆盖寄存器
用户视角下,%pc后移一条指令,返回值放在约定的寄存器%a0,就像一瞬间完成了系统调用一般,从而完成了系统调用的透明化
kernel 可能与用户内存进行读写交互,但二者的页表并不一致,因此我们需要编写在 kernel 与 user 间进行内存交互的辅助函数,在 xv6 中则是 vm.c 中的 copyin/copyout 逻辑
到这里,我们发现了矛盾,kernel 与 user 的页表是隔离的,才只能通过
trapframe这样的中介进行通信,如果我们在彼此中插入对方的代码映射呢?在 user 中插入 kernel 的映射,就不需要大费周章地更换页表,直接换权限,运行kernel代码就好了
在 kernel 中插入 user 的映射,就不需要两头跑 copy 信息,直接读出 user 的内存即可这种设计明显可行,因为虚拟内存是无限的,我们稍微加一点限制,将 user 和 kernel 的映射强制分区,便可以在同一张页表里映射两态的空间
本节,我们回答了以下问题:
- 硬件如何响应中断信号
- 中断服务如何切换线程的上下文
- 连接中断源与中断处理的胶水代码是怎样的
trampoline与trapframe映射的设计目的是什么- 调用
syscall时,xv6 内部发生了什么 trap机制如何作用于其他模块