Skip to content

中断处理

中断处理是操作系统内核最核心的机制之一。理解中断处理,是理解内核如何与硬件交互、如何实现并发、如何进行进程调度的关键。

为什么需要中断?

在深入实现细节之前,先理解中断存在的意义。

轮询 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 ID
  • gp(全局指针):传统上用于全局数据访问,但在 NoobKernel 中被重新定义

最终设计:gp 指向 ktrapframe

NoobKernel 约定: - gp 寄存器始终指向当前进程的 ktrapframe - 每个进程的 ktrapframe 是其 proc 结构的一部分 - 进程切换时更新 gp

这样设计的好处:

  1. 中断入口极简:中断发生时,直接用 gp 保存寄存器,无需任何计算

    sd ra, 24(gp)   # 直接保存到 trapframe
    sd sp, 32(gp)
    ...
    

  2. 支持进程切换kerneltrap 调用 sched_yield 后,gp 被更新为新进程的 trapframe。返回时自然恢复新进程的状态。

  3. 汇编代码简洁:不需要复杂的地址计算

为什么不用函数调用保存?

读者可能会问:为什么不直接用函数调用,让编译器自动保存 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 返回
      ▼
继续执行(可能是另一个进程)

用户态中断处理(概念)

用户态中断更复杂,因为还需要切换页表。核心思想:

  1. 用户态中断发生时:硬件跳转到 stvec,此时用的是用户页表
  2. trampoline 页:一段特殊的代码,同时映射在用户和内核地址空间
  3. 保存现场:在 trampoline 中保存用户寄存器到 trapframe
  4. 切换页表:从用户页表切换到内核页表
  5. 进入内核:跳转到内核态处理函数

为什么需要 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 使用特殊的中断确认机制:

  1. Claim:读取 PLIC 寄存器,获取当前最高优先级的中断号
  2. 处理:调用对应设备的中断处理函数
  3. 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 的中断处理设计围绕几个核心思想:

  1. gp 寄存器专用化:快速定位 trapframe,简化汇编代码,支持进程切换
  2. 中断嵌套控制:通过深度计数正确处理嵌套关中断
  3. 锁与中断配合:自旋锁自动关中断,避免死锁
  4. 标志延迟调度:定时器中断设置标志,统一触发调度

这些设计相互配合,构成了一个完整的中断处理框架。

思考题

  1. 如果用 tp 寄存器存储 trapframe 指针而不是 CPU ID,会有什么问题?
  2. 为什么不在中断处理函数中直接切换进程,而是先返回再通过 kernelret 恢复?
  3. 如果忘记调用 plic_complete,会发生什么?
  4. 中断嵌套控制能否用递归锁替代?为什么?

下一步