xv6--一个类Unix的教学用操作系统

第4章 陷阱和系统调用

2020-11-22  本文已影响0人  橡树人

有3类事件可导致CPU把普通的指令执行搁置在一边,强制把控制权转移到能处理事件的特定代码处。

本书中使用陷阱trap作为这3种情形的泛称。

当陷阱出现时,无论正在执行什么代码都需要恢复,不应该感知到发生了任何特殊的事件。

即,我们通常希望陷阱是透明的,这对于中断来说极其重要,因为被中断的代码不期望感知到有特殊事件发生了。

通常的步骤是:

xv6内核处理所有类型的陷阱。
对系统调用来说,这是很自然的事。
对中断来说,也是很有意义的,因为隔离性要求:用户进程不能直接访问设备,只有内核有处理设备所需的状态。
对异常来说,也很有意义,因为xv6对来自用户空间的所有异常的作出的响应是杀掉相应的进程。

xv6的陷阱处理分为4个阶段:

虽然这3类陷阱之间的共性建议:内核可使用一条代码路径来处理所有类型的陷阱,但是事实证明,针对用户空间陷阱、内核空间陷阱、计时器中断等3种不同的情形,有单独的汇编向量及C陷阱处理程序是很方便的

第4.1节 RISV的陷阱机制

每个RISC-V的CPU都有一套控制寄存器,内核可向其中写入信息来告知CPU如何处理陷阱,内核可从中读数据来查找有关已发生的陷阱信息。

riscv.h中包含了xv6使用的定义。

以上5个跟陷阱相关的寄存器都是在超级用户模式下处理的,在用户模式下不能读写这5个寄存器的值。

在机器模式下,有等价的一套控制寄存器来用于陷阱处理。

xv6仅在计时器中断的特殊情况下使用这些寄存器。

在多核处理器的每个CPU都有自己的一套类似这样的寄存器;在任意给定时刻,可能不止有一个CPU在处理陷阱。

当需要强制处理一个陷阱时,RISV硬件对除了计时器中断外的所有陷阱类型做下面几件事:

  1. 如果陷阱是一个设备中断,且sstatus中的SIE标记位被清除了,则什么都不做;

  2. 清除sstatus中的SIE标记,使中断失效;

  3. 拷贝pc到spec;

  4. 保存当前模式到sstatus的SPP标记位;

  5. 设置scause寄存器来反映陷阱的起因;

  6. 设置模式为超级用户模式;

  7. 拷贝stvec到pc

  8. 跳转到新的pc处开始执行

注意:
CPU没有切换到内核的页表,没有切换到内核栈中,没有保存除了pc之外的任何寄存器。这些是内核软件必须要做的任务。

理由:CPU在处理陷阱的过程中做少量的工作是为了给软件提供更大的灵活性。比如,一些操作系统在某些情况下不需要页表切换的,这可以提升性能。

能不能对CPU的陷阱处理步骤进行进一步的简化?
假设CPU不切换程序寄存器pc。
则还在运行用户指令,陷阱就切换到超级用户模式了。这样,那些用户指令就能破坏用户/内核隔离机制了,比如通过修改satp寄存器来指向允许访问整个物理内存的页表了。
因此,CPU切换到由stvec寄存器指定的内核指令地址是非常重要的。

4.2 来自用户空间的陷阱

当CPU在用户空间执行时,如果用户程序做了一个系统调用,或者做了非法的事,或者某个设备中断了,则就可能会发生一个陷阱。

处理来自用户空间的陷阱的代码路径是先uservec,后usertrap
返回时是先usertrapret,后userret

来自用户空间的陷阱处理代码要比来自内核的更具有挑战性,因为satp指向的是一个没有映射到内核的用户页表,栈指针可能包含一个无效甚至是恶意的值。

因为RISC-V硬件在陷阱期间不切换页表,则用户页表必须包含uservec的映射,即stvec指向的陷阱向量指令。

uservec必须切换satp以指向内核页表;为了在切换后继续执行指令,必须将uservec映射到内核页表中跟用户页表中相同的地址。

xv6使用包含uservectrampoline页来满足这些约束。xv6将trampoline页映射到内核页表和每个用户页表中的虚拟地址是相同的。这个虚拟地址就是TRAMPOLINE
trampoline.S中设置了trampoline的内容。
当执行用户代码时,设置stevecuservec

