一、陷阱
有三种事件会导致中央处理器搁置普通指令的执行,并强制将控制权转移到处理该事件的特殊代码上。一种情况是系统调用,当用户程序执行ecall
指令要求内核为其做些什么时;另一种情况是异常,(用户或内核)指令做了一些非法的事情,例如除以零或使用无效的虚拟地址;第三种情况是设备中断,一个设备,例如当磁盘硬件完成读或写请求时,向系统表明它需要被关注。我们使用陷阱(trap
)作为这些情况的通用术语。
通常,陷阱发生时正在执行的任何代码都需要稍后恢复,并且不需要意识到发生了任何特殊的事情。也就是说,我们经常希望陷阱是透明的;这对于中断尤其重要,中断代码通常难以预料。通常的顺序是陷阱强制将控制权转移到内核;内核保存寄存器和其他状态,以便可以恢复执行;内核执行适当的处理程序代码(例如,系统调用接口或设备驱动程序);内核恢复保存的状态并从陷阱中返回;原始代码从它停止的地方恢复。
xv6内核处理所有陷阱。这对于系统调用来说是顺理成章的。由于隔离性要求用户进程不直接使用设备,而且只有内核具有设备处理所需的状态,因而对中断也是有意义的。因为xv6通过杀死违规程序来响应用户空间中的所有异常,它也对异常有意义。
Xv6陷阱处理分为四个阶段: RISC-V CPU采取的硬件操作、为内核C代码执行而准备的汇编程序集“向量”、决定如何处理陷阱的C陷阱处理程序以及系统调用或设备驱动程序服务例程。虽然三种陷阱类型之间的共性表明内核可以用一个代码路径处理所有陷阱,但对于三种不同的情况:来自用户空间的陷阱、来自内核空间的陷阱和定时器中断,分别使用单独的程序集向量和C陷阱处理程序更加方便。
二、RISC-V陷入机制
每个RISC-V CPU都有一组控制寄存器,内核通过向这些寄存器写入内容来告诉CPU如何处理陷阱,内核可以读取这些寄存器来明确已经发生的陷阱。RISC-V文档包含了完整的内容。riscv.h(kernel/riscv.h:1)包含在xv6中使用到的内容的定义。以下是最重要的一些寄存器概述:
stvec
:内核在这里写入其陷阱处理程序的地址;RISC-V跳转到这里处理陷阱。sepc
:当发生陷阱时,RISC-V会在这里保存程序计数器pc
(因为pc
会被stvec
覆盖)。sret
(从陷阱返回)指令会将sepc
复制到pc
。内核可以写入sepc
来控制sret
的去向。scause
: RISC-V在这里放置一个描述陷阱原因的数字。sscratch
:内核在这里放置了一个值,这个值在陷阱处理程序一开始就会派上用场。sstatus
:其中的SIE
位控制设备中断是否启用。如果内核清空SIE
,RISC-V将推迟设备中断,直到内核重新设置SIE
。SPP
位指示陷阱是来自用户模式还是管理模式,并控制sret
返回的模式。
上述寄存器都用于在管理模式下处理陷阱,在用户模式下不能读取或写入。在机器模式下处理陷阱有一组等效的控制寄存器,xv6仅在计时器中断的特殊情况下使用它们。
多核芯片上的每个CPU都有自己的这些寄存器集,并且在任何给定时间都可能有多个CPU在处理陷阱。
当需要强制执行陷阱时,RISC-V硬件对所有陷阱类型(计时器中断除外)执行以下操作:
- 如果陷阱是设备中断,并且状态
SIE
位被清空,则不执行以下任何操作。 - 清除
SIE
以禁用中断。 - 将
pc
复制到sepc
。 - 将当前模式(用户或管理)保存在状态的
SPP
位中。 - 设置
scause
以反映产生陷阱的原因。 - 将模式设置为管理模式。
- 将
stvec
复制到pc
。 在新的pc上开始执行。
请注意,CPU不会切换到内核页表,不会切换到内核栈,也不会保存除pc之外的任何寄存器。内核软件必须执行这些任务。CPU在陷阱期间执行尽可能少量工作的一个原因是为软件提供灵活性;例如,一些操作系统在某些情况下不需要页表切换,这可以提高性能。
你可能想知道CPU硬件的陷阱处理顺序是否可以进一步简化。例如,假设CPU不切换程序计数器。那么陷阱可以在仍然运行用户指令的情况下切换到管理模式。但因此这些用户指令可以打破用户/内核的隔离机制,例如通过修改satp
寄存器来指向允许访问所有物理内存的页表。因此,CPU使用专门的寄存器切换到内核指定的指令地址,即stvec
,是很重要的。
三、从用户空间陷入
如果用户程序发出系统调用(ecall
指令),或者做了一些非法的事情,或者设备中断,那么在用户空间中执行时就可能会产生陷阱。来自用户空间的陷阱的高级路径是uservec
(kernel/trampoline.S:16),然后是usertrap
(kernel/trap.c:37);返回时,先是usertrapret
(kernel/trap.c:90),然后是userret
(kernel/trampoline.S:16)。
下面是跳板代码trampoline.S中uservec
的汇编实现,程序触发陷阱时,会切换到监管者模式,并跳转到stvec
寄存器指向的地址,即uservec
。这段代码负责保存用户寄存器,切换到内核页表,并跳转到处理函数usertrap
。
# 用户态陷阱(Trap)处理的入口函数
uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# sscratch points to where the process's p->trapframe is
# mapped into user space, at TRAPFRAME.
#
# swap a0 and sscratch
# so that a0 is TRAPFRAME
csrrw a0, sscratch, a0 // 交换 a0 与 sscratch
// 保存用户寄存器到 Trapframe
// 将用户程序的所有寄存器状态存入内存,确保内核处理完毕后能恢复用户现场,每个寄存器的保存位置按 8 字节对齐
# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
sd tp, 64(a0)
sd t0, 72(a0)
sd t1, 80(a0)
sd t2, 88(a0)
sd s0, 96(a0)
sd s1, 104(a0)
sd a1, 120(a0)
sd a2, 128(a0)
sd a3, 136(a0)
sd a4, 144(a0)
sd a5, 152(a0)
sd a6, 160(a0)
sd a7, 168(a0)
sd s2, 176(a0)
sd s3, 184(a0)
sd s4, 192(a0)
sd s5, 200(a0)
sd s6, 208(a0)
sd s7, 216(a0)
sd s8, 224(a0)
sd s9, 232(a0)
sd s10, 240(a0)
sd s11, 248(a0)
sd t3, 256(a0)
sd t4, 264(a0)
sd t5, 272(a0)
sd t6, 280(a0)
# save the user a0 in p->trapframe->a0
csrr t0, sscratch // 读取 sscratch(此时存用户原始的 a0)
sd t0, 112(a0) // 将用户 a0 存入 Trapframe 的 112 偏移处
# restore kernel stack pointer from p->trapframe->kernel_sp
ld sp, 8(a0) // 从 Trapframe 加载内核栈指针到 sp
# make tp hold the current hartid, from p->trapframe->kernel_hartid
ld tp, 32(a0) // 加载当前 Hart ID 到 tp(多核处理)
# load the address of usertrap(), p->trapframe->kernel_trap
ld t0, 16(a0) // 加载 usertrap() 函数地址到 t0
# restore kernel page table from p->trapframe->kernel_satp
ld t1, 0(a0) // 加载内核页表地址到 t1
csrw satp, t1 // 切换页表(SATP
sfence.vma zero, zero // 刷新 TLB,确保地址转换生效
# a0 is no longer valid, since the kernel page
# table does not specially map p->tf.
# jump to usertrap(), which does not return
jr t0 // 跳转到 usertrap() 函数
来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为satp指向不映射内核的用户页表,栈指针可能包含无效甚至恶意的值。
由于RISC-V硬件在陷阱期间不会切换页表,所以用户页表必须包括uservec
(stvec
指向的陷阱向量指令)的映射。uservec
必须切换satp
以指向内核页表;为了在切换后继续执行指令,uservec
必须在内核页表中与用户页表中映射相同的地址。
xv6使用包含uservec
的蹦床页面(trampoline page)来满足这些约束。xv6将蹦床页面映射到内核页表和每个用户页表中相同的虚拟地址。这个虚拟地址是TRAMPOLINE
。蹦床内容在trampoline.S中设置,并且(当执行用户代码时)stvec
设置为uservec
(kernel/trampoline.S:16)。
当uservec
启动时,所有32个寄存器都包含被中断代码所拥有的值。但是uservec
需要能够修改一些寄存器,以便设置satp
并生成保存寄存器的地址。RISC-V以sscratch
寄存器的形式提供了帮助。uservec
开始时的csrrw
指令交换了a0
和sscratch
的内容。现在用户代码的a0
被保存了;uservec
有一个寄存器(a0
)可以使用;a0
包含内核以前放在sscratch
中的值。
uservec
的下一个任务是保存用户寄存器。在进入用户空间之前,内核先前将sscratch
设置为指向一个每个进程的陷阱帧,该帧(除此之外)具有保存所有用户寄存器的空间(kernel/proc.h:44)。因为satp
仍然指向用户页表,所以uservec
需要将陷阱帧映射到用户地址空间中。每当创建一个进程时,xv6就为该进程的陷阱帧分配一个页面,并安排它始终映射在用户虚拟地址TRAPFRAME
,该地址就在TRAMPOLINE
下面。尽管使用物理地址,该进程的p->trapframe
仍指向陷阱帧,这样内核就可以通过内核页表使用它。
因此在交换a0
和sscratch
之后,a0
持有指向当前进程陷阱帧的指针。uservec
现在保存那里的所有用户寄存器,包括从sscratch
读取的用户的a0
。
陷阱帧包含指向当前进程内核栈的指针、当前CPU的hartid
、usertrap
的地址和内核页表的地址。uservec
取得这些值,将satp
切换到内核页表,并调用usertrap
。
usertrap
是 xv6 内核中处理 用户态陷阱 的核心函数,负责区分陷阱类型(系统调用、设备中断、异常),并调用相应处理逻辑。usertrap
函数的实现如下:
// handle an interrupt, exception, or system call from user space.
// called from trampoline.S
//
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0) // 检查sstatus寄存器SPP位,确保陷阱来自用户态,防止内核态误触发
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec); // 将 stvec 寄存器指向内核态陷阱处理程序 kernelvec,进入内核后,后续陷阱(如时钟中断)需由 kernelvec 处理,避免递归调用用户态处理逻辑
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc(); // 将 sepc(Supervisor Exception Program Counter)的值保存到进程的 trapframe 中
if(r_scause() == 8){ // 系统调用
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4; // 跳过 ecall 指令,ecall 指令占 4 字节,返回到下一条用户指令
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers.
intr_on(); // 允许中断响应,避免长时间关闭中断导致设备阻塞
syscall(); // 根据 a7 寄存器的系统调用号调用具体实现
} else if((which_dev = devintr()) != 0){ // 设备中断
// devintr() 逻辑:若为时钟中断(which_dev == 2),标记进程需让出 CPU 。若为其他设备中断(如磁盘 I/O 完成),触发设备驱动处理
// ok
} else { // 异常
// 通过 scause 识别具体原因(如非法指令、缺页异常)
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
// 未处理的异常(如除零错误)导致进程被杀死,防止系统崩溃
p->killed = 1;
}
if(p->killed) // // 检查进程是否被终止
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2) // 定时器中断触发调度
yield();
usertrapret(); // 恢复用户态环境
}
usertrap
的任务是确定陷阱的原因,处理并返回(kernel/trap.c:37)。如上所述,它首先改变stvec
,这样内核中的陷阱将由kernelvec
处理。它保存了sepc
(保存的用户程序计数器),再次保存是因为usertrap
中可能有一个进程切换,可能导致sepc
被覆盖。如果陷阱来自系统调用,syscall
会处理它;如果是设备中断,devintr
会处理;否则它是一个异常,内核会杀死错误进程。系统调用路径在保存的用户程序计数器pc
上加4,因为在系统调用的情况下,RISC-V会留下指向ecall指令的程序指针(返回后需要执行ecall之后的下一条指令)。在退出的过程中,usertrap
检查进程是已经被杀死还是应该让出CPU(如果这个陷阱是计时器中断)。
返回用户空间的第一步是调用usertrapret
(kernel/trap.c:90)。该函数设置RISC-V控制寄存器,为将来来自用户空间的陷阱做准备。这涉及到将stvec
更改为指向uservec
,准备uservec
所依赖的陷阱帧字段,并将sepc
设置为之前保存的用户程序计数器。最后,usertrapret
在用户和内核页表中都映射的蹦床页面上调用userret
;原因是userret
中的汇编代码会切换页表。
usertrapret
函数实现如下:
// return to user space
//
void
usertrapret(void)
{
struct proc *p = myproc();
// we're about to switch the destination of traps from
// kerneltrap() to usertrap(), so turn off interrupts until
// we're back in user space, where usertrap() is correct.
intr_off(); // 关闭中断,在切换陷阱处理入口和关键寄存器时,防止中断嵌套导致状态不一致
// send syscalls, interrupts, and exceptions to trampoline.S
w_stvec(TRAMPOLINE + (uservec - trampoline)); // 设置陷阱处理入口,将 stvec 寄存器指向用户态陷阱入口 uservec,确保下次陷阱时能正确跳转
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
p->trapframe->kernel_satp = r_satp(); // kernel page table
p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap; // 陷阱处理函数
p->trapframe->kernel_hartid = r_tp(); // hartid for cpuid()
// set up the registers that trampoline.S's sret will use
// to get to user space.
// set S Previous Privilege mode to User.
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode
x |= SSTATUS_SPIE; // enable interrupts in user mode
w_sstatus(x);
// set S Exception Program Counter to the saved user pc.
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
uint64 satp = MAKE_SATP(p->pagetable);
// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}
userret
实现如下:
userret:
# userret(TRAPFRAME, pagetable)
# switch from kernel to user.
# usertrapret() calls here.
# a0: TRAPFRAME, in user page table.
# a1: user page table, for satp.
# switch to the user page table.
csrw satp, a1
sfence.vma zero, zero
# put the saved user a0 in sscratch, so we
# can swap it with our a0 (TRAPFRAME) in the last step.
ld t0, 112(a0)
csrw sscratch, t0
# restore all but a0 from TRAPFRAME
ld ra, 40(a0)
ld sp, 48(a0)
ld gp, 56(a0)
ld tp, 64(a0)
ld t0, 72(a0)
ld t1, 80(a0)
ld t2, 88(a0)
ld s0, 96(a0)
ld s1, 104(a0)
ld a1, 120(a0)
ld a2, 128(a0)
ld a3, 136(a0)
ld a4, 144(a0)
ld a5, 152(a0)
ld a6, 160(a0)
ld a7, 168(a0)
ld s2, 176(a0)
ld s3, 184(a0)
ld s4, 192(a0)
ld s5, 200(a0)
ld s6, 208(a0)
ld s7, 216(a0)
ld s8, 224(a0)
ld s9, 232(a0)
ld s10, 240(a0)
ld s11, 248(a0)
ld t3, 256(a0)
ld t4, 264(a0)
ld t5, 272(a0)
ld t6, 280(a0)
# restore user a0, and save TRAPFRAME in sscratch
csrrw a0, sscratch, a0
# return to user mode and user pc.
# usertrapret() set up sstatus and sepc.
sret
usertrapret
对userret
的调用将指针传递到a0
中的进程用户页表和a1
中的TRAPFRAME
(kernel/trampoline.S:88)。userret
将satp
切换到进程的用户页表。回想一下,用户页表同时映射蹦床页面和TRAPFRAME
,但没有从内核映射其他内容。同样,蹦床页面映射在用户和内核页表中的同一个虚拟地址上的事实允许用户在更改satp
后继续执行。userret
复制陷阱帧保存的用户a0
到sscratch
,为以后与TRAPFRAME
的交换做准备。从此刻开始,userret
可以使用的唯一数据是寄存器内容和陷阱帧的内容。下一个userret
从陷阱帧中恢复保存的用户寄存器,做a0
与sscratch
的最后一次交换来恢复用户a0
并为下一个陷阱保存TRAPFRAME
,并使用sret
返回用户空间。
四、案例:调用系统调用
以initcode.S调用exec
系统调用(user/initcode.S:11)为例。让我们看看用户调用是如何在内核中实现exec
系统调用的。
exec
系统调用汇编如下:
# exec(init, argv)
.globl start
start:
la a0, init // la(Load Address)将符号 init 的地址加载到寄存器 a0
la a1, argv // 将符号 argv 的地址加载到寄存器 a1
li a7, SYS_exec // i(Load Immediate)将立即数 SYS_exec 加载到寄存器 a7
ecall // 触发环境调用(Environment Call),从用户态切换到内核态,执行系统调用
用户代码将exec
需要的参数放在寄存器a0
和a1
中,并将系统调用号放在a7
中。系统调用号与syscalls
数组中的条目相匹配,syscalls
数组是一个函数指针表(kernel/syscall.c:108)。ecall
指令陷入(trap)到内核中,执行uservec
、usertrap
和syscall
,和我们之前看到的一样。
syscalls
数组实现如下,exec
的系统调用号为7,会执行对应的处理函数sys_exec()
。
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};
// 上述数组中对应的值定义在syscall.h中,定义如下:
// System call numbers
#define SYS_fork 1
#define SYS_exit 2
#define SYS_wait 3
#define SYS_pipe 4
#define SYS_read 5
#define SYS_kill 6
#define SYS_exec 7
#define SYS_fstat 8
#define SYS_chdir 9
#define SYS_dup 10
#define SYS_getpid 11
#define SYS_sbrk 12
#define SYS_sleep 13
#define SYS_uptime 14
#define SYS_open 15
#define SYS_write 16
#define SYS_mknod 17
#define SYS_unlink 18
#define SYS_link 19
#define SYS_mkdir 20
#define SYS_close 21
syscall
(kernel/syscall.c:133)从陷阱帧(trapframe)中保存的a7
中检索系统调用号(p->trapframe->a7),并用它索引到syscalls
中,对于第一次系统调用,a7
中的内容是SYS_exec
(kernel/syscall. h:8),导致了对系统调用接口函数sys_exec
的调用。
syscall函数实现如下:
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
当系统调用接口函数返回时,syscall
将其返回值记录在p->trapframe->a0
中。这将导致原始用户空间对exec()
的调用返回该值,因为RISC-V上的C调用约定将返回值放在a0
中。系统调用通常返回负数表示错误,返回零或正数表示成功。如果系统调用号无效,syscall
打印错误并返回-1。
五、从内核空间陷入
xv6根据执行的是用户代码还是内核代码,对CPU陷阱寄存器的配置有所不同。当在CPU上执行内核时,内核将stvec
指向kernelvec
(kernel/kernelvec.S:10)的汇编代码。由于xv6已经在内核中,kernelvec
可以依赖于设置为内核页表的satp
,以及指向有效内核栈的栈指针。kernelvec
保存所有寄存器,以便被中断的代码最终可以不受干扰地恢复。
kernelvec
内容如下:
kernelvec:
// make room to save registers.
addi sp, sp, -256
// save the registers.
sd ra, 0(sp)
sd sp, 8(sp)
sd gp, 16(sp)
sd tp, 24(sp)
sd t0, 32(sp)
sd t1, 40(sp)
sd t2, 48(sp)
sd s0, 56(sp)
sd s1, 64(sp)
sd a0, 72(sp)
sd a1, 80(sp)
sd a2, 88(sp)
sd a3, 96(sp)
sd a4, 104(sp)
sd a5, 112(sp)
sd a6, 120(sp)
sd a7, 128(sp)
sd s2, 136(sp)
sd s3, 144(sp)
sd s4, 152(sp)
sd s5, 160(sp)
sd s6, 168(sp)
sd s7, 176(sp)
sd s8, 184(sp)
sd s9, 192(sp)
sd s10, 200(sp)
sd s11, 208(sp)
sd t3, 216(sp)
sd t4, 224(sp)
sd t5, 232(sp)
sd t6, 240(sp)
// call the C trap handler in trap.c
call kerneltrap
// restore registers.
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
// not this, in case we moved CPUs: ld tp, 24(sp)
ld t0, 32(sp)
ld t1, 40(sp)
ld t2, 48(sp)
ld s0, 56(sp)
ld s1, 64(sp)
ld a0, 72(sp)
ld a1, 80(sp)
ld a2, 88(sp)
ld a3, 96(sp)
ld a4, 104(sp)
ld a5, 112(sp)
ld a6, 120(sp)
ld a7, 128(sp)
ld s2, 136(sp)
ld s3, 144(sp)
ld s4, 152(sp)
ld s5, 160(sp)
ld s6, 168(sp)
ld s7, 176(sp)
ld s8, 184(sp)
ld s9, 192(sp)
ld s10, 200(sp)
ld s11, 208(sp)
ld t3, 216(sp)
ld t4, 224(sp)
ld t5, 232(sp)
ld t6, 240(sp)
addi sp, sp, 256
// return to whatever we were doing in the kernel.
sret
kernelvec
将寄存器保存在被中断的内核线程的栈上,这是有意义的,因为寄存器值属于该线程。如果陷阱导致切换到不同的线程,那这一点就显得尤为重要——在这种情况下,陷阱将实际返回到新线程的栈上,将被中断线程保存的寄存器安全地保存在其栈上。
kernelvec
在保存寄存器后跳转到kerneltrap
(kernel/trap.c:134)。kerneltrap
为两种类型的陷阱做好了准备:设备中断和异常。它调用devintr
(kernel/trap.c:177)来检查和处理前者。如果陷阱不是设备中断,则必定是一个异常,内核中的异常将是一个致命的错误;内核调用panic
并停止执行。
kerneltrap
函数实现如下:
// interrupts and exceptions from kernel code go here via kernelvec,
// on whatever the current kernel stack is.
void
kerneltrap()
{
int which_dev = 0;
uint64 sepc = r_sepc(); // 保存陷阱发生时的程序计数器
uint64 sstatus = r_sstatus(); // 保存陷阱前的状态寄存器
uint64 scause = r_scause(); // 保存陷阱原因
// SSTATUS_SPP 位为 0 表示陷阱来自用户态,但 kerneltrap 应仅处理内核态陷阱,因此触发 panic
if((sstatus & SSTATUS_SPP) == 0)
panic("kerneltrap: not from supervisor mode");
// 内核陷阱处理期间必须关闭中断(intr_get() == 0),防止嵌套陷阱破坏关键状态
if(intr_get() != 0)
panic("kerneltrap: interrupts enabled");
// devintr() 函数:识别中断来源
// 定时器中断:返回 2(which_dev == 2)
// 磁盘/控制台中断:返回 1
// 未知中断:返回 0,触发 panic
if((which_dev = devintr()) == 0){
printf("scause %p\n", scause);
printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
panic("kerneltrap");
}
// give up the CPU if this is a timer interrupt.
if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
yield(); // 主动让出 CPU,触发调度器选择新进程
// the yield() may have caused some traps to occur,
// so restore trap registers for use by kernelvec.S's sepc instruction.
w_sepc(sepc);
w_sstatus(sstatus);
}
如果由于计时器中断而调用了kerneltrap
,并且一个进程的内核线程正在运行(而不是调度程序线程),kerneltrap
会调用yield
,给其他线程一个运行的机会。在某个时刻,其中一个线程会让步,让我们的线程和它的kerneltrap
再次恢复。
当kerneltrap
的工作完成后,它需要返回到任何被陷阱中断的代码。因为一个yield
可能已经破坏了保存的sepc
和在sstatus
中保存的前一个状态模式,因此kerneltrap
在启动时保存它们。它现在恢复这些控制寄存器并返回到kernelvec
(kernel/kernelvec.S:48)。kernelvec
从栈中弹出保存的寄存器并执行sret
,将sepc
复制到pc
并恢复中断的内核代码。
当CPU从用户空间进入内核时,xv6将CPU的stvec
设置为kernelvec
;您可以在usertrap
(kernel/trap.c:29)中看到这一点。内核执行时有一个时间窗口,但stvec
设置为uservec
,在该窗口中禁用设备中断至关重要。幸运的是,RISC-V总是在开始设置陷阱时禁用中断,xv6在设置stvec
之前不会再次启用中断。
六、页面错误异常
Xv6对异常的响应相当无趣: 如果用户空间中发生异常,内核将终止故障进程。如果内核中发生异常,则内核会崩溃。真正的操作系统通常以更有趣的方式做出反应。
例如,许多内核使用页面错误来实现写时拷贝版本的fork
——copy on write (COW) fork。要解释COW fork,可以查看页表那一篇博客:xv6的fork
通过调用uvmcopy
(kernel/vm.c:309) 为子级分配物理内存,并将父级的内存复制到其中,使子级具有与父级相同的内存内容。如果父子进程可以共享父级的物理内存,则效率会更高。然而武断地实现这种方法是行不通的,因为它会导致父级和子级通过对共享栈和堆的写入来中断彼此的执行。
由页面错误驱动的COW fork
可以使父级和子级安全地共享物理内存。当CPU无法将虚拟地址转换为物理地址时,CPU会生成页面错误异常。Risc-v有三种不同的页面错误: 加载页面错误 (当加载指令无法转换其虚拟地址时),存储页面错误 (当存储指令无法转换其虚拟地址时) 和指令页面错误 (当指令的地址无法转换时)。scause
寄存器中的值指示页面错误的类型,stval
寄存器包含无法翻译的地址。
COW fork
中的基本计划是让父子最初共享所有物理页面,但将它们映射为只读。因此,当子级或父级执行存储指令时,risc-v CPU引发页面错误异常。为了响应此异常,内核复制了包含错误地址的页面。它在子级的地址空间中映射一个权限为读/写的副本,在父级的地址空间中映射另一个权限为读/写的副本。更新页表后,内核会在导致故障的指令处恢复故障进程的执行。由于内核已经更新了相关的PTE
以允许写入,所以错误指令现在将正确执行。
COW
策略对fork
很有效,因为通常子进程会在fork
之后立即调用exec,用新的地址空间替换其地址空间。在这种常见情况下,子级只会触发很少的页面错误,内核可以避免拷贝父进程内存完整的副本。此外,COW fork
是透明的: 无需对应用程序进行任何修改即可使其受益。
除COW fork
以外,页表和页面错误的结合还开发出了广泛有趣的可能性。另一个广泛使用的特性叫做惰性分配——lazy allocation。它包括两部分内容:首先,当应用程序调用sbrk
时,内核增加地址空间,但在页表中将新地址标记为无效。其次,对于包含于其中的地址的页面错误,内核分配物理内存并将其映射到页表中。由于应用程序通常要求比他们需要的更多的内存,惰性分配可以称得上一次胜利: 内核仅在应用程序实际使用它时才分配内存。像COW fork
一样,内核可以对应用程序透明地实现此功能。
利用页面故障的另一个广泛使用的功能是从磁盘分页。如果应用程序需要比可用物理RAM更多的内存,内核可以换出一些页面: 将它们写入存储设备 (如磁盘),并将它们的PTE
标记为无效。如果应用程序读取或写入被换出的页面,则CPU将触发页面错误。然后内核可以检查故障地址。如果该地址属于磁盘上的页面,则内核分配物理内存页面,将该页面从磁盘读取到该内存,将PTE
更新为有效并引用该内存,然后恢复应用程序。为了给页面腾出空间,内核可能需要换出另一个页面。此功能不需要对应用程序进行更改,并且如果应用程序具有引用的地址 (即,它们在任何给定时间仅使用其内存的子集),则该功能可以很好地工作。
结合分页和页面错误异常的其他功能包括自动扩展栈空间和内存映射文件。
七、总结
本篇文章阐述了陷阱的含义,介绍了RISC-V的陷入机制,结合xv6操作系统的源码,完整的介绍了在用户空间发生陷阱时操作系统如何进行处理以及在内核空间发生陷阱时如何处理,同时结合系统调用和页面错误两个案例分别从实际应用角度对两种陷入机制进行具体的介绍。