中断处理¶
中断处理是操作系统内核最核心的机制之一。理解中断处理,是理解内核如何与硬件交互、如何实现并发、如何进行进程调度的关键。
为什么需要中断?¶
在深入实现细节之前,先理解中断存在的意义。
轮询 vs 中断¶
假设没有中断机制,内核想要知道定时器到期或磁盘数据就绪,只能不断轮询(polling):
while (true) {
if (timer_expired()) { handle_timer(); }
if (disk_ready()) { read_disk(); }
if (keyboard_input()) { handle_keyboard(); }
}
这种方式的问题: 1. 浪费 CPU:大部分时间在做无用检查 2. 响应延迟:检查完磁盘后,定时器可能已经过期很久 3. 无法真正并发:CPU 无法执行其他任务
中断机制让硬件主动通知 CPU:"我有事要处理",CPU 可以在做其他事情时等待通知。
RISC-V 的中断模型¶
RISC-V 将中断分为三类:
| 类型 | 来源 | 用途 |
|---|---|---|
| 软件中断 | 软件触发(ecall 或 IPI) |
系统调用、核间通信 |
| 定时器中断 | 定时器硬件 | 驱动调度、时间统计 |
| 外部中断 | 外设(磁盘、网卡等) | 设备 I/O 完成 |
每个 CPU 核有三种特权模式:M-Mode(机器态)、S-Mode(内核态)、U-Mode(用户态)。OpenSBI 运行在 M-Mode,内核运行在 S-Mode,用户程序运行在 U-Mode。
关键点:OpenSBI(M-Mode)会将中断委托给内核(S-Mode)处理,这就是为什么内核能够直接处理中断。
中断处理的核心挑战¶
当中断发生时,CPU 正在执行某段代码,可能持有重要状态。中断处理必须解决几个核心问题:
1. 如何保存和恢复现场?¶
中断是异步发生的,处理完中断后必须能精确恢复到中断前的状态。这需要: - 保存所有寄存器 - 保存 CSR(控制和状态寄存器) - 保持栈的一致性
2. 如何在处理中断时支持进程切换?¶
中断处理函数可能调用调度器,切换到另一个进程。这意味着: - 返回时可能不是原来的进程 - 需要一种机制让新进程正确恢复
3. 如何处理中断嵌套?¶
在处理中断时可能又发生中断。如果不控制,栈可能溢出,或破坏数据一致性。
gp 寄存器的作用:快速定位 trapframe¶
这是 NoobKernel 中断处理设计最关键的一点。
问题:中断发生时如何找到保存寄存器的地方?¶
每个进程有自己的内核栈和 trapframe。中断发生时,我们需要快速定位当前进程的 trapframe,把寄存器保存进去。
方案对比¶
方案一:通过当前栈指针计算
假设每个进程的 trapframe 在其内核栈底部:
栈指针 sp 指向当前位置
sp 往下找 → 可以找到 trapframe?
问题: - 栈指针在函数调用时会移动,难以精确定位 - 中断发生时不知道当前位置 - 进程切换后,栈指针变了
方案二:使用专用寄存器
让某个寄存器始终指向当前进程的 trapframe。RISC-V 有 32 个通用寄存器,哪个可以被征用?
sp(栈指针):不能动,中断处理需要栈tp(线程指针):已用于存储 CPU IDgp(全局指针):传统上用于全局数据访问,但在 NoobKernel 中被重新定义
最终设计:gp 指向 ktrapframe¶
NoobKernel 约定:
- gp 寄存器始终指向当前进程的 ktrapframe
- 每个进程的 ktrapframe 是其 proc 结构的一部分
- 进程切换时更新 gp
这样设计的好处:
-
中断入口极简:中断发生时,直接用
gp保存寄存器,无需任何计算sd ra, 24(gp) # 直接保存到 trapframe sd sp, 32(gp) ... -
支持进程切换:
kerneltrap调用sched_yield后,gp被更新为新进程的 trapframe。返回时自然恢复新进程的状态。 -
汇编代码简洁:不需要复杂的地址计算
为什么不用函数调用保存?¶
读者可能会问:为什么不直接用函数调用,让编译器自动保存 callee-saved 寄存器?
kernelvec:
call kerneltrap # 让编译器处理
sret
问题在于:
1. 编译器不知道要保存哪些 CSR(sepc, sstatus, scause)
2. 函数调用会修改栈,但中断入口时栈的状态很关键
3. 进程切换需要改变返回地址,函数调用机制不支持
所以必须用手写汇编精确控制。
中断处理的完整流程¶
理解了核心设计后,看完整流程:
内核态中断处理¶
内核代码执行中
│
▼ 中断信号到达
│ 硬件动作:
│ 1. PC → sepc(保存返回地址)
│ 2. 权限位 → sstatus.spp
│ 3. 跳转到 stvec(kernelvec)
▼
kernelvec(汇编)
│ 保存寄存器到 ktrapframe(用 gp 快速定位)
│ 调用 kerneltrap(C 函数)
▼
kerneltrap(C)
│ 根据 scause 判断中断类型
│ 处理定时器/外部/软件中断
│ 可能触发调度
▼
kernelret(汇编)
│ 恢复寄存器(gp 可能已变,指向新进程)
│ 执行 sret 返回
▼
继续执行(可能是另一个进程)
用户态中断处理(概念)¶
用户态中断更复杂,因为还需要切换页表。核心思想:
- 用户态中断发生时:硬件跳转到
stvec,此时用的是用户页表 - trampoline 页:一段特殊的代码,同时映射在用户和内核地址空间
- 保存现场:在 trampoline 中保存用户寄存器到 trapframe
- 切换页表:从用户页表切换到内核页表
- 进入内核:跳转到内核态处理函数
为什么需要 trampoline?因为切换页表后,原来的代码位置可能不再有效。trampoline 在两个地址空间都有映射,保证切换时能继续执行。
定时器中断:调度的驱动力¶
定时器中断是内核实现"伪并发"的关键。
为什么需要定时器中断?¶
如果没有定时器中断,一个进程可能永远不放弃 CPU(死循环或不调用 sched_yield)。定时器中断让内核有机会定期介入,强制调度。
时间片轮转¶
时间线:
├──── 10ms ────┼──── 10ms ────┼──── 10ms ────┤
进程 A 进程 B 进程 A
↓ ↓ ↓
定时器中断 定时器中断 定时器中断
↓ ↓ ↓
触发调度 触发调度 触发调度
每 10ms(TIMER_IRQ_HZ = 100),定时器硬件触发一次中断。中断处理函数设置 need_resched 标志,后续会调用调度器。
为什么不在中断中直接调度?¶
理论上可以在定时器中断中直接调用 sched_yield,但 NoobKernel 选择设置标志:
void handle_timer(void) {
thiscpu()->need_resched = true; // 只设标志
// 设置下一次定时器
}
原因: 1. 灵活性:可能在其他地方也需要调度,统一处理 2. 中断上下文限制:某些情况下不适合在中断上下文做复杂操作 3. 减少中断延迟:快速退出中断,让其他中断有机会响应
外部中断:设备驱动的桥梁¶
外部中断来自 PLIC(Platform Level Interrupt Controller),是设备与内核通信的主要方式。
中断号到设备¶
PLIC 为每个中断源分配一个编号: - IRQ 1:VirtIO 块设备 - IRQ 2-8:其他 VirtIO 设备 - IRQ 10:UART
中断处理的"claim-complete"协议¶
PLIC 使用特殊的中断确认机制:
- Claim:读取 PLIC 寄存器,获取当前最高优先级的中断号
- 处理:调用对应设备的中断处理函数
- Complete:写入中断号,告知 PLIC 处理完成
为什么要这样做?因为 PLIC 需要知道中断何时处理完毕,才能重新发送同一中断。如果忘记 Complete,该中断源会被"卡住"。
中断嵌套控制¶
为什么需要嵌套控制?¶
考虑以下场景:
1. 函数 A 获取锁 L,自动关中断
2. 函数 A 调用函数 B
3. 函数 B 也获取锁 L(错误,但我们要防止灾难)
4. 函数 B 释放锁 L,自动开中断
5. 此时还在函数 A 中,但中断已开!
如果简单的"获取锁关中断,释放锁开中断",嵌套调用会破坏中断状态。
解决方案:中断深度计数¶
struct cpu {
u64 intr_state; // 保存原始中断状态
int intr_depth; // 嵌套深度
};
void intr_off(void) {
if (intr_depth == 0) {
intr_state = r_sstatus(); // 第一次才保存
}
intr_depth++;
// 关中断
}
void intr_on(void) {
intr_depth--;
if (intr_depth == 0) {
// 最外层才真正恢复
w_sstatus(intr_state);
}
}
只有最外层(intr_depth 从 1 变为 0)才真正恢复中断状态。
自旋锁为什么需要关中断?¶
这是一个经典的并发问题。
场景:死锁风险¶
CPU 0:
1. 获取锁 L(假设不关中断)
2. [中断发生]
→ 中断处理函数也要获取锁 L
→ 永远等待(死锁)
解决方案¶
获取自旋锁时自动关中断,保证在持锁期间不会被中断打断:
acquire(lock):
intr_off() // 关中断
while (locked) // 等待锁
locked = 1
release(lock):
locked = 0
intr_on() // 开中断
这样,中断处理函数要么在获取锁之前发生,要么在释放锁之后发生,永远不会在持锁期间发生。
中断与进程状态¶
中断可能触发进程切换¶
当中断处理函数调用 sched_yield 时,会切换到另一个进程。这发生在 kerneltrap 返回之前,所以返回时恢复的是新进程的状态。
为什么这是安全的?¶
因为所有进程的状态都已经保存在各自的 ktrapframe 中。切换进程只是改变 gp 寄存器指向的 trapframe,返回时会自动恢复新进程的状态。
总结¶
NoobKernel 的中断处理设计围绕几个核心思想:
- gp 寄存器专用化:快速定位 trapframe,简化汇编代码,支持进程切换
- 中断嵌套控制:通过深度计数正确处理嵌套关中断
- 锁与中断配合:自旋锁自动关中断,避免死锁
- 标志延迟调度:定时器中断设置标志,统一触发调度
这些设计相互配合,构成了一个完整的中断处理框架。
思考题¶
- 如果用
tp寄存器存储 trapframe 指针而不是 CPU ID,会有什么问题? - 为什么不在中断处理函数中直接切换进程,而是先返回再通过
kernelret恢复? - 如果忘记调用
plic_complete,会发生什么? - 中断嵌套控制能否用递归锁替代?为什么?