6.5 uservec函数

回到XV6和RISC-V,现在程序位于trampoline page的起始,也是uservec函数的起始。我们现在需要做的第一件事情就是保存寄存器的内容。在RISC-V上,如果不能使用寄存器,基本上不能做任何事情。所以,对于保存这些寄存器,我们有什么样的选择呢?

在一些其他的机器中,我们或许直接就将32个寄存器中的内容写到物理内存中某些合适的位置。但是我们不能在RISC-V中这样做,因为在RISC-V中,supervisor mode下的代码不允许直接访问物理内存。所以我们只能使用page table中的内容,但是从前面的输出来看,page table中也没有多少内容。

虽然XV6并没有使用,但是另一种可能的操作是,直接将SATP寄存器指向kernel page table,之后我们就可以直接使用所有的kernel mapping来帮助我们存储用户寄存器。这是合法的,因为supervisor mode可以更改SATP寄存器。但是在trap代码当前的位置,也就是trap机制的最开始,我们并不知道kernel page table的地址。并且更改SATP寄存器的指令,要求写入SATP寄存器的内容来自于另一个寄存器。所以,为了能执行更新page table的指令,我们需要一些空闲的寄存器,这样我们才能先将page table的地址存在这些寄存器中,然后再执行修改SATP寄存器的指令。

对于保存用户寄存器,XV6在RISC-V上的实现包括了两个部分。第一个部分是,XV6在每个user page table映射了trapframe page,这样每个进程都有自己的trapframe page。这个page包含了很多有趣的数据,但是现在最重要的数据是用来保存用户寄存器的32个空槽位。所以,在trap处理代码中,现在的好消息是,我们在user page table有一个之前由kernel设置好的映射关系,这个映射关系指向了一个可以用来存放这个进程的用户寄存器的内存位置。这个位置的虚拟地址总是0x3ffffffe000。

如果你想查看XV6在trapframe page中存放了什么,这部分代码在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,

a0现在的值是0x3fffffe000,这是trapframe page的虚拟地址。它之前保存在SSCRATCH寄存器中,但是我们现在交换到了a0中。我们也可以打印SSCRATCH寄存器,

它现在的内容是2,这是a0寄存器之前的值。a0寄存器保存的是write函数的第一个参数,在这个场景下,是Shell传入的文件描述符2。所以我们现在将a0的值保存起来了,并且我们有了指向trapframe page的指针。现在我们正在朝着保存用户寄存器的道路上前进。实际上,这就是trampoline.S中接下来30多个奇怪指令的工作。这些指令就是的执行sd,将每个寄存器保存在trapframe的不同偏移位置。因为a0在交换完之后包含的是trapframe page地址,也就是0x3fffffe000。所以,每个寄存器被保存在了偏移量+a0的位置。这些存储的指令比较无聊,我就不介绍了。

学生提问:当与a0寄存器进行交换时,trapframe的地址是怎么出现在SSCRATCH寄存器中的?

Robert教授:在内核前一次切换回用户空间时,内核会执行set sscratch指令,将这个寄存器的内容设置为0x3fffffe000,也就是trapframe page的虚拟地址。所以,当我们在运行用户代码,比如运行Shell时,SSCRATCH保存的就是指向trapframe的地址。之后,Shell执行了ecall指令,跳转到了trampoline page,这个page中的第一条指令会交换a0和SSCRATCH寄存器的内容。所以,SSCRATCH中的值,也就是指向trapframe的指针现在存储与a0寄存器中。

同一个学生提问:这是发生在进程创建的过程中吗?这个SSCRATCH寄存器存在于哪?

Robert教授:这个寄存器存在于CPU上,这是CPU上的一个特殊寄存器。内核在什么时候设置的它呢?这有点复杂。它被设置的实际位置,我们可以看下图,

选中的代码是内核在返回到用户空间之前执行的最后两条指令。在内核返回到用户空间时,会恢复所有的用户寄存器。之后会再次执行交换指令,csrrw。因为之前内核已经设置了a0保存的是trap frame地址,经过交换之后SSCRATCH仍然指向了trapframe page地址,而a0也恢复成了之前的数值。最后sret返回到了用户空间。

你或许会好奇,a0是如何有trapframe page的地址。我们可以查看trap.c代码,

这是内核返回到用户空间的最后的C函数。C函数做的最后一件事情是调用fn函数,传递的参数是TRAMFRAME和user page table。在C代码中,当你调用函数,第一个参数会存在a0,这就是为什么a0里面的数值是指向trapframe的指针。fn函数是就是刚刚我向你展示的位于trampoline.S中的代码。

学生提问:当你启动一个进程,之后进程在运行,之后在某个时间点进程执行了ecall指令,那么你是在什么时候执行上一个问题中的fn函数呢?因为这是进程的第一个ecall指令,所以这个进程之前应该没有调用过fn函数吧。

Robert教授:好的,或许对于这个问题的一个答案是:一台机器总是从内核开始运行的,当机器启动的时候,它就是在内核中。 任何时候,不管是进程第一次启动还是从一个系统调用返回,进入到用户空间的唯一方法是就是执行sret指令。sret指令是由RISC-V定义的用来从supervisor mode转换到user mode。所以,在任何用户代码执行之前,内核会执行fn函数,并设置好所有的东西,例如SSCRATCH,STVEC寄存器。

