6.4 ECALL指令之后的状态

现在我执行ecall指令,

第一个问题,执行完了ecall之后我们现在在哪?我们可以打印程序计数器(Program Counter)来查看。

可以看到程序计数器的值变化了,之前我们的程序计数器还在一个很小的地址0xde6,但是现在在一个大得多的地址。

我们还可以查看page table,我通过在QEMU中执行info mem来查看当前的page table,可以看出,这还是与之前完全相同的page table,所以page table没有改变。

根据现在的程序计数器,代码正在trampoline page的最开始,这是用户内存中一个非常大的地址。所以现在我们的指令正运行在内存的trampoline page中。我们可以来查看一下现在将要运行的指令。

这些指令是内核在supervisor mode中将要执行的最开始的几条指令,也是在trap机制中最开始要执行的几条指令。因为gdb有一些奇怪的行为,我们实际上已经执行了位于trampoline page最开始的一条指令(注,也就是csrrw指令),我们将要执行的是第二条指令。

我们可以查看寄存器,对比6.3中的图可以看出,寄存器的值并没有改变,这里还是用户程序拥有的一些寄存器内容。

所以,现在寄存器里面还都是用户程序的数据,并且这些数据也还只保存在这些寄存器中,所以我们需要非常小心,在将寄存器数据保存在某处之前,我们在这个时间点不能使用任何寄存器,否则的话我们是没法恢复寄存器数据的。如果内核在这个时间点使用了任何一个寄存器,内核会覆盖寄存器内的用户数据,之后如果我们尝试要恢复用户程序,我们就不能恢复寄存器中的正确数据,用户程序的执行也会相应的出错。

学生提问:我想知道csrrw指令是干什么的?

Robert教授:我们过几分钟会讨论这部分。但是对于你的问题的答案是,这条指令交换了寄存器a0和sscratch的内容。这个操作超级重要,它回答了这个问题,内核的trap代码如何能够在不使用任何寄存器的前提下做任何操作。这条指令将a0的数据保存在了sscratch中,同时又将sscratch内的数据保存在a0中。之后内核就可以任意的使用a0寄存器了。

我们现在在这个地址0x3ffffff000,也就是上面page table输出的最后一个page,这是trampoline page。我们现在正在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将程序计数器的值保存在了SEPC寄存器。我们可以通过打印程序计数器看到这里的效果,

尽管其他的寄存器还是还是用户寄存器,但是这里的程序计数器明显已经不是用户代码的程序计数器。这里的程序计数器是从STVEC寄存器拷贝过来的值。我们也可以打印SEPC(Supervisor Exception Program Counter)寄存器,这是ecall保存用户程序计数器的地方。

这个寄存器里面有熟悉的地址0xde6,这是ecall指令在用户空间的地址。所以ecall至少保存了程序计数器的数值。

第三,ecall会跳转到STVEC寄存器指向的指令。

所以现在,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尽量的简单可以提升软件设计的灵活性。

(以下问答来自于视频60:00处,因为相关就移过来了)

学生提问:为什么我们在gdb中看不到ecall的具体内容?或许我错过了,但是我觉得我们是直接跳到trampoline代码的。

Robert教授:ecall只会更新CPU中的mode标志位为supervisor,并且设置程序计数器成STVEC寄存器内的值。在进入到用户空间之前,内核会将trampoline page的地址存在STVEC寄存器中。所以ecall的下一条指令的位置是STVEC指向的地址,也就是trampoline page的起始地址。(注,实际上ecall是CPU的指令,自然在gdb中看不到具体内容)

Last updated