参考:
https://www.cnblogs.com/mlmz/p/16083803.html
Trap机制
Trap机制就是用户空间有内核空间的切换,目的是为了安全性隔离,并为了兼顾效率,由于系统调用与lazy allocation等导致的page
falut的频繁发生,Trap要设计的尽可能简单
有三种情况会发生trap:
- 程序执行系统调用
- 程序出现了类似page fault、运算时除以0的错误,就是异常
- 一个设备触发了中断使得当前程序运行需要响应内核设备驱动,就是中断
初始时,shell程序(也就是shell脚本的解释器)运行在用户态,如果要执行系统调用,比如write,就会从拥有user权限并且位于用户空间切换到拥有supervisor权限的内核。切换到内核需要修改一个程序状态.
其中最重要的就是32个用户寄存器,使用寄存器的指令性能最好,
注意PC,MODE,SATP,STVEC,SEPC,SSCRATCH这些寄存器不属于32个寄存器之中。
Trap过程中寄存器的变化:
- 由于内核程序也需要使用这些寄存器,并且为了安全性考虑,内核代码不应该去使用用户态下的寄存器中的数据,因为其中可能保存着恶意数据,所以为了安全性与透明性,在Trap之前,以及回到用户态之后,这些寄存器的值不能够改变
- pc寄存器也需要保存,这样返回内核的时候才知道去执行哪一条用户态指令
- 我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令
- SATP寄存器现在正指向user page table,而user page table只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将SATP指向kernel page table。
- Trap过程也需要去将堆栈寄存器(堆栈寄存器属于32个用户寄存器)指向位于内核的一个地址,因为我们需要一个堆栈来调用内核的C函数。
supervisor mode的特权
- 可以读写控制寄存器了。比如说,当你在supervisor mode时,你可以:读写SATP寄存器,也就是page table的指针;STVEC,也就是处理trap的内核指令地址;SEPC,保存当发生trap时的程序计数器;SSCRATCH,保存了trapframe page的用户页表虚拟地址,等等。在supervisor mode你可以读写这些寄存器,而用户代码不能做这样的操作。
- 它可以使用PTE_U标志位为0的PTE。当PTE_U标志位为1的时候,表明用户代码可以使用这个页表;如果这个标志位为0,则只有supervisor mode可以使用这个页表
supervisor mode也存在限制
- supervisor mode中的代码并不能读写任意物理地址。在supervisor mode中,就像普通的用户代码一样,也需要通过page table来访问内存。如果一个虚拟地址并不在当前由SATP指向的page table中,又或者SATP指向的page table中PTE_U=1(也就是用户态才能够读的页表项),那么supervisor mode不能使用那个地址。所以,即使我们在supervisor mode,我们还是受限于当前page table设置的虚拟地址。
Trap的执行流程
以write系统调用举例:
- 在用户态把系统调用号放到a7寄存器
- ecall指令(ecall是一个硬件指令)
- trampoline中的uservec()
- trap.c中的usertrap()
- syscall()
- sys_write()
- 回到trap.c中usertrap()
- trap.c中的usertrapret()
- trampoline中的userret()
- 回到用户态
Trapframe Page:
Trapframe page 是用于存储处理异常情况的上下文信息的页面。当处理器发生异常或中断时,操作系统需要保存当前正在执行进程的状态,包括寄存器的值、程序计数器、堆栈指针等。这些信息被保存在 trapframe page 中,以便在异常处理程序执行完毕后能够恢复进程的状态。Trapframe page 通常是每个进程都有一个,用于存储该进程的异常上下文。
Trampoline Page:
Trampoline page 是用于进程间切换的辅助页面。在一些操作系统的设计中,当发生进程切换时,会使用一个 trampoline page 来跳转到新的进程执行。Trampoline page 包含了切换进程所需的指令和数据,它负责将控制权传递给新的进程,并在进程切换完成后恢复原始进程的状态。Trampoline page 在进程切换过程中充当临时的桥梁,确保进程切换的顺利执行。
ECALL之前
wirte函数关联到了一个库函数,这个库函数在user/usys.S中
在ecall之前的用户页表的最后两项表示trampoline与trampframe页
由于标志位u未置位,那么只有在supervisor mode才能访问这两个PTE,在ecall之后,可以访问每一个进程虚拟地址空间中的trampoline与trapframe页
ECALL之后
ecall并不会切换page table,这是ecall指令的一个非常重要的特点。所以这意味着,trap处理代码必须存在于每一个user page table中。在从内核空间进入到用户空间之前,内核会设置好STVEC寄存器指向内核希望trap代码运行的位置。
内核已经事先设置好了STVEC寄存器的内容为0x3ffffff000,这就是trampoline page的起始位置。STVEC寄存器的内容,就是在ecall指令执行之后,我们会在这个特定地址执行指令的原因。
我们是通过ecall走到trampoline page的,而ecall实际上只会改变三件事情:
- 第一,ecall将代码从user mode改到supervisor mode。
- 第二,ecall将程序计数器的值保存在了SEPC寄存器。SEPC寄存器保存了程序计数器的值。
- 第三,ecall会跳转到STVEC寄存器指向的指令。
由于ECALL指令将将user mode改为supervisor mode,(这个时候页表还是用户页表)
那么这个时候就可以可以访问页表项的最后一项
- trampoline page的第一条指令是csrrw a0, sscratch, a0,这条指令将a0的数据保存在了sscratch中,同时又将sscratch内的数据保存在a0中。之后内核就可以任意的使用a0寄存器了。
- trampoline page包含了trap处理代码,因为ecall并不会切换page table,我们需要在user page
table中的某个地方来执行最初的内核代码。
为了保持ecall指令的灵活性,ecall指令不会不会保存用户寄存器,或者切换page table指针来指向kernel page table,或者自动的设置Stack Pointer指向kernel stack,或者直接跳转到kernel的C代码。
ecall帮我们做了一点点工作,但是实际上我们离执行内核中的C代码还差的很远。接下来:
- 我们需要保存32个用户寄存器的内容,这样当我们想要恢复用户代码执行时,我们才能恢复这些寄存器的内容。
- 因为现在我们还在user page table,我们需要切换到kernel page table。
- 我们需要创建或者找到一个kernel stack,并将Stack Pointer寄存器的内容指向那个kernel
stack。这样才能给C代码提供栈。 - 我们还需要跳转到内核中C代码的某些合理的位置。
uservec
uservec是trampoline页的最开始的函数
每个进程被创建的时候会被分配一个trapframe,并做好在user page table中做好映射,其在进程的虚拟地址空间中trampoline的正下方
- 使用csrrw指令,交换a0和sscratch两个寄存器的内容,sscratch中存有的是trapframe page的虚拟地址
- 之后是保存用户的32个寄存器到trapframe
每个进程的trapframe还保留了5个内核数据
其中kernel_sp就是进程的内核栈,寄存器sp的值会被设置为它
保存CPU核的编号到tp寄存器,在内核中好几个地方都会使用了这个值,例如,内核可以通过这个值确定某个CPU核上运行了哪些进程。
将之后要执行的usertrap函数的指针放入t0寄存器
将用户页表转换为内核页表
进入usertrap函数
usertrap函数
首先将kernelvec()的地址放入stvec寄存器中,用户态的时候放的是trampoline的地址,在内核态,处理trap的函数是kernelvec()
之后是保存sepc寄存器,其中保存的是当发生trap时的程序计数器,因为可能发生这种情况:当程序还在内核中执行时,我们可能切换到另一个进程,并进入到那个程序的用户空间,然后那个进程可能再调用一个系统调用进而导致SEPC寄存器的内容被覆盖。所以,我们需要保存当前进程的SEPC寄存器到一个与该进程关联的内存中,这样这个数据才不会被覆盖。这里我们使用trapframe来保存这个程序计数器。
根据SCAUSE寄存器中的数字判断trap的类型做出相应的处理,
- 对于系统调用,我们需要p->trapframe->epc += 4;,因为我们希望返回到用户态时,去执行ecall下面的一条指令,对于其他的trap类型,比如page fault就不会+4
由于有些系统调用需要大量时间处理,所以当保存好了当前进程的寄存器等相关状态后,可以打开中断,之后去调用syscall(),系统调用号用来匹配系统调用函数表,如果系统调用号是合法的,那么执行对应的系统调用,如果不合法,那就将代表错误的返回值-1放到a0寄存器中,如果系统调用号合法,那么就会去执行对应实现系统调用的内核函数,并将实现系统调用的内核函数的返回值放入a0中
可以看到内核中的实现系统调用的函数就是通过进程的trapframe获取参数
如果是设备中断,就交给devintr
如果是异常,那么就终止该进程的运行。
- 处理完成系统调用等问题后,回到**usertrap函数,**执行usertrapret(void)函数
usertrapret
首先是关中断,
我们之前在系统调用的过程中是打开了中断的,这里关闭中断是因为我们将要更新STVEC寄存器来指向用户空间的trap处理代码,而之前在内核中的时候,我们指向的是内核空间的trap处理代码(6.6)。我们关闭中断因为当我们将STVEC更新到指向用户空间的trap处理代码时,我们仍然在内核中执行代码。如果这时发生了一个中断,那么程序执行会走向用户空间的trap处理代码,即便我们现在仍然在内核中,出于各种各样具体细节的原因,这会导致内核出错。所以我们这里关闭中断。
变量satp中存的是用户页表的物理地址,之后将调用trampoline中的userret函数,将TRAPFRAME与satp作为参数,也就是放在a0与a1中,转入userret 函数
userret
将内核页表转为用户页表
trapframe中的a0寄存器保存的是系统调用的返回值,由于先需要使用trapframe,所以现在的a0放的是trapframe的地址
在trapframe中的寄存器的值放回到进程对应寄存器后,交换sscratch与a0的值,那么sscratch中就有了trapframe的地址,a0中存的就是系统调用的返回值,最后再执行sret返回
sret会做三件事:
- 程序会切换回user mode
- SEPC寄存器的数值会被拷贝到PC寄存器(程序计数器)
- 重新打开中断