XV6 的中断是怎么做的?
复习一下 XV6 中断的执行流程
今天来好好研究研究 xv6 的中断是怎样的流程。之前在 MIT 6.S081 的实验中做过相关的实验,隔了一段时间之后,忘的差不多了。昨天面试的时候面试官抓着中断这块狠狠问,我记不清 xv6 里面的“中断向量表”是怎么实现的,只是知道这个和系统调用那个表不一样,所以今天来整理复习一下。代码就直接看原仓库了
回顾——中断是什么
先把中断相关的概念理清楚,这里仅给出简要的版本,详细内容可以自行搜索。中断是计算机在运行时,当出现紧急情况/特定事件时,暂停当前执行程序,转而去执行处理该事件的程序,处理完成后再返回
常见的中断源有两种:
- 硬件中断源:外部设备(键盘、鼠标、打印机),定时器,硬件故障(电源故障、内存错误)
- 软件中断源:程序特定指令或执行异常,如系统调用、除数为 0,非法指令等
XV6 的中断流程
uservec
XV6 是基于 riscv 的操作系统,所以首先需要了解 riscv 是如何处理中断的。riscv 架构有 stvec
和 scause
这两个相关寄存器,在发生中断时,处理器会将中断号存在 scause
中,并跳到 stvec
所在的位置执行相应的处理程序
中断号和异常号
异常通常是由程序执行过程中的错误或特殊指令触发的,scause
寄存器最高位为 0;中断是由外部设备或者定时器等触发的,scause
寄存器最高位为 1
- 常见异常号
异常号 | 名称 | 描述 |
---|---|---|
0 | 指令地址对齐错误 Instruction address misaligned |
当取指地址未按要求对齐时触发 |
1 | 指令访问故障 Instruction access fault |
取指时发生访问错误,比如访问了无效的内存地址 |
2 | 非法指令 Illegal instruction |
执行了不合法的指令 |
3 | 断点 Breakpoint |
执行到断点指令时触发 |
4 | 加载地址对齐错误 Load address misaligned |
加载数据时地址未按要求对齐 |
5 | 加载访问故障 Load access fault |
加载数据时发生访问错误 |
6 | 存储 / 条件式内存访问地址对齐错误 Store/AMO address misaligned |
存储数据或执行原子内存操作时地址未按要求对齐 |
7 | 存储 / 条件式内存访问访问故障 Store/AMO access fault |
存储数据或执行原子内存操作时发生访问错误 |
8 | 环境调用从用户模式 Environment call from U-mode |
用户模式下执行 ecall 指令触发 |
9 | 环境调用从监管者模式 Environment call from S-mode |
监管者模式下执行 ecall 指令触发 |
11 | 环境调用从机器模式 Environment call from M-mode |
机器模式下执行 ecall 指令触发 |
12 | 指令页故障 Instruction page fault |
取指时发生页故障 |
13 | 加载页故障 Load page fault |
加载数据时发生页故障 |
15 | 存储 / 条件式内存访问页故障 Store/AMO page fault |
存储数据或执行原子内存操作时发生页故障 |
- 常见中断号
中断号 | 描述 |
---|---|
0x8000000000000001L | 用户模式下的软件中断 |
0x8000000000000005L | 监督者模式下的定时器中断 |
0x8000000000000009L | 监督者模式下的外部中断,通常由外设通过 PLIC 触发 |
如何跳转到 uservec
riscv 中并没有一个实际的“中断向量表”,而是在中断发生时直接跳转到 stvec
所在的地址执行命令,并将中断原因存在 scause
中,下面是设置 stvec
的代码:
1
2
3
4
// kernel/trap.c:99-101 usertrapret()
// send syscalls, interrupts, and exceptions to uservec in trampoline.S
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);
这里会有一个问题,
usertrapret
这个函数是在中断返回时调用的,那操作系统启动时第一次调用它是在什么时候?这个问题暂且不表,之后会解释
所以说一旦发生中断,XV6 会直接跳转到 kernel/trampoline.S
中的 uservec
这段汇编代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
...
# 声明 uservec 为全局符号,方便其他文件引用
.globl uservec
# uservec 标签,是实际处理陷阱的入口点
uservec:
#
# trap.c 文件会将 stvec 寄存器设置为指向此处,
# 所以当用户空间发生陷阱时,会从这里开始执行,
# 此时处于监管模式,但使用的是用户页表。
#
# 将用户空间的 a0 寄存器的值保存到 sscratch 寄存器中,
# 这样 a0 寄存器就可以用来访问陷阱帧(TRAPFRAME)。
csrw sscratch, a0
# 每个进程都有独立的 p->trapframe 内存区域,
# 但在每个进程的用户页表中,它都被映射到相同的虚拟地址(TRAPFRAME)。
# 将 TRAPFRAME 的地址加载到 a0 寄存器
li a0, TRAPFRAME
# 把用户空间的寄存器保存到陷阱帧(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)
# 把用户空间的 a0 寄存器的值保存到 p->trapframe->a0 位置
# 从 sscratch 寄存器读取之前保存的用户 a0 的值到 t0 寄存器
csrr t0, sscratch
# 将 t0(即用户 a0 的值)保存到陷阱帧偏移 112 处
sd t0, 112(a0)
# 从 p->trapframe->kernel_sp 位置加载内核栈指针并初始化 sp 寄存器
# 从陷阱帧偏移 8 处加载内核栈指针的值到 sp 寄存器
ld sp, 8(a0)
# 让 tp 寄存器保存当前硬件线程 ID(hartid),从 p->trapframe->kernel_hartid 获取
# 从陷阱帧偏移 32 处加载当前硬件线程 ID 的值到 tp 寄存器
ld tp, 32(a0)
# 从 p->trapframe->kernel_trap 位置加载 usertrap() 函数的地址到 t0 寄存器
ld t0, 16(a0)
# 从 p->trapframe->kernel_satp 位置获取内核页表的地址到 t1 寄存器
ld t1, 0(a0)
# 等待之前的内存操作完成,确保它们使用的是用户页表
sfence.vma zero, zero
# 安装内核页表,将内核页表地址写入 satp 寄存器
csrw satp, t1
# 刷新 TLB 中过时的用户页表项
sfence.vma zero, zero
# 跳转到 usertrap() 函数开始执行陷阱处理代码,此函数不会返回
jr t0
简单来说,这段汇编的作用就是在用户空间发生陷阱时,保存用户上下文,切换到内核页表,然后跳转到 usertrap()
函数进行陷阱处理。这里要注意的是:
-
sscratch
寄存器将a0
的值先暂存起来,方便后面加载TRAPFRAME
的地址,最后a0
的值也会放回原位 - 系统调用号是存在
proc->trapframe->a7
这个位置的 - 页表切换时需要使用
sfence.vma zero, zero
刷新 TLB - 这个
satp
存的是页表基地址
usertrap
代码写的比较清晰,直接看代码吧
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0)
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel.
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter.
p->trapframe->epc = r_sepc();
if(r_scause() == 8){
// system call
if(killed(p))
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction.
p->trapframe->epc += 4;
// an interrupt will change sepc, scause, and sstatus,
// so enable only now that we're done with those registers.
intr_on();
syscall();
} else if((which_dev = devintr()) != 0){
// ok
} else {
printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
printf(" sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval());
setkilled(p);
}
if(killed(p))
exit(-1);
// give up the CPU if this is a timer interrupt.
if(which_dev == 2)
yield();
usertrapret();
}
做了这么几件事:
- 在内核时也可能会发生中断,所以将寄存器
stvec
的值设为kernaltrap
- 保存用户
pc
的值 - 如果
scause
的值是 8,执行系统调用(并且要打开中断) - 如果
scause
是其它值,执行devintr()
(这个函数还是通过scause
寄存器判断是 PILC、时钟中断还是其它) - 如果是时钟中断,
yield
出去 - 调用
usertrapret
返回
简单来说,usertrap
这一步就是检查一下 scause
这个值,如果是 8 就走系统调用,如果是其它值,就走别的处理函数。这里其实也能发现,如果想添加对缺页中断的处理,只需要判断下 r_scause() == 13
就行
系统调用的 scause
为 8(用户主动调用中断),此时会进入 syscall()
函数。在这个函数中,有一个系统调用表,执行的函数是 a7
寄存器对应位置的函数,函数的参数保存在 a0
~ a5
这几个寄存器中。这部分反倒比较简单;当所有中断程序处理完成后,程序会调用 usertrapret
从内核态返回到用户态
usertrapret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//
// 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 uservec in trampoline.S
uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
w_stvec(trampoline_uservec);
// set up trapframe values that uservec will need when
// the process next traps into 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 userret in 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 trampoline_userret = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64))trampoline_userret)(satp);
}
这部分主要做了如下工作:
- 关闭中断:避免切换过程中被中断干扰,确保操作原子性。
- 设置陷阱向量:将陷阱处理入口指向 trampoline.S 中的 uservec,用于后续用户态陷阱处理。
- 配置陷阱帧(trapframe)
- 内核页表:保存内核页表地址(kernel_satp)。
- 内核栈:指向进程内核栈顶部(kernel_sp)。
- 陷阱处理函数:指定 usertrap 为下次内核态陷阱入口。
- hart ID:记录当前 CPU 核心 ID(kernel_hartid)。
- 设置特权寄存器
- sstatus:
- 清除 SPP 标志(特权模式为用户态)。
- 启用用户态中断(SPIE 置位)。
- sepc:恢复用户程序计数器(epc),指定返回用户态后的执行起点。
- sstatus:
- 切换页表:计算用户页表的 satp 值,传递给 trampoline.S 的 userret 函数。
- 跳转至用户态
- 调用 userret 函数,完成:
- 页表切换(内核 → 用户)。
- 恢复用户寄存器。
- 通过 sret 指令进入用户态并开启中断。
- 调用 userret 函数,完成:
userret
这个函数就是将 TRAPFRAME
上保留的寄存器的值全部保存到 CPU 中,继续执行原先的用户线程
如果在中断时又来一个中断怎么办
如果在执行系统调用时又发生了时钟中断等其他事情,这时候已经在用户态了,该怎么办?XV6 当然考虑了这种情况。前面在 usertrap
函数开始时就将 stvec
设置为了 kernelvec
,如果发生中断,就跳到 kernelvec
对应的汇编代码。这一段汇编相对来说更加简单,代码不给了,简单说一下:
- 将
sp
减 256,留够寄存器的空间 - 将当前的寄存器的值存到栈上
- 调用
kerneltrap
- 将步骤 2 保存的值 restore
-
sp
加 256,将预留的空间回退
在 kerneltrap
函数中只检查了两种事情:硬件中断以及时钟中断
第一次中断怎么设置的
如果认真研究了代码,会发现 stvec
只会在调用 usertrapret
的时候进行设置,那问题来了:第一次调用 usertrapret
是在哪里呢?
经过简单的搜索,发现这个函数是在 forkret
函数调用的,而这个函数是在 allocproc
函数中设置的。所以说,其实每个进程的 stvec
都是在创建进程的第一时间执行的,这样就确保了该进程在执行过程中可以正确处理中断。
那第一个进程是在哪里执行的呢?是 userinit()
。让我们看看操作系统的 main
函数吧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
...
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
...
}
scheduler();
}
在 main
函数的 userinit
中创建了第一个用户进程,这其实是一个祖宗进程,其他所有的进程都是从这里 fork
得到的。第一个进程的含金量也挺高的,可以大概看看它干了些什么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int
main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
for(;;){
// this call to wait() returns if the shell exits,
// or if a parentless process exits.
wpid = wait((int *) 0);
if(wpid == pid){
// the shell exited; restart it.
break;
} else if(wpid < 0){
printf("init: wait returned an error\n");
exit(1);
} else {
// it was a parentless process; do nothing.
}
}
}
}
其实就是先创建标准输入输出,然后 fork
一个进程调用 shell,最后死循环
时钟中断和进程调度
话说回来,操作系统的 main
函数中,调用完 usertrap
,就会进入 scheduler
函数调度这些进程,这是一个及其简单切简陋的死循环时间轮调度,因为所有的中断都是在 userinit
创建的进程中处理的,scheduler
函数不会收到任何影响,怎么理解呢?看下 scheduler
的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
// The most recent process to run may have had interrupts
// turned off; enable them to avoid a deadlock if all
// processes are waiting.
intr_on();
int found = 0;
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->state == RUNNABLE) {
...
swtch(&c->context, &p->context);
...
}
release(&p->lock);
}
...
}
}
通过 swtch
执行对应的进程。如果发生时钟中断,就会执行 usertrap
中的 yield
函数,yield
函数调用后会直接跳到 scheduler
的 swtch
的下一行继续执行
总结
XV6 通过 uservec
→ usertrap
→ usertrapret
的闭环实现用户态与内核态的中断处理,结合 RISC-V 特权指令(如 sret
、csrw
)和页表切换,确保中断响应的高效性与安全性。其设计核心在于 寄存器上下文管理 和 页表动态切换,为多任务并发提供了底层支持
对于时钟中断,还得看一下 scheduler
函数中 swtch
和 yield
这两个函数的配合