当uservec开始执行时,所有32个寄存器包含的都是被打断代码的值。
但是,为了设置satp及生成保存寄存器值的地址,uservec需要能修改一些寄存器。

RISC-V以sscratch寄存器的形式提供了一个帮手。
在uservec开头的csrrw指令交换了a0寄存器和sscratch寄存器的值。

现在用户代码的a0寄存器的值被保存了;
uservec有一个寄存器a0可以使用了;
ao包含了内核先前放在sscratch里的值。

uservec的下一个任务是保存用户寄存器的值。
在进入用户空间前,内核之前设置sscratch指向每个进程的trapframe,该trapframe有空间来保存所有的寄存器。
由于satp仍指向用户页表,则uservec需要将trapframe映射到用户地址空间。

当创建进程时,xv6会分配一个页给该进程的trapframe,将该页映射到用户虚拟地址TRAPFRAME。进程的p->trapframe指向的就是陷阱栈,不过是它的物理地址,以便内核能通过内核页表来使用它。

因此,在交换了a0和sscratch后,a0持有了一个指向当前进程陷阱栈的指针。现在,uservec可以将所有的用户寄存器保存到陷阱栈中,包括用户的a0。

陷阱栈包含了指向当前进程内核栈的指针、当前CPU的hartid、usertrap的地址、内核页表的地址。

uservec检索这些值,切换satp指向内核页表,调用usertrap。

usertrap的工作是判断陷阱的起因,处理陷阱,并返回。
首先,修改stvec,使得陷阱在内核中将被kernelvec处理。
然后,保存sepc的值,因为在usertrap中的一个进程切换可能会导致sepc的值被覆盖。
接着,如果一个陷阱是系统调用,则syscall会处理陷阱;
如果陷阱是一个设备中断,则devintr会处理陷阱;
如果陷阱是一个异常,则内核会杀掉出错的进程;

系统调用路径会将保存的用户pc加4,因为在系统调用情形下,RISC-V会让程序指针指向ecall指令。

退出过程中,usertrap会检查进程是否被杀死,或者应该让出CPU(如果这是一个计时器中断)。

如何返回到用户空间?

第一步:调用usertrapret。
该函数先设置RISC-V的控制寄存器为将来的来自用户空间的陷阱做好准备。包括:设置stvec指向uservec,准备好uservec依赖的trapframe字段,设置sepc为先前保存的用户pc。
然后,调用在trampoline页上的userret,该trampiline页在用户页表和内核页表中都有映射,理由是在userret中的汇编码将切换页表。

usertrapret对userret的调用传递了一个指向进程用户页表(在a0中)和TRAPFRAME(在a1中)的指针。

userret切换satp指向进程的用户页表。
回想一下,用户页表既映射了trampoline页,也映射了TRPFRAME,但没有映射来自内核的其他内容。
在用户页表和内核页表中,trampoline页被映射到相同的虚拟地址,这就允许:在修改satp之后,uservec继续执行。

userret拷贝trapframe上保存的用户a0到sscratch中,为以后的TRAPFRAME交换做好准备。

从现在起,userret能使用的数据只有寄存器的内容和trapframe的内容。
接下来,userret从trapframe中恢复被保存的寄存器,做最后一次a0和sscratch的交换来恢复a0,为接下来的陷阱而保存TRAPFRAME,使用sret返回到用户空间。

4.3 代码:系统调用

第2章以initcode.S结束,在initcode.S中触发了exec系统调用。让我们看看用户的调用是如何抵达内核中的exec系统调用实现的。

用户代码在寄存器a0和a1中放入了用于exec的参数,在寄存器a7中放入了系统调用编号。

系统调用编号是跟syscalls数组的条目相匹配的,其中syscalls是一个由多个函数指针组成的表。

ecall指令进入到内核,执行uservec、usertrap,然后是syscall。

syscall从在trapframe上被保存的寄存器a7的值中检索出系统调用号,使用它作为syscalls的索引。寄存器a7包含的值为SYS_exec,导致调用系统调用实现sys_exec。

当从系统调用实现函数中返回时,syscall在p->trapframe->a0中记录返回值。由于在RISC-V上的C调用约定在寄存器a0中放入返回值,则前述操作会导致初始用户空间对exec()调用返回syscall在trapframe->a0中放入的值。

系统调用约定返回负数表示错误,0或者整数表示成功。