学生提问:当我们在汇编代码中执行ecall指令,是什么触发了trampoline代码的执行,是CPU中的从user到supervisor的标志位切换吗?

Robert教授:在我们的例子中,Shell在用户空间执行了ecall指令。ecall会完成几件事情,ecall指令会设置当前为supervisor mode,保存程序计数器到SEPC寄存器,并且将程序计数器设置成控制寄存器STVEC的内容。STVEC是内核在进入到用户空间之前设置好的众多数据之一,内核会将其设置成trampoline page的起始位置。所以,当ecall指令执行时,ecall会将STVEC拷贝到程序计数器。之后程序继续执行,但是却会在当前程序计数器所指的地址,也就是trampoline page的起始地址执行。

学生提问:寄存器保存在了trapframe page,但是这些寄存器用户程序也能访问,为什么我们要使用内存中一个新的区域(指的是trapframe page),而不是使用程序的栈?

Robert教授:好的,这里或许有两个问题。第一个是,为什么我们要保存寄存器?为什么内核要保存寄存器的原因,是因为内核即将要运行会覆盖这些寄存器的C代码。如果我们想正确的恢复用户程序,我们需要将这些寄存器恢复成它们在ecall调用之前的数值,所以我们需要将所有的寄存器都保存在trapframe中,这样才能在之后恢复寄存器的值。

另一个问题是,为什么这些寄存器保存在trapframe,而不是用户代码的栈中?这个问题的答案是,我们不确定用户程序是否有栈,必然有一些编程语言没有栈,对于这些编程语言的程序,Stack Pointer不指向任何地址。当然,也有一些编程语言有栈,但是或许它的格式很奇怪,内核并不能理解。比如,编程语言以堆中以小块来分配栈,编程语言的运行时知道如何使用这些小块的内存来作为栈,但是内核并不知道。所以,如果我们想要运行任意编程语言实现的用户程序,内核就不能假设用户内存的哪部分可以访问,哪部分有效,哪部分存在。所以内核需要自己管理这些寄存器的保存,这就是为什么内核将这些内容保存在属于内核内存的trapframe中,而不是用户内存。

程序现在仍然在trampoline的最开始,也就是uservec函数的最开始,我们基本上还没有执行任何内容。我在寄存器拷贝的结束位置设置了一个断点,我们在gdb中让代码继续执行,现在我们停在了下面这条ld(load)指令。

这条指令正在将a0指向的内存地址往后数的第8个字节开始的数据加载到Stack Pointer寄存器。a0的内容现在是trapframe page的地址,从本节第一张图中,trapframe的格式可以看出,第8个字节开始的数据是内核的Stack Pointer(kernel_sp)。trapframe中的kernel_sp是由kernel在进入用户空间之前就设置好的,它的值是这个进程的kernel stack。所以这条指令的作用是初始化Stack Pointer指向这个进程的kernel stack的最顶端。指向完这条指令之后,我们打印一下当前的Stack Pointer寄存器,

这是这个进程的kernel stack。因为XV6在每个kernel stack下面放置一个guard page,所以kernel stack的地址都比较大。

下一条指令是向tp寄存器写入数据。因为在RISC-V中,没有一个直接的方法来确认当前运行在多核处理器的哪个核上,XV6会将CPU核的编号也就是hartid保存在tp寄存器。在内核中好几个地方都会使用了这个值,例如,内核可以通过这个值确定某个CPU核上运行了哪些进程。我们执行这条指令,并且打印tp寄存器。

我们现在运行在CPU核0,这说的通,因为我之前配置了QEMU只给XV6分配一个核,所以我们只能运行在核0上。

下一条指令是向t0寄存器写入数据。这里写入的是我们将要执行的第一个C函数的指针,也就是函数usertrap的指针。我们在后面会使用这个指针。

下一条指令是向t1寄存器写入数据。这里写入的是kernel page table的地址,我们可以打印t1寄存器的内容。

实际上严格来说,t1的内容并不是kernel page table的地址,这是你需要向SATP寄存器写入的数据。它包含了kernel page table的地址,但是移位了(注,详见4.3),并且包含了各种标志位。

下一条指令是交换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代码在用户空间和内核空间都映射到了同一个地址。

完全正确。我不知道你们是否还记得user page table的内容,trampoline page在user page table中的映射与kernel page table中的映射是完全一样的。这两个page table中其他所有的映射都是不同的,只有trampoline page的映射是一样的,因此我们在切换page table时,寻址的结果不会改变,我们实际上就可以继续在同一个代码序列中执行程序而不崩溃。这是trampoline page的特殊之处,它同时在user page table和kernel page table都有相同的映射关系。

之所以叫trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。

最后一条指令是jr t0。执行了这条指令,我们就要从trampoline跳到内核的C代码中。这条指令的作用是跳转到t0指向的函数中。我们打印t0对应的一些指令,

可以看到t0的位置对应于一个叫做usertrap函数的开始。接下来我们就要以kernel stack,kernel page table跳转到usertrap函数。

Last updated