中断分类体系与设计考量:五类中断 + 五大矛盾
这是 interrupts(内核中断处理总纲)的拆分篇之一,讲两件事:中断怎么分类、内核为什么这么设计(由原「分类体系」「设计考量」两篇合并)。配套拆分篇:处理流程 · 上半部 / 下半部机制与内部实现 · 开销量化。
第一部分:中断分类体系
"中断"是广义统称,从触发源和性质两个维度可建立完整分类框架:

外部中断(硬件中断 / Hardware IRQ)—— 最常见的一类
定义:外设通过中断控制器向 CPU 发出的异步信号,请求 CPU 注意。
触发路径:
外设(网卡/磁盘/键盘) → 中断控制器(APIC) → CPU INTR 引脚 → CPU 在指令边界检测 → 进入中断处理关键特性:
- 可屏蔽:通过 EFLAGS 寄存器的
IF(Interrupt Flag)位控制。cli指令关中断(清 IF),sti指令开中断(置 IF)。中断 handler 执行期间同类中断通常被屏蔽。 - 异步:随时可能来,与当前执行的指令流无关。
- 中断向量号:每个 IRQ 有唯一的中断向量号(0~255),CPU 用它查 IDT(中断描述符表)找到对应的 handler 入口地址。
常见外部中断源:
| IRQ 类型 | 触发场景 | 频率特征 | 性能影响 |
|---|---|---|---|
| 网卡收包中断 | 数据包到达网卡 | 高吞吐时可到数十万/秒 | %irq/%soft 飙升,单核瓶颈 |
| 磁盘 IO 完成中断 | DMA 传输结束,设备通知 CPU | 与 IOPS 成正比 | NVMe 下可达百万级 |
| 定时器中断(Timer) | 时钟芯片周期性 tick | 固定频率(通常 250/1000 Hz) | 即使系统空闲也持续发生 |
| 键盘/鼠标中断 | 用户输入 | 极低频 | 可忽略 |
| USB 设备中断 | U 盘、外设插拔/数据 | 中低频 | 通常可忽略 |
异常(Exception / Fault)—— 指令自己引发的"中断"
定义:CPU 在执行某条指令时发现无法继续,主动触发异常处理流程。异常和引发它的指令是同步的——异常一定发生在某条具体指令的执行过程中。
与外部中断的本质区别:
| 维度 | 外部中断(IRQ) | 异常(Exception) |
|---|---|---|
| 触发时机 | 指令边界(两条指令之间) | 指令执行过程中 |
| 与指令的关系 | 异步,无关 | 同步,由该指令引发 |
| 可屏蔽性 | 可通过 IF 屏蔽 | 不可屏蔽 |
| 返回地址 | 下一条指令的地址 | 取决于异常类型(当前指令或下一条) |
| 触发源 | CPU 外部 | CPU 内部 |
异常的三个子类:
| 子类 | 行为 | 返回地址 | 典型例子 |
|---|---|---|---|
| Fault(故障) | 可修复,修复后重执行当前指令 | 指向当前指令 | 缺页(page fault):页不在内存,内核换入后重试;除零 |
| Trap(陷阱) | 执行完再报告 | 指向下一条指令 | 断点(int 3)、单步调试 |
| Abort(终止) | 不可恢复 | 无意义(进程将终止) | 硬件错误、双重故障(double fault) |
缺页异常是性能分析中最关键的异常:
- 惰性分配(
mmap预留地址空间但不立即分配物理页)→ 首次访问触发缺页 → 内核分配物理页 → 重执行指令。详见 mmap。 - TLB miss 后硬件走 page walk 查页表 → 如果页表项显示页不在内存 → 触发缺页异常。详见 tlb。
- SIGSEGV 的本质是缺页异常无法被满足(访问非法地址)→ 内核给进程发信号。详见 signals。
软中断陷入(Trap / Software Interrupt)—— 程序主动"敲门"
定义:用户态程序主动通过特定指令(int 0x80 / syscall / sysenter)触发 CPU 从 ring3 切换到 ring0,进入内核态执行系统调用。
与外设中断的本质区别:
外设中断:外设说"我有事" → CPU 被动停下 → 进内核
软中断陷入:程序说"帮我做事" → CPU 主动切换 → 进内核重要术语澄清:这里的"软中断陷入"(trap,如
int 0x80)和下半部机制中的"软中断(softirq)"完全是两码事,只是中文都叫"软中断"。前者是触发 CPU 进内核的一种方式(系统调用的入口),后者是内核延后执行中断重活的一种机制(见 下半部机制)。后文凡说"软中断"均指后者。
详见 syscall 中关于 syscall/sysret 指令的完整分析。
NMI(Non-Maskable Interrupt,不可屏蔽中断)—— 最高优先级的硬件信号
定义:硬件发出的、不能被 cli 指令屏蔽的中断,用于处理最紧急的硬件事件。即使 CPU 正在关中断处理其他事情,NMI 也能强行插入。
典型用途:
- 硬件故障告警:内存 ECC 错误、PCIe 总线错误。
- 看门狗(Watchdog):检测 CPU 是否卡死(hard lockup),如果 CPU 长时间不响应,NMI 看门狗触发 panic。
- perf PMU 采样:CPU 性能计数器(PMU)溢出后产生 PMI(Performance Monitoring Interrupt),在现代 x86 上 PMI 走 NMI 通道。这就是
perf record能采样到关中断代码的原因——普通 IRQ 会被屏蔽,NMI 不会。详见 perf-internals。
NMI 的设计约束:
- NMI handler 内不能使用任何可能阻塞的锁(因为可能打断了正持有同一把锁的普通中断 handler)。
- NMI handler 必须极其简单、绝对不能触发缺页异常(会导致死锁)。
IPI(Inter-Processor Interrupt,处理器间中断)—— 多核协作的通信机制
定义:一个 CPU 核向另一个(或一组)CPU 核发送的中断信号,用于多核之间的协调通信。
典型用途:
| 场景 | 触发条件 | 接收核的动作 |
|---|---|---|
| TLB shootdown | 一个核改了页表(如 munmap),需让其他核刷新 TLB | 收到 IPI 的核立即刷掉对应 TLB 条目 |
| 重新调度(Reschedule) | 一个核唤醒了一个高优先级 task,需要目标核立即重新调度 | 目标核设置 need_resched 标志,在最近的机会调度 |
| RCU 回调 | 强制目标核进入静默期以推进 RCU 宽限期 | 目标核在 safe point 处理 RCU 回调 |
| Function call | 一个核要另一个核执行某函数(smp_call_function) | 目标核执行指定函数 |
IPI 的性能代价:IPI 本质是让目标核中断当前工作、保存现场、处理 IPI、再恢复——这在目标核正跑关键业务代码时尤其昂贵。TLB shootdown 是 IPI 的最大来源,频繁的 munmap/mprotect 会导致大量 IPI,拖慢整个系统。
第二部分:内核处理中断的设计考量(五大矛盾)
中断处理不是简单的"来了就干",内核在设计中需要平衡多方面的冲突需求:
核心矛盾一:响应延迟 vs 处理工作量
越快响应 → 关中断窗口越短 → 不会丢事件 → 但能干的活有限
干更多活 → 功能更完整 → 但关中断窗口变长 → 可能丢后续中断内核的解法——上半部/下半部拆分(详见 下半部机制):上半部在关中断环境下只做最紧急的应答和数据搬运(< 几微秒),下半部在开中断环境下干重活(协议栈处理、唤醒进程等)。
核心矛盾二:单核集中 vs 多核分散
中断全压一个核 → 简单、cache 热 → 但单核瓶颈、其他核闲着
中断分散多核 → 吞吐提升 → 但 cache 颠簸、处理逻辑变复杂内核的解法:
- 中断亲和性(
/proc/irq/<n>/smp_affinity):管理员可手动将 IRQ 绑定到指定核。 - irqbalance 守护进程:自动根据负载将中断分散到不同核。
- 网卡多队列(RSS):硬件层面将收包中断分散到多个核。
- RPS/RFS:软件层面在软中断阶段将包分给不同核处理。
核心矛盾三:关中断 vs 开中断 —— 保护临界区
中断 handler 和普通内核代码可能访问相同的数据结构。如果普通内核代码正在修改某数据结构时被中断打断,而中断 handler 也去改同一个结构 → 数据损坏。
内核的解法:
- 关中断(
local_irq_disable):简单粗暴,但关太久会丢中断。 - 自旋锁 + 关中断(
spin_lock_irqsave):同时拿锁和关中断,保证普通代码和中断 handler 不会同时访问共享数据。 - PER-CPU 变量:每个核私有的数据,不需要锁、不需要关中断——中断处理中大量使用。
核心矛盾四:中断上下文不能睡眠
中断 handler 运行时没有"当前进程"的概念(它是借用被打断进程的内核栈临时运行的),如果 handler 内调用 sleep(),没有可挂起的实体,系统直接卡死。
为什么睡眠是致命的——对比正常睡眠和中断上下文中的"伪睡眠":
正常睡眠流程:
task 调 schedule() → 当前 task 状态 → SLEEPING
→ task 从就绪队列摘除 → 切换 stack → 选下一个 task
中断上下文的"睡眠":
handler 调 schedule() → current = 被打断的 task(不是 handler 自己!)
→ 把这个 task 标记为 SLEEPING → 把它从就绪队列摘除
→ 但 handler 不是这个 task,handler 本身没有 task_struct
→ 切换后 CPU 去跑别的 task
→ handler 剩下半段代码再也不会被执行
→ 关中断环境 + 无法唤醒 = 这个 CPU 核彻底失去响应什么操作会隐式睡眠(最常见的坑):
| 操作 | 为什么危险 | 替代方案 |
|---|---|---|
mutex_lock() | 拿不到锁 → schedule() 休眠 | spin_lock() / spin_lock_irqsave() |
kmalloc(GFP_KERNEL) | 没内存 → 触发页面回收 → 可能睡眠 | kmalloc(GFP_ATOMIC) |
copy_from_user() | 缺页 → 等待磁盘 → sleep | copy_from_user_inatomic()(只做不睡眠的尝试,失败返回 -EFAULT) |
down(&sem) | 信号量拿不到 → sleep | spin_lock 或 RCU |
wait_event*() | 条件等待 → sleep | 改为 workqueue 中处理 |
msleep() / usleep_range() | 显式睡眠 | 根本不可以在中断上下文用 |
| 访问被换出的内核页 | 缺页异常 → 可能等 IO → sleep | 确保数据在不可换出内存中 |
schedule() 直接调用 | 手动调度 | 绝对禁止 |
破了会怎样——典型死锁场景:
CPU 0 正在内核态执行某系统调用:
spin_lock(&lock_A) ← 拿了锁
... 执行中 ...
→ 中断到达!
→ 中断 handler 也想拿 lock_A:
spin_lock(&lock_A)
→ 自旋等待 lock_A 被释放
→ 但释放 lock_A 的代码在被打断的上下文中
→ CPU 0 永远等不到锁释放
→ CPU 0 硬死锁,NMI watchdog 触发 kernel panic内核有防御检查:如果 in_interrupt() 为真且代码尝试睡眠,might_sleep() 会打印 WARNING 和调用栈。CONFIG_DEBUG_ATOMIC_SLEEP 可在开发阶段捕获这类 bug。
正确的逃生方式——workqueue:如果中断处理中确实需要做"可能睡眠"的事情(如分配大内存、等 IO、拿互斥锁),标准做法是:在中断上半部触发一个 workqueue,把重活交给 kworker 内核线程在进程上下文中执行。kworker 有完整的 task_struct 和独立的页表,可以安全调 schedule()。总结:上半部和 softirq/tasklet 在中断上下文运行 → 严禁睡眠、严禁阻塞;需要睡眠的活丢给工作队列(workqueue)。
核心矛盾五:中断风暴(Interrupt Storm)
高速网络下,如果每个包都来一次中断,中断频率会达到数十万次/秒——每次中断都有固定开销(保存/恢复现场、cache 污染),CPU 全耗在中断进出上,有效工作极少。
内核的解法——NAPI(New API):
- 第一个包触发中断 → 上半部应答设备后关掉该网卡的后续中断 → 触发 NET_RX 软中断 → 软中断用**轮询(poll)**方式批量收包 → 收完一批后再开中断。
- 中断 + 轮询混合:低负载时用中断保证低延迟,高负载时自动切换为轮询保证高吞吐。这是 Linux 网络子系统的基石设计,详细交接过程见 上半部 / 下半部机制与内部实现 的 NAPI 一节。
一句话总结:五类中断(外部中断/异常/trap/NMI/IPI)构成分类全景,而内核处理中断的五大矛盾(响应 vs 工作量、单核 vs 多核、关中断保护、不能睡眠、中断风暴)决定了"上半部/下半部拆分 + NAPI 轮询"这套设计——分类告诉你"来了什么",设计告诉你"为什么这么处理"。