4. 中断与锁:中断禁用与启用(intr_on/intr_off)、中断处理程序中的锁使用规则、锁与中断的交互

在 xv6 内核中,中断与锁的交互是导致死锁的最常见根源之一。中断处理程序(Interrupt Handler)可以随时抢占正在运行的进程或内核线程,如果此时被抢占的代码正持有某个锁,而中断处理程序又试图获取同一个锁,就会产生 单 CPU 自死锁(Single-CPU Self-Deadlock)。本章深入分析中断禁用/启用机制、中断处理程序中的锁使用规则,以及锁与中断的交互模型。

核心原则: 在 xv6 中,任何被中断处理程序可能访问的锁,都必须在获取该锁之前禁用中断;否则,同一 CPU 上会发生递归锁获取导致的死锁。

4.1 中断禁用与启用:intr_on / intr_off

xv6 在 RISC-V 架构上通过 intr_off() 和 intr_on() 两个内联函数控制当前 CPU 的中断使能状态。它们直接操作 sstatus 寄存器的 SIE(Supervisor Interrupt Enable)位。

函数 操作 效果
intr_off() 清除 sstatus.SIE 位 禁用当前 CPU 的所有中断(时钟、设备、软件中断)
intr_on() 设置 sstatus.SIE 位 启用当前 CPU 的所有中断
intr_get() 读取 sstatus.SIE 位 返回当前中断使能状态(0=禁用,1=启用)
实现细节: xv6 的 push_off() 和 pop_off() 是中断禁用/启用的嵌套封装。它们维护一个每 CPU 的计数器 mycpu()->noff,确保中断在嵌套锁释放完毕后才被重新启用,避免过早启用中断导致锁状态不一致。

下面的 SVG 展示了中断禁用与启用对锁获取流程的影响:

中断禁用与锁获取流程 未禁用中断(死锁风险) 持有锁 L 中断发生 尝试获取锁 L 死锁 禁用中断(安全) intr_off() 持有锁 L 释放锁 L intr_on()

4.2 中断处理程序中的锁使用规则

xv6 的中断处理程序(如 devintr()、clockintr())运行在中断上下文中,它们必须遵守严格的锁使用规则,否则会破坏内核数据结构的完整性。

4.2.1 三条黄金规则

  1. 规则一:中断处理程序只能获取“中断安全”的锁。 如果一个锁可能被中断处理程序获取,那么所有普通代码在获取该锁之前必须禁用中断。
  2. 规则二:中断处理程序不能睡眠或阻塞。 中断上下文没有进程结构,不能调用 sleep() 或等待锁时让出 CPU。
  3. 规则三:中断处理程序必须尽快完成。 长时间持有锁会延迟其他 CPU 或中断的执行,影响系统实时性。
常见错误: 在 xv6 的 uartintr() 中,如果开发者在未禁用中断的情况下获取 uart_tx_lock,而普通代码路径也获取同一锁,就会导致死锁。xv6 的 uartputc() 在获取锁前调用 acquire(&uart_tx_lock),而 acquire 内部会调用 push_off() 禁用中断,从而避免此问题。

4.2.2 中断处理程序锁使用流程

下面的 SVG 展示了 xv6 中一个典型的中断处理程序锁获取流程:

中断处理程序锁获取流程 中断发生(trap.c) devintr() 判断类型 时钟中断 设备中断 clockintr() uartintr() / virtio_disk_intr() 获取 ticks_lock 获取 uart_tx_lock 中断返回

4.3 锁与中断的交互模型

锁与中断的交互在 xv6 中通过 push_off() / pop_off() 机制实现。理解这个交互模型是调试死锁的关键。

4.3.1 push_off / pop_off 机制

xv6 的 acquire() 和 release() 内部自动调用中断禁用/启用函数,形成嵌套保护:

// kernel/spinlock.c
void acquire(struct spinlock *lk) {
    push_off();  // 禁用中断 + 计数器 +1
    if (holding(lk))
        panic("acquire");
    while (__sync_lock_test_and_set(&lk->locked, 1) != 0)
        ;
    __sync_synchronize();
    lk->cpu = mycpu();
}

void release(struct spinlock *lk) {
    if (!holding(lk))
        panic("release");
    lk->cpu = 0;
    __sync_synchronize();
    __sync_lock_release(&lk->locked);
    pop_off();  // 计数器 -1,若为 0 则启用中断
}
嵌套计数器 noff 的作用: 当代码嵌套获取多个锁时,push_off() 每次将 mycpu()->noff 加 1,pop_off() 每次减 1。只有 noff 回到 0 时,才真正调用 intr_on() 启用中断。这保证了在持有任何锁期间,中断始终被禁用。

4.3.2 锁与中断交互的状态机

下面的 SVG 展示了锁与中断交互的完整状态转换:

锁与中断交互状态机 状态:空闲 中断启用 状态:持有锁 中断禁用 状态:嵌套锁 中断禁用 状态:中断处理 中断禁用 状态:死锁 CPU 自旋等待 acquire() acquire() release() 中断触发 尝试获取同一锁

4.3.3 中断与锁交互的典型场景

场景 描述 是否安全
普通代码获取锁,中断处理程序获取同一锁 普通代码在 acquire() 中禁用中断,中断处理程序无法抢占 安全
普通代码持有锁,中断处理程序获取不同锁 中断处理程序获取另一个锁,不会产生死锁 安全
普通代码未禁用中断,中断处理程序获取同一锁 中断处理程序自旋等待锁释放,但锁永远不会释放 死锁
两个 CPU 分别持有锁 A 和 B,互相等待 经典多 CPU 死锁,与中断无关 死锁

4.4 调试技巧:中断与锁死锁的识别

当 xv6 发生中断相关的死锁时,系统会表现为某个 CPU 持续自旋,无法响应任何中断。以下是调试步骤:

  1. 检查 sstatus.SIE 位: 如果中断被禁用且 CPU 在 acquire() 循环中,说明可能发生了中断死锁。
  2. 查看 mycpu()->noff 计数器: 如果 noff > 0,说明中断被禁用,且锁尚未释放。
  3. 检查锁的持有者: 通过 lk->cpu 查看锁被哪个 CPU 持有,以及该 CPU 当前执行的代码位置。
  4. 使用 QEMU 的 info registers: 在 QEMU 监视器中查看 sstatus 寄存器的值,确认中断状态。
调试命令示例: 在 QEMU 监视器中输入 info cpus 查看每个 CPU 的当前指令地址,然后使用 elf2hex 或 addr2line 将地址转换为源代码行号,定位死锁位置。

4.5 总结

中断与锁的交互是 xv6 内核设计中最为精妙也最容易出错的部分。通过 push_off() / pop_off() 机制,xv6 确保了在持有任何自旋锁期间,当前 CPU 的中断被禁用,从而避免了单 CPU 自死锁。理解这一机制,是掌握 xv6 并发控制和死锁调试的基础。