如果系统调用号非法,则syscall会输出错误,并返回-1。

4.4 代码:系统调用参数

寄存器->陷阱帧trapframe

内核中的系统调用实现需要找到用户代码传递的参数。

因为用户代码调用的是经过包装的系统调用函数,所以参数最开始是按照RISC-V的C调用约定,保存在寄存器中的。

内核的陷阱trap代码将寄存器的值保存到当前进程的陷阱帧trap frame中,内核代码是从当前进程的trapframe上找到系统调用参数的。

函数argint、argaddr、argfd分别从陷阱帧上检索第n个参数作为整数、指针、或者文件描述符。这3个函数都是调用argraw来检索到合适的被保存的用户寄存器。

有些系统调用传递指针作为参数,内核必须使用这些指针来读写用户内存。比如,系统调用exec传递给内核一个指针数组,引用的是在用户空间的字符串参数。

这些指针带来两个挑战:

  1. 用户程序可能会出错或者有恶意,可能会传递给内核一个无效的指针、或者旨在骗过内核来访问内核的内存代替访问用户内存。
  2. xv6的内核页表映射跟用户页表映射不一样,所以内核不能使用普通的指令来加载或者保存来自用户提供的地址。

内核实现了向用户提供的地址以及从用户提供的地址安全转移数据的功能,比如fetchstr函数。

诸如exec等文件系统调用使用fetchstr函数来从用户空间检索字符串式的文件名参数。

fetchstr函数调用copyinstr来做实际的工作。

copyinstr从用户页表pagetale中的虚拟地址srcva处最多拷贝max个字节。它使用walkaddr来遍历软件形式的页表来确定srcva对应的物理地址pa0。因为内核映射所有的RAM地址到相同的内核虚拟地址,所以copyinstr可以直接从pa0拷贝字符串字节到dst。

walkaddr会检查用户提供的虚拟地址是否属于用户地址空间,所以程序不能骗过内核去读其他内存。

类似的函数还有copyout:将数据从内核拷贝到用户提供的地址。

4.5 内核空间中的陷阱

来自内核空间的陷阱处理步骤

根据是在用户空间执行代码还是在内核空间执行代码,xv6配置CPU陷阱寄存器的方式是不一样的。

当内核在CPU上执行时,内核将stvec指向在kernelvec处的汇编代码。

由于xv6已经在内核里了,kernelvec能依赖stap来设置内核页表和栈指针来引用有效的内核栈。

kernelvec保存所有的寄存器,以便被打断的代码最终能无扰动地恢复执行。

kernelvec将寄存器保存在被打断的内核线程的栈上,这是有意义的,因为此时这些寄存器的值是属于该内核线程的。如果陷阱导致切换到一个不同的线程,这一点非常重要,陷阱将实际返回到新线程的栈上,同时安全地把被打断线程的寄存器保存在自己的栈上。

在保存完寄存器后,kernelvec就跳转到kerneltrap。

kerneltrap为两类陷阱做好准备:设备中断和异常。
它调用devintr来检查和处理设备中断。如果陷阱不是设备中断,则一定是异常;如果在xv6内核中发生了异常,则通常是一个致命错误;内核调用panic并停止执行。

如果由于计时器中断调用了kerneltrap,且一个进程的内核线程正在运行,则kerneltrap调用yield来给其他进程一个运行的机会。在将来的某个时间点,总有某个线程会yied,让我们的线程及它的kerneltrap恢复执行。

当kerneltrap执行完后,需要返回到被陷阱打断的代码处。kerneltrap检索那些控制寄存器的值,返回给kernelvec。

kernelvec从栈上弹出保存的寄存器值,并执行sret;sret拷贝sepc到pc,恢复执行被打断代码的值。

由于一个yield会干扰spec和sstatus,所以kernel在一开始就先保存spec和sstatus的值。

非常值得思考的一个问题是:如果kerneltrap由于计时器中断调用了yield,则陷阱返回是如何发生的?

当CPU从用户空间进入内核,xv6设置该CPU的stvec指向kernelvec。
当内核正在执行,但stvec指向uservec时,存在一个时间窗口。
在这个时间窗口内,使设备中断失效时非常关键的。

幸运的是,RISC-V在取一个陷阱的开始就让中断失效,直到xv6设置了stvec后,才让中断生效。

上一篇下一篇

猜你喜欢

热点阅读