本文为MIT 6.S081课程第四章教材内容翻译加整理。
本课程前置知识主要涉及:
上一篇文章中,我们通过源码跟踪了整个Trap的执行流程,本文追踪我们通过Debug的方式,来验证上一篇文章的说法。
我们先来简单回顾一下整个Trap的执行流程:
接下来,我们将切换到gdb的世界,通过跟踪一个XV6的系统调用,也就是Shell将它的提示信息通过write系统调用走到操作系统再输出到console的过程。
我们可以看到,用户代码sh.c初始了这一切:
write函数首先将SYS_write加载到a7寄存器,SYS_write是常量16。这里告诉内核,我想要运行第16个系统调用,而这个系统调用正好是write。之后这个函数中执行了ecall指令,从这里开始代码执行跳转到了内核。内核完成它的工作之后,代码执行会返回到用户空间,继续执行ecall之后的指令,也就是ret,最终返回到Shell中。所以ret从write库函数返回到了Shell中。
通过getCmd函数的汇编源码,可以看到调用write函数对应的汇编指令,这段汇编指令主要作了参数准备,最后跳转到write函数地址执行,write汇编代码如下所示:
li a7 16这条指令占两个字节,所以0xdec+2=0xdee
这里有一些数值我们还不知道,也不关心,但是这里的a0,a1,a2是Shell传递给write系统调用的参数。所以a0是文件描述符2;a1是Shell想要写入字符串的指针;a2是想要写入的字符数。我们还可以通过打印Shell想要写入的字符串内容,来证明断点停在我们认为它应该停在的位置。
可以看出,输出的确是美元符($)和一个空格。所以,我们现在位于我们期望所在的write系统调用函数中。
有一件事情需要注意,上图的寄存器中,程序计数器(pc)和堆栈指针(sp)的地址现在都在距离0比较近的地址,这进一步印证了当前代码运行在用户空间,因为用户空间中所有的地址都比较小。但是一旦我们进入到了内核,内核会使用大得多的内存地址。
系统调用的时间点会有大量状态的变更,其中一个最重要的需要变更的状态,并且在它变更之前我们对它还有依赖的,就是是当前的page table。
这里输出的是物理内存地址,它并没有告诉我们有关page table中的映射关系是什么,page table长什么样。
但是在QEMU中有一个方法可以打印当前的page table。从QEMU界面,输入ctrl a + c可以进入到QEMU的console,之后输入info mem,QEMU会打印完整的page table。
这是个非常小的page table,它只包含了6条映射关系:
最后两条PTE的虚拟地址非常大,非常接近虚拟地址的顶端,如果你读过了XV6的书,你就知道这两个page分别是trapframe page和trampoline page。你可以看到,它们都没有设置u标志,所以用户代码不能访问这两条PTE。一旦我们进入到了supervisor mode,我们就可以访问这两条PTE了。
对于这里page table,有一件事情需要注意:
PTE中a标志位有什么作用?
可以看到程序计数器的值变化了,之前我们的程序计数器还在一个很小的地址0xde6,但是现在在一个大得多的地址。
关于gdb-multiarch无法追踪ecall指令调用的问题:
解决方案可参考:
本文自行编译的gdb由于缺失部分py gdb模块文件,所以调试过程中会出现错误提示,但是不影响调试,所以本文把相关错误全部模糊化处理掉了。
我们还可以查看page table,我通过在QEMU中执行info mem来查看当前的page table,可以看出,这还是与之前完全相同的page table,所以page table没有改变:
根据现在的程序计数器,代码正在trampoline page的最开始,这是用户内存中一个非常大的地址。所以现在我们的指令正运行在内存的trampoline page中。我们可以来查看一下现在将要运行的指令。
这些指令是内核在supervisor mode中将要执行的最开始的几条指令,也是在trap机制中最开始要执行的几条指令。
我们可以查看寄存器,对比之前的图可以看出,寄存器的值并没有改变,这里还是用户程序拥有的一些寄存器内容。
所以,现在寄存器里面还都是用户程序的数据,并且这些数据也还只保存在这些寄存器中,所以我们需要非常小心,在将寄存器数据保存在某处之前,我们在这个时间点不能使用任何寄存器,否则的话我们是没法恢复寄存器数据的。
如果内核在这个时间点使用了任何一个寄存器,内核会覆盖寄存器内的用户数据,之后如果我们尝试要恢复用户程序,我们就不能恢复寄存器中的正确数据,用户程序的执行也会相应的出错。
csrrw指令是干什么的?
我们现在在这个地址0x3ffffff000,也就是上面page table输出的最后一个page,这是trampoline page。
这里的控制是通过STVEC寄存器完成的,这是一个只能在supervisor mode下读写的特权寄存器。在从内核空间进入到用户空间之前,内核会设置好STVEC寄存器指向内核希望trap代码运行的位置:
我们是通过ecall走到trampoline page的,而ecall实际上只会改变三件事情:
尽管其他的寄存器还是还是用户寄存器,但是这里的程序计数器明显已经不是用户代码的程序计数器。这里的程序计数器是从STVEC寄存器拷贝过来的值。我们也可以打印SEPC(Supervisor Exception Program Counter)寄存器,这是ecall保存用户程序计数器的地方。
这个寄存器里面有熟悉的地址0xde6,这是ecall指令在用户空间的地址。所以ecall至少保存了程序计数器的数值。
所以现在,ecall帮我们做了一点点工作,但是实际上我们离执行内核中的C代码还差的很远。接下来:
ecall并不会为我们做这里的任何一件事。
当然,我们可以通过修改硬件让ecall为我们完成这些工作,而不是交给软件来完成。并且,我们也将会看到,在软件中完成这些工作并不是特别简单。所以你现在就会问,为什么ecall不多做点工作来将代码执行从用户空间切换到内核空间呢?为什么ecall不会保存用户寄存器,或者切换page table指针来指向kernel page table,或者自动的设置Stack Pointer指向kernel stack,或者直接跳转到kernel的C代码,而不是在这里运行复杂的汇编代码?
实际上,有的机器在执行系统调用时,会在硬件中完成所有这些工作。但是RISC-V并不会,RISC-V秉持了这样一个观点:
切换页表需要刷新TLB,所以代价还是比较大的。
所以,ecall尽量的简单可以提升软件设计的灵活性。
现在程序位于trampoline page的起始,也是uservec函数的起始。
我们现在需要做的第一件事情就是保存寄存器的内容。
在RISC-V上,如果不能使用寄存器,基本上不能做任何事情。所以,对于保存这些寄存器,我们有什么样的选择呢?
在一些其他的机器中,我们或许直接就将32个寄存器中的内容写到物理内存中某些合适的位置。但是我们不能在RISC-V中这样做,因为在RISC-V中,supervisor mode下的代码不允许直接访问物理内存。所以我们只能使用page table中的内容,但是从前面的输出来看,page table中也没有多少内容。
虽然XV6并没有使用,但是另一种可能的操作是,直接将SATP寄存器指向kernel page table,之后我们就可以直接使用所有的kernel mapping来帮助我们存储用户寄存器:
对于保存用户寄存器,XV6在RISC-V上的实现包括了两个部分:
如果你想查看XV6在trapframe page中存放了什么,这部分代码在proc.h中的trapframe结构体中:
你可以看到很多槽位的名字都对应了特定的寄存器。在最开始还有5个数据,这些是内核事先存放在trapframe中的数据。
所以,如何保存用户寄存器的一半答案是,内核非常方便的将trapframe page映射到了每个user page table。
另一半的答案在于我们之前提过的SSCRATCH寄存器。这个由RISC-V提供的SSCRATCH寄存器,就是为接下来的目的而创建的。
csrrw指令是干什么的?
下面查看一下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寄存器中的?
内核在返回到用户空间之前执行的最后两条指令:
你或许会好奇,a0是如何有trapframe page的地址。我们可以查看trap.c代码:
这是内核返回到用户空间的最后的C函数。C函数做的最后一件事情是调用fn函数,传递的参数是TRAMFRAME和user page table。在C代码中,当你调用函数,第一个参数会存在a0,这就是为什么a0里面的数值是指向trapframe的指针。fn函数是就是刚刚我向你展示的位于trampoline.S中的代码。
程序现在仍然在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寄存器。
下一条指令是向t0寄存器写入数据。这里写入的是我们将要执行的第一个C函数的指针,也就是函数usertrap的指针。我们在后面会使用这个指针。
下一条指令是向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中?看起来我们现在没有崩溃并且还在执行这些指令, 这是为什么 ?
最后一条指令是jr t0。执行了这条指令,我们就要从trampoline跳到内核的C代码中。这条指令的作用是跳转到t0指向的函数中。我们打印t0对应的一些指令:
可以看到t0的位置对应于一个叫做usertrap函数的开始。接下来我们就要以kernel stack,kernel page table跳转到usertrap函数。
usertrap函数是位于trap.c文件的一个函数。
既然我们已经运行在C代码中,接下来,我在gdb中输入tui enable打开对于C代码的展示:
我们现在在一个更加正常的世界中,我们正在运行C代码,应该会更容易理解。我们仍然会读写一些有趣的控制寄存器,但是环境比起汇编语言来说会少了很多晦涩。
有很多原因都可以让程序运行进入到usertrap函数中来,比如系统调用,运算时除以0,使用了一个未被映射的虚拟地址,或者是设备中断。usertrap某种程度上存储并恢复硬件状态,但是它也需要检查触发trap的原因,以确定相应的处理方式,我们在接下来执行usertrap的过程中会同时看到这两个行为。
接下来,让我们一步步执行usertrap函数。
它做的第一件事情是更改STVEC寄存器。
取决于trap是来自于用户空间还是内核空间,实际上XV6处理trap的方法是不一样的。目前为止,我们只讨论过当trap是由用户空间发起时会发生什么。如果trap从内核空间发起,将会是一个非常不同的处理流程,因为从内核发起的话,程序已经在使用kernel page table。所以当trap发生时,程序执行仍然在内核的话,很多处理都不必存在。
在内核中执行任何操作之前,usertrap中先将STVEC指向了kernelvec变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。
出于各种原因,我们需要知道当前运行的是什么进程,我们通过调用myproc函数来做到这一点。myproc函数实际上会查找一个根据当前CPU核的编号索引的数组,CPU核的编号是hartid,如果你还记得,我们之前在uservec函数中将它存在了tp寄存器。这是myproc函数找出当前运行进程的方法。
接下来我们要保存用户程序计数器,它仍然保存在SEPC寄存器中,但是可能发生这种情况:
接下来我们需要找出我们现在会在usertrap函数的原因。根据触发trap的原因,RISC-V的SCAUSE寄存器会有不同的数字。数字8表明,我们现在在trap代码中是因为系统调用。可以打印SCAUSE寄存器,它的确包含了数字8,我们的确是因为系统调用才走到这里的。
所以,我们可以进到这个if语句中。接下来第一件事情是检查是不是有其他的进程杀掉了当前进程,但是我们的Shell没有被杀掉,所以检查通过。
在RISC-V中,存储在SEPC寄存器中的程序计数器,是用户程序中触发trap的指令的地址。但是当我们恢复用户程序时,我们希望在下一条指令恢复,也就是ecall之后的一条指令。所以对于系统调用,我们对于保存的用户程序计数器加4,这样我们会在ecall的下一条指令恢复,而不是重新执行ecall指令。
XV6会在处理系统调用的时候使能中断,这样中断可以更快的服务,有些系统调用需要许多时间处理。中断总是会被RISC-V的trap硬件关闭,所以在这个时间点,我们需要显式的打开中断。
下一行代码中,我们会调用syscall函数。这个函数定义在syscall.c。
它的作用是从syscall表单中,根据系统调用的编号查找相应的系统调用函数。如果你还记得之前的内容,Shell调用的write函数将a7设置成了系统调用编号,对于write来说就是16。所以syscall函数的工作就是获取由trampoline代码保存在trapframe中a7的数字,然后用这个数字索引实现了每个系统调用的表单。
我们可以打印num,的确是16。这与Shell调用的write函数写入的数字是一致的。
之后查看通过num索引得到的函数,正是sys_write函数。sys_write函数是内核对于write系统调用的具体实现。这里再往后的代码执行就非常复杂了,我就不具体介绍了。在这节课中,对于系统调用的实现,我只对进入和跳出内核感兴趣。这里我让代码直接执行sys_write函数。
这里有件有趣的事情,系统调用需要找到它们的参数。你们还记得write函数的参数吗?分别是文件描述符2,写入数据缓存的指针,写入数据的长度2。syscall函数直接通过trapframe来获取这些参数,就像这里刚刚可以查看trapframe中的a7寄存器一样,我们可以查看a0寄存器,这是第一个参数,a1是第二个参数,a2是第三个参数。
现在syscall执行了真正的系统调用,之后sys_write返回了。
这里向trapframe中的a0赋值的原因是:
这意味sys_write的返回值是2,符合传入的参数,这里只写入了2个字节。
从syscall函数返回之后,我们回到了trap.c中的usertrap函数。
我们再次检查当前用户进程是否被杀掉了,因为我们不想恢复一个被杀掉的进程。当然,在我们的场景中,Shell没有被杀掉。
最后,usertrap调用了一个函数usertrapret。
usertrap函数的最后调用了usertrapret函数,来设置好我之前说过的,在返回到用户空间之前内核要做的工作。我们可以查看这个函数的内容。
它首先关闭了中断。我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新STVEC寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码。
我们关闭中断因为当我们将STVEC更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
在下一行我们设置了STVEC寄存器指向trampoline代码,在那里最终会执行sret指令返回到用户空间。位于trampoline代码最后的sret指令会重新打开中断。这样,即使我们刚刚关闭了中断,当我们在执行用户代码时中断是打开的。
接下来的几行填入了trapframe的内容,这些内容对于执行trampoline代码非常有用。这里的代码就是:
现在我们在usertrapret函数中,我们正在设置trapframe中的数据,这样下一次从用户空间转换到内核空间时可以用到这些数据。
为什么trampoline代码中不保存SEPC寄存器?
接下来我们要设置SSTATUS寄存器,这是一个控制寄存器。
我们在trampoline代码的最后执行了sret指令。这条指令会将程序计数器设置成SEPC寄存器的值,所以现在我们将SEPC寄存器的值设置成之前保存的用户程序计数器的值。在不久之前,我们在usertrap函数中将用户程序计数器保存在trapframe中的epc字段。
接下来,我们根据user page table地址生成相应的SATP值,这样我们在返回到用户空间的时候才能完成page table的切换。
倒数第二行的作用是计算出我们将要跳转到汇编代码的地址。我们期望跳转的地址是tampoline中的userret函数,这个函数包含了所有能将我们带回到用户空间的指令。所以这里我们计算出了userret函数的地址。
倒数第一行,将fn指针作为一个函数指针,执行相应的函数(也就是userret函数)并传入两个参数,两个参数存储在a0,a1寄存器中。
现在程序执行又到了trampoline代码:
(注,sfence.vma是清空页表缓存)
在uservec函数中,第一件事情就是交换SSRATCH和a0寄存器。而这里,我们将SSCRATCH寄存器恢复成保存好的用户的a0寄存器。
(注,这里有点绕,本质就是通过当前的a0寄存器找出存在trapframe中的a0寄存器)
为止目前,所有的寄存器内容还是属于内核:
接下来的这些指令将a0寄存器指向的trapframe中,之前保存的寄存器的值加载到对应的各个寄存器中。之后,我们离能真正运行用户代码就很近了。
现在trapframe中的a0寄存器是我们执行系统调用的返回值吗?
现在我们打印所有的寄存器:
我不确定你们是否还记得,但是这些寄存器的值就是我们在最最开始看到的用户寄存器的值。
a0寄存器现在还是个例外,它现在仍然是指向trapframe的指针,而不是保存了的用户数据。
接下来,在我们即将返回到用户空间之前,我们交换SSCRATCH寄存器和a0寄存器的值。
sret是我们在kernel中的最后一条指令,当我执行完这条指令:
现在我们回到了用户空间。打印PC寄存器:
这是一个较小的指令地址,非常像是在用户内存中。如果我们查看sh.asm,可以看到这个地址是write函数的ret指令地址。
这里代码有些许改动,所以write函数起始地址与上面给出的不太一样
所以,现在我们回到了用户空间,执行完ret指令之后我们就可以从write系统调用返回到Shell中了。或者更严格的说,是从触发了系统调用的write库函数中返回到Shell中。
最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。
另一方面,XV6实现trap的方式比较特殊,XV6并不关心性能。但是通常来说,操作系统的设计人员和CPU设计人员非常关心如何提升trap的效率和速度。必然还有跟我们这里不一样的方式来实现trap,当你在实现的时候,可以从以下几个问题出发: