MIT 6.S081 Lecture 6: System Call Entry/Exit
6.S081第六课,以RISC-V的体系结构为例,教授OS陷入内核态的流程。阅读RISC-V Calling Convention第四章除第六节部分,观看课程视频。
What is Trap?
所有系统调用、异常(exception 或 fault)与中断(device interruption)都以同样的方式陷入OS内核,可以统称为陷入(Trap)。这在不同的语境下,有不同的称呼,比如CSAPP的分类就不一样。但总体上,仍然是系统调用、异常和中断这几种形式。陷入内核的设计需要考虑隔离与内核执行性能。这节课以 RISC-V xv6 为例讲了 Trap 的通用设计部分,并详细分析了系统调用。后面两节课还会讲异常与中断。
当发生陷入,用户态的CPU资源需要保存,如通用寄存器(RISC-V至少是32个),程序计数器pc ,当前优先级(可能从用户态陷入,也可能从内核态再陷入),还有包括 satp 等控制状态寄存器在内的一些特殊的寄存器。更抽象的目标就是不让用户态代码干涉整个陷入流程,让陷入对于用户代码是透明的。简单看,xv6的陷入流程就是,
- switch to supervisor mode(ecall does)
- save 32 user registers and pc
- switch to kernel page table and kernel stack
- jump to kernel C code
What does the CPU’s “mode” protect?
CPU的优先级实际上隔离了对CPU控制状态寄存器(csr, Control and status Register)的操作,既有Privileged registers,也有 Privileged instructions 。在 supervisor mode 下,CPU才能执行使用控制寄存器的指令。
Constrol register | Description |
---|---|
satp | page table physical address. 只有内核能编程页表。 |
stvec | ecall jumps here in kernel; points to trampoline. 指令 ecall 真实跳转的地址,在 xv6 里为 trampoline 页。 |
sepc | ecall saves user pc here. 从陷入返回的PC地址。 |
sscratch | address of trapframe. 保存 trapframe 地址,这个帧记录 Trap 前后需要保存的数据。 |
所谓的 Privileged instructions 包括读写 csr 的指令 csrr (read), csrw (write), csrrw (swap)
与返回用户空间的指令 sret
。当然 supervisor 可以使用没有 PTE_U 标志的 PTE 。但 supervisor 除了以上内容没有其他能力,例如不可能使用不在页表中的地址。所以内核需要小心设置资源以正常工作。p.s. xv6在 struct proc 里存了用户页表地址,所以可以操作用户页表。
How a system call traps in xv6?
以系统调用 write 为例,xv6所有系统调用的入口指令在 usys.S 中都是同样形式,
.global write
write:
li a7, SYS_write
ecall
ret
向寄存器 a7
写入系统调用号后,指令 ecall
其实只做三件事,
- 即改变优先级到 supervisor mode
- 将
pc
保存到寄存器sepc
- 跳转到寄存器
stvec
保存的地址。 p.s. 我以为,名字 stvec 正是 supervisor trap vector 的 acronym 。
RISC-V将 ecall 指令设计得如此简单,也是为了给内核的设计者极大的灵活性。比如,内核来区分 Trap 的不同类型;内核可以不做页表转换,用户态页表也包含内核的页;对于简单的系统调用甚至不用内核栈;优化需要保存的寄存器数量。
xv6就完全利用了RISC-V的特性。通过在内核与用户态中共享 trampoline 页,共享指令代码以便跳转。页 trampoline 本质是个代码页。而页 trapframe 是数据页,需要保存的CPU资源保存在 trapframe 中以便还原,以下为 trapframe 的部分定义,
struct trapframe {
/* 0 */ uint64 kernel_satp; // kernel page table
/* 8 */ uint64 kernel_sp; // top of process's kernel stack
/* 16 */ uint64 kernel_trap; // usertrap()
/* 24 */ uint64 epc; // saved user program counter
/* 32 */ uint64 kernel_hartid; // saved kernel tp
/* 40 */ uint64 ra;
/* 48 */ uint64 sp;
/* 56 */ uint64 gp;
/* 64 */ uint64 tp;
/* 72 */ uint64 t0;
… // other general registers
};
trapframe 维护内核的内核的页表,因为内核的代码与数据不在用户页表;维护进程的内核栈,以便内核代码可以执行;维护 kernel_trap 为内核处理用户陷入的内核代码地址 usertrap(in kernel/trap.c),为了在 trampoline 代码中读取并跳转;还需要维护内核 CPU hartid,因为可能有多个CPU执行内核代码。页trampoline 与 trapframe 在分配一个进程时就会分配,初始化并映射在用户页表。以下为完整的 write 系统调用的在 ecall 之后的执行流程,ecall -> trampoline.S -> trap.c -> kernel implementation -> trap.c -> trampoline.S -> sret
write() | write() returns | User |
---|---|---|
uservec() in trampoline.S | userret() in trampoline.S | Kernel |
usertrap() in trap.c | usertrapret() in trap.c | |
syscall() in syscall.c | syscall() in syscall.c | |
sys_write() in sysfile.c |
xv6在 trampoline.S 中保存通用寄存器,切换内核页表与栈,最后跳转到内核代码 trap.c 中。由 trap.c 的 usertrap 函数处理不同类型的 trap 并跳转到具体的内核实现。返回流程就是还原,还有设置部分控制状态寄存器 sstatus
、sepc
与satp
的内容。关于 trampoline.S 与 trap.c 的代码解读在教材第四章与课程笔记中都有详细记录,可以结合代码仔细阅读。
Some Tricks
如何在 debug 中了解是否处于 supervisor mode 中?可以读当前 pc
地址的页是否没有 PTE_U
设置。
如果编写汇编代码,可以直接写汇编文件,所有指令都在文件中,但所有寄存器分配与调用规范都需要人工,对于一些特殊的固定流程的代码可以这么做,比如 trampoline.S 文件负责Trap 的通用流程(没有分支逻辑)。当然,还可以在C代码中通过编译器扩展嵌入汇编指令,编译器仍然可以帮助分配、保存与还原寄存器。这适合在内核代码中操作一些控制状态寄存器,如 risc.h 中的内联汇编。
Conclusion
这节课主要讲RISV-V xv6的 Trap流程,并详细追踪了系统调用的流程。因为要求隔离与执行效率,陷入与退出内核态远比函数调用要复杂。
如果将内核内存映射到每个进程的用户页表(具有适当的PTE权限标志),就可以消除对 trampoline 页的需求。当从用户空间陷入到内核时,也没有页表切换的需要。反过来,这将允许内核中的系统调用实现利用当前进程映射的用户内存,允许内核代码直接对用户指针解引用。许多操作系统都使用了这些想法来提高效率。
Xv6没有如此设计,是为了减少内核中由于无意使用用户指针而出现安全bug的可能性,并降低确保用户和内核虚拟地址不重叠所需的一些复杂性。
工业级操作系统实现有 copy-on-write,lazy allocation,请求分页,对磁盘的分页,内存映射文件等特性。此外,它们将尝试使用所有的物理内存,用于应用程序或缓存(例如,文件系统的缓冲区缓存,书后面的8.2节中介绍)。在这方面,Xv6是过于简单的。Xv6不使用所有的物理内存。如果Xv6耗尽内存,它会向正在运行的应用程序返回一个错误或终止它,而不是在页表中替换页(evict page)。
我们在学习过程中,只需要抓住OS共同的特性,理解不同设计的差异即可。