MIT6.S081中Systemcall的调用流程
前言
MIT6.S081 项目中的许多实验都涉及到了系统调用(System Call)的使用,我在实验过程中往往依葫芦画瓢在原有的System Call基础上使用,没有真正理解System Call的流程,本篇笔记打算以sys_sleep为例子梳理一下Xv6系统中 System Call的调用流程。
一、用户态(/user/)中的系统调用
我们可以在/user/user.h中找到sleep函数的函数声明,却无法找到sleep函数的函数定义。
实际上我们可以在user/usys.pl中找到除了函数调用以外sleep函数的出现,
1 | // user/usys.pl |
二、生成的usys.S文件中的汇编语言
通过user/usys.pl的代码我们可以推测其为每一个系统调用(System Call)都进行了entry操作,生成到了user/usys.S文件中,该文件每个系统调用都对应三行汇编语言,我只复制了与sleep相关的代码
1 | # generated by usys.pl - do not edit |
#include "kernel/syscall.h"代表汇编文件前插入了kernel/syscall.h,我们在遇到不懂的内容时,应该去该文件查看寻找问题的答案。
1.li a7, SYS_sleep
这行代码的含义是将系统调用号 SYS_sleep 加载到寄存器 a7 中。在 RISC-V 架构中,寄存器 a7 通常被用作系统调用号的参数寄存器。因此,这行代码的目的是将 SYS_sleep 这个系统调用的编号加载到 a7 寄存器,以便在调用系统调用时使用。
SYS_sleep的值可以在kernel/syscall.h中找到,为13。该文件给每一个system call都对应了一个数字。
1 | ... |
2.ecall(该指令为CPU指令)
在Lec06 Isolation & system call entry/exit (Robert)课程中我们可以学习到ecall指令的作用(ecall内容的笔记基本摘抄自课程翻译,不理解可以去看原课程):
2.1 ecall将代码从user mode改到supervisor mode
注意:作为代码编写者的我们并没有办法能直接分辨系统处于user mode还是supervisor mode,下图为PTE结构,Flag中存在标志位U,xv6系统标志位U为1的时候,只有user mode下能访问而supervisor mode不能访问,我们通过这个方法来间接分辨系统处于什么状态。

2.2 ecall将程序计数器的值保存在了SEPC寄存器
SEPC寄存器保存的值将在 supervisor mode 返回至 user mode时发挥作用
2.3 ecall会跳转到STVEC寄存器指向的指令(uservec函数)
所以现在,ecall帮我们做了一点点工作,但是实际上我们离执行内核中的C代码还差的很远。接下来:
- 我们需要保存32个用户寄存器的内容,这样当我们想要恢复用户代码执行时,我们才能恢复这些寄存器的内容。
- 因为现在我们还在user page table,我们需要切换到kernel page table。
- 我们需要创建或者找到一个kernel stack,并将Stack Pointer寄存器的内容指向那个kernel stack。这样才能给C代码提供栈。
- 我们还需要跳转到内核中C代码的某些合理的位置。
ecall并不会为我们做这里的任何一件事(以上四点的详细实现过程在三、uservec上)。
2.3.1为什么ecall指令在xv6做的事情很少?(摘抄自课程翻译)
当然,我们可以通过修改硬件让ecall为我们完成这些工作,而不是交给软件来完成。并且,我们也将会看到,在软件中完成这些工作并不是特别简单。所以你现在就会问,为什么ecall不多做点工作来将代码执行从用户空间切换到内核空间呢?为什么ecall不会保存用户寄存器,或者切换page table指针来指向kernel page table,或者自动的设置Stack Pointer指向kernel stack,或者直接跳转到kernel的C代码,而不是在这里运行复杂的汇编代码?
实际上,有的机器在执行系统调用时,会在硬件中完成所有这些工作。但是RISC-V并不会,RISC-V秉持了这样一个观点:ecall只完成尽量少必须要完成的工作,其他的工作都交给软件完成。这里的原因是,RISC-V设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。所以你可以这样想,尽管XV6并没有使用这里提供的灵活性,但是一些其他的操作系统用到了。
- 举个例子,因为这里的ecall是如此的简单,或许某些操作系统可以在不切换page table的前提下,执行部分系统调用。切换page table的代价比较高,如果ecall打包完成了这部分工作,那就不能对一些系统调用进行改进,使其不用在不必要的场景切换page table。
- 某些操作系统同时将user和kernel的虚拟地址映射到一个page table中,这样在user和kernel之间切换时根本就不用切换page table。对于这样的操作系统来说,如果ecall切换了page table那将会是一种浪费,并且也减慢了程序的运行。
- 或许在一些系统调用过程中,一些寄存器不用保存,而哪些寄存器需要保存,哪些不需要,取决于于软件,编程语言,和编译器。通过不保存所有的32个寄存器或许可以节省大量的程序运行时间,所以你不会想要ecall迫使你保存所有的寄存器。
- 最后,对于某些简单的系统调用或许根本就不需要任何stack,所以对于一些非常关注性能的操作系统,ecall不会自动为你完成stack切换是极好的。
所以,ecall尽量的简单可以提升软件设计的灵活性。
2.3.2 为什么我们在gdb中看不到ecall的具体内容?(课堂学生提问)
ecall只会更新CPU中的mode标志位为supervisor,并且设置程序计数器成STVEC寄存器内的值。在进入到用户空间之前,内核会将trampoline page的地址存在STVEC寄存器中。所以ecall的下一条指令的位置是STVEC指向的地址,也就是trampoline page的起始地址。(注,实际上ecall是CPU的指令,自然在gdb中看不到具体内容)
3.ret
返回到用户态程序原本的运行位置,PC值从SEPC寄存器中读取
三、trampoline page
在ecall函数执行完后,程序pc值指向了uservec函数,该函数位于trampoline page的起始,仍处于 user page table中(意味着页表没有发生切换,该页表上没有处理system call 的内核代码,需要我们切换到kernel page table上)

