本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:Lab3->Page tables
操作系统MIT6.S081:P4->RISC-V calling conventions and stack frames
操作系统MIT6.S081:[xv6参考手册第4章]->Trap与系统调用
文章目录
一、trap机制
trap是什么
掌握程序运行时如何完成用户空间和内核空间的切换是非常必要的。比如
①每当程序执行系统调用
②程序出现了类似page fault、运算时除以0的错误
③一个设备触发了中断,使得当前程序运行需要响应内核设备驱动
都会发生这样的切换 ,这里用户空间和内核空间的切换通常被称为trap。trap涉及了许多精心的设计和重要的细节,这些细节对于实现安全隔离和性能来说非常重要。很多应用程序因为系统调用、page fault等原因会频繁地切换到内核中,所以trap机制还需要要尽可能的简单。
trap的工作流程
trap的使用场景
初始的场景如下所示。我们有一些用户应用程序(例如Shell)运行在用户空间,同时我们还有内核空间。Shell可能会执行系统调用,将程序运行切换到内核运行。比如XV6启动之后Shell输出的一些提示信息,就是通过执行write
系统调用来输出的。
我们需要清楚如何让程序的运行,从只拥有user权限并且位于用户空间的Shell,切换到拥有supervisor权限的内核。
寄存器状态
在这个过程中,硬件的状态将会非常重要,因为我们很多的工作都是将硬件从适合运行用户应用程序的状态改变到适合运行内核代码的状态。我们最关心的状态可能是32个用户寄存器,这在上节课中有介绍。RISC-V总共有32个比如a0
,a1
这样的寄存器,用户应用程序可以使用全部的寄存器以获取高性能。
这里的很多寄存器都有特殊的作用,我们之后都会看到。
①栈指针(Stack Pointer),也叫做栈寄存器,保存栈帧的地址。
②程序计数器(Program Counter Register)
③模式(Mode),表明当前模式的标志位。这个标志位表明了当前是supervisor mode还是user mode。当我们在运行Shell的时候,自然是在user mode。
还有一堆控制CPU工作方式的寄存器:
④SATP(Supervisor Address Translation and Protection)寄存器,它包含了指向页表的物理内存地址。
还有一些对于今天讨论非常重要的寄存器:
⑤STVEC(Supervisor Trap Vector Base Address Register)寄存器,它指向了内核中处理trap的指令的起始地址。
⑥SEPC(Supervisor Exception Program Counter)寄存器,在trap的过程中保存程序计数器的值。
⑦SSCRATCH(Supervisor Scratch Register)寄存器。
这些寄存器表明了执行系统调用时计算机的状态。
寄存器状态修改
可以肯定的是,在trap
的最开始,CPU的所有状态都设置成运行用户代码而不是内核代码。在trap处理的过程中,我们需要更改一些这里的状态,或者对状态做一些操作,这样我们才可以运行系统内核中普通的C程序。接下来我们预览一下需要做的操作:
①首先,我们需要保存32个用户寄存器。因为很显然我们需要恢复用户应用程序的执行,尤其是当用户程序随机地被设备中断所打断时,我们希望内核能够响应中断,之后在用户程序完全无感知的情况下再恢复用户代码的执行。所以这意味着32个用户寄存器不能被内核弄乱。但是这些寄存器又要被内核代码所使用,所以在trap
之前,你必须先在某处保存这32个用户寄存器。
②程序计数器也需要在某个地方保存,它几乎跟一个用户寄存器的地位是一样的,我们需要能够在用户程序运行中断的位置继续执行用户程序。
③我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令。
④satp
寄存器现在正指向用户页表,而用户页表只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将satp
指向内核页表。
⑤我们需要将栈寄存器指向位于内核的一个地址,因为我们需要一个栈来调用内核的C函数。
一旦我们设置好了,并且所有的硬件状态都适合在内核中使用, 我们需要跳入内核的C代码。一旦我们运行在内核的C代码中,那就跟平常的C代码是一样的。
trap机制的一些注意事项
之后我们会讨论内核通过C代码做了什么工作,但是今天讨论的是如何将将程序执行从用户空间切换到内核的一个位置,这样我们才能运行内核的C代码。
①操作系统的一些high-level的目标限制了这里的实现,其中一个目标是安全和隔离。我们不想让用户代码干扰到这里的user/kernel切换,否则有可能会破坏安全性。所以这意味着,trap
中涉及到的硬件和内核机制不能依赖任何来自用户空间东西。比如说我们不能依赖32个用户寄存器,它们可能保存的是恶意的数据。所以,XV6的trap
机制不会查看这些寄存器,而只是将它们保存起来。
②在操作系统的trap
机制中,我们想保留隔离性并防御来自用户代码的可能的恶意攻击。但另一方面,我们想要让trap
机制对用户代码是透明的。也就是说,我们想要执行trap
,然后在内核中执行代码,之后再恢复代码到用户空间。这个过程中,用户代码并不会注意到发生了什么,这样也就更容易编写用户代码。
③需要注意的是,虽然我们这里关心隔离和安全,但是今天我们只会讨论从用户空间切换到内核空间相关的安全问题。当然,系统调用的具体实现(如write在内核的具体实现)以及内核中任何的代码也必须小心并安全地写好。因此,即使从用户空间到内核空间的切换十分安全,整个内核的其他部分也必须非常安全,并时刻小心用户代码可能会尝试欺骗它。
④在前面介绍的寄存器中,保存mode标志的寄存器需要讨论一下。当我们在用户空间时,这个标志位对应的是user mode,当我们在内核空间时,这个标志位对应supervisor mode。但是有一点很重要:当这个标志位从user mode变更到supervisor mode时,我们能得到什么样的权限。实际上,这里获得的额外权限实在是有限。也就是说,并不是像你可以在supervisor mode完成但是不能在user mode完成一些工作那么有特权。我们接下来看看supervisor mode可以控制什么。
----可以读写控制寄存器。比如说,当你在supervisor mode时,你可以:读写SATP寄存器,也就是页表的指针;STVEC,也就是处理trap的内核指令地址;SEPC,保存当发生trap时的程序计数器;SSCRATCH等等。在supervisor mode你可以读写这些寄存器,而用户代码不能做这样的操作。
----可以使用PTE_U标志位为0的PTE。当PTE_U标志位为1时,表明用户代码可以使用这个页表。如果这个标志位为0,则只有supervisor mode可以使用这个页表。
这两点就是supervisor mode可以做的事情,除此之外就不能再干别的事情了。
注意: 需要特别指出的是,supervisor mode中的代码并不能读写任意物理地址。在supervisor mode中,就像普通的用户代码一样,也需要通过页表来访问内存。如果一个虚拟地址并不在当前由SATP指向的页表中,又或者SATP指向的页表中PTE_U=1,那么supervisor mode不能使用那个地址。所以,即使我们在supervisor mode,我们还是受限于当前页表设置的虚拟地址。
二、trap代码执行流程
trap代码执行流程
我们跟踪如何在Shell中调用write系统调用。从Shell的角度来说,这就是个Shell代码中的C函数调用。但是实际上,
write
通过执行ecall
指令来执行系统调用,ecall
指令会切换到具有supervisor mode的内核中。
①在这个过程中,内核中执行的第一个指令是一个由汇编语言写的函数,叫做uservec
。这个函数是内核代码trampoline.s文件的一部分。
之后,在这个汇编函数中,代码执行跳转到了由C语言实现的函数usertrap
中,这个函数在trap.c中。
现在代码运行在C中,所以代码更加容易理解。在usertrap
这个C函数中,我们执行了一个叫做syscall
的函数。
这个函数会在一个表单中,根据传入的代表系统调用的数字进行查找,并在内核中执行具体实现了系统调用功能的函数。对于我们来说,这个函数就是sys_write
。
sys_write
会将要显示数据输出到console上。当它完成了之后,它会返回到syscall
函数,然后syscall
函数返回到用户空间。
因为我们现在相当于在ecall
之后中断了用户代码的执行,为了用户空间的代码恢复执行,需要做一系列的事情。在syscall
函数中,会调用一个函数叫做usertrapret
,它也位于trap.c中,这个函数完成了部分在C代码中实现的返回到用户空间的工作。
除此之外,最终还有一些工作只能在汇编语言中完成。这部分工作通过汇编语言实现,并且存在于trampoline.s文件中的userret
函数中。
最终,在这个汇编函数中会调用机器指令返回到用户空间,并且恢复ecall
之后的用户程序的执行。
问答
学生提问:vm.c运行在什么mode下?
Robert教授:vm.c中的所有函数都是内核的一部分,所以运行在supervisor mode。
学生提问:为什么这些函数叫这些名字?
Robert教授:现在的函数命名比较乱,明年我会让它们变得更加合理一些。(助教说:我认为命名与寄存器的名字有关)。
学生提问:难道vm.c里的函数不是要直接访问物理内存吗?
Robert教授:是的,这些函数能这么做的原因是:内核在页表中精心地设置好直接映射关系,这样当内核收到了一个读写虚拟内存地址的请求,会通过内核页表将这个虚拟内存地址翻译成与之等价物理内存地址,再完成读写。所以,一旦使用了内核页表,就可以非常方便的在内核中使用所有这些直接的映射关系。但是直到trap
机制切换到内核之前,这些映射关系都不可用。直到trap
机制将程序运行切换到内核空间之前,我们使用的仍然是用户页表。
学生提问:这个问题或许并不完全相关,read
和write
系统调用相比内存的读写,它们的代价都高的多,因为它们需要切换模式并来回捣腾。有没有可能当你执行打开一个文件的系统调用时, 直接得到一个页表映射,而不是返回一个文件描述符?这样只需要向对应于设备的特定的地址写数据,程序就能通过页表访问特定的设备。你可以设置好限制,就像文件描述符只允许修改特定文件一样,这样就不用像系统调用一样在用户空间和内核空间来回捣腾了。
Robert教授:这是个很好的想法,实际上很多操作系统都提供这种叫做内存映射文件访问(MMAP, Memory-mapped file access)的机制。在这个机制里面通过页表可以将用户空间的虚拟地址空间对应到文件内容,这样你就可以通过内存地址直接读写文件。实际上,你们将在mmap实验中完成这个机制。对于许多程序来说,这个机制的确会比直接调用read/write
系统调用要快的多。
三、ecall指令之前的状态
系统调用大致流程
接下来我们将切换到gdb中,跟踪一个XV6的系统调用,也就是Shell将它的提示信息通过
write
系统调用走到操作系统再输出到console的过程。你们可以看到,用户代码sh.c初始了这一切。
上图中选中的行,是一个write
系统调用,它将“$”写入到文件描述符2。接下来我将打开gdb并启动XV6。
作为用户代码的Shell调用write
时,实际上调用的是关联到Shell的一个库函数。你可以查看这个库函数的源代码,在usys.s中。
上面这几行代码就是实际被调用的write
函数的实现,这是个非常短的函数。
①它首先将SYS_write
加载到a7
寄存器,SYS_write
是常量16。这里告诉内核,我想要运行第16个系统调用,而这个系统调用正好是write
。
②之后这个函数中执行了ecall
指令,从这里开始代码执行跳转到了内核。
③内核完成它的工作之后,代码执行会返回到用户空间,继续执行ecall
之后的指令,也就是ret
,最终返回到Shell中,所以ret从write库函数返回到了Shell中。
过程详解
为了展示这里的系统调用,我会在
ecall
指令处放置一个断点。为了能放置断点,我们需要知道ecall
指令的地址,我们可以通过查看由XV6编译过程产生的sh.asm找出这个地址。sh.asm是带有指令地址的汇编代码,找到ecall
指令的地址(0xde6
)后在那里放置一个断点。
现在,我要让XV6开始运行。我期望的是XV6在Shell代码中正好在执行ecall
之前就会停住。
完美,从gdb可以看出,我们下一条要执行的指令就是ecall
。我们来检验一下我们是否在我们期望的位置,打印程序计数器(Program Counter),正好在我们期望的位置0xde6
。
我们还可以输入info reg
打印全部32个用户寄存器,
这里有一些数值我们还不知道,也不关心。但是这里的a0
、a1
、a2
是Shell传递给write
系统调用的参数。a0
是文件描述符2;a1
是Shell想要写入字符串的指针;a2
是想要写入的字符数。我们还可以通过打印Shell想要写入的字符串内容,来证明断点停在我们认为它应该停在的位置。
可以看出,输出的确是美元符$
和一个空格。所以,我们现在位于期望的write
系统调用函数中。
注意: 上图的寄存器中,程序计数器(pc)和栈指针(sp)的地址都在距离0比较近的地方,这进一步印证了当前代码运行在用户空间,因为用户空间中所有的地址都比较小。但是一旦我们进入到了内核,内核会使用大得多的内存地址。
系统调用的时间点会有大量状态的变更,其中一个最重要的需要变更的状态,并且在它变更之前我们对它还有依赖的,就是当前的页表。我们可以查看satp
寄存器。
这里输出的是物理内存地址,它并没有告诉我们有关页表中的映射关系是什么,页表长什么样。但是幸运的是,在QEMU中有一个方法可以打印当前的页表。从QEMU界面输入ctrl a + c可以进入到QEMU的console,之后输入info mem,QEMU会打印完整的页表。
这是个非常小的页表,它只包含了6条映射关系。这是用户程序Shell的页表,而Shell是一个非常小的程序,这6条映射关系包含Shell的指令、数据以及一个无效的page用来作为guard page,以防止Shell尝试使用过多的stack page。我们可以看出:
①第三行的page,因为在attr这一列它并没有设置u
标志位。
②attr
这一列是PTE的标志位,前面三个标志位rwx
表明这个page是否可以读、写、执行指令。
③u
标志位表明PTE_U
标志位是否被设置,用户代码只能访问u
标志位设置了的PTE。
④第5个标志位是Global
。
⑥第6个标志位是a
(Accessed),表明这条PTE是不是被使用过。
⑦最后一个标志位d
(Dirty)表明这条PTE是不是被写过。
最后两条PTE的虚拟地址非常大,非常接近虚拟地址的顶端。如果你读过了XV6的参考手册,你就知道这两个page分别是trapframe page和trampoline page。你可以看到,它们都没有设置u
标志,所以用户代码不能访问这两条PTE。一旦我们进入到了supervisor mode,我们就可以访问这两条PTE了。
注意: 对于这里的页表,它并没有包含任何内核部分的地址映射。这里既没有对于kernel data的映射,也没有对于kernel指令的映射。除了最后两条PTE,这个页表几乎是完全为用户代码执行而创建,所以它对于在内核执行代码并没有直接特殊的作用。
接下来,我会在Shell中打印出write函数的内容。
程序计数器现在指向ecall
指令,我们接下来要执行ecall
指令。现在我们还在用户空间,但是马上我们就要进入内核空间了。
问答
学生提问:PTE中的这些标志位是什么意思?
Robert教授:a
标志位这表示这条PTE是不是被代码访问过,是不是曾经有一个被访问过的地址包含在这个PTE的范围内。d
标志位表明是否曾经有写指令使用过这条PTE。这些标志位由硬件维护以方便操作系统使用。对于比XV6更复杂的操作系统,当物理内存吃紧的时候,可能会通过将一些页面写入到磁盘中,同时将相应的PTE设置成无效,来释放物理内存page。你可以想到,这里有很多策略可以让操作系统来挑选哪些page可以释放。我们可以查看a
标志位来判断这条PTE是否被使用过,如果它没有被使用或者最近没有被使用,那么这条PTE对应的page适合用来保存到磁盘中。类似的,d
标志位告诉内核,这个page最近被修改过。不过XV6没有这样的策略。
四、ecall指令之后的状态
ecall指令执行流程
现在我执行
ecall
指令
第一个问题: 执行完了ecall
之后我们现在在哪?我们可以打印程序计数器(Program Counter)来查看。
①可以看到程序计数器的值变化了,之前我们的程序计数器还在一个很小的地址0xde6
,但是现在在一个大得多的地址。
②我们还可以查看页表,我通过在QEMU中执行info mem来查看当前的页表。可以看出,这还是与之前完全相同的页表,所以页表没有改变。
程序计数器状态
根据现在的程序计数器,代码正在trampoline page的最开始,这是用户内存中一个非常大的地址。所以现在我们的指令正运行在内存的trampoline page中,我们可以来查看一下现在将要运行的指令。这些指令是内核在supervisor mode中将要执行的最开始的几条指令,也是在trap机制中最开始要执行的几条指令。因为gdb有一些奇怪的行为,我们实际上已经执行了位于trampoline page最开始的一条指令(csrrw
指令),我们将要执行的是第二条指令。
寄存器状态
我们可以查看寄存器,寄存器的值并没有改变,这里还是用户程序拥有的一些寄存器内容。
所以,现在寄存器里面还都是用户程序的数据,并且这些数据也还只保存在这些寄存器中。所以我们需要非常小心,在将寄存器数据保存在某处之前,我们在这个时间点不能使用任何寄存器,否则的话我们是没法恢复寄存器数据的。如果内核在这个时间点使用了任何一个寄存器,内核会覆盖寄存器内的用户数据,之后如果我们尝试要恢复用户程序,我们就不能恢复寄存器中的正确数据,用户程序的执行也会相应的出错。
问答
学生提问:我想知道csrrw
指令是干什么的?
Robert教授:我们过几分钟会讨论这部分。但是对于你的问题的答案是,这条指令交换了寄存器a0
和sscratch
(特殊临时寄存器)的内容。这个操作超级重要,它回答了这个问题,内核的trap
代码如何能够在不使用任何寄存器的前提下做任何操作。这条指令将a0
的数据保存在了sscratch
中,同时又将sscratch
内的数据保存在a0
中,之后内核就可以任意的使用a0
寄存器了。
页表状态
我们现在在地址0x3ffffff000
,也就是上面页表输出的最后一个page,这是trampoline page。我们现在正在trampoline page中执行程序,这个page包含了内核的trap处理代码的第一条指令。
ecall并不会切换页表,这是ecall指令的一个非常重要的特点。所以这意味着,trap处理代码必须存在于每一个用户页表中。因为ecall并不会切换页表,我们需要在用户页表中的某个地方来执行最初的内核代码。而这个trampoline page是由内核小心地映射到每一个用户页表中,以使得当我们仍然在使用用户页表e时,内核在一个地方能够执行trap机制的最开始的一些指令。
stvec寄存器状态
这里的控制是通过stvec
寄存器完成的,这是一个只能在supervisor mode下读写的特权寄存器。在从内核空间进入到用户空间之前,内核会设置好stvec
寄存器指向内核希望trap代码运行的位置。
所以如你所见,内核已经事先设置好了stvec寄存器的内容为0x3ffffff000,这就是trampoline page的起始位置。stvec寄存器的内容就是在ecall指令执行之后我们会在这个特定地址执行指令的原因。
查看当前处于哪种模式
最后我想提示你们,即使trampoline page是在用户地址空间的用户页表完成的映射,用户代码也不能写它。这是因为这些page对应的PTE并没有设置PTE_U
标志位,这也是为什么trap机制是安全的。我一直在告诉你们我们现在已经在supervisor mode了,但是实际上我并没有任何能直接确认当前在哪种mode下的方法。不过我的确发现程序计数器现在正在trampoline page执行代码,而这些page对应的PTE并没有设置PTE_U
标志位。所以现在只有当代码在supervisor mode时,才可能在程序运行的同时而不崩溃。所以,我从代码没有崩溃和程序计数器的值推导出我们必然在supervisor mode。
ecall改变了什么
我们是通过ecall
走到trampoline page的,而ecall
实际上只会改变三件事情:
①第一,ecall
将代码从user mode改到supervisor mode。
②第二,ecall
将程序计数器的值保存在了sepc
寄存器,我们可以通过打印程序计数器看到这里的效果。
尽管其他的寄存器还是还是用户寄存器,但是这里的程序计数器明显已经不是用户代码的程序计数器,这里的程序计数器是从stvec
寄存器拷贝过来的值。我们也可以打印sepc
(Supervisor Exception Program Counter)寄存器,这是ecall
保存用户程序计数器的地方。
这个寄存器里面有熟悉的地址0xde6
,这是ecall
指令在用户空间的地址。所以ecall
至少保存了程序计数器的数值。
③第三,ecall
会跳转到stvec
寄存器指向的指令。
距离执行内核中的C代码还需要做什么工作
所以现在ecall
帮我们做了一点点工作,但是实际上我们离执行内核中的C代码还差的很远。接下来:
①我们需要保存32个用户寄存器的内容,这样当我们想要恢复用户代码执行时,我们才能恢复这些寄存器的内容。
②因为现在我们还在用户页表,我们需要切换到内核页表。
③我们需要创建或者找到一个内核栈,并将Stack Pointer寄存器的内容指向那个内核栈,这样才能给C代码提供栈。
④我们还需要跳转到内核中C代码的某些合理的位置。
RISC-V的ecall是否会继续做上述操作
顺带说一句,ecall
并不会为我们做这里的任何一件事。当然,我们可以通过修改硬件让ecall
为我们完成这些工作,而不是交给软件来完成。并且,我们也将会看到,在软件中完成这些工作并不是特别简单。所以你现在就会问,为什么ecall
不多做点工作来将代码执行从用户空间切换到内核空间呢?为什么ecall
不会保存用户寄存器,或者切换页表指针来指向内核页表,或者自动的设置Stack Pointer指向内核栈,或者直接跳转到kernel的C代码,而不是在这里运行复杂的汇编代码?
RISC-V的ecall不会继续做上述操作的原因
实际上,已经有机器完成了所有这些事情,在系统调用期间的硬件中实现。但是RISC-V并不会,RISC-V秉持了这样一个观点:ecall
只完成尽量少必须要完成的工作,其他的工作都交给软件完成。这里的原因是,RISC-V设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。所以你可以这样想,尽管XV6并没有使用这里提供的灵活性,但是一些其他的操作系统用到了。
实际案例:
①举个例子,因为这里的ecall
是如此的简单,或许某些操作系统可以在不切换页表的前提下执行部分系统调用。切换页表的代价比较高,如果ecall
打包完成了这部分工作,那就不能对一些系统调用进行改进,使其不用在不必要的场景切换页表。
②某些操作系统同时将user和kernel的虚拟地址映射到一个页表中,这样在user和kernel之间切换时根本就不用切换页表。对于这样的操作系统来说,如果ecall
切换了页表那将会是一种浪费,并且也减慢了程序的运行。
③或许在一些系统调用过程中,一些寄存器不用保存。而哪些寄存器需要保存、哪些不需要取决于软件、编程语言、编译器。通过不保存所有的32个寄存器或许可以节省大量的程序运行时间,所以你不会想要ecall
迫使你保存所有的寄存器。
④最后,对于某些简单的系统调用或许根本就不需要任何stack,所以对于一些非常关注性能的操作系统,ecall
不会自动为你完成stack切换是极好的。
所以,ecall尽量的简单可以提升软件设计的灵活性。
五、uservec函数
保存寄存器
问题引出
----回到XV6和RISC-V,现在程序位于trampoline page的起始处,也是uservec
函数的起始处。在RISC-V上如果不能使用寄存器基本上不能做任何事情,所以我们现在需要做的第一件事情就是保存寄存器的内容。对于保存这些寄存器,我们有什么样的选择呢?
----在一些其他的机器中,我们或许直接就将32个寄存器中的内容写到物理内存中某些合适的位置。但是我们不能在RISC-V中这样做,因为在RISC-V中,supervisor mode下的代码不允许直接访问物理内存。所以我们只能使用页表中的内容,但是从前面的输出来看,页表中也没有多少内容。
---- 虽然XV6并没有使用,但是另一种可能的操作是直接将satp
寄存器指向内核页表,之后我们就可以直接使用所有的内核映射来帮助我们存储用户寄存器。这是合法的,因为supervisor mode可以更改satp
寄存器。但是在trap
代码当前的位置,也就是trap
机制的最开始,我们并不知道内核页表的地址。并且更改satp
寄存器的指令,要求写入satp
寄存器的内容来自于另一个寄存器。所以,为了能执行更新页表的指令,我们需要一些空闲的寄存器,这样我们才能先将页表的地址存在这些寄存器中,然后再执行修改satp
寄存器的指令。
对于保存用户寄存器,XV6在RISC-V上的实现包括了两个部分:
①第一个部分是,XV6在每个用户页表映射了trapframe page,这样每个进程都有自己的trapframe page。这个page包含了很多有趣的不同类型的数据,但是现在最重要的数据是用来保存用户寄存器的32个空槽位。所以,在trap处理代码中,现在的好消息是我们在用户页表中有一个之前由内核设置好的映射关系,这个映射关系指向了一个可以用来存放这个进程的用户寄存器的内存位置。这个位置的虚拟地址总是0x3ffffffe000
。
如果你想查看XV6在trapframe page中存放了什么,这部分代码在proc.h中的trapframe
结构体中。
你可以看到很多槽位的名字都对应了特定的寄存器,这都是内核事先存放在trapframe中的数据。比如第一个数据保存了内核页表的地址,这将会是trap处理代码将要加载到satp
寄存器的数值。所以,如何保存用户寄存器的一半答案是,内核非常方便的将trapframe page映射到了每个用户页表。
②另一半的答案在于我们之前提过的sscratch
寄存器。这个由RISC-V提供的sscratch
寄存器,就是为接下来的目的而创建的。在进入到用户空间之前,内核会将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)时,set sscratch
保存的就是指向trapframe的地址。之后,Shell执行了ecall
指令,跳转到了trampoline page,这个page中的第一条指令会交换a0
和sscratch
寄存器的内容。所以sscratch
中的值也就是指向trapframe的指针现在存储于a0
寄存器中。
学生提问:这是发生在进程创建的过程中吗?这个sscratch
寄存器存在于哪?
Robert教授:这个寄存器存在于CPU上,这是CPU上的一个特殊寄存器。内核在什么时候设置的它呢?这有点复杂。它被设置的实际位置,我们可以看下图。
选中的代码是内核在返回到用户空间之前执行的最后两条指令。在内核返回到用户空间时,会恢复所有的用户寄存器。之后会再次执行交换指令csrrw
。因为之前内核已经设置了a0
保存的是trapframe地址,经过交换之后sscratch
仍然指向了trapframe page地址,而a0
也恢复成了之前的数值。最后sret
返回到了用户空间。你或许会好奇,a0
是如何有trapframe page的地址,我们可以查看trap.c代码。
这是内核返回到用户空间的最后的C函数。C函数做的最后一件事情是调用fn
函数,传递的参数是TRAMFRAME和用户页表。在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
指令执行时,会将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
寄存器写入数据。这里写入的是内核页表的地址,我们可以打印t1
寄存器的内容。
实际上严格来说,t1
的内容并不是内核页表的地址,这是你需要向satp
寄存器写入的数据。它包含了内核页表的地址,但是移位了,并且包含了各种标志位。
下一条指令是交换satp
和t1
寄存器。这条指令执行完成之后,当前程序会从用户页表切换到内核页表。现在我们在QEMU中打印页表,可以看出与之前的页表完全不一样。
现在这里输出的是由内核设置好的巨大的内核页表,所以现在我们成功的切换了页表。我们在这个位置进展的很好,Stack Pointer指向了内核栈;我们有了内核页表,可以读取内核数据。我们已经准备好了执行内核中的C代码了。
Robert教授:这里还有个问题,为什么代码没有崩溃?毕竟我们在内存中的某个位置执行代码,程序计数器保存的是虚拟地址,如果我们切换了页表,为什么同一个虚拟地址不会通过新的页表寻址走到一些无关的page中?看起来我们现在没有崩溃并且还在执行这些指令。有人来猜一下原因吗?
学生回答:因为我们还在trampoline代码中,而trampoline代码在用户空间和内核空间都映射到了同一个地址。
Robert教授:完全正确。我不知道你们是否还记得用户页表的内容,trampoline page在用户页表中的映射与内核页表中的映射是完全一样的。这两个页表中其他所有的映射都是不同的,只有trampoline page的映射是一样的,因此我们在切换页表时,寻址的结果不会改变,我们实际上就可以继续在同一个代码序列中执行程序而不崩溃。这是trampoline page的特殊之处,它同时在用户页表和内核页表都有相同的映射关系。之所以叫trampoline page,是因为你某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。
最后一条指令是jr t0
。执行了这条指令,我们就要从trampoline跳到内核的C代码中。这条指令的作用是跳转到t0
指向的函数中。我们打印t0
对应的一些指令。
可以看到t0
的位置对应于一个叫做usertrap
函数的开始。接下来我们就要以内核栈、内核页表跳转到usertrap
函数。
六、usertrap函数
usertrap工作流程
usertrap
函数是位于trap.c文件的一个函数。
既然我们已经运行在C代码中,接下来,我在gdb中输入tui enable打开对于C代码的展示。
我们现在在一个更加正常的世界中,我们正在运行C代码,应该会更容易理解。我们仍然会读写一些有趣的控制寄存器,但是环境比起汇编语言来说会少了很多晦涩。现在我们在usertrap
,有很多原因都可以让程序运行进入到usertrap
函数中来,比如系统调用,运算时除以0,使用了一个未被映射的虚拟地址,或者是设备中断。usertrap
某种程度上存储并恢复硬件状态,但是它也需要检查触发trap
的原因,以确定相应的处理方式,我们在接下来执行usertrap
的过程中会同时看到这两个行为。
问答
学生提问:为什么我们在gdb中看不到ecall的具体内容?或许我错过了,但是我觉得我们是直接跳到trampoline代码的。
Robert教授:ecall只会更新CPU中的mode标志位为supervisor,并且设置程序计数器成stvec
寄存器内的值。在进入到用户空间之前,内核会将trampoline page的地址存在stvec
寄存器中。所以ecall
的下一条指令的位置是stvec
指向的地址,也就是trampoline page的起始地址。(实际上ecall
是CPU的指令,自然在gdb中看不到具体内容,只能看到ecall
指令执行前后寄存器的变化)
具体流程
接下来,让我们一步步执行usertrap
函数。
它做的第一件事情是更改stvec
寄存器。取决于trap是来自于用户空间还是内核空间,XV6处理trap的方法是不一样的。目前为止,我们只讨论过当trap是由用户空间发起时会发生什么。如果trap从内核空间发起,将会是一个非常不同的处理流程。因为从内核发起的话,程序已经在使用内核页表。所以当trap发生时,程序执行仍然在内核的话,很多处理都不必存在。在内核中执行任何操作之前,usertrap
中先将stvec
指向了kernelvec
变量,这是内核空间trap处理代码的位置,而不是用户空间trap处理代码的位置。
出于各种原因,我们需要知道当前运行的是什么进程,我们通过调用myproc
函数来做到这一点。myproc
函数实际上会查找一个根据当前CPU核的编号索引的数组,CPU核的编号是hartid。如果你还记得,我们之前在uservec
函数中将它存在了tp
寄存器。
接下来我们要保存用户程序计数器,它仍然保存在sepc
寄存器中,但是可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致sepc
寄存器的内容被覆盖。所以,我们需要保存当前进程的sepc
寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。这里我们使用trapframe来保存这个程序计数器。
接下来我们需要找出我们现在会在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
赋值的原因是:所有的系统调用都有一个返回值,比如write
会返回实际写入的字节数,而RISC-V上的C代码的习惯是函数的返回值存储于寄存器a0
。所以为了模拟函数的返回,我们将返回值存储在trapframe的a0
中。之后,当我们返回到用户空间,trapframe中的a0
槽位的数值会写到实际的a0
寄存器,Shell会认为a0
寄存器中的数值是write
系统调用的返回值。执行完这一行代码之后,我们打印这里trapframe中a0
的值,可以看到输出2。
这意味这sys_write的返回值是2,符合传入的参数,这里只写入了2个字节。
从syscall函数返回之后,我们回到了trap.c中的usertrap
函数。
我们再次检查当前用户进程是否被杀掉了,因为我们不想恢复一个被杀掉的进程。当然,在我们的场景中,Shell没有被杀掉。
最后,usertrap调用了一个函数usertrapret
。
七、usertrapret函数
usertrapret工作流程
usertrap
函数的最后调用了usertrapret
函数,来设置好我之前说过的,在返回到用户空间之前内核要做的工作。我们可以查看这个函数的内容。
它首先关闭了中断。我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新stvec
寄存器来指向用户空间的trap
处理代码。而之前在内核中的时候,我们指向的是内核空间的trap
处理代码。我们关闭中断因为当我们将stvec
更新到指向用户空间的trap
处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap
处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
在下一行我们设置了stvec
寄存器指向trampoline代码,在那里最终会执行sret
指令返回到用户空间。位于trampoline代码最后的sret
指令会重新打开中断。这样,即使我们刚刚关闭了中断,当我们在执行用户代码时中断是打开的。
接下来的几行填入了trapframe的内容,这些内容对于执行trampoline代码非常有用。这里的代码就是:
①存储了内核页表的指针。
②存储了当前用户进程的内核栈。
③存储了usertrap
函数的指针,这样trampoline代码才能跳转到这个函数。
④从tp
寄存器中读取当前的CPU核编号,并存储在trapframe中,这样trampoline代码才能恢复这个数字,因为用户代码可能会修改这个数字。
问答
学生提问:为什么trampoline代码中不保存sepc
寄存器?
Robert教授:可以存储。trampoline代码没有像其他寄存器一样保存这个寄存器,但是我们非常欢迎大家修改XV6来保存它。如果你还记得的话,这个寄存器实际上是在C代码usertrap
中保存的,而不是在汇编代码trampoline中保存的。我想不出理由这里哪种方式更好。用户寄存器(User Registers)必须在汇编代码中保存,因为任何需要经过编译器的语言(例如C语言)都不能修改任何用户寄存器。所以对于用户寄存器,必须要在进入C代码之前在汇编代码中保存好。但是对于sepc
寄存器(这是一个控制寄存器),我们可以早点保存或者晚点保存。
现在我们在usertrapret
函数中,我们正在设置trapframe中的数据,这样下一次从用户空间转换到内核空间时可以用到这些数据。
接下来我们要设置sstatus
寄存器,这是一个控制寄存器。这个寄存器的SPP
bit位控制了sret
指令的行为,该bit为0表示下次执行sret
的时候,我们想要返回user mode而不是supervisor mode。这个寄存器的SPIE
bit位控制了在执行完sret
之后,是否打开中断。因为我们在返回到用户空间之后,我们的确希望打开中断,所以这里将SPIE
bit位设置为1。修改完这些bit位之后,我们会把新的值写回到sstatus
寄存器。
我们在trampoline代码的最后执行了sret
指令。这条指令会将程序计数器设置成sepc
寄存器的值,所以现在我们将sepc
寄存器的值设置成之前保存的用户程序计数器的值。在不久之前,我们在usertrap
函数中将用户程序计数器保存在trapframe中的epc
字段。
我们根据用户页表地址生成相应的satp
值,这样我们在返回到用户空间的时候才能完成页表的切换。实际上,我们会在汇编代码trampoline中完成页表的切换,并且也只能在trampoline中完成切换,因为只有trampoline中代码是同时在用户和内核空间中映射。但是我们现在还没有在trampoline代码中,我们现在还在一个普通的C函数中,所以这里我们将页表指针准备好,并将这个指针作为第二个参数传递给汇编代码,这个参数会出现在a1
寄存器。
fn: 倒数第二行的作用是计算出我们将要跳转到汇编代码的地址。我们期望跳转的地址是tampoline中的userret
函数,这个函数包含了所有能将我们带回到用户空间的指令。所以这里我们计算出了userret
函数的地址。倒数第一行,将fn
指针作为一个函数指针,执行相应的函数(也就是userret
函数)并传入两个参数,两个参数存储在a0
,a1
寄存器中。
八、userret函数
userret工作流程
现在程序执行又到了trampoline代码。
第一步是切换页表。在执行csrw satp, a1
之前,页表应该还是巨大的内核页表。这条指令会将用户页表指针(在usertrapret
中作为第二个参数传递给了这里的userret
函数,所以存在a1
寄存器中)存储在satp
寄存器中。执行完这条指令之后,页表就变成了小得多的用户页表。
我们执行info mem
查看页表,可以看到切换过来了。
幸运的是,用户页表也映射了trampoline page,所以程序还能继续执行而不是崩溃。(注:sfence.vma
是清空页表缓存)。
在uservec
函数中,第一件事情就是交换sscratch
和a0
寄存器。而这里,我们将sscratch
寄存器恢复成保存好的用户的a0
寄存器。在这里a0
是trapframe的地址,因为C代码usertrapret
函数中将trapframe地址作为第一个参数传递过来了。112是a0
寄存器在trapframe中的位置。(本质就是通过当前的a0
寄存器找出存在trapframe中的a0
寄存器)我们先将这个地址里的数值保存在t0
寄存器中,之后再将t0
寄存器的数值保存在sscratch
寄存器中。
为止目前,所有的寄存器内容还是属于内核。
接下来的这些指令将a0
寄存器指向的trapframe中之前保存的寄存器的值加载到对应的各个寄存器中。之后,我们离能真正运行用户代码就很近了。
问答
学生提问:现在trapframe中的a0
寄存器是我们执行系统调用的返回值吗?
Robert教授:是的,系统调用的返回值覆盖了我们保存在trapframe中的a0
寄存器的值。我们希望用户程序Shell在a0
寄存器中看到系统调用的返回值。所以,trapframe中的a0
寄存器现在是系统调用的返回值2。相应的sscratch
寄存器中的数值也应该是2,可以通过打印寄存器的值来验证。
现在我们打印所有的寄存器。
我不确定你们是否还记得,但是这些寄存器的值就是我们在最最开始看到的用户寄存器的值。例如SP寄存器保存的是user stack地址,这是一个在较小的内存地址;a1寄存器是我们传递给write
的缓存区指针;a2
是我们传递给write
函数的写入字节数。
a0
寄存器现在还是个例外,它现在仍然是指向trapframe的指针,而不是保存了的用户数据。
接下来,在我们即将返回到用户空间之前,我们交换sscratch
寄存器和a0
寄存器的值。前面我们看过了sscratch
现在的值是系统调用的返回值2,a0
寄存器是trapframe的地址。交换完成之后,a0
持有的是系统调用的返回值,sscratch
持有的是trapframe的地址。之后trapframe的地址会一直保存在sscratch
中,直到用户程序执行了另一次trap
。现在我们还在kernel中,sret
是我们在kernel中的最后一条指令,当我执行完这条指令:
①程序会切换回user mode
②sepc
寄存器的数值会被拷贝到PC寄存器(程序计数器)
③重新打开中断
现在我们回到了用户空间,打印pc
寄存器。
这是一个较小的指令地址,非常像是在用户内存中。如果我们查看sh.asm,可以看到这个地址是write
函数的ret
指令地址。
所以,现在我们回到了用户空间,执行完ret
指令之后我们就可以从write
系统调用返回到Shell中了。或者更严格的说,是从触发了系统调用的write
库函数中返回到Shell中。
问答
学生提问:你可以再重复一下在sret过程中,中断会发生什么吗?你说我们把它关闭了,然后又有别的东西把它们打开了。
Robert教授:sret打开了中断。所以在supervisor mode中的最后一个指令,我们会重新打开中断。同时,也将程序计数器设置为等于sepc
。切换到用户模式sret
将重新启用中断。这意味着用户程序可能会运行很长时间,最好是能在这段时间响应例如磁盘中断。
总结
最后总结一下,系统调用被刻意设计的看起来像是函数调用,但是背后的user/kernel转换比函数调用要复杂的多。
①之所以这么复杂,很大一部分原因是要保持user/kernel之间的隔离性,内核不能信任来自用户空间的任何内容。
②另一方面,XV6实现trap
的方式比较特殊,XV6并不关心性能。但是通常来说,操作系统的设计人员和CPU设计人员非常关心如何提升trap
的效率和速度,必然还有跟我们这里不一样的方式来实现trap
。当你在实现的时候,可以从以下几个问题出发:
----硬件和软件需要协同工作,你可能需要重新设计XV6,重新设计RISC-V来使得这里的处理流程更加简单,更加快速。
----另一个需要时刻记住的问题是,恶意软件是否能滥用这里的机制来打破隔离性。