Trap
# 1. Trap 机制
用户空间与内核空间的切换被称为 trap。它涉及的细节对于实现 isolation 非常重要,很多程序要么因为 system call、要么因为 page fault 而频繁切换到内核,所以,trap 机制要尽可能简单。
# 1.1 硬件的状态
我们已经了解用户程序执行系统调用会切换到内核来完成,我们需要清楚如何让程序从只拥有 user 权限的程序切换到拥有 supervisor 权限的内核。这个过程中,硬件的状态会非常重要,因为我们很多的工作都是将硬件从适合运行用户应用程序的状态,改变到适合运行内核代码的状态。
我们最关心的状态可能是 32 个用户寄存器,比如 a0、a1,用户程序可以使用这些全部的寄存器。这 32 个寄存器中很多具有特殊作用,特别是栈寄存器。
此外,还包括:
- 程序计数器(Program Counter Register),指示当前执行的指令
- 表明当前 mode 的标志位,指示当前是 user mode 还是 supervisor mode
- 一堆控制 CPU 工作方式的寄存器,比如
- STAP 寄存器包含了指向 page table 的物理内存地址;
- STVEC 寄存器,指向了内核中处理 trap 的指令的起始地址
- SEPC 寄存器,在 trap 的过程中保存程序计数器的值
- SSRATCH 寄存器
这些寄存器表明了执行 system call 时计算机的状态。
# 1.2 trap 的大致过程
trap 最开始时,CPU 的所有状态都被设置为运行用户代码,在这个过程中,我们需要更改一些状态,使之可以运行 kernel 中的普通 C 程序,接下来我们预览一下需要做的操作:
- 首先,我们需要保存 32 个用户寄存器,以便之后恢复用户程序的执行
- 保存程序计数器,以便之后恢复
- 将 mode 改为 supervisor mode
- 将 STAP 中指向的 user page table 改为指向 kernel page table
- 将堆栈寄存器指向位于 kernel 的一个地址,因为我们需要一个堆栈来调用内核的 C 函数
- 一旦我们设置好了,并且所有的硬件状态都适合在内核中使用, 我们需要跳入内核的 C 代码。
一旦我们运行位于内核的 C 代码,那就跟平常的 C 代码是一样的了。之后我们会讨论内核通过C代码做了什么工作,但是今天的讨论是如何从将程序执行从用户空间切换到内核的一个位置,这样我们才能运行内核的 C 代码。
# 1.3 trap 的设计目标
在实现 trap 时,我们的目标是安全和隔离。我们不想让用户代码介入到 user/kernel 的切换,否则会破坏安全性,同时我们想让 trap 机制对用户代码是透明的,内核执行代码并不需要让用户代码察觉到什么,这样也更容易编写用户代码。
# 1.4 内核态可以做什么
在 trap 过程中,涉及到 mode 标志位从 user mode 切换到 supervisor mode,当处于 supervisor mode 时,我们获得了什么样的权限呢?
实际上,这里获取到的额外权限很有限,大概有:
- 可以读写一切控制寄存器了,比如 SATP register、STVEC register 等
- 可以使用页表 PTE 中
PTE_U
标志位是 0 的 PTE 了
除了之外,supervisor mode 就没有额外的其他可以做的事了。
需要特别指出的是,supervisor mode 中的代码并不能读写任意物理地址。在 supervisor mode 中,就像普通的用户代码一样,也需要通过 page table 来访问内存。如果一个虚拟地址并不在当前由 SATP 指向的 page table 中,又或者 SATP 指向的 page table 中 PTE_U=1
,那么 supervisor mode 不能使用那个地址。所以,即使我们在 supervisor mode,我们还是受限于当前 page table 设置的虚拟地址。
接下来,我们看一下进入到内核空间时,trap 代码的执行流程。
# 2. Trap 代码的执行流程
这里简单介绍一下 trap 代码的执行流程。具体的细节会在后面使用 GDB 跟踪源码时进行介绍。
我们以如何在 shell 中调用 write 系统调用为例子。
在 shell 角度来说,write 就是 shell 代码中的 C 函数调用,但在实现中,write 通过执行 ECALL 指令来执行系统调用。ECALL 指令会切换到 supervisor mode 的内核,这个过程中,内核中执行的第一个指令是一个由汇编语言写的函数,叫做 uservec,这个函数是内核代码 trampoline.s 文件的一部分。
之后,在这个汇编函数中,代码执行跳转到由 C 语言实现的 usertrap (trap.c)函数中,这个函数执行了一个叫做 syscall 的函数,syscall 函数会在一个表单中,根据传入的系统调用号找到实现了对应实现了系统调用功能的函数,比如 sys_write。sys_write 将数据显示到 console 上,并返回给 syscall 函数。
现在我们需要恢复到用户空间的代码执行,这需要做一些事情。
在 syscall 函数中,会调用一个叫做 usertrapret(trap.c)的函数,这个函数完成了部分方便在 C 代码中实现的返回到用户空间的工作。除此之外,最终还有一些工作只能在汇编语言中完成。这部分工作通过汇编语言实现,并且存在于 trampoline.s 文件中的 userret 函数中。最终,在这个汇编函数中会调用机器指令返回到用户空间,并且恢复 ECALL 之后的用户程序的执行。
# 3. ECALL 指令之前的状态
现在我们通过 GDB 来跟踪 XV6 做一个系统调用的源码的运行流程,展示 shell 如何将它的提示信息通过 write 系统调用走到 OS 再输出到 console 的过程。可以看到,用户代码 sh.c 初始了这一切:
上图中,write() 函数将提示信息($
符之类的)写入到文件描述符 2。作为用户代码的 Shell 调用 write 时,实际上调用的是关联到 Shell 的一个库函数。你可以查看这个库函数的源代码,在 usys.s。
上面这几行非常短的代码实现了 write 函数:它首先将SYS_write加载到a7寄存器,SYS_write是常量16。这里告诉内核,我想要运行第16个系统调用,而这个系统调用正好是write。之后这个函数中执行了ecall指令,从这里开始代码执行跳转到了内核。内核完成它的工作之后,代码执行会返回到用户空间,继续执行ecall之后的指令,也就是ret,最终返回到Shell中。所以ret从write库函数返回到了Shell中。
为了展示这里的系统调用,我们在 ecall 指令处放置一个断点(通过 sh.asm 文件看到改指令对应的行数),这个指令的地址是 0xde6。我们在 0xde6 处放置一个断点,让 XV6 开始运行,可以看到代码正好在执行 ecall 之前停住:
为了检查我们真的在自己期望的位置,可以打印一下程序计数器:
可以看到程序计数器的值正是 0xde6,我们还可以输入 info reg 来打印全部 32 个用户寄存器:
这里很多寄存器的数值我们没必要关心,只注意一下这里的 a0、a1、a2 是 Shell 传递给 write 系统调用的参数,所以 a0 是文件描述符 2,a1 是 shell 想写入的字符串,a2 是想要写入的字符数。我们还可以通过打印 Shell 想要写入的字符串内容,来证明断点停在我们认为它应该停在的位置:
可以看到,确实是想要打印美元符和一个空格。
有一件事情需要注意,上图的寄存器中,程序计数器(pc)和堆栈指针(sp)的地址现在都在距离0比较近的地址,这进一步印证了当前代码运行在用户空间,因为用户空间中所有的地址都比较小。但是一旦我们进入到了内核,内核会使用大得多的内存地址。
系统调用的时间点会有大量状态的变更,其中最重要需要变更的状态就是 page table,而且我们在变更之前还对它有依赖。STAP 寄存器保存了 page table 的物理内存地址。QEMU 中可以使用 ctrl a + c
来进入 QEMU 的 console,之后输入 info mem 就可以看到完整的 page table:
这个 user page table 只包含了6条映射关系,这写映射关系是有关 shell 程序的指令和数据的,以及一个无效的 page 作为 guard page,以防止 shell 尝试使用过多的 stack page。我们还可以在 attr 那一列看到每个 PTE 的标志位。
这些标志位,rwx 表示这个 page 是否可读、可写、可执行指令,u 标志位即 PTE_U,表示 user mode 是否可以使用这个 PTE,a 标志位(Access)表示这个 PTE 是否被使用过,d 标志位(Dirty)表示这个 PTE 是否被写过。
这个小小的 page table 中,可以看到最后两条PTE的虚拟地址非常大,非常接近虚拟地址的顶端,如果你读过了 XV6 的书,你就知道这两个 page 分别是 trapframe page 和 trampoline page。你可以看到,它们都没有设置 u 标志,所以用户代码不能访问这两条 PTE。一旦我们进入到了 supervisor mode,我们就可以访问这两条 PTE 了。
对于这里的 page table 需要注意,它并没有包含任何 kernel 部分的地址映射,既没有对于 kernel data 的映射,也没有对于 kernel 指令的映射,除了最后两条 PTE,这个 page table 几乎是完全为用户代码执行而创建,所以它对于在内核执行代码并没有直接特殊的作用。
接下来,程序计数器现在指向 ecall 指令,我们马上就要执行 ecall 指令了,现在我们还是在用户空间,但是马上我们就要进入内核空间了。
# 4. ECALL 指令之后的状态
现在我们执行 ECALL 指令:
第一个问题:执行完了 ecall 之后我们现在在哪?我们可以打印程序计数器来查看:
(gdb) stepi
0x0000003ffffff004 in ?? ()
=> 0x0000003ffffff004: 23 34 15 02 sd ra,40(a0)
(gdb) print $pc
$3 = (void (*)()) 0x3ffffff004
2
3
4
5
可以看到程序计数器的值变化了,之前我们的程序计数器还在一个很小的地址 0xde6,但是现在在一个大得多的地址。我们还可以查看page table,我通过在 QEMU 中执行 info mem 来查看当前的 page table,可以看出,这还是与之前完全相同的 page table,所以 page table 没有改变。
根据现在的程序计数器,代码正在 trampoline page 的最开始,这是用户内存中一个非常大的地址。所以现在我们的指令正运行在内存的 trampoline page 中。我们可以来查看一下现在将要运行的指令:
这些指令是内核在supervisor mode中将要执行的最开始的几条指令,也是在trap机制中最开始要执行的几条指令。因为gdb有一些奇怪的行为,我们实际上已经执行了位于trampoline page最开始的一条指令(注,也就是csrrw指令),我们将要执行的是第二条指令。
我们可以查看寄存器,对比之前寄存器的值可以发现,寄存器的值并没有改变,这里还是用户程序拥有的一些寄存器内容:
所以,现在寄存器里面还都是用户程序的数据,并且这些数据也还只保存在这些寄存器中,所以我们需要非常小心,在将寄存器数据保存在某处之前,我们在这个时间点不能使用任何寄存器,否则的话我们是没法恢复寄存器数据的。
前面 trap 之后执行的第一个指令是 csrrw,它交换了寄存器 a0 和 sscratch 的内容,使得内核在之后可以任意的使用a0寄存器了。这条指令将 a0 的数据保存在了 sscratch 中,同时又将 sscratch 内的数据保存在 a0 中。
现在我们正在执行 trampoline page 中的程序,这个 page 包含了内核的 trap 处理代码。ecall 并不会切换 page table,这是 ecall 指令的一个非常重要的特点。所以这意味着,trap 处理代码必须存在于每一个 user page table 中。因为 ecall 并不会切换page table,我们需要在 user page table 中的某个地方来执行最初的内核代码。而这个 trampoline page,是由内核小心的映射到每一个 user page table 中,以使得当我们仍然在使用 user page table 时,内核在一个地方能够执行 trap 机制的最开始的一些指令。
这里的控制是通过 STVEC 寄存器完成的,这是一个只能在supervisor mode下读写的特权寄存器。在从内核空间进入到用户空间之前,内核会设置好 STVEC 寄存器指向内核希望 trap 代码运行的位置。
所以如你所见,内核已经事先设置好了STVEC寄存器的内容为0x3ffffff000,这就是trampoline page的起始位置。STVEC寄存器的内容,就是在ecall指令执行之后,我们会在这个特定地址执行指令的原因。
最后,我想提示你们,即使trampoline page是在用户地址空间的user page table完成的映射,用户代码不能写它,因为这些page对应的PTE并没有设置PTE_u标志位。这也是为什么trap机制是安全的。
我一直在告诉你们我们现在已经在supervisor mode了,但是实际上我并没有任何能直接确认当前在哪种mode下的方法。不过我的确发现程序计数器现在正在trampoline page执行代码,而这些page对应的PTE并没有设置PTE_u标志位。所以现在只有当代码在supervisor mode时,才可能在程序运行的同时而不崩溃。所以,我从代码没有崩溃和程序计数器的值推导出我们必然在supervisor mode。
我们是通过 ecall 走到 trampoline page 的,而 ecall 实际上只会改变三件事情:
- ECALL 将 user mode 改为 supervisor mode
- ECALL 将 PC 的值保存在了 SEPC 寄存器中
- ECALL 会跳转到 STVEC 寄存器指向的指令(也就是将 STVEC 的值拷贝到了 PC 中)
所以,ECALL 虽然帮我们做了一点工作,但实际上离能够执行内核的 C 代码还差得很远,接下来我们还需要:
- 保存 32 个用户寄存器的值,以便之后恢复
- 将 user page table 切换到 kernel page table
- 创建一个 kernel stack,并将 stack pointer 寄存器的内容指向那个 kernel stack,这样才能给 C 代码提供栈
- 还需要跳转到内核 C 代码中的某些合理的位置
ECALL 并不会帮我们做上面这些事情。当然,我们可以通过修改硬件让 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 尽量的简单可以提升软件设计的灵活性。
# 5. uservec 函数
现在我们处于 trampoline page 的起始,也是 uservec 函数的起始。注意,现在 32 位寄存器还没有保存,page table 也是使用的 user page table。
# 5.1 保存 32 位寄存器
我们需要做的第一件事情就是保存寄存器的内容,在 RISC-V 中,如果不能使用寄存器,基本上不能做任何事情。
其他的一些机器或许可以直接将 32 个寄存器的内容直接写道物理内存中某些合适的位置,但我们在 RISC-V 中不可以,因为 RISC-V 中 supervisor mode 的代码也不允许直接使用物理内存,我们还是只能使用 page table 中的内容。
对于保存用户寄存器,XV6 在 RISC-V 上的实现包括两个部分:第一部分是,XV6 在每个 user page table 映射了 trapframe page,这样每个进程都有自己的 trapframe page,kernel 在 trapframe page 上预留了用来保存用户寄存器的 32 个空槽位(对应了 proc.h 中的 trapframe 结构体):
你可以看到很多槽位的名字都对应了特定的寄存器。在最开始还有5个数据,这些是内核事先存放在trapframe中的数据。比如第一个数据保存了kernel page table地址,这将会是trap处理代码将要加载到SATP寄存器的数值。
所以,如何保存用户寄存器的一半答案是,内核非常方便的将 trapframe page 映射到了每个 user page table。
另一半的答案在于我们之前提过的 SSCRATCH 寄存器,这个由 RISC-V 提供的 SSCRATCH 寄存器,就是为接下来的目的而创建的。在进入到user space之前,内核会将trapframe page的地址保存在这个寄存器中,也就是0x3fffffe000这个地址。更重要的是,RISC-V有一个指令允许交换任意两个寄存器的值。而SSCRATCH寄存器的作用就是保存另一个寄存器的值,并将自己的值加载给另一个寄存器。如果我查看trampoline.S代码:
第一件事情就是执行 csrrw 指令,交换了 a0 和 sscratch 的内容,交换后,a0 的值就是 trapframe page 的虚拟地址,而接下来的指令,就是将各个用户寄存器的内容保存到 trapframe page 的不同偏移位置上,可以看到,下面的每个指令都是将某个寄存器保存到 offset + a0
的位置,这部分指令比较无聊,就不介绍了。
trapframe的地址是怎么出现在SSCRATCH寄存器中的?
在内核前一次切换回用户空间时,内核会执行set sscratch指令,将这个寄存器的内容设置为0x3fffffe000,也就是trapframe page的虚拟地址。所以,当我们在运行用户代码,比如运行Shell时,SSCRATCH保存的就是指向trapframe的地址。
# 5.2 设置 stack pointer
程序现在仍然在 uservec 函数的最开始位置,还没有执行什么实际的内容,现在来到了寄存器拷贝结束后的指令:ld 指令
这条指令正在将 a0 指向的内存地址往后数的第 8 个字节开始的数据加载到 Stack Pointer 寄存器。我们知道 a0 的内容是 trapframe page 的地址,他的第 8 个字节开始的数据是 kernel stack pointer(kernel_sp
)。trapframe 中的 kernel_sp 是由 kernel 在进入用户空间之前就设置好的,它的值是这个进程的 kernel stack。所以这条指令的作用是初始化 Stack Pointer 指向这个进程的 kernel stack 的最顶端。
执行完 ld 指令后,打印一下 stack pointer 寄存器:
这是这个进程的kernel stack。因为XV6在每个kernel stack下面放置一个guard page,所以kernel stack的地址都比较大。
# 5.3 写 tp 寄存器
下一条指令是向tp寄存器写入数据。因为在RISC-V中,没有一个直接的方法来确认当前运行在多核处理器的哪个核上,XV6会将CPU核的编号也就是hartid保存在tp寄存器。在内核中好几个地方都会使用了这个值,例如,内核可以通过这个值确定某个CPU核上运行了哪些进程。我们执行这条指令,并且打印tp寄存器。
我们现在运行在CPU核0,这说的通,因为我之前配置了QEMU只给XV6分配一个核,所以我们只能运行在核0上。
# 5.4 写 t0 寄存器
下一条指令是向t0寄存器写入数据。这里写入的是我们将要执行的第一个C函数的指针,也就是函数usertrap的指针。我们在后面会使用这个指针。
# 5.5 切换 kernel page table
下一条指令是向t1寄存器写入数据。这里写入的是kernel page table的地址,我们可以打印t1寄存器的内容。
实际上严格来说,t1的内容并不是kernel page table的地址,这是你需要向SATP寄存器写入的数据。它包含了kernel page table的地址,但是移位了,并且包含了各种标志位。
下一条指令是交换SATP和t1寄存器。这条指令执行完成之后,当前程序会从user page table切换到kernel page table。现在我们在QEMU中打印page table,可以看出与之前的page table完全不一样:
现在这里输出的是由内核设置好的巨大的 kernel page table。所以现在我们成功的切换了 page table,我们在这个位置进展的很好,Stack Pointer 指向了 kernel stack;我们有了 kernel page table,可以读取 kernel data。我们已经准备好了执行内核中的 C 代码了。
这里还有个问题,为什么代码没有崩溃?毕竟我们在内存中的某个位置执行代码,程序计数器保存的是虚拟地址,如果我们切换了page table,为什么同一个虚拟地址不会通过新的page table寻址走到一些无关的page中?看起来我们现在没有崩溃并且还在执行这些指令。
原因在于我们还在trampoline代码中,而trampoline代码在用户空间和内核空间都映射到了同一个地址。所以即使切换了 page table,对于当前 PC 中的地址来说,寻址的结果不会改变。
之所以叫 trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。
# 5.6 准备进入 kernel 的 C 代码
最后一条指令是 jr t0
。执行了这条指令,我们就要从 trampoline 跳到内核的 C 代码中。这条指令的作用是跳转到 t0 指向的函数中。我们打印 t0 对应的一些指令,
可以看到 t0 的位置对应于一个叫做 usertrap 函数的开始。接下来我们就要以 kernel stack,kernel page table 跳转到 usertrap 函数。
# 6. usertrap 函数
usertrap 函数是位于 trap.c 文件的一个函数。我们现在在一个更加正常的世界中,我们正在运行C代码,应该会更容易理解。我们仍然会读写一些有趣的控制寄存器,但是环境比起汇编语言来说会少了很多晦涩。
//
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if(p->killed)
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
有很多原因都可以让程序运行进入到usertrap函数中来,比如系统调用,运算时除以0,使用了一个未被映射的虚拟地址,或者是设备中断。usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式,我们在接下来执行usertrap的过程中会同时看到这两个行为。
接下来,让我们一步步执行usertrap函数。
# 6.1 更改 STVEC 寄存器
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
2
3
4
5
6
7
8
9
10
11
12
13
它做的第一件事情是更改STVEC寄存器。取决于trap是来自于用户空间还是内核空间,实际上XV6处理trap的方法是不一样的。目前为止,我们只讨论过当trap是由用户空间发起时会发生什么。如果trap从内核空间发起,将会是一个非常不同的处理流程,因为从内核发起的话,程序已经在使用kernel page table。所以当trap发生时,程序执行仍然在内核的话,很多处理都不必存在。
在内核中执行任何操作之前,usertrap中先将STVEC指向了kernelvec变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。
# 6.2 获取当前 proc
struct proc *p = myproc();
出于各种原因,我们需要知道当前运行的是什么进程,我们通过调用myproc函数来做到这一点。myproc函数实际上会查找一个根据当前CPU核的编号索引的数组,CPU核的编号是hartid,如果你还记得,我们之前在uservec函数中将它存在了tp寄存器。这是myproc函数找出当前运行进程的方法。
# 6.3 保存用户程序计数器
// save user program counter.
p->trapframe->epc = r_sepc();
2
接下来我们要保存用户程序计数器,它仍然保存在SEPC寄存器中,但是可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致SEPC寄存器的内容被覆盖。所以,我们需要保存当前进程的SEPC寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。这里我们使用trapframe来保存这个程序计数器。
# 6.4 找出触发 usertrap 的原因
if(r_scause() == 8){
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
syscall();
} else if ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
接下来我们需要找出我们现在会在usertrap函数的原因。根据触发trap的原因,RISC-V的SCAUSE寄存器会有不同的数字。数字8表明,我们现在在trap代码中是因为系统调用。可以打印SCAUSE寄存器,它的确包含了数字8,我们的确是因为系统调用才走到这里的。
所以,我们可以进到这个if语句中。接下来第一件事情是检查是不是有其他的进程杀掉了当前进程,但是我们的 Shell 没有被杀掉,所以检查通过。
在 if 代码块内:
p->trapframe->epc += 4;
在RISC-V中,存储在SEPC寄存器中的程序计数器,是用户程序中触发trap的指令的地址。但是当我们恢复用户程序时,我们希望在下一条指令恢复,也就是ecall之后的一条指令。所以对于系统调用,我们对于保存的用户程序计数器加4,这样我们会在ecall的下一条指令恢复,而不是重新执行ecall指令。
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on();
2
3
XV6会在处理系统调用的时候使能中断,这样中断可以更快的服务,有些系统调用需要许多时间处理。中断总是会被RISC-V的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。
下一行代码中,我们会调用syscall函数。
# 6.5 syscall
syscall 函数定义在 syscall.c:
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
它的作用是从syscall表单中,根据系统调用的编号查找相应的系统调用函数。如果你还记得之前的内容,Shell调用的write函数将a7设置成了系统调用编号,对于write来说就是16。所以syscall函数的工作就是获取由trampoline代码保存在trapframe中a7的数字,然后用这个数字索引实现了每个系统调用的表单。
之后查看通过num索引得到的函数,正是sys_write函数。sys_write函数是内核对于write系统调用的具体实现。
这里有件有趣的事情,系统调用需要找到它们的参数。你们还记得write函数的参数吗?分别是文件描述符2,写入数据缓存的指针,写入数据的长度2。syscall函数直接通过trapframe来获取这些参数,就像这里刚刚可以查看trapframe中的a7寄存器一样,我们可以查看a0寄存器,这是第一个参数,a1是第二个参数,a2是第三个参数。
现在 syscall 执行了真正的系统调用,之后 sys_write 返回了:
p->trapframe->a0 = syscalls[num]();
这里向trapframe中的a0赋值的原因是:所有的系统调用都有一个返回值,比如write会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0,所以为了模拟函数的返回,我们将返回值存储在trapframe的a0中。之后,当我们返回到用户空间,trapframe中的a0槽位的数值会写到实际的a0寄存器,Shell会认为a0寄存器中的数值是write系统调用的返回值。执行完这一行代码之后,我们打印这里trapframe中a0的值,可以看到输出2。这意味这sys_write的返回值是2,符合传入的参数,这里只写入了2个字节。
# 6.6 再次检查进程是否被杀死
从syscall函数返回之后,我们回到了trap.c中的usertrap函数:
我们再次检查当前用户进程是否被杀掉了,因为我们不想恢复一个被杀掉的进程。当然,在我们的场景中,Shell没有被杀掉。
# 6.7 进入 usertrapret 函数
usertrapret();
最后,usertrap 调用了一个函数 usertrapret。
# 7. usertrapret 函数
usertrap 函数最后调用了 usertrapret 函数来完成从内核态到用户态之前内核需要做的工作。
# 7.1 关闭中断并设置 STVEC
//
// return to user space
//
void
usertrapret(void)
{
struct proc *p = myproc();
// we're about to switch the destination of traps from
// kerneltrap() to usertrap(), so turn off interrupts until
// we're back in user space, where usertrap() is correct.
intr_off();
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - trampoline));
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
// set up the registers that trampoline.S's sret will use
// to get to user space.
// set S Previous Privilege mode to User.
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
它首先关闭了中断。我们之前在 system call 过程中打开了中断,这里需要关闭中断是因为我们将要更新 STVEC 寄存器使之指向用户空间的 trap 处理代码(更新之前它指向的是内核空间的 trap 处理代码),而在更新之后,我们仍然处于内核空间中,这时如果发生了一个中断,那么程序执行就会走向用户空间的 trap 处理代码,即使我们仍然在内核空间中,出于一些细节原因,在内核空间态中进入到用户空间的 trap 代码会导致内核出错,所以这里需要关闭中断。
在下一行我们设置了 STVEC 寄存器使之指向了 trampoline 代码,在那里最终会执行 sret 指令返回到用户空间中,这个 sret 指令会重新打开中断,这样即使我们刚刚关闭了中断,当我们在执行用户空间代码时中断也是打开的。
# 7.2 填入 trapframe 内容
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap;
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
2
3
4
5
6
接下来的几行填入了 trapframe 的内容,这些内容对于执行 trampoline 代码非常有用。这里的代码就是:
- 存储了 kernel page table 的指针
- 存储了当前用户进程的 kernel stack
- 存储了 usertrap 函数的指针,这样 trampoline 代码才能跳转到这个函数
- 从 tp 寄存器中读取当前的 CPU 核编号,并存储在 trapframe 中,这样 trampoline 代码才能恢复这个数字,因为用户代码可能会修改这个数字
通过填充 trapframe 的以上数据,这样下一次从用户空间转换到内核空间时就可以用到这些数据。
# 7.3 设置 SSTAUS 寄存器
// set S Previous Privilege mode to User.
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
2
3
4
5
接下来我们设置 SSTAUS 寄存器,这是一个控制寄存器:
- 其 SPP bit 位控制了 sret 指令的行为,该 bit 位为 0 表示下次执行 sret 时我们想要返回 user mode 而不是 supervisor mode
- 其 SPIE bit 位控制了在执行完 sret 后是否打开中断。因为我们在返回到用户空间后,我们是希望打开中断的,所以这里将该位置为 1
在修改完这些 bit 后,我们就把新的值写回到 SSTAUS 寄存器中。
# 7.4 设置 SPEC 寄存器
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
2
我们在 trampoline 代码最后执行了 sret 指令,这条指令会将 PC 设置为 SPEC 寄存器的值,所以我们现在将 SPEC 的值设置为我们之前保存的用户程序寄存器的值,也就是之前我们保存在 trapframe 中 epc 字段的值。
# 7.5 计算 STAP 值
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
2
接下来,我们根据 user page table 的地址计算出相应的 STAP 的值,这样我们在返回到用户空间的时候才可以完成 page table 的切换。
由于只有 trampoline 汇编代码才是同时在用户空间和内核空间中同时存在 PTE 映射的,而我们现在还只是在一个普通的 C 函数中,所以我们现在只是将 page table 指针准备好,并将其准备传递给汇编代码,这个值将会出现在 a1 寄存器中。
# 7.6 计算 userret 函数地址
// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
2
3
4
5
倒数第二行的作用是计算出我们将要跳转到汇编代码的地址。我们期望跳转的地址是 tampoline 中的 userret 函数(一个汇编函数),这个函数包含了所有能将我们带回到用户空间的指令,所以这里计算出了 userret 函数的地址。
倒数第一行,将 fn 指针作为第一个函数指针,执行相应的函数(也就是 userret)函数,并传入两个参数,两个参数存储到 a0、a1 寄存器中。
# 8. userret 函数
现在代码执行又到了 trampoline 代码了。
.globl userret
userret:
# userret(TRAPFRAME, pagetable)
# switch from kernel to user.
# usertrapret() calls here.
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# switch to the user page table.
csrw satp, a1
sfence.vma zero, zero
# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
ld t0, 112(a0)
csrw sscratch, t0
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
ld t1, 80(a0)
ld t2, 88(a0)
ld s0, 96(a0)
ld s1, 104(a0)
ld a1, 120(a0)
ld a2, 128(a0)
ld a3, 136(a0)
ld a4, 144(a0)
ld a5, 152(a0)
ld a6, 160(a0)
ld a7, 168(a0)
ld s2, 176(a0)
ld s3, 184(a0)
ld s4, 192(a0)
ld s5, 200(a0)
ld s6, 208(a0)
ld s7, 216(a0)
ld s8, 224(a0)
ld s9, 232(a0)
ld s10, 240(a0)
ld s11, 248(a0)
ld t3, 256(a0)
ld t4, 264(a0)
ld t5, 272(a0)
ld t6, 280(a0)
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 8.1 切换 page table
csrw satp, a1
sfence.vma zero, zero
2
第一步是切换 page table。在执行 csrw satp, a1
之前,STAP 还是指向巨大的 kernel page table,这条指令将 user page table 放到了 STAP 寄存器中。幸运的是,user page table 中也映射了 trampoline page,所以程序还能继续执行而不是崩溃。
sfence.vma 清空了页表缓存。
# 8.2 恢复 SSCRATCH 寄存器
ld t0, 112(a0)
csrw sscratch, t0
2
在之前的 uservec 函数中,第一件事情就是交换了 SSCRATCH 和 a0 寄存器。所以这里需要将 SSCRATCH 寄存器恢复成保存好的用户的 a0 寄存器。这段代码的本质就是,通过当前 a0 寄存器找出存在于 trapframe 中的 a0 寄存器值,然后将其保存在 t0 中,再将 t0 保存到 SSCRATCH 中。
# 8.3 恢复用户寄存器
到目前为止,所有用户寄存器的值还都是属于内核,接下来将之前保存的各寄存器的值加载到对应的各寄存器中。
a0 现在还是个例外,他现在仍然是指向 trapframe 的值,而不是保存了的用户数据。
接下来,我们交换 SSCRATCH 和 a0 的值,前面我们看过了SSCRATCH现在的值是系统调用的返回值2,a0寄存器是trapframe的地址。交换完成之后,a0持有的是系统调用的返回值,SSCRATCH持有的是trapframe的地址。之后trapframe的地址会一直保存在SSCRATCH中,直到用户程序执行了另一次trap。
现在我们还是在 kernel 中。
# 8.4 执行 sret
sret 是我们在 kernel 中的最后一条指令,当我们执行完这条指令后:
- 程序会切换回 user mode
- SEPC 寄存器的数值将会被拷贝到 PC 寄存器(程序计数器)
- 重新打开中断
现在我们回到了用户空间,打印 PC 寄存器:
这是一个较小的指令地址,非常像是在用户空间中。如果我们查看 sh.asm,可以看到这个地址就是 write 函数的 ret 指令地址,所以,我们现在回到了用户空间,执行完 ret 指令后我们就可以从 write 系统调用返回到 shell 中了。或者更严格地说,是从触发了系统调用的 write 库函数中返回到 shell 中。
# 9. 总结
最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的 user/kernel 转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持 user/kernel 之间的隔离性,内核不能信任来自用户空间的任何内容。
另一方面,XV6 实现 trap 的方式比较特殊,XV6 并不关心性能。但是通常来说,操作系统的设计人员和 CPU 设计人员非常关心如何提升 trap 的效率和速度。必然还有跟我们这里不一样的方式来实现 trap,当你在实现的时候,可以从以下几个问题出发:
- 硬件和软件需要协同工作,你可能需要重新设计 XV6,重新设计 RISC-V 来使得这里的处理流程更加简单,更加快速。
- 另一个需要时刻记住的问题是,恶意软件能否滥用这里的机制来打破隔离性。
好的,这就是这节课的全部内容。