MIT 6.S081 Lecture 6: System Call Entry/Exit

Dare and the world always yields.

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的陷入流程就是,

  1. switch to supervisor mode(ecall does)
  2. save 32 user registers and pc
  3. switch to kernel page table and kernel stack
  4. 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 其实只做三件事,

  1. 即改变优先级到 supervisor mode
  2. pc 保存到寄存器 sepc
  3. 跳转到寄存器 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 并跳转到具体的内核实现。返回流程就是还原,还有设置部分控制状态寄存器 sstatussepcsatp的内容。关于 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共同的特性,理解不同设计的差异即可。

Reference

  1. MIT 6.S081 2021
  2. RISC-V Calling Convention
  3. Videos