Isolation and system call entry/exit
这一节的笔记写的有点乱,Chapter4会好一些
Trap机制
程序运行时会发生用户空间与内核空间的切换,每当:
程序执行系统调用;
程序出现类似
page fault、运算除以零的错误;一个设备触发了中断使得当前程序运行需要响应内核设备驱动
都会发生切换。我们把这里用户空间和内核空间的切换称为trap。
RISC-V有32个用户寄存器。其中需要关注的有:
堆栈寄存器Stack Pointer
程序计数器Program Counter Register
寄存器中表明当前
mode的标志位SATP寄存器Supervisor Address Translation and Protection
STVEC寄存器Supervisor Trap Vector Base Address Register
SEPC寄存器Supervisor Exception Program Counter
SSRATCH寄存器Supervisor Scratch Register
在trap最开始的时候,CPU处于user mode,因此我们要改一些状态才可以运行系统内核中的代码:
首先,需要保存32个用户寄存器,因为后面需要恢复用户应用程序的执行;
程序计数器同样需要被保存;
然后将
mode改为supervisor mode;修改SATP寄存器指向,原先指向的是
user page table,需要转向kernel page table;需要将堆栈寄存器指向位于内核的一个地址,我们利用它来调用内核的函数代码;
设置完成后进入内核代码。
我们仍然希望保持良好的隔离性,因此trap中涉及到的硬件和内核机制不能依赖任何来自用户空间的东西。
额外说明一下
mode标志位寄存器:从user mode切换到supervisor mode后,实际上只能多做两件事。一是可以读写控制寄存器,即上面提到的那些寄存器;二是可以使用PTE_U标志位为0的PTE。但此模式仍然不能读写任意物理地址,它同样受限于当前page table设置的虚拟地址。
Trap代码执行流程
我们以Shell中调用write系统调用为例。这通过执行ecall指令来执行。ecall指令会切换到supervisor mode的内核中。然后内核会执行uservec,这是一个由汇编语言写的函数,位于trampoline.S中;在这个汇编函数中,代码执行会跳转到usertrap,这个函数在trap.c中;在usertrap中,会执行一个叫做syscall的函数,这个函数在一个表单中根据传入的数字查找已经实现的对应的系统调用函数,对于本例是sys_write;syswrite会将要显示的数据输出到console上,完成后返回给syscall。
上述执行流程相当于在ecall处就中断了用户代码的执行,然后我们需要恢复它。
syscall函数会调用usertrapret函数,这个函数会完成方便在C代码中实现的返回用户空间操作;然后再运行userret函数,完成只能在汇编语言中完成的操作;最终,这个汇编函数会调用机器指令返回用户空间,并恢复用户程序的执行。
ECALL指令之前的状态
接下来使用gdb来跟踪write系统调用,我们跟踪Shell将提示信息通过write系统调用走到操作系统再走到console的过程。
上面write(2, "$", 2)将"$"写入到文件描述符2。接下来打开gdb:
然后:
当Shell调用write时,实际调用的是关联到Shell的一个库函数,位于usys.S中:
它将SYS_write加载到a7寄存器中,SYS_write是对应数字16;然后执行ecall指令,代码执行跳转到内核;执行完后返回用户空间,执行ret,返回Shell中。
通过查看xv6编译产生的sh.asm寻找ecall指令的地址:
在ecall指令处设置断点,检查pc确认一下,然后打印全部32个用户寄存器:
其中a0、a1、a2是Shell传递给write系统调用的参数。a0是文件描述符,a1是Shell想写入字符串的指针,a2是想写入的字符数。
另外,上图寄存器中pc和sp的地址都在距离0比较仅的地址,这也可以进一步印证当前代码运行在用户空间中。因为用户空间中所有的地址都比较小。
系统调用时会有大量状态的变更,其中最重要的就是当前的page table。我们可以查看STAP寄存器:
0x0表明分页功能未启用,CPU当前处于直接物理地址访问模式,未启用虚拟内存。
我们可以在QEMU中打印当前的page table,在QEMU界面输入ctrl a + c进入QEMU的console,然后输入info mem可以打印完整的page table。
这里打印的是超长的
kernel page table,但老师这里在用户空间🤔,不知道是不是Lab的代码的原因,考虑到csrrw那个地方也不一样。
attr列是PTE的标志位。rwx表示这个page可读可写也可执行指令,u表示PTE_U是否被设置,再然后是Global标志位,a表明PTE是否被使用过,d表明是否被写过。
另外,最后两条PTE的虚拟地址非常大,接近虚拟地址的顶端,分别是trapframe和trampoline。
接下来在Shell中打印出write的内容:
ECALL指令之后的状态
现在执行ecall指令:
看一下接下来要运行的指令:
ecall会将代码从user mode改到supervisor mode,然后将程序计数器的值保存到SEPC寄存器,再跳转到STVEC寄存器指向的指令。
接下来我们还需要进行:
保存32个用户寄存器的内容;
从
user page table切换到kernel page table;需要将Stack Pointer寄存器指向一个
kernel stack,给C代码提供栈;需要跳转到内核中C代码的特定位置。
基于RISC-V的设计理念,这些交给软件来完成。
RISC-V认为,
ecall只完成尽量少且必须完成的工作,其他都交给软件完成。这样可以为软件和操作系统程序员提供最大的灵活性。
uservec函数
现在程序位于trampoline page的起始处,也是uservec函数的开始。由于RISC-V中supervisor mode下的代码不允许直接访问物理内存,因此在拷贝用户寄存器时,只能使用page table而不能直接使用物理内存。另一种思路是在supervisor mode下更改SATP寄存器,使其指向kernel page table,但是在当前位置我们并不知道kernel page table的地址。
xv6的实现包含两个部分。
第一个部分里,xv6在每个user page table映射了trapframe page,这是由kernel设置的,映射指向一个可以用来存放这个进程的用户寄存器的内存位置,这个位置的虚拟地址是固定的。
以上就是xv6在tramframe page中存放的内容。最开始的五个数据是内核预先存放的数据,如第一个保存了kernel page table的地址,这是trap处理代码将要加载到SATP寄存器中的数。
另一部分是SSCRATCH寄存器。在进入到user page之前,内核会将trapframe page的地址保存在这个寄存器中。
usertrap函数
usertrap首先更改STVEC寄存器,将其指向kernelvec变量,这是内核空间trap处理代码的位置;然后保存用户程序计数器到SEPC寄存器中;接下来根据触发trap的原因,设置SCAUSE寄存器的值;然后就是一些处理操作,在Chapter4中有介绍。
usertrapret函数
usertrap最后会调用usertrapret函数来设置返回用户空间前内核要做的工作。它首先会关闭中断,因为我们要更新STVEC寄存器指向用户空间的trap处理代码;然后设置STVEC寄存器指向trampoline代码,这里最终会执行sret指令返回用户空间,该指令又会重新打开中断;接下来会填充一些trapframe的内容,包括kernel page table的指针、储存了当前用户进程的kernel stack、储存了usertrap函数的指针、从tp寄存器中读取的CPU核编号。
sret指令会将程序计数器设置成SEPC寄存器的值,并根据user page table地址生成相应的SATP值,这样在返回用户空间时才能完成page table的切换。
userret函数
第一步是切换page table,切换到user page table;然后将SSCRATCH寄存器恢复成保存好的用户的a0寄存器,这里a0是trapframe的地址。
sret是在kernel中的最后一条指令,执行完后就会回到user mode。
Last updated