1.trampoline 映射时的特别之处
在kernel page table中,trampoline使用直接映射,映射虚拟地址最高处为trampoline
1 | // map the trampoline page to the highest address, |
而在为进程分配user page table时,虚拟地址的最高处同样映射为Trampoline,同时PTE_U为复位(0),表明只有supervisor可以使用它,并且映射trampoline时使用的物理地址与kernel page table使用的物理地址一致,这意味着:**kernel page table与user page table的虚拟地址的最高处映射了同一个Trampoline**。
1 | // Create a user page table for a given process, |
2.Uservec函数
uservec 为汇编代码,代码可以在kernel/trampoline.S中查看到
1 | .globl uservec |
2.1 csrrw a0, sscratch, a0
在进入到user space之前,内核会将trapframe page的地址保存在SSCRATCH寄存器中,也就是0x3fffffe000这个地址。
csrrw指令,交换了a0与sscratch寄存器的值,现在a0寄存器中存储的是trapframe page的地址,其在user page table
中被映射到了Trampoline的下方。
问题:当与a0寄存器进行交换时,trapframe的地址是怎么出现在SSCRATCH寄存器中的?
在内核前一次切换回用户空间时,内核会执行set sscratch指令,将这个寄存器的内容设置为0x3fffffe000,也就是trapframe page的虚拟地址。所以,当我们在运行用户代码,比如运行Shell时,SSCRATCH保存的就是指向trapframe的地址。之后,Shell执行了ecall指令,跳转到了trampoline page,这个page中的第一条指令会交换a0和SSCRATCH寄存器的内容。所以,SSCRATCH中的值,也就是指向trapframe的指针现在存储与a0寄存器中
深入提问:这是发生在进程创建的过程中吗?这个SSCRATCH寄存器存在于哪?
这个寄存器存在于CPU上,这是CPU上的一个特殊寄存器。内核在什么时候设置的它呢?这有点复杂。它被设置的实际位置,我们可以看下kernel/trampoline.S末尾的代码
1 | // kernel/trampoline.S |
代码是内核在返回到用户空间之前执行的最后两条指令。在内核返回到用户空间时,会恢复所有的用户寄存器。之后会再次执行交换指令,csrrw。**因为之前内核已经设置了a0保存的是trapframe地址(注意:从uservec到trampoline代码结尾过程中执行了诸如usertrap、usertrapret等代码,那么a0寄存器一定不是之前csrrw a0, sscratch, a0代码修改后的a0值)**,经过交换之后SSCRATCH仍然指向了trapframe page地址,而a0也恢复成了之前的数值。
最后sret返回到了用户空间
a0寄存器中的值是怎么来的?
查看kernel/trap.c中usertrapret函数的最后两行
1 | // jump to trampoline.S at the top of memory, which |
这是内核返回到用户空间的最后的C函数。C函数做的最后一件事情是调用fn函数,传递的参数是TRAMFRAME和user page table。在C代码中,当你调用函数,第一个参数会存在a0,这就是为什么a0里面的数值是指向trapframe的指针。
当你启动一个进程,之后进程在运行,之后在某个时间点进程执行了ecall指令,那么你是在什么时候执行上一个问题中的fn函数呢?因为这是进程的第一个ecall指令,所以这个进程之前应该没有调用过fn函数吧?(学生提问)
好的,或许对于这个问题的一个答案是:一台机器总是从内核开始运行的,当机器启动的时候,它就是在内核中。 任何时候,不管是进程第一次启动还是从一个系统调用返回,进入到用户空间的唯一方法是就是执行sret指令。sret指令是由RISC-V定义的用来从supervisor mode转换到user mode。所以,在任何用户代码执行之前,内核会执行fn函数,并设置好所有的东西,例如SSCRATCH,STVEC寄存器。
2.2 sd ra, 40(a0)
现在我们保存了a0中原有的值,并且得到了trapframe page的地址,user mode下其余寄存器中的值,可以通过sd指令保存到user page table中trapframe的不同偏移位置
问题 寄存器保存在了trapframe page,但是这些寄存器用户程序也能访问,为什么我们要使用内存中一个新的区域(指的是trapframe page),而不是使用程序的栈?
这里或许有两个问题:
1.为什么我们要保存寄存器?为什么内核要保存寄存器的原因,是因为内核即将要运行会覆盖这些寄存器的C代码。如果我们想正确的恢复用户程序,我们需要将这些寄存器恢复成它们在ecall调用之前的数值,所以我们需要将所有的寄存器都保存在trapframe中,这样才能在之后恢复寄存器的值
2.为什么这些寄存器保存在trapframe,而不是用户代码的栈中?这个问题的答案是,我们不确定用户程序是否有栈,必然有一些编程语言没有栈,对于这些编程语言的程序,Stack Pointer不指向任何地址。当然,也有一些编程语言有栈,但是或许它的格式很奇怪,内核并不能理解。比如,编程语言以堆中以小块来分配栈,编程语言的运行时知道如何使用这些小块的内存来作为栈,但是内核并不知道。所以,如果我们想要运行任意编程语言实现的用户程序,内核就不能假设用户内存的哪部分可以访问,哪部分有效,哪部分存在。所以内核需要自己管理这些寄存器的保存,这就是为什么内核将这些内容保存在属于内核内存的trapframe中,而不是用户内存
2.3 csrr t0, sscratch ;sd t0, 112(a0)
将sscratch中的值(实际上是a0寄存器中的原始值)保存在t0寄存器当中,将t0寄存器中的值保存在trapframe page偏移112字节的位置当中。
1 | //kernel/proc.h |
根据结构体定义,trapframe page 偏移112字节的位置是a0寄存器。
所以这两行代码的实际作用:将a0寄存器原本的值正确的保存在trapframe 当中
2.4 ld sp, 8(a0)
该指令将a0偏移8个字节的数据加载到sp(stack pointer)寄存器当中,a0寄存器当中存储的是trapframe page的地址。通过查看trapframe page的结构:
1 | //kernel/proc.h |
所以这条指令的作用是初始化Stack Pointer指向这个进程的kernel stack的最顶端。
接下来的代码作用相似,从trapframe page当中取出current hartid(因为在RISC-V中,没有一个直接的方法来确认当前运行在多核处理器的哪个核上,XV6会将CPU核的编号也就是hartid保存在tp寄存器)、address of usertrap(将要执行的第一个C函数的指针,也就是函数usertrap的指针。我们在后面会使用这个指针)
2.5 ld t1, 0(a0); csrw satp, t1; sfence.vma zero, zero
第一行代码的作用是将kernel_satp 代码加载到t1寄存器
第二行代码的作用是将stap寄存器与t1寄存器的交换,当前程序会从user page table切换到kernel page table
第三行代码的作用是,清除TLB缓存,当我们切换stap寄存器所存的值时,一般都需要清除TLB缓存。
为什么代码没有崩溃?(来自于老师提问)
毕竟我们在内存中的某个位置执行代码,程序计数器保存的是虚拟地址,如果我们切换了page table,为什么同一个虚拟地址不会通过新的page table寻址走到一些无关的page中?看起来我们现在没有崩溃并且还在执行这些指令。
答案
trampoline page在user page table中的映射与kernel page table中的映射是完全一样的。这两个page table中其他所有的映射都是不同的,只有trampoline page的映射是一样的,因此我们在切换page table时,寻址的结果不会改变,我们实际上就可以继续在同一个代码序列中执行程序而不崩溃。这是trampoline page的特殊之处,它同时在user page table和kernel page table都有相同的映射关系。
之所以叫trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。
2.6 jr t0
t0寄存器的值在2.4中提到过,现在其中的值为usertrap函数的地址,通过这段代码我们跳转到了usertrap函数,进行接下来的操作。
四、Usertrap函数
在Uservec函数的结尾,我们跳转到了Usertrap函数,所以程序现在运行usertrap函数
1 | // |
在usertrap函数中我们可以看到,倘若中断的原因是由system call引起的(r_scause() == 8),那么程序将先执行syscall()函数,再执行usertrapret()函数
什么时候r_scause()被设置
每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。
以下是最重要的一些寄存器概述:
stvec:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。sepc:当发生陷阱时,RISC-V会在这里保存程序计数器pc(因为pc会被stvec覆盖)。sret(从陷阱返回)指令会将sepc复制到pc。内核可以写入sepc来控制sret的去向。scause: RISC-V在这里放置一个描述陷阱原因的数字。sscratch:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。sstatus:其中的SIE位控制设备中断是否启用。如果内核清空SIE,RISC-V将推迟设备中断,直到内核重新设置SIE。SPP位指示陷阱是来自用户模式还是管理模式,并控制sret返回的模式。
上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。
多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
- 如果陷阱是设备中断,并且状态SIE位被清空,则不执行以下任何操作。
- 清除SIE以禁用中断。
- 将
pc复制到sepc。 - 将当前模式(用户或管理)保存在状态的SPP位中。
- 设置
scause以反映产生陷阱的原因。 - 将模式设置为管理模式。
- 将
stvec复制到pc。 - 在新的
pc上开始执行。
综上所述,这是硬件进行的操作,所以我们在代码中看不见过程
五、Systemcall
在usertrap函数中我们进入了systemcall函数,在用户态执行ecall指令前,我们将系统调用号设置到了寄存器a7,执行ecall指令后,uservec函数将进程寄存器的所有值都被存入到了trapframe当中,所以我们通过num = p->trapframe->a7;取出系统调用号。
p->trapframe->a0 = syscalls[num]();
这里向trapframe中的a0赋值的原因是:所有的系统调用都有一个返回值,比如write会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0,所以为了模拟函数的返回,我们将返回值存储在trapframe的a0中。之后,当我们返回到用户空间,trapframe中的a0槽位的数值会写到实际的a0寄存器,Shell会认为a0寄存器中的数值是write系统调用的返回值。
1 | //kernel/syscall.c |
在kernel/syscall.c中,我们可以看出syscalls是一个函数指针数组,根据我们取出的系统调用号来调用不同的函数。
1 | static uint64 (*syscalls[])(void) = { |
我们传入的系统调用号对应的是SYS_sleep,所以执行sys_sleep函数,函数定义如下:
1 | uint64 |
注意:我们传入到内核的只有系统调用号,并没有其他途径告知内核传入参数的个数,所以我们自定义系统调用时,需要在用户态将函数声明中形参的个数与内核中系统调用函数中取参的个数相匹配,数目对不上编译器检查不出来错误。
sys_sleep函数利用argint函数取出应该休眠的时间(若用户态为sleep(10),则n==10),进行接下来的休眠操作。
六、Usertrapret
在Usertrap函数中我们可以看出,系统调用执行完成后,我们应该执行usertrapret函数
1 | // |
七、Userret
1 | .globl userret |
sret最终返回到usermode,此时 **我们处于 系统调用 化身成为的三条汇编指令的最后一句:ret**,执行后回到调用sleep函数的地方
八、总结
系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。




