MIT6.S081操作系统学习-2
Trap机制
今天要讨论的内容是程序是如何完成从用户空间到内核空间的切换的,这种机制称为trap,今天的讨论会更加深入,更加底层也更加困难,希望大家能够尽可能的跟上。
在xv6系统中,我们的设计是当用户程序发生系统调用、异常以及中断时,将会触发trap机制,完成由用户空间到内核空间的转换。
以用户程序调用系统调用触发trap机制为例,研究底层是如何完成这种切换的。在这个过程中硬件的状态非常重要,有很多工作都是将硬件的状态由适合用户空间的状态改为适合内核的状态。而在诸多硬件中,我们最关心的是32个用户寄存器的状态(RISC-V中提供了32个寄存器,包括了堆栈寄存器Stack Register),除了用户寄存器外还有一些对该过程非常重要的寄存器:
STAP寄存器,指向了当前程序的最高级页表的地址;
PC寄存器,也叫程序计数器,存储当前程序将要执行的指令;
STVEC寄存器,指向内核中处理trap指令的起始地址;
SEPC寄存器,在处理trap过程中用于保存PC寄存器中的值;
用于表示系统当前状态的标识位mode
SSRATCH寄存器,xx;
在接下来的讨论中我们可以看到这些寄存器是如何使用的。
在进入底层之前,我们来思考一下,从用户空间转换为内核空间需要完成哪些工作。
- 保存用户空间32个寄存器的内容
- 保存当前程序计数器(pc)中的内容
- 修改之前提到的内核标志位,标识系统此时进入内核区
- 将用户页表切换为内核页表
- 将堆栈寄存器指向内核中的一个地址,因为需要使用一个堆栈来执行内核中的程序
其中1,2项工作的目的是在内核完成对应任务后,利用保存好的数据恢复用户程序的执行。
接下来我们深入研究一下这个过程,观察操作系统内部做了哪些工作来完成该任务。当我们在shell中调用write系统调用时,实际上会执行以下代码
这是一个汇编程序,第一个语句表示将SYS_wirte中的数据加载到a7寄存器中,在xv6系统中SYS_write对应的是数字16(之前说过在用户空间执行的这些系统调用其实是某个数字的映射)。接下来会执行ecall指令,从这里开始系统进入了内核,内核完成工作后会执行ret将执行结果返回给shell。
我们来看下这个过程究竟发生了什么,可以肯定的是系统一开始肯定是位于用户空间的,我们可以打印当前pc中的内容
可以发现将要执行的指令为0xde6这就是ecall指令。还可以查看当前页表中的内容
可以发现该页表只有6条记录,这是因为shell是一个非常小的程序,它的内存占用非常小,只有3条记录真正用于该程序,还有一条是无效page作为guard page使用(第3条记录),这几条记录的地址都比较小,这也证明了我们当前还处于用户区(用户区占用的地址都是比较小的地址,内核区的地址比较大,这样设计的好处是使用户区的内存很难影响到内核区)。
接下来,我们来看一下执行了ecall指令后,系统发生了哪些变化。首先来查看一下将要执行的指令,同样打印pc中的内容
可以发现这是一个非常大的地址,进一步查看当前页表中的内容
可以发现和之前的页表完全相同。
根据以上信息我们现在可以知道的只是我们当前将要执行的指令发生了变化。那么它之前存储的那个值也就是0xde6又被保存到了哪里?系统现在是在内核还是在用户区?接下来会执行哪些操作呢?
对于第一个问题,我的解答是oxde6将会保存在SEPC寄存器中,而STVEC寄存器中的内容也就是0x3ffffff0会被加载到pc中,这也就是为什么系统当前执行的指令是这个,至于STVEC中的内容这是内核在返回用户空间之前设置的(正如之前提到的在设备启动时会先进入机器模式,然后迅速进入内核模式最后返回到用户空间),第二个问题我们马上就会知道,至于最后一个问题,我们不妨来查看一下
可以发现系统其实已经执行完了一条指令,这条指令非常重要,待会我们也会讨论这条指令。这些指令其实是进入内核后首先需要执行的指令,也是trap机制中最开始要执行的指令。
可以知道的是系统当前执行的指令为0x3ffffff000,你会发现这是页表中最后一条记录的地址。那么现在我可以肯定的告诉你们,我们现在已经进入了内核。因为页表中最后一条记录的u标志位为0,表示该记录只能由内核操作,而刚才我们看到系统确实执行了这条指令,也就是说系统使用了页表中的最后一条记录,而只有内核才能使用该条记录。
页表中的最后一个page其实是trampoline page,这是一个非常重要的page,它包含了内核的trap处理代码。其实页表中最后两条记录存在于每个用户程序的页表中,这是因为ecall指令不会完成页表切换的工作(这里的原因是,RISC-V设计者想要为软件和操作系统的程序员提供最大的灵活性,这样他们就能按照他们想要的方式开发操作系统。),因此必须设计一种方法至少完成进入内核的一些初始化操作。因此内核小心的将这两条记录放置在每个用户程序的页表中(由于ecall指令不会完成切换页表以及设置堆栈等操作,这是一种迫不得已的选择),至于另一条记录有什么作用,我们待会也会讲到。
之所以能够安心的将这两条如此重要的记录放置在每个用户程序的页表中,我们刚才也提到了这两条记录的u标志位为0,用户程序无法使用这两条记录,因此能够保证内核的安全。
这里还有一个值得我们思考的问题:既然ecall不会完成页表的切换,而系统当前已经进入了内核,也就是说我们的内核程序当前使用的是用户程序的页表,为啥系统没有崩溃呢?
–解答:这是因为我们当前还处于trampoline程序中,而该程序依赖的trampoline page在用户页表和内核页表中的映射关系是一样的。也就是说该程序可以通过用户页表找到对应的物理地址,也能通过内核页表找打对应的物理地址。
我们来总结一下,ecall指令到底完成了哪些工作:
1. 设置mode标志位,标志系统进入内核空间
1. 将pc中的内容保存到SEPC寄存器中
1. 将STVEC寄存器中的内容加载到pc中,再根据pc中的内容跳转到指定位置
ecall指令只完成了一小部分工作,想要运行位于内核区的其他c程序,还需要完成很多工作。
要想运行c程序,需要使用寄存器,而现在这些寄存器中存放着用户程序中的值。因此,必须将这些寄存器中的值保存在某个地方,而这个地方就是用户页表中另一条记录指向的trapframe page。我们可以查看一下trapframe page中存放了什么
可以看到很多内容都对应着寄存器的名字,因此如何保存寄存器值的一半答案就是这些值最终会被保存在trapframe page中,另一半答案和我们之前看到的csrrw a0,sscratch,a0指令有关。该指令的作用是交换a0和sscratch中的值,其中sscratch中的值就是0x3fffffe000即trapframe 的位置(和STVEC一样,也是由内核提前设置好的)。为什么需要交换这两个寄存器的值呢?因为刚才说了,程序执行时需要寄存器的参与,我们需要一个空闲寄存器,(由于函数调用时往往将第一个参数存放在a0中,内核交换过程就是是由调用一个函数完成,因此a0中存放着sscratch中的值),我们具体来看一下关于保存的代码
这是trampoline中的代码,可以发现保存时以a0作为基准,加上偏移量作为存放的地址。在此之后,会执行以下代码(同样位于trampoline中)
ld sp,8(a0)表示将a0偏移8个字节所在的地址加载到sp中,而这个地址就是内核的Stack Pointer,这是一个比较重要的节点。
在之后会向t1寄存器中写入内容,这里写入的是kernel page table的地址,接下来会交换SATP和t1寄存器的内容,至此一切工作准备就绪,可以运行位于内核中的c程序了。