xv6系统调用流程——MIT6.S081操作系统
这篇文章通过gdb跟踪基于risc-v架构的xv6系统中write
系统调用的处理流程。
系统调用是操作系统给应用程序提供的操作底层硬件资源的简单清晰的接口,隐藏底层资源的复杂性,比如UNIX会把网络、磁盘等一系列东西都抽象成文件,然后你可以简单的使用write
对它们进行读写,你无需关心磁道、扇区等概念。
同时,由于系统调用会与底层资源通信,所以一定要在内核态执行,在xv6中称为supervisor mode
,这里一定涉及到用户内核态的转换。
在转换过程中需要保存用户程序执行的现场,安全的陷入内核,执行实际系统调用,并恢复程序执行现场。
C中的包装方法#
C库中的write
调用,也就是我们程序中进行系统调用的函数:
int write(int, const void*, int);
它实际上是实际系统调用的一个包装,它(以及所有其它的系统调用)定义在user/user.h
中,只有一个函数描述,实际的函数体是由三条汇编指令完成的。
通过查看编译后的程序的asm
,我们可以看到write
函数实际的指令。
将
SYS_write
常量保存到a7
寄存器中执行
ecall
进入内核ret
返回
SYS_write
是实际的write
系统调用的系统调用号,实际的值为16,定义在kernel/syscall.h
中,所有的系统调用号都定义在这。

ecall#
ecall
是进行实际系统调用的入口。它是由risc-v提供的一个用于实现系统调用的指令,通常由低特权的代码发起,用来执行高特权代码,比如UserMode到SupervisorMode、SupervisorMode到MachineMode。
若你在UMode,ecall
指令会做三件事:
将
pc
保存到sepc
(S态异常程序计数器)将权限提升至
SMode
跳转到
STVEC
(S态陷阱向量基地址寄存器)
通过GDB查看write
中这几条汇编指令,第一个就是熟悉的将16
(SYS_write)存到a7
中:

然后,我们执行stepi
执行ecall
,注意在执行前,pc
指向的位置是0x2d2
,这是一个用户地址空间中很小的地址,指向的就是用户空间的write
包装函数中的li
指令地址。
当我们stepi
执行ecall
后,按照risc-v中的约定,现在sepc
中保存了原来的pc
,也就是0x2d4
(用户代码中ecall
的地址),pc
应该跳转到stvec
寄存器指向的位置。

trampoline初识#
实际上,stvec
寄存器指向了0x3ffffff000
这个位置,无论是用户虚拟地址空间还是内核虚拟地址空间,其最顶部,也就是0x3ffffff000
的位置都被映射成了相同的东西,也就是下图中的这个trampoline
。

trampoline
直译过来是蹦床的意思,你可以理解为它是一个U态到S态的蹦床。不过注意,执行ecall
后我们已经处于S态,但我们还不能贸然的执行内核代码,因为我们还要保存原来执行系统调用的用户进程的数据。
trampoline
中的实际代码在kernel/trampoline.S
中可以看到,实际上,也就是上面那张截图中的汇编代码。
trampoline是一个神奇的东西,由于我们需要从用户态转换到内核态,执行内核代码,所以我们肯定要切换用户页表到内核页表,而无论用户页表还是内核页表中都有trampoline这个东西,并映射到了相同的位置,所以,在trampoline代码中切换页表是安全的,切换页表后程序不会崩溃,对于trampoline中的下一条指令,在切换后的页表中仍然存在相同的虚拟地址。
trampoline流程#
到了trampoline中,实际上我们已经在SMode了,只不过当前的用户寄存器保存的还是用户进程的数据,页表、sp指针等都是用户进程的。
sscratch寄存器和进程的trapframe#
在进入用户空间之前(无论是由于进程启动还是从中断中恢复),内核会先设置sscratch
寄存器指向trapframe,它是每个进程都有的一个用于存储所有用户寄存器的结构体,并且,它也被映射到用户页表的TRAPFRAME部分,位于TRAMPOLINE的下面,所以也可以通过用户页表访问。
从源码上看,trapframe实际上是在陷入内核时用于保存进程的寄存器啥的乱七八糟的东西的一个结构体,它在kernel/proc.h
中被定义,trampoline代码实际上会将用户寄存器保存到该结构体中。

