XV6学习(5)陷阱和系统调用
在操作系统中,有三种情况会导致CPU的控制流发生转移:用户态中通过ecall
指令进入内核态;异常发生,如除零、访问非法地址;设备中断,如硬盘完成读写请求。上面这些情况可以统称为陷阱(trap)。
陷阱在一般情况下应该是透明的,即当执行完处理程序后能够恢复之前程序的状态。这就要求在陷入内核态时,内核要保存之前的寄存器等状态信息,当执行完处理程序之后再进行恢复。
在XV6中处理陷阱有以下四步:CPU进行硬件操作,汇编向量被设置,C陷阱处理程序决定如何处理,系统调用或设备驱动处理该陷阱。内核中通常分三种情况来分别处理这些陷阱:用户态陷阱、内核态陷阱、时钟中断。
RISC-V CPU有一系列控制寄存器来决定如何处理陷阱,这些寄存器是由内核来设置的。
stvec
:陷阱处理程序入口,CPU会跳转到此处来处理陷阱sepc
:保存陷阱发生时的pc
,使用sret
指令会将pc
恢复scause
:陷阱原因sscratch
:内核保存特定的值,见下文sstatus
:sstatus
中的SIE
位控制中断是否允许;SPP
位表示陷阱来自用户模式还是监管模式。
当发生陷阱时,硬件会进行以下操作:
- 如果是设备中断,并且
SIE
是清空的,就不响应 - 清空
SIE
以关闭中断 - 保存
pc
到sepc
- 保存当前模式到
SPP
- 设置
scause
- 切换到监管模式
- 拷贝
stvec
到pc
- 开始执行处理程序
硬件不会自动切换内核页表和内核栈,也不会保存除pc
以外的寄存器,处理程序必须完成上述工作。这样设计可以给软件更好的灵活性。而设置pc
的工作必须由硬件完成,因为当切换到内核态时,用户指令可能会破坏隔离性。
用户态陷阱
XV6的用户态陷阱处理流程如下:uservec
-> usertrap
-> usertrapret
-> userret
。
由于CPU不会进行页表切换,因此用户页表必须包含uservec
函数(stvec
所指向的函数)的映射。该函数要将satp
切换为内核页表,为了切换后的指令能继续执行,该函数必须在用户页表和内核页表中有相同的地址。为了满足上述要求,XV6将一个叫trampoline
的页映射到相同的虚拟地址TRAMPOLINE
,其中包含了trampoline.S
的指令,并设置stvec
为uservec
。
uservec
在进入uservec
函数时,所有的32个寄存器都是被中断代码所享有的,而uservec
需要使用寄存器来执行指令,因此,RISC-V提供了sscratch
寄存器,通过csrrw a0, sscratch, a0
指令,保存a0
,之后就可以使用a0
寄存器了。
之后,函数就需要保存所有用户寄存器到trapframe
结构体中,该结构体的地址在进入用户模式之前,被保存在sscratch
寄存器中,因此经过之前的csrrw
操作后,就被保存在a0
中。当创建进程时,内核会申请一个页面保存trapframe
,该页面就位于TRAMPOLINE
下方,进程的p->trapframe
也指向该页面。
最后,函数从trapframe
中取出内核栈地址、hartid、usertrap
的地址、内核页表地址,切换页表,跳转到usertrap
函数。
usertrap
usertrap
的工作即判断陷阱类型并处理,最后返回。函数首先将stvec
设置为kernelvec
的地址,使内核态发生的中断由kernelvec
函数来处理。之后保存sepc
寄存器,防止其被覆盖。然后判断陷阱类型,如果是系统调用,就将pc
指向ecall
的下一条指令,然后交给syscall
函数处理;如果是设备中断,就交给devintr
;否则就是异常,那么就终止该进程的运行。在最后会判断进程是否已经被杀死或者当发生时钟中断时,让出处理器。
usertrapret
该函数首先将stvec
设置为uservec
的地址,之后设置trapframe
(这些内容在uservec
中会使用到),然后恢复sepc
寄存器。最后,调用userret
函数。
最后,在userret
函数中进行与uservec
相反的步骤,将页表和寄存器进行恢复。
系统调用
以initcode.S
中的系统调用为例,将两个参数分别放在a0
a1
寄存器中,将系统调用号放在a7
寄存器中,然后执行ecall指令。
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
而在syscall
函数中,会取出a7
的值,然后查找syscalls
数组,找到相应的处理函数即sys_exec
,交由该函数进行处理,最后将返回值放在trapframe->a0
中。
内核态陷阱
内核态陷阱的处理路径为:kernelvec
-> kerneltrap
-> kernelvec
kernelvec
由于陷阱发生在内核态,因此,不需要对satp
和栈指针进行处理,只需要保存所有通用寄存器即可。之后跳转到kerneltrap
进行处理,当该函数返回后,再恢复所保存的寄存器。
kerneltrap
kerneltrap
只需要处理两种陷阱:设备中断和异常。通过调用devintr
判断是否为设备中断,如果不是设备中断,那么就是异常,且该异常发生在内核态,内核调用panic
函数终止执行。如果是时钟中断,那么就让出处理器。由于yield
函数会导致sepc
sstatus
寄存器被修改,因此在kerneltrap
中要对其进行保存和恢复。
缺页异常
在XV6中,并没有对异常进行处理,仅仅是简单地kill或panic。而在真实操作系统中,会对异常进行具体的处理。例如使用缺页异常来实现COW(copy on write)fork。
在RISC-V中,有三种不同的缺页异常:load page faults(当load指令转换虚拟地址时发生),store page faults(当store指令转换虚拟地址时发生),instruction page faults(当指令的地址转化时发生)。在scause
寄存器中保存了异常原因,stval
中保存了转换失败的地址。
COW fork使子进程与父进程享有相同的物理页面,但是设置为只读的。当子进程或父进程执行store指令时,就会触发异常,此时再对页面进行拷贝,然后以读写的模式映射到父子进程的地址空间。
另一种技术是lazy allocation,当应用调用sbrk时,增长地址空间,但在页表中标记新地址为无效的。当在新地址上发生缺页异常后,才真正地分配物理页面给进程。
paging from disk即虚拟内存,操作系统选择一部分保存到磁盘上并标记页表项为无效,当读写该页面时再从磁盘中取回内存。除此之外,还有如automatically extending stacks 和 memory-mapped files等技术也使用了缺页异常。