保存用户寄存器#
现在,所有用户寄存器都被正确保存到trapframe中了,除了a0
,现在需要处理它。
需要注意的是,一旦一个用户寄存器被保存到用户进程的trapframe中,内核代码就能随意操作它,不用担心用户数据丢失,就像上面使用t0
来做中间寄存器一样。
切换到内核执行环境#
用户寄存器已经保存好了,在执行内核代码之前,还需要加载内核的执行环境,比如将sp
寄存器换成内核栈,将satp
寄存器切换成内核页表。
可以看到这里,从进程的trapframe
中读出了内核栈指针、hartid、陷阱处理程序usertrap
的地址以及内核页表,并设置到对应的寄存器上。
进程中的内核数据从哪里来?
无论是进程最初启动,还是从中断、陷阱中恢复,都会先执行对这些内核数据的设置。
一旦我们执行了csrw satp, t1
这一行代码,用户页表就被切换到内核页表,此时,任何位于用户页表中的内容都无法访问了。
内存屏障的执行也很有必要,当我们还在用户页表下工作时,TLB中有很多基于用户页表的虚拟地址到物理地址的映射,一旦页表切换,这些映射就是错的了,所以,我们需要执行内存屏障将它们清空掉。
执行usertrap——陷阱处理程序#
上面的ld t0, 16(a0)
中将内核中的陷阱处理程序地址写到t0
寄存器了,所以trampoline中的下一行代码就是jr t0
,跳转到陷阱处理程序。注意下图执行si
后ASM窗口和REG窗口中pc寄存器的变化,它从trampoline跳转到了陷阱处理程序——usertrap
。

如果你的GDB加载的文件不是kernel/kernel
,你没法跟踪它的源码,可以使用file kernel/kernel
加载内核文件然后再用layout src
跟踪源码。


现在,我们进入到kernel/trap.c
的usertrap
函数中,这就是陷阱处理程序。
陷阱处理程序要处理的东西比我们想的复杂,除了系统调用外,它还可能是因为程序运行中出现错误等原因必须陷入内核。此外,它还可能本身就是从内核空间进入的。这里,我们不考虑我们不需要考虑的代码,只考虑从用户空间通过系统调用进入的情况。
解释一下上面最后一行代码,由于当前进程可能会由于时间片不足被切换到其它进程,所以这里我们不能保证sepc是否会被其它进程冲掉(比如它再进行一次系统调用),所以这里还需要将它保存到trapframe中。这行代码不写在trampoline中,而是以C语言的形式写出来,可能是因为从各种其它方面进入的代码也需要修改这个寄存器,所以从这里统一修改吧,也有可能是因为sd
的操作数必须是用户寄存器。
继续往下
syscall#
syscall
执行实际的系统调用函数,通过之前在C中的包装函数里,我们将系统调用号SYS_write
保存在了用户寄存器a7
中,在trampoline代码中,我们将它保存在了进程的trapframe中,现在,我们在syscall
里,通过进程的trapframe->a7
读取到这个系统调用号,执行对应的内核中的系统调用程序。
每一个系统调用有一个返回值,这个返回值保存在trapframe->a0
中,如果系统调用号未知,就保存-1
。
回到usertrap#
下面是usertrap的完整代码:
从陷阱中返回——usertrapret#
现在,系统调用已经执行完毕,是时候做必要的恢复并返回到用户空间。usertrapret
函数就是完成这个工作的。
比较值得一提的,这个fn是一个函数指针,它指向了内存顶部的trampoline代码中的userret
,调用这个函数指针,并将TRAPFRAME
和satp
作为参数传递,它们会被存到a0
和a1
上。现在,我们可以进入trampoline的userret
。
回到trampoline——userret#
首先就将a1
和satp
做了一个交换,并执行了一个内存屏障。相当于将页表切换回用户页表了。
同样,由于trampoline在用户页表和进程页表间被映射到了相同的虚拟地址上,这个切换不会发生问题。
现在,a0
是函数指针那里传入的trapframe,从trapframe中找到原始进程中的a0
(112(a0)),与sscratch
进行交换。这一步是为了userret
中最后一步的交换做准备,先不用管,只需要知道现在sscratch
中保存了用户的a0
寄存器值,而目前的a0
保存的确实是trapframe的值,这是函数指针调用处传过来的。
下面,将所有trapframe中的东西存回用户寄存器
总结#
在用户模式,调用C函数库中的系统调用的封装,如
write
该封装中会将具体的系统调用号加载到a7中,然后调用
ecall
指令ecall
指令会做三件事切换UMode到SMode
将用户pc保存到satp
跳转到stvec指定的位置,也就是trampoline
在trampoline中
将用户寄存器保存到进程的trapframe中
从trapframe中读取内核栈、内核页表、中断处理程序
usertrap
的位置,当前CPU核心id加载内核栈到sp、切换satp位内核页表,跳转到陷阱处理程序
陷阱处理程序将trapframe中的epc写成
ecall
的下一条指令,调用syscall
执行系统调用syscall
调用具体的在内核中的系统调用代码,然后将返回值写到a0usertrap
执行usertrapret
,为返回用户空间做准备比如设置进程trapframe中的内核相关的信息,内核栈、内核页表、中断处理程序位置等
设置
sret
指令的控制寄存器,以在sret
执行时顺利恢复到用户模式设置
sepc
为trapframe的epc使用函数指针,调用trampoline代码中的userret,并将TRAPFREAM作为a0、用户页表位置作为a1
trampoline中的userret做的就很简单了,切换回用户页表,从trapframe恢复用户寄存器
执行sret返回到UMode
参考#
Misunderstanding RISC-V ecalls and syscalls - Juraj's Blog
xv6--内存管理 - CSDN
MIT6.S081 Operating System Engineering
感谢ChatGPT和newbing的大力支持