﻿# 内核中断处理 —— 从硬件信号到上半部/下半部的完整剖析

> CPU 正跑着你的代码，网卡收到一个包、磁盘写完一次、时钟每毫秒滴答一次——这些**异步事件**都靠**中断(interrupt)** 打断 CPU、让内核立刻处理。中断是操作系统响应外部世界的**唯一异步入口**，它的处理效率直接影响系统吞吐和延迟。本篇系统论述：**中断的分类体系**（硬中断/软中断/外部中断/异常中断/NMI/IPI，每种详细展开）、**内核处理中断的设计考量**（五大矛盾与权衡）、**完整处理流程**（从 IRQ 信号到 handler 返回的每一步，含 IST 栈切换机制、中断上下文运行时限制、中断返回触发调度）、**中断处理的开销量化**（到底多贵、贵在哪）、**上半部/下半部机制**（softirq/tasklet/workqueue/NAPI），以及**完整的观测手段与跨文档关系**。


> 相关：系统调用（同为进内核，主动同步陷入 vs 中断被动异步打断）见 [syscall.md](/concepts/process/syscall.md)；中断上下文的运行时限制（不能睡眠——§2.4、无 task_struct/不换页表/Ring 0——§3.2）、IST 栈切换机制（§3.1）、中断返回触发调度（§3.3）；中断上下文切换的概览见 [context-switch.md](/concepts/process/context-switch.md) §4.3；中断处理后唤醒进程 → 调度见 [scheduling.md](/concepts/process/scheduling.md)；中断亲和性（IRQ affinity）见 [irq-affinity.md](/concepts/process/irq-affinity.md)；线程绑核见 [thread-affinity.md](/concepts/process/thread-affinity.md)；本文涉及的硬件寄存器（RFLAGS.IF/APIC/IDT 等）速查见 [x86-64-registers.md](/concepts/process/x86-64-registers.md)；`%irq`/`%soft` 观测见 [../../tools/cpu/mpstat.md](/tools/cpu/mpstat.md)；中断计数见 [../../tools/proc/procfs.md](/tools/proc/procfs.md)；DMA 完成后靠中断通知 CPU 见 [../io/dma.md](/concepts/io/dma.md)；中断控制器的进化（8259A→APIC→xAPIC→x2APIC→MSI-X）见 [../io/8259.md](/concepts/io/8259.md) §七和 [irq-affinity.md](/concepts/process/irq-affinity.md) §二；网卡收包完整流程见下文 §四。

## 零、一句话认知：中断 = 硬件异步打断 CPU，内核放下手头活去处理

正常情况下 CPU 一条条执行指令。当外设（网卡/磁盘/键盘/时钟）需要 CPU 注意时，它通过中断控制器（APIC）给 CPU 发一个**中断请求（IRQ）**。CPU 在**当前指令边界**停下，**保存现场**，跳到内核预先登记的**中断处理程序（ISR / interrupt handler）**，处理完**恢复现场**、回到原来被打断的地方继续跑——被打断的进程通常毫不知情。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<r>> #E3F2FD
  BorderColor<<r>> #1976D2
  BackgroundColor<<k>> #C8E6C9
  BorderColor<<k>> #388E3C
  BackgroundColor<<h>> #FFE0B2
  BorderColor<<h>> #EF6C00
}
rectangle "CPU 正在跑\n进程 A 的用户代码" <<r>> as A
rectangle "外设(网卡/磁盘/时钟)\n产生 IRQ →中断控制器(APIC)→ CPU" <<h>> as HW
rectangle "内核中断处理\n① 存现场 ② 查 IDT 找 handler\n③ 执行 handler ④ 恢复现场" <<k>> as K
A -right-> HW : 被异步打断\n(在当前指令边界)
HW -down-> K : CPU 跳到对应 IRQ 的 handler
K -up-> A : 处理完 iret 返回\n进程 A 从断点继续(通常无感)
note bottom of K : 关键:中断是**异步**的、随时可能来;\n和系统调用(进程主动陷入,同步)相对
@enduml
```

> **核心记忆**：中断是**硬件发起的、异步的**打断，不是进程主动的。它和[系统调用](/concepts/process/syscall.md)都让 CPU 进内核态，但系统调用是进程"我主动要服务"（同步），中断是外设"我有事，你必须停下"（异步）。二者共用"存现场→进内核→干活→恢复现场"的骨架，但触发方完全不同。

---

## 一、中断的完整分类体系

"中断"是广义统称。从触发源和性质两个维度可以建立起完整的分类框架：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ext>> #FFCDD2
  BorderColor<<ext>> #C62828
  BackgroundColor<<int>> #C8E6C9
  BorderColor<<int>> #388E3C
  BackgroundColor<<trap>> #E3F2FD
  BorderColor<<trap>> #1976D2
  BackgroundColor<<nmi>> #FFE0B2
  BorderColor<<nmi>> #EF6C00
  BackgroundColor<<ipi>> #E1BEE7
  BorderColor<<ipi>> #7B1FA2
}
rectangle "**外部中断 (external interrupt)**\n= 硬件中断 (hardware IRQ)\n外设通过中断控制器发信号\n· 可屏蔽 (通过 IF 标志位)\n· 异步:随时可能来\n· 网卡收包、磁盘完成、键盘、时钟" <<ext>> as EXT
rectangle "**异常 (exception/fault)**\n= 同步中断\n当前指令执行过程中出错\n· 不可屏蔽\n· 同步:指令引发的\n· 缺页、除零、非法指令、断点" <<int>> as INT
rectangle "**软中断陷入 (trap)**\n= 指令主动触发的陷入\n· 同步:程序主动发起\n· int 0x80 / syscall / sysenter\n· 系统调用的入口机制" <<trap>> as TRAP
rectangle "**NMI 不可屏蔽中断**\n硬件严重事件\n· 不可屏蔽、优先级最高\n· 异步\n· 硬件故障、看门狗、perf PMU" <<nmi>> as NMI
rectangle "**IPI 处理器间中断**\nCPU 核之间发信号\n· 异步:其他核发起\n· TLB shootdown、重新调度\n· 多核协调的基础设施" <<ipi>> as IPI
@enduml
```

### 1.1 外部中断（硬件中断 / Hardware IRQ）—— 最常见的一类

**定义**：外设通过中断控制器向 CPU 发出的异步信号，请求 CPU 注意。

**触发路径**：

```bash
外设(网卡/磁盘/键盘) → 中断控制器(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 盘、外设插拔/数据 | 中低频 | 通常可忽略 |

### 1.2 异常（Exception / Fault）—— 指令自己引发的"中断"

**定义**：CPU 在执行某条指令时**发现无法继续**，主动触发异常处理流程。异常和引发它的指令是**同步**的——异常一定发生在某条具体指令的执行过程中。

**与外部中断的本质区别**：

| 维度 | 外部中断（IRQ） | 异常（Exception） |
|------|---------------|------------------|
| 触发时机 | 指令边界（两条指令之间） | 指令**执行过程中** |
| 与指令的关系 | 异步，无关 | 同步，由该指令引发 |
| 可屏蔽性 | 可通过 IF 屏蔽 | **不可屏蔽** |
| 返回地址 | 下一条指令的地址 | 取决于异常类型（当前指令或下一条） |
| 触发源 | CPU 外部 | CPU 内部 |

**异常的三个子类**：

| 子类 | 行为 | 返回地址 | 典型例子 |
|------|------|---------|---------|
| **Fault（故障）** | 可修复，修复后**重执行**当前指令 | 指向**当前指令** | **缺页（page fault）**：页不在内存，内核换入后重试；除零 |
| **Trap（陷阱）** | 执行完再报告 | 指向**下一条指令** | 断点（`int 3`）、单步调试 |
| **Abort（终止）** | **不可恢复** | 无意义（进程将终止） | 硬件错误、双重故障（double fault） |

**缺页异常是性能分析中最关键的异常**：

- 惰性分配（`mmap` 预留地址空间但不立即分配物理页）→ 首次访问触发缺页 → 内核分配物理页 → 重执行指令。详见 [../elf/mmap.md](/concepts/elf/mmap.md)。
- TLB miss 后硬件走 page walk 查页表 → 如果页表项显示页不在内存 → 触发缺页异常。详见 [../cache/tlb.md](/concepts/cache/tlb.md)。
- SIGSEGV 的本质是**缺页异常无法被满足**（访问非法地址）→ 内核给进程发信号。详见 [../../crash/signals.md](/crash/signals.md)。

### 1.3 软中断陷入（Trap / Software Interrupt）—— 程序主动"敲门"

**定义**：用户态程序**主动**通过特定指令（`int 0x80` / `syscall` / `sysenter`）触发 CPU 从 ring3 切换到 ring0，进入内核态执行系统调用。

**与外设中断的本质区别**：

```bash
外设中断：外设说"我有事" → CPU 被动停下 → 进内核
软中断陷入：程序说"帮我做事" → CPU 主动切换 → 进内核
```

> **重要术语澄清**：这里的"软中断陷入"（trap，如 `int 0x80`）和下文 §六讲的下半部机制"软中断（softirq）"**完全是两码事**，只是中文都叫"软中断"。前者是**触发 CPU 进内核的一种方式**（系统调用的入口），后者是**内核延后执行中断重活的一种机制**。本文 §六之后说的"软中断"均指后者。

详见 [syscall.md](/concepts/process/syscall.md) 中关于 `syscall`/`sysret` 指令的完整分析。

### 1.4 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 不会。详见 [../../tools/code/perf-internals.md](/tools/code/perf-internals.md)。

**NMI 的设计约束**：

- NMI handler 内**不能使用任何可能阻塞的锁**（因为可能打断了正持有同一把锁的普通中断 handler）。
- NMI handler 必须极其简单、绝对不能触发缺页异常（会导致死锁）。

### 1.5 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，拖慢整个系统。

---

## 二、内核处理中断的设计考量 —— 面对的核心矛盾与权衡

中断处理不是简单的"来了就干"，内核在设计中需要平衡多方面的冲突需求：

### 2.1 核心矛盾一：响应延迟 vs 处理工作量

```bash
越快响应 → 关中断窗口越短 → 不会丢事件 → 但能干的活有限
干更多活 → 功能更完整 → 但关中断窗口变长 → 可能丢后续中断
```

**内核的解法——上半部/下半部拆分**（详见 §六）：上半部在关中断环境下只做最紧急的应答和数据搬运（< 几微秒），下半部在开中断环境下干重活（协议栈处理、唤醒进程等）。

### 2.2 核心矛盾二：单核集中 vs 多核分散

```bash
中断全压一个核 → 简单、cache 热 → 但单核瓶颈、其他核闲着
中断分散多核 → 吞吐提升 → 但 cache 颠簸、处理逻辑变复杂
```

**内核的解法**：

- **中断亲和性**（`/proc/irq/<n>/smp_affinity`）：管理员可手动将 IRQ 绑定到指定核。
- **irqbalance 守护进程**：自动根据负载将中断分散到不同核。
- **网卡多队列（RSS）**：硬件层面将收包中断分散到多个核。
- **RPS/RFS**：软件层面在软中断阶段将包分给不同核处理。

### 2.3 核心矛盾三：关中断 vs 开中断 —— 保护临界区

中断 handler 和普通内核代码可能访问相同的数据结构。如果普通内核代码正在修改某数据结构时被中断打断，而中断 handler 也去改同一个结构 → **数据损坏**。

**内核的解法**：

- **关中断（`local_irq_disable`）**：简单粗暴，但关太久会丢中断。
- **自旋锁 + 关中断（`spin_lock_irqsave`）**：同时拿锁和关中断，保证普通代码和中断 handler 不会同时访问共享数据。
- **PER-CPU 变量**：每个核私有的数据，不需要锁、不需要关中断——中断处理中大量使用。

### 2.4 核心矛盾四：中断上下文不能睡眠

中断 handler 运行时没有"当前进程"的概念（它是借用被打断进程的内核栈临时运行的），如果 handler 内调用 `sleep()`，没有可挂起的实体，系统直接卡死。

**为什么睡眠是致命的**——对比正常睡眠和中断上下文中的"伪睡眠"：

```bash
正常睡眠流程：
  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()` 直接调用 | 手动调度 | 绝对禁止 |

**破了会怎样**——典型死锁场景：

```bash
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）**——它在**进程上下文**（`kworker` 内核线程）中运行，可以安全睡眠。

### 2.5 核心矛盾五：中断风暴（Interrupt Storm）

高速网络下，如果每个包都来一次中断，中断频率会达到数十万次/秒——每次中断都有固定开销（保存/恢复现场、cache 污染），CPU 全耗在中断进出上，有效工作极少。

**内核的解法——NAPI（New API）**：

- 第一个包触发中断 → 上半部应答设备后**关掉该网卡的后续中断** → 触发 NET_RX 软中断 → 软中断用**轮询（poll）**方式批量收包 → 收完一批后再开中断。
- **中断 + 轮询混合**：低负载时用中断保证低延迟，高负载时自动切换为轮询保证高吞吐。这是 Linux 网络子系统的基石设计。

---

## 三、中断处理完整流程 —— 从 IRQ 信号到 handler 返回的每一步

以下以 x86-64 架构为例，追踪一次外部中断从硬件信号到软件处理完毕的完整路径：

```plantuml
@startuml
!pragma teoz true
skinparam backgroundColor #FAFAFA
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "外设\n(网卡/磁盘)" as DEV
participant "APIC\n中断控制器" as APIC
participant "CPU 硬件" as CPU
participant "IDT\n中断描述符表" as IDT
participant "内核 entry\n(汇编入口)" as ENTRY
participant "do_IRQ()\nC 语言处理" as DOIRQ
participant "上半部\nhandler" as TOP
participant "下半部\nsoftirq/workqueue" as BOTTOM
DEV -[#37474F]-> APIC : ① 外设产生中断信号(电平/边沿)
APIC -[#37474F]-> CPU : ② APIC 拉高 CPU INTR 引脚,\n传递中断向量号
note over CPU #FFF9C4
③ CPU 在当前指令边界检测 INTR:
- 如果 IF=0(关中断),暂不响应
- 如果 IF=1(开中断),开始处理
end note
CPU -[#37474F]-> CPU : ④ 硬件自动保存部分现场\n(SS/RSP/RFLAGS/CS/RIP 压栈)
CPU -[#37474F]-> IDT : ⑤ 用中断向量号 × 16 索引 IDT\n读出 gate descriptor(handler 地址+特权级)
IDT -[#37474F]-> CPU : ⑥ 返回 handler 入口地址(如 common_interrupt)
CPU -[#37474F]-> ENTRY : ⑦ 跳到汇编入口,切换内核栈\n保存全部通用寄存器(pt_regs)
ENTRY -[#37474F]-> DOIRQ : ⑧ 调用 C 函数 do_IRQ()
DOIRQ -[#37474F]-> DOIRQ : ⑨ 应答 APIC(写 EOI 寄存器)\n让 APIC 可以发下一个中断
DOIRQ -[#37474F]-> TOP : ⑩ 根据 IRQ 号找到对应的\n上半部 handler(设备驱动注册的)
TOP -[#37474F]-> TOP : ⑪ 上半部处理:应答设备、搬数据\n标记下半部待办,立刻返回
DOIRQ -[#37474F]-> BOTTOM : ⑫ do_IRQ 返回前检查\n是否有 pending softirq,有则执行
ENTRY -[#37474F]-> CPU : ⑬ 恢复通用寄存器\niret 恢复 SS/RSP/RFLAGS/CS/RIP
CPU -[#37474F]-> CPU : ⑭ 回到被打断的进程\n从断点继续执行(完全无感)
note over TOP #FFCDD2
关中断/屏蔽同类中断窗口
必须极快(< 几微秒)
end note
note over BOTTOM #C8E6C9
开中断环境,可被打断
但仍在中断上下文(不可睡眠)
end note
@enduml
```

**流程逐步拆解**：

| 步骤 | 谁在做 | 做什么 | 耗时量级 |
|------|-------|--------|---------|
| ①→② | 外设 + APIC | 产生 IRQ，APIC 仲裁优先级，投递到目标 CPU | < 100 ns |
| ③→⑥ | CPU 硬件 | 检测 INTR、压栈 SS/RSP/RFLAGS/CS/RIP、查 IDT | ~ 几十个 CPU 周期 |
| ⑦ | 汇编入口 | 切换内核栈、`SAVE_ALL` 压全部通用寄存器 | ~ 几十个周期 |
| ⑧→⑨ | `do_IRQ()` | 应答 APIC（写 EOI）、irq_enter 记账 | < 100 ns |
| ⑩→⑪ | 上半部 handler | 设备应答、数据搬运、标记下半部 | 几微秒（严格限制） |
| ⑫ | 下半部检查 | 如果 softirq pending，就地执行 | 取决于工作量 |
| ⑬→⑭ | 汇编出口 | `RESTORE_ALL` 恢复寄存器、`iret` 返回 | ~ 几十个周期 |

**关键细节**：

- 步骤 ④ 的硬件自动压栈只保存了 5 个寄存器（SS/RSP/RFLAGS/CS/RIP），通用寄存器由软件（步骤 ⑦）保存——硬件只做最少的事。
- 步骤 ⑨ 的 EOI（End of Interrupt）写入是关键一步：不写 EOI，APIC 不会发送**任何**同优先级或更低优先级的中断，所以 EOI 写的时机直接影响中断延迟。
- 步骤 ⑫ 的 softirq 执行是"搭便车"——在中断返回路径上顺便处理，避免额外的上下文切换。但如果 softirq 太多，内核会把它们推给 `ksoftirqd` 内核线程。

### 3.1 IST 栈切换机制 —— 硬件如何为中断准备独立运行环境

x86-64 提供了 **IST（Interrupt Stack Table）** 机制，在 TSS（Task State Segment）中为每种中断类型预设独立的栈指针。当中断发生时，CPU 根据 IDT 条目的 IST 字段，**直接从 TSS.IST[n] 加载 RSP**，一次性切到独立中断栈——task 的内核栈完全不被触及。

```bash
TSS 中的 IST 条目（每核一个 TSS）：
┌──────────────────────────┐
│ IST[0] → 不使用（默认栈）  │
│ IST[1] → NMI handler 栈   │  ← NMI 用独立栈
│ IST[2] → Double Fault 栈  │  ← 严重异常用独立栈
│ IST[3] → Machine Check 栈 │  ← MCE 用独立栈
│ IST[4] → Debug 异常栈     │
│ IST[5] → 普通设备中断栈    │  ← 大多数 IRQ 用这个
│ IST[6] → （保留）          │
│ IST[7] → （保留）          │
└──────────────────────────┘
```

**为什么需要独立中断栈**：

1. **防止栈溢出**：如果中断直接借被打断线程的内核栈，而该栈已经接近用完（深度递归、大的局部变量），中断 handler 再压栈 → 栈溢出 → 静默损坏相邻数据 → 难排查的随机崩溃。
2. **隔离故障**：独立栈把中断处理和被中断的 task 隔离开，栈损坏不会互相影响。
3. **NMI 尤其需要**：NMI 可能在任何时候到达（包括在内核栈即将耗尽时），它必须有自己可靠的栈才能完成诊断/panic 日志记录。

下面以 IDT entry 配置了 IST=5 的设备中断为例，分两种场景追踪硬件栈切换的完整时序。

**情况一：Task A 在用户态时被中断**

CPU 发现 CPL 改变（3→0），直接从 TSS.IST[5] 读栈指针，所有状态保存 + handler 执行都在 IST 栈上完成——Task A 的内核栈完全不参与：

```plantuml
@startuml
!pragma teoz true
skinparam backgroundColor #FAFAFA
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
}
participant "用户栈\nTask A ring 3" as US
participant "TSS\nIST[5]指针" as TSS
participant "中断栈\nIST[5]" as IS
participant "IDT" as IDT
participant "handler" as H
note over US: ① Task A 在用户态执行
RSP → 用户栈 ring 3
note over US #FFCDD2: ② 硬件中断到达
CPU 开始中断响应流程
US -> TSS: ③ CPU 从 TSS.IST[5] 读取栈指针
直接作为新的 ring0 RSP
note over TSS, IS: ④ RSP 从用户栈切到 IST 栈
Task A 的内核栈完全不被触及
note over IS: ⑤ 因为 CPL 改变了 (3→0)
CPU 压入 SS、RSP(用户栈)
RFLAGS、CS、RIP
共 5 个值到 IST 栈
IS -> IDT: ⑥ CPU 从 IDT gate 读 handler 地址
IDT -> H: ⑦ CS:RIP 加载为 handler 入口
压入 error_code (若有)
handler 在 IST 栈上开始执行
H -> IS: ⑧ SAVE_ALL
压全部通用寄存器到 IST 栈
note right #C8E6C9
  整个中断处理过程
  都在 IST 栈上完成
  Task A 的内核栈完全隔离
  其中保存了用户态的 5 个值
end note
H -> H: ⑨ do_IRQ 上半部 + 下半部
H -> IS: ⑩ RESTORE_ALL
从 IST 栈弹出全部通用寄存器
H -> IS: ⑪ iret 返回时逐步弹出:
RIP、CS  → 回到被打断的用户代码
RFLAGS   → 恢复标志位
RSP、SS  → 切回 ring 3 用户栈
US <- IS: ⑫ Task A 恢复执行
RSP 回到用户栈，完全无感
@enduml
```

**情况二：Task A 在内核态时被中断**

CPL 不变（0→0），同样直接切到 IST 栈。硬件只压 3 个值（无需保存 SS/RSP）；iret 返回后内核需要显式恢复 RSP 回到内核栈继续执行：

```plantuml
@startuml
!pragma teoz true
skinparam backgroundColor #FAFAFA
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
}
participant "内核栈\nTask A ring 0" as KS
participant "TSS\nIST[5]指针" as TSS
participant "中断栈\nIST[5]" as IS
participant "IDT" as IDT
participant "handler" as H
note over KS: ① Task A 在内核态执行 syscall
RSP → 内核栈 ring 0
note over KS #FFCDD2: ② 硬件中断到达
CPU 开始中断响应流程
KS -> TSS: ③ CPU 从 TSS.IST[5] 读取栈指针
直接作为新的 RSP
note over TSS, IS: ④ RSP 从内核栈切到 IST 栈
note over IS: ⑤ 因为 CPL 未改变 (0→0)
CPU 只压入 RFLAGS、CS、RIP
共 3 个值到 IST 栈
不需要保存 SS/RSP
IS -> IDT: ⑥ CPU 从 IDT gate 读 handler 地址
IDT -> H: ⑦ CS:RIP 加载为 handler 入口
压入 error_code (若有)
handler 在 IST 栈上开始执行
H -> IS: ⑧ SAVE_ALL
压全部通用寄存器到 IST 栈
note right #C8E6C9
  与情况一完全一致:
  全程在 IST 栈上运行
  只是 IST 栈上只保存了 3 个硬件值
  返回时需显式切回内核栈
end note
H -> H: ⑨ do_IRQ 上半部 + 下半部
H -> IS: ⑩ RESTORE_ALL
从 IST 栈弹出全部通用寄存器
H -> KS: ⑪ iret 之前显式恢复 RSP
从 IST 栈切回 Task A 的内核栈
H -> KS: ⑫ iret 从内核栈弹出
RIP、CS → 回到被打断的内核代码
RFLAGS → 恢复标志位
note over KS: ⑬ Task A 回到内核栈
继续执行被中断的 syscall
@enduml
```

**两者完整对比**：

|  | 用户态被中断 | 内核态被中断 |
|--|-------------|------------|
| Task A 此时在哪 | ring 3，RSP → 用户栈 | ring 0，RSP → 内核栈 |
| CPU 第一步 | 从 TSS IST[5] 读 RSP → 切到 IST 栈 | 从 TSS IST[5] 读 RSP → 切到 IST 栈 |
| 硬件压栈 | **5 个** SS/RSP/RFLAGS/CS/RIP | **3 个** RFLAGS/CS/RIP |
| Handler 在哪运行 | IST 栈 | IST 栈 |
| 返回时 RSP 恢复 | iret 自动恢复 SS:RSP → 切回用户栈 | 内核显式恢复 RSP → 切回内核栈 |
| **Task A 的内核栈** | **完全不参与** | 返回前恢复 |

> 关键认知：

> 1. **IST 是一次性直接切换**：只要 IDT entry 的 IST ≠ 0，CPU 就**直接从 TSS.IST[n] 加载 RSP**，不存在"先切到内核栈、再切到 IST 栈"的两段切换。

> 2. **Task A 的内核栈在情况一中完全不被触及**——这正是 IST 的核心价值：彻底隔离，中断处理不会污染被中断 task 的栈。

> 3. **情况一的完整往返**：ring 3 → CPU 读 IST[5] 切栈 → IST 栈上压 5 个用户态值 → handler 执行 → iret 从 IST 栈弹出 5 个值 → SS:RSP 恢复 → 回到 ring 3。

**中断栈大小**：per-CPU 的中断栈通常为 **16KB**（`CONFIG_IRQ_STACK_SIZE`），独立的 NMI 栈也是 16KB。相比进程内核栈（通常 8-16KB），中断处理的嵌套深度有限，16KB 足够。

### 3.2 中断上下文的运行时限制

理解了 IST 栈切换后，还需要理解中断 handler 运行时受到的各种"与众不同"的约束。这些约束不是特权不够，而是**上下文缺失**导致的。

#### 3.2.1 没有 task_struct —— 中断是一个没有"身份证"的内核代码路径

中断处理程序没有 `task_struct`，不能用 `ps` 看到它、调度器也感知不到它。内核用 `preempt_count` 字段中的 bit 来标记当前 CPU 是否在中断上下文中：

```bash
preempt_count 的低位编码了当前 CPU 的"原子上下文"深度：
  bit 0-7:   PREEMPT_MASK      （可抢占计数）
  bit 8-15:  SOFTIRQ_MASK       （软中断计数）
  bit 16-19: HARDIRQ_MASK       （硬中断嵌套深度）
  bit 20:    NMI_MASK           （NMI 标志）
  bit 21:    PREEMPT_NEED_RESCHED（需要重新调度标志）
```

内核提供三个宏来判断当前是否在中断上下文：

| 宏 | 含义 | 判断条件 |
|----|------|---------|
| `in_irq()` | 正在硬中断 handler 中 | `preempt_count & HARDIRQ_MASK != 0` |
| `in_softirq()` | 正在软中断中 | `preempt_count & SOFTIRQ_MASK != 0` |
| `in_interrupt()` | 在任意中断上下文 | `in_irq() \|\| in_softirq() \|\| in_nmi()` |

**为什么没有 task_struct 会导致关键限制**：

- 睡眠意味着"把当前 task 设为 SLEEPING → 切走"——没有 task 可设、没有可挂起的实体。睡了就再也回不来。
- 调度器选下一个 task 依赖 `current` 宏（从内核栈底读 `task_struct` 指针）。在中断上下文中 `current` 指向**被打断的那个 task**，但它不是"真正的中断处理进程"——选择调度它没有意义。
- `copy_from_user` / `copy_to_user` 依赖 `current->mm` 来解析用户态地址。中断上下文能用是因为它借用了被打断 task 的 mm，但这个 task 的页表可能正在被其他核修改（如 `munmap`），直接访问用户地址不安全。

**和内核线程的对比**：

| | 软中断 (softirq) | 工作队列 (workqueue) | 内核线程 (`kworker`) |
|---|---|---|---|
| 有 task_struct | ❌ 没有 | ✅ 有（kworker 线程） | ✅ 有 |
| 可被调度器看到 | ❌ | ✅ | ✅ |
| 能睡眠 | ❌ | ✅ | ✅ |
| 适用场景 | 极轻量、不可阻塞的活 | 需要睡眠/较重的活 | 通用的内核后台任务 |

> 内核线程（`kworker`）是中断处理想"睡觉"时的逃生出口——把需要阻塞的活丢给 workqueue，它切换到了进程上下文去跑。正是 workqueue 拥有 task_struct，才敢做 mutex_lock 和 kmalloc(GFP_KERNEL) 这种事。详见 §六。

#### 3.2.2 不切换页表 —— 为什么能又不安全

中断处理程序**不切换 CR3**，地址空间仍然是被打断线程的。这在带来便利的同时也有深刻的设计隐患。

**为什么不换是可行的**：内核地址空间在所有进程中是**共享**的——无论被打断的是进程 A 还是 B，内核代码段、内核数据段、内核堆栈、设备 MMIO 映射都在同一块虚拟地址区域内（x86-64 的 `0xffff800000000000` ~ `0xffffffffffffffff`）。中断 handler 和它要访问的内核数据结构都在这个共享区域内，CR3 指向谁不重要。

```bash
虚拟地址空间布局（x86-64, 4-level paging）：
┌──────────────────────────────┐ 0xffffffffffffffff
│  内核空间（所有进程共享）        │
│  · 内核代码 .text              │ ← 中断 handler 在这里
│  · 内核数据 / 设备 MMIO        │ ← 需要访问的数据结构在这里
│  · 内核栈 (per-task)           │
│  · 中断栈 (per-CPU, IST)      │ ← 中断跑在这
├──────────────────────────────┤ 0xffff800000000000 (hole)
│         未映射的 gap            │
├──────────────────────────────┤ 0x00007fffffffffff
│  用户空间（进程私有）            │ ← CR3 在这里有差异
│  · 进程 A 的代码/堆/栈          │
└──────────────────────────────┘ 0x0000000000000000
```

**为什么不安全**：

- **缺页风险**：如果被中断的 task 的页表中，中断 handler 要访问的某个内核虚拟地址**恰好不在 TLB** 中且需要走 page walk——而 page walk 过程中涉及的各级页表页**本身**可能被换出 → 缺页异常。内核栈通常是锁定的（不会换出），但访问某些动态分配的内核内存时就有风险。
- **用户地址不可直接访问**：中断 handler 不能直接用 `copy_from_user(用户态地址)`——虽然页表是同一个，但此时 `current` 指向被打断的 task，而这个 task 的 `mm_struct` 可能正在被另一个 CPU 修改（比如正在 `munmap`）。这就是为什么中断 handler 如需访问用户数据，必须走 workqueue 到进程上下文。
- **访问被中断 task 的内核栈**：大部分情况合法（内核栈页不会被换出），但要警惕不要写越界——中断栈和内核栈是独立的。

> 与进程上下文切换的最核心对比：进程切换要 `switch_mm`（换 CR3），中断不换。也正因为不换，它才没有"地址空间切换"的开销——硬件压栈 + IDT 跳转只花几十个周期，远比 `switch_mm` 的数百个周期 + TLB 失效要轻。

#### 3.2.3 全程内核态 —— Ring 0 的特权与限制

中断处理代码全程在 **CPL=0（Ring 0 / 内核态）** 运行。这意味着：

**能做的事**：

- 执行特权指令（`cli`/`sti` 开关中断、`mov cr3` 改页表、写 MSR 寄存器、访问 IO 端口）。
- 访问全部内核虚拟地址空间，包括设备 MMIO 区域。
- 修改所有内核数据结构（但要持锁）。

**不能做的事——并非特权不够，而是上下文缺失**：

- **不能调度（`schedule()`）**：没有合法的 task_struct 可切换，原因见 §2.4。
- **不能触碰用户态地址**：`get_user()` / `put_user()` 会检查 `current->mm` 的正确性，以及页表是否有效。对端的用户页可能在另一个 CPU 上正被回收，存在 TOCTOU 风险。应该走 `copy_from_user_inatomic()`（不做缺页修复的尝试）。
- **不能用 `mutex_lock()`**：互斥锁在拿不到时会 `schedule()` 睡眠——回到"不能调度"的问题。

`iret`（Interrupt Return）是中断的唯一出路。它一次性地恢复 CS/RIP/RFLAGS/SS/RSP，特权级可能从 Ring 0 切回 Ring 3（如果被中断时在用户态），也可能留在 Ring 0（如果中断嵌套）。在 `iret` 之前，内核会检查 `TIF_NEED_RESCHED` 标志——这是衔接"中断上下文"和"真实进程调度"的关键点。

### 3.3 中断返回时的调度触发 —— 何时真正发生进程切换

中断本身不涉及调度，但中断返回是**触发进程调度最频繁的入口**。整个过程如下：

```plantuml
@startuml
!pragma teoz true
skinparam backgroundColor #FAFAFA
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
}
participant "Task A\n(运行中)" as A
participant "CPU 硬件" as CPU
participant "中断 handler\n(中断上下文)" as IRQ
participant "软中断\n(softirq)" as SOFT
participant "调度检查点\n(ret_to_user)" as RET
participant "Task B\n(就绪/高优先)" as B
A -> A: Task A 在用户态执行
CPU -> CPU: 外设发中断 → 硬件自动保存\n5 寄存器 → 切中断栈 → IDT
CPU -> IRQ: 跳到 handler 入口
IRQ -> IRQ: ① 上半部执行\n应答设备、搬运数据
IRQ -> IRQ: ② 标记下半部(softirq pending)\n或 try_to_wake_up(B)\n把 Task B 加入就绪队列
note right #FFCDD2: Task B 只是"就绪"了\n还没被运行\nA 仍在"current"
IRQ -> IRQ: ③ 上半部返回 do_IRQ 之前\n检查 pending softirq
IRQ -> SOFT: ④ 有 pending → 执行 softirq\n(仍在中断上下文！)
SOFT -> SOFT: ⑤ NET_RX 走协议栈\n数据入 socket 缓冲区
note right #C8E6C9: softirq 中可以唤醒\n更多等待进程(也是\n只标记就绪,不切)
SOFT -> IRQ: ⑥ softirq 执行完毕\n准备 iret 返回用户态
IRQ -> RET: ⑦ iret 返回路径上\n进入 ret_to_user / 中断返回前
RET -> RET: ⑧ 检查 TIF_NEED_RESCHED！
alt TIF_NEED_RESCHED = 1 (Task B 优先级更高)
  RET -> RET: ⑨a 调用 schedule()
  RET -> B: ⑨b context_switch(A, B)\n→ 这里才发生真正的上下文切换！
  B -> B: ⑩ Task B 开始运行
  note right #C8E6C9: 一次硬件中断\n触发了一次非自愿上下文切换\nA 被统计: nvcswch+1
else TIF_NEED_RESCHED = 0 (没有更高优先级的任务)
  RET -> A: ⑨b iret 直接返回用户态
  A -> A: ⑩ Task A 从断点继续\n完全无感
  note right #FFF9C4: 这次中断从头到尾\n没有发生进程调度
end
@enduml
```

**关键时间线总结**：

```bash
中断到达 → 硬件保存/切栈/查 IDT(几十周期) → 上半部(几微秒)
     → softirq(可选,几微秒~几十微秒) → iret 返回前检查 need_resched
     → 若需调度 → context_switch(1-5μs) → 新 task 运行
```

> **核心结论**：中断上下文中的"处理"（上半部+下半部）全程不发生进程调度，唤醒操作只是改了 task 的状态位和队列。真正的进程切换发生在 `iret` 返回前的 `TIF_NEED_RESCHED` 检查点。这也是为什么使用 `pidstat -w` 看到的 `nvcswch` 高——那些非自愿切换的触发源十有八九是中断唤醒了更高优先级任务，而切换本身发生在中断返回路径上，不是中断 handler 内部。

---

### 3.4 完整示例：磁盘 DMA 完成中断的全过程

以下将本文所有概念串联到一个真实场景中：

```bash
初始状态：Task A 调 read(fd, buf) → 数据不在 page cache → 发起 DMA 读
         → Task A 设为 UNINTERRUPTIBLE → schedule() → CPU 去跑 Task B
磁盘 DMA 完成后：
1. 磁盘控制器通过 MSI-X 向 CPU 发中断
2. CPU 在 Task B 的用户态指令边界检测到中断 → 硬件走 IST[5] 切中断栈
   → IDT 查 handler → 进入中断上下文
3. 上半部：NVMe 驱动 handler → 应答设备 → 更新 page cache 元数据
   → 标记 BLOCK_SOFTIRQ pending → 返回 do_IRQ
4. 下半部(BLOCK_SOFTIRQ)：page cache 写入完成 → bio_endio()
   → 调用 try_to_wake_up(A)，把 A 加入就绪队列
5. iret 返回前：检查到当前 B 的 need_resched 标志
   → 若 A 优先级 > B → schedule() → context_switch(B, A)
   → A 从 UNINTERRUPTIBLE 状态恢复，继续执行 read() 剩余的代码
6. A 返回用户态，read() 带回数据
全程：
  - 中断上下文处理 × 1（IST 栈隔离，无进程调度）
  - 进程上下文切换 × 1（B → A，发生在 iret 前）
  - A 的 voluntary_ctxt_switches +1（读阻塞时）
```

> 区分要点：中断本身的处理（步骤 2-4）全程在"中断上下文"，发生在 IST 独立栈上，完全没有进程调度。步骤 5 的 `schedule() → context_switch()` 才是真正的进程调度，只不过它不是在中断 handler 中间发生的，而是在 "中断返回前" 的 `need_resched` 检查点触发的。这两者的时序不要混淆。

---

## 四、中断处理的开销 —— 到底多贵、贵在哪

中断不是免费的。理解它的开销构成，才能理解为什么高吞吐场景要减少中断次数（NAPI）、为什么要把中断从关键核挪走（亲和性）。

### 4.1 开销的四个组成部分

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<h>> #FFCDD2
  BorderColor<<h>> #C62828
  BackgroundColor<<c>> #FFE0B2
  BorderColor<<c>> #EF6C00
  BackgroundColor<<p>> #E3F2FD
  BorderColor<<p>> #1976D2
  BackgroundColor<<t>> #C8E6C9
  BorderColor<<t>> #388E3C
}
rectangle "**① 硬件开销 (Hardware Overhead)**\n· CPU 检测 INTR、查 IDT、压栈\n· 流水线冲刷(打断乱序执行)\n· ~ 几十到上百个 CPU 周期" <<h>> as H
rectangle "**② 上下文保存/恢复 (Context Save/Restore)**\n· SAVE_ALL:压全部通用寄存器\n· RESTORE_ALL:弹出恢复\n· 切换内核栈\n· ~ 100-200 个 CPU 周期" <<c>> as C
rectangle "**③ 缓存污染 (Cache Pollution)**\n· handler 代码/数据驱逐原进程的\n  L1/L2 cache 热数据\n· 返回后进程 cache miss 暴增\n· **这是最难量化的开销**" <<p>> as P
rectangle "**④ TLB 冲刷 (TLB Flush)**\n· 如果中断 handler 换了页表\n  (不太常见,但 IPI TLB shootdown 会)\n· 或 handler 访问的内存跨度大\n  导致 TLB miss 页表遍历\n· 单次 TLB miss → 数十到数百周期" <<t>> as T
@enduml
```

### 4.2 量化分析

| 开销类型 | 典型耗时 | 可优化? | 优化手段 |
|---------|---------|---------|---------|
| 硬件中断响应（检测→查IDT→压栈） | ~50-80 周期（约 15-25 ns @3GHz） | 否，CPU 硬件固化的 | — |
| 上下文保存/恢复（SAVE_ALL/RESTORE_ALL） | ~100-200 周期（约 30-65 ns） | 否，架构必需的 | — |
| 上半部 handler 执行 | 1-5 微秒（设计目标） | 是 | 只做最少的事，其他推下半部 |
| 下半部 softirq 执行 | 不定（取决于负载） | 是 | NAPI 轮询、多队列分散 |
| 返回后 L1 cache miss | 每次 4-5 周期（L2 12 周期，L3 40+ 周期） | 部分 | 中断亲和性让 handler 常驻同一核 |
| 返回后 TLB miss | 每次 10-50 周期（取决于页表层级） | 部分 | 大页、中断栈 per-CPU |

### 4.3 中断频率的典型数值

```bash
空闲系统（只有 timer tick）：250 Hz（CONFIG_HZ=250）或 1000 Hz
   → 每秒 250/1000 次中断，开销可忽略
高吞吐网络（10Gbps，小包 64B）：
   满线速 = 14.88 Mpps → 如果每包一次中断 = 1488 万次/秒
   → 每包约 67 ns 预算，中断进出就耗光了 → 必须 NAPI 批量收包
NVMe 磁盘（百万 IOPS）：
   100 万 IOPS → 100 万次 IO 完成中断/秒
   → 必须用中断合并(interrupt coalescing)减少中断次数
```

### 4.4 中断开销的观测

```bash
# 看每个 CPU 硬中断/软中断占比
mpstat -P ALL 1        # %irq(硬中断) / %soft(软中断)
# 看中断总数（vmstat 的 'in' 列）
vmstat 1               # in = 每秒中断次数（含 timer tick）
# 看每个 IRQ 在每个 CPU 上的累计次数
cat /proc/interrupts   # LOC 行 = 本地 timer tick 中断
# 看每种软中断的每核计数
cat /proc/softirqs     # NET_RX/NET_TX 飙升 = 网络繁忙
```

- **`%irq` 高**（硬中断）：设备中断频繁，可能中断风暴、驱动问题。
- **`%soft` 高**（软中断）：多为网络收发繁忙，`cat /proc/softirqs` 看 `NET_RX`/`NET_TX` 增长。
- **`ksoftirqd/<n>` 吃 CPU**：软中断多到当场处理不完，被推给内核线程消化——网络风暴的典型信号。
- **中断总数异常高**：`vmstat` 的 `in` 列远大于 `HZ * 核数` 时，说明除 timer 外还有大量设备中断。

---

## 五、中断的入口基础设施：IDT（中断描述符表）

### 5.1 IDT 是什么

IDT（Interrupt Descriptor Table）是一张**系统级表**，将中断向量号（0~255）映射到对应的 handler 入口地址和特权级。CPU 收到中断向量号后，用它乘以 16（每个 gate descriptor 16 字节）索引 IDT，读出 handler 信息，跳转执行。

```bash
中断向量号 × 16 → IDT 基址 + 偏移 → Gate Descriptor → Handler 地址 + IST/DPL 信息
```

### 5.2 IDT 中的条目类型

| Gate 类型 | 用途 | 典型向量号 |
|-----------|------|-----------|
| **Interrupt Gate** | 外部中断 handler，进入时自动关中断（清 IF） | 32~255（设备 IRQ） |
| **Trap Gate** | 异常 handler，进入时**不关中断** | 0~31（缺页、除零等） |
| **Task Gate** | 硬件任务切换（已废弃，现代 OS 不用） | 极少使用 |

> **Interrupt Gate vs Trap Gate 的关键区别**：Interrupt Gate 进入时 CPU 自动清 IF 标志（关中断），防止嵌套中断；Trap Gate 不清 IF，允许在异常处理期间响应中断。缺页异常用 Trap Gate——因为缺页处理可能触发磁盘 IO，期间必须能响应磁盘完成中断。

### 5.3 前 32 个向量：Intel 保留的异常/陷阱

| 向量号 | 名称 | 类型 | 说明 |
|--------|------|------|------|
| 0 | #DE Divide Error | Fault | 除零 |
| 3 | #BP Breakpoint | Trap | `int 3` 断点指令 |
| 6 | #UD Invalid Opcode | Fault | 非法指令 |
| 8 | #DF Double Fault | Abort | 处理异常时又出异常 |
| 13 | #GP General Protection | Fault | 一般保护错误（权限违规） |
| 14 | #PF Page Fault | Fault | **缺页**（最重要的异常） |
| 18 | #MC Machine Check | Abort | 硬件错误（CPU/内存/总线） |

---

## 六、上半部 / 下半部机制 —— 解决"要快又要干重活"的核心设计

### 6.1 为什么要拆

一个中断 handler 执行期间，通常**同类中断被屏蔽**（甚至全部关中断），这段时间：

- 不能响应新的同类中断 → **handler 拖太久会丢事件**（比如网卡下一个包来了没人收）。
- 别的高优先级事情也被耽误。

但有些中断要干的活不少（处理一个网络包要走完协议栈）。矛盾就是：**handler 要尽量短，可活儿又不少。**

内核的解法——**把中断处理劈成两半**：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<t>> #FFCDD2
  BorderColor<<t>> #C62828
  BackgroundColor<<b>> #C8E6C9
  BorderColor<<b>> #388E3C
}
rectangle "上半部 (top half)\n= 硬中断 handler\n**关着中断、必须极快**\n只做最紧急的:\n· 应答硬件(告诉设备'收到了')\n· 把数据搬到内存/挂个标记\n· 登记'下半部待办',然后立刻返回" <<t>> as T
rectangle "下半部 (bottom half)\n**开着中断、稍后执行**\n干真正的重活:\n· 处理网络包、走协议栈\n· 唤醒等待的进程\n· 可被更高优先级中断打断" <<b>> as B
T -down-> B : 上半部登记待办后返回,\n下半部在'合适的时机'被调度执行
note right of T : 越短越好——\n这期间响应不了新中断
note right of B : 把耗时活儿从'关中断'的\n窗口里挪出来
@enduml
```

- **上半部（硬中断 handler）**：在关中断/屏蔽同类中断的状态下运行，**只做必须立刻做的最少事**：应答设备、把关键数据拿走、标记"还有活要干"，然后火速返回。
- **下半部**：上半部登记的"待办"，在**开中断**的环境里稍后执行，干那些耗时的活。这样"关中断的窗口"被压到最短。

> **一句话**：上半部"快接快应答"，下半部"慢工出细活"。网卡就是典型——上半部只把包从网卡搬进内存队列并应答，下半部（NET_RX 软中断）才慢慢走协议栈。这正是 `mpstat` 里 `%soft` 高常常是网络繁忙的原因。

### 6.2 下半部的三种机制：softirq / tasklet / workqueue

内核实现"下半部"有三种机制，区别在**能不能睡眠**和**并发性**：

| 机制 | 运行上下文 | 能睡眠? | 并发性 | 典型用途 |
|------|-----------|---------|--------|---------|
| **软中断 softirq** | 中断上下文 | **不能** | 同一种可在多核**并行** | 网络收发（NET_RX/NET_TX）、块设备、定时器——**性能最关键、内核预定义的固定几种** |
| **tasklet** | 中断上下文（基于 softirq 实现） | **不能** | 同一个 tasklet **不会并行**（串行化） | 一般设备驱动的下半部，比 softirq 好用、无需静态注册 |
| **工作队列 workqueue** | **进程上下文**（内核线程 `kworker`） | **能睡眠** | 可调度 | 需要睡眠/阻塞、或耗时长的活（如访问慢设备、分配大内存） |

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<s>> #FFE0B2
  BorderColor<<s>> #EF6C00
  BackgroundColor<<w>> #C8E6C9
  BorderColor<<w>> #388E3C
}
rectangle "中断上下文\n(softirq / tasklet)\n· 不能睡眠、不能阻塞\n· 不能调用可能睡眠的函数\n· 快进快出" <<s>> as S
rectangle "进程上下文\n(workqueue → kworker 内核线程)\n· 能睡眠、能阻塞\n· 由调度器正常调度\n· 适合耗时/要等待的活" <<w>> as W
note bottom of S : 为什么不能睡?\n中断上下文没有'进程'可挂起,\n睡了就没人能唤醒它、系统卡死
note bottom of W : 有自己的 task_struct,\n睡了调度器会切别的任务
@enduml
```

**为什么中断上下文不能睡眠**：睡眠意味着"挂起当前任务、切换到别的任务"，但中断上下文**不属于任何进程**（它是借用被打断进程的栈临时跑的），没有可挂起的实体，睡下去就再也回不来 → 死锁/卡死。所以 softirq/tasklet 里**严禁调用可能睡眠的函数**（如加互斥锁、分配可能触发回收的内存）。

### 6.3 Linux 预定义的软中断类型

| 软中断 | 优先级 | 用途 |
|--------|--------|------|
| `HI_SOFTIRQ` | 0（最高） | 高优先级 tasklet |
| `TIMER_SOFTIRQ` | 1 | 定时器到期处理 |
| `NET_TX_SOFTIRQ` | 2 | 网络包发送 |
| `NET_RX_SOFTIRQ` | 3 | 网络包接收（**最高频的软中断**） |
| `BLOCK_SOFTIRQ` | 4 | 块设备 IO 完成 |
| `TASKLET_SOFTIRQ` | 6 | 普通 tasklet |
| `SCHED_SOFTIRQ` | 7 | 调度相关 |
| `HRTIMER_SOFTIRQ` | 8 | 高精度定时器 |
| `RCU_SOFTIRQ` | 9 | RCU 回调处理 |

### 6.4 softirq 的执行时机

softirq 在三个位置被检查和执行：

1. **中断返回路径**（`do_IRQ` 返回前）：最常见，搭中断返回的便车。
2. **`local_bh_enable`**：当代码重新开启下半部时，检查并执行 pending softirq。
3. **`ksoftirqd` 内核线程**：如果 softirq 太多、前两个位置处理不完，内核将它们推给每个核的 `ksoftirqd/<n>` 线程慢慢消化——这时在 `top` 里会看到 `ksoftirqd` 吃 CPU、`%soft` 高。

### 6.5 NAPI（网络中断合并）

高速网络下，如果每个包都来一次中断，中断就会把 CPU 淹没（中断风暴）。NAPI 的做法是：第一个包来了触发中断后，**关掉该网卡后续中断，改用轮询（poll）批量收包**，收完再开中断——**中断 + 轮询混合**，大幅降低高负载下的中断开销。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<int>> #FFCDD2
  BorderColor<<int>> #C62828
  BackgroundColor<<poll>> #C8E6C9
  BorderColor<<poll>> #388E3C
}
rectangle "低负载:中断模式\n每个包一次中断\n延迟低(来了就处理)" <<int>> as INT
rectangle "高负载:轮询模式(NAPI)\n中断 + 关中断 + 批量 poll\n吞吐高(每次处理一批)" <<poll>> as POLL
INT -down-> POLL : 中断频率超阈值,\n自动切换为 NAPI 轮询
POLL -up-> INT : 负载降低后,\n重新开中断
@enduml
```

### 6.6 上半部如何构造下半部任务 —— 三种机制的内部实现

这是本节最核心的问题：**上半部只是"硬中断 handler"，它如何把重活交给下半部？**答案是两种"接力棒"方式——在中断上下文能跑的下半部（softirq/tasklet）用**位图+队列**交接，需要进程上下文的下半部（workqueue）用**唤醒内核线程**交接。

#### 6.6.1 raise_softirq 内部机制：一个位图就搞定

`raise_softirq()` 和 `__raise_softirq_irqoff()`（已关中断时调用）做的事非常简单——**只在当前 CPU 的 `per-CPU` 位图上置一个 bit**：

```bash
__raise_softirq_irqoff(TIMER_SOFTIRQ) 实际做的事：
  1. 读取 per-CPU 变量 softirq_pending(this_cpu)
  2. 把第 TIMER_SOFTIRQ 位设为 1
  3. 写回 softirq_pending(this_cpu)
  没有复杂的队列、
  没有动态分配、
  没有任何可能失败的操作
```

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 10
rectangle "每个 CPU 都有自己的一份" {
  rectangle "softirq_pending[CPU0]" as P0 {
    (<color:green>0</color>) as bit0
    (<color:green>0</color>) as bit1
    (<color:red>1</color>) as bit2
    (<color:green>0</color>) as bit3
    ...
  }
  rectangle "softirq_pending[CPU1]" as P1 {
    (<color:green>0</color>) as bit0_1
    (<color:green>0</color>) as bit1_1
    (<color:green>0</color>) as bit2_1
    (<color:green>0</color>) as bit3_1
    ...
  }
}
note right of P0
  CPU0 上来了定时器中断
  上半部调用 __raise_softirq_irqoff(TIMER_SOFTIRQ)
  → CPU0 的 bit 1 被置 1 (图中红色)
  CPU1 完全不受影响
end note
note right of P1
  网卡中断亲和到了 CPU1
  → CPU1 上 bit 3 (NET_RX) 被置 1
  "谁触发、标记谁的"
end note
@enduml
```

**关键设计**：

- **无锁**：每个 CPU 只改自己的 `softirq_pending`，不需要任何锁
- **极快**：就是一条 `or` 指令（外加一个 `wakeup_softirqd` 检查），几个 CPU 周期
- **无开销**：没有内存分配、没有链表操作，不会失败

#### 6.6.2 do_softirq 如何执行待办任务

当代码在三个执行点（中断返回 / `local_bh_enable` / `ksoftirqd`）发现 `local_softirq_pending() != 0` 时，调用 `__do_softirq()`：

```bash
__do_softirq():
  1. 重置本地 pending 位图为 0（用 xchg 原子交换）
  2. for (pending != 0; prio = 0..NR_SOFTIRQS; prio++) :
       if (pending & (1 << prio)):
           调用 softirq_vec[prio].action(softirq_vec[prio])
           // → 在中断上下文中执行！不能睡眠
  3. 如果执行完又有新的 pending（说明下半部又触发了下半部）:
       → ksoftirqd 被唤醒，剩下的活交给它
         （防止下半部无限递归导致栈溢出或饿死别的任务）
```

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 10
participant "硬中断返回路径\n(irq_exit)" as IRQ
participant "do_softirq" as SOFT
participant "ksoftirqd/n" as KS
IRQ -> SOFT: invoke_softirq()
SOFT -> SOFT: 1. old = xchg(&pending, 0)\n原子取出所有待办位
SOFT -> SOFT: 2. 从 HI_SOFTIRQ → RCU_SOFTIRQ\n逐一遍历 pending 位
SOFT -> SOFT: 3. 找到置位 → 调用 softirq_vec[n].action()
SOFT -> SOFT: 4. 如果一次遍历后有新的 pending\n→ 最多重复 MAX_RESTART 次(10次)
SOFT -> KS: 第 11 次还有 pending?\nwakeup_softirqd() → ksoftirqd 接管
note right of KS: ksoftirqd 是进程上下文\n可以慢慢处理\n不会被硬中断打断
@enduml
```

**注册 softirq**：`open_softirq(NET_RX_SOFTIRQ, net_rx_action)` 在启动时把 `net_rx_action` 函数指针填入 `softirq_vec[NET_RX_SOFTIRQ].action`。**softirq 是编译期静态分配的，不能动态增删类型**——这就是为什么普通驱动不用 softirq 而用 tasklet/workqueue。

#### 6.6.3 tasklet_schedule 内部机制：基于软中断的轻型队列

tasklet 是对 softirq 的高级封装——底层走 `TASKLET_SOFTIRQ` 或 `HI_SOFTIRQ`，但加了**自动序列化**。

**数据结构**：

```bash
struct tasklet_struct {
    void (*func)(unsigned long);   // 下半部函数
    unsigned long data;            // 传给 func 的参数
    unsigned long state;           // 状态位:
                                   //   bit 0 (TASKLET_STATE_SCHED): 已排入队列
                                   //   bit 1 (TASKLET_STATE_RUN):   正在执行
    struct tasklet_struct *next;   // 链表节点
};
```

**`tasklet_schedule(t)` 做的事**：

```bash
tasklet_schedule(t):
  1. 检查 t->state & TASKLET_STATE_SCHED
     → 如果已设置，直接返回（不会重复排队）
  2. 设置 t->state |= TASKLET_STATE_SCHED
  3. 把 t 头插到 per-CPU 链表 tasklet_vec[cpu]
  4. raise_softirq_irqoff(TASKLET_SOFTIRQ)
     → 通知 softirq 框架：这 CPU 有 tasklet 要跑
```

**执行时（tasklet_action 被 do_softirq 调用）**：

```bash
tasklet_action():
  1. 取出当前 CPU 的 tasklet_vec 链表
  2. 遍历链表，对每个 tasklet:
       if (t->state & TASKLET_STATE_RUN):
            → 跳过！（已被其他 CPU 执行中）
       else:
           设置 t->state |= TASKLET_STATE_RUN
           调用 t->func(t->data)
           清除 TASKLET_STATE_RUN | TASKLET_STATE_SCHED
```

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 10
participant "驱动上半部\n(硬中断 handler)" as TOP
participant "per-CPU\ntasklet_vec" as VEC
participant "softirq 位图\n(TASKLET_SOFTIRQ)" as BIT
participant "do_softirq →\ntasklet_action" as DISP
TOP -> VEC: 1. tasklet_struct 头插链表
note right of VEC: 每个 CPU 有独立的链表\n无锁操作
VEC -> BIT: 2. __raise_softirq_irqoff(TASKLET_SOFTIRQ)
note right of BIT: 就设置一个 bit\n和 6.6.1 完全一样
BIT -> DISP: 3. 中断返回时 do_softirq 发现 pending
DISP -> DISP: 4. 遍历 tasklet_vec 链表\n逐个执行 t->func()
note right of DISP
  同一个 tasklet 如果正在其他 CPU 上执行
  → TASKLET_STATE_RUN 已置位 → 跳过
  → 同一个 tasklet 不会同时跑在两个核上
  但不同 tasklet 可以并行
end note
@enduml
```

> **tasklet 和 softirq 的本质关系**：tasklet 就是一堆 `tasklet_struct` 挂在 per-CPU 链表上，执行时机由 `TASKLET_SOFTIRQ` 这个软中断号触发。driver 开发者不需要知道 `softirq_vec[]` 的存在——`tasklet_schedule()` 和 `tasklet_init()` 两个 API 就够。

#### 6.6.4 queue_work 内部机制：从中断上下文跳转到进程上下文

工作队列和 softirq/tasklet 的**根本区别**：下半部跑在**内核线程 `kworker`** 里，有完整的 `task_struct`，可以睡眠。

**数据结构**：

```bash
struct work_struct {
    work_func_t  func;         // 下半部函数
    struct list_head  entry;   // 链表节点
};
// per-CPU 工作队列
struct pool_workqueue {
    struct worker_pool *pool;  // 绑定的 worker 池
    struct list_head   worklist; // 待办 work 链表
};
```

**`queue_work(wq, work)` 做的事**：

```bash
queue_work(wq, work) / schedule_work(work):  // schedule_work 默认用 system_wq
  1. 把 work 尾插入 pool_workqueue->worklist 链表
  2. 检查是否有空闲的 kworker 线程
     → 有：唤醒它
     → 没有且允许：创建新的 kworker
  3. 返回
```

**kworker 执行**：

```bash
kworker 线程主循环（进程上下文！）:
  1. 从 pool_workqueue->worklist 取出一个 work_struct
  2. 调用 work->func(work)
     → 这里可以 sleep、拿 mutex、分配大内存
  3. 检查是否有新 work 入队
     → 有：继续执行
     → 没有：让出 CPU，进入睡眠
```

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 10
participant "上半部\n(硬中断 handler)" as TOP
participant "pool_workqueue\n->worklist" as WLIST
participant "kworker/n\n(内核线程)" as KW
participant "调度器" as SCHED
== 上半部: 排队 work ==
TOP -> WLIST: 1. queue_work(wq, work)\nwork 尾插入链表
WLIST -> TOP: 2. 立即返回（极快）
note right of TOP: 上半部只排队 + 唤醒 kworker\n不做真正的工作
== kworker: 执行 work ==
TOP -> KW: 3. wake_up_process(kworker)
KW -> SCHED: 4. 变为 RUNNABLE，等调度
SCHED -> KW: 5. 调度器选中，开始执行
KW -> KW: 6. work->func(work)\n可以 sleep / block / 拿 mutex
note right of KW: 和上半部完全不同的世界\n有 task_struct\n可以睡眠、被抢占
@enduml
```

> **关键差异**：`queue_work` 从上半部返回前，**work 还没执行**——只是排了队、唤醒了 kworker。kworker 什么时候开始跑，由调度器决定。而 softirq/tasklet 在中断返回时就可以执行（还在中断上下文里）。

#### 6.6.5 三种下半部交接方式总结

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 10
rectangle "上半部\n(硬中断 handler)" as TOP
rectangle "softirq\n(位图)" as SOFT
rectangle "tasklet\n(per-CPU 链表)" as TASK
rectangle "workqueue\n(worklist + kworker)" as WQ
TOP -down-> SOFT : raise_softirq_irqoff()\n→ 原子 or 一个 bit\n→ 几个 CPU 周期
TOP -down-> TASK : tasklet_schedule()\n→ 头插 per-CPU 链表\n→ 再 raise_softirq(TASKLET)
TOP -down-> WQ : queue_work()\n→ 尾插 worklist\n→ wake_up_process(kworker)
note right of SOFT
  语义: "这个 CPU 有活要干"
  数据交接: 上半部把数据写入
    共享的 ring buffer / 描述符
    下半部从同一个 buffer 读
  最轻量、最快
end note
note right of TASK
  语义: "这个 tasklet 需要跑"
  比 softirq 多了序列化保证
  同一个 tasklet 不并发
end note
note right of WQ
  语义: "帮我在进程上下文跑这段代码"
  可以睡眠、拿锁、分配内存
  但延迟最高 (等调度器)
end note
@enduml
```

**上半部和下半部之间的数据交接**：通常是通过上半部申请一块内存（或从 ring buffer 预分配中取一块）→ 填数据 → 挂到共享队列 → 下半部从队列中取出来处理。**数据本身不经过 raise_softirq / tasklet_schedule / queue_work 传递**——这些 API 只传递"有活要干"这个信号。

#### 6.6.6 NAPI 的 handoff 具体流程（最完整的实例）

以 Intel igb 网卡驱动为例，看上半部怎么把数据"交接"给下半部：

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 10
participant "网卡硬件" as NIC
participant "上半部\nigb_msix_ring()" as TOP
participant "napi_struct\n(per-queue)" as NAPI
participant "softirq 位图\n(NET_RX_SOFTIRQ)" as BITMAP
participant "下半部\nigb_poll()" as BOTTOM
participant "协议栈" as STACK
== 第一个包触发中断 ==
NIC -> TOP: MSI-X 中断\n"RX queue 0 有包"
== 上半部: 三步交接 ==
TOP -> TOP: 1. 读硬件寄存确认 RX 事件
TOP -> TOP: 2. 写 IMC 掩码寄存器\n屏蔽该队列后续 MSI-X
note right of TOP: 关中断防风暴\n后续包靠 poll 收
TOP -> NAPI: 3. napi_schedule(&napi)
note right of NAPI
  a) 设置 NAPI_STATE_SCHED
  b) napi_struct 加入 CPU 的 softnet_data->poll_list
  c) 调用 __raise_softirq_irqoff(NET_RX_SOFTIRQ)
end note
NAPI -> BITMAP: raise_softirq(NET_RX_SOFTIRQ)
BITMAP -> TOP: 4. 上半部返回
== 下半部: 中断返回时执行 ==
BITMAP -> BOTTOM: 中断返回 → do_softirq → net_rx_action\n→ 遍历 poll_list → igb_poll(napi, 64)
BOTTOM -> NIC: 5. 循环读硬件 RX 描述符环\n最多收 64 个包
NIC --> BOTTOM: 每个包: 数据在 DMA buffer 里
BOTTOM -> STACK: 6. napi_gro_receive()\n把 skb 送入协议栈
BOTTOM -> NIC: 7. 收完 → 重新分配 DMA buffer\n更新描述符环的 OWN 位
BOTTOM -> NAPI: 8. 如果收够了 budget(64) 还没收完\n→ 再 napi_schedule() 一次\n如果收完了 → 清除 NAPI_STATE_SCHED\n重新开 MSI-X 中断
@enduml
```

> 这就是为什么 NAPI 在高负载下效率高：一次中断 → 批量收 64 个包 → 如果还没收完再继续。从"一包一中断"变成了"一中断收一批"。而整个 handoff 的关键数据结构就是 `napi_struct`——它在 `igb_msix_ring`（上半部）和 `igb_poll`（下半部）之间传递，包含了收包的 ring buffer 指针和当前处理状态。

#### 6.6.7 ksoftirqd：当下半部太多时的安全阀

如果 do_softirq 循环 10 次后还有 pending（新包不断涌来），或者检测到执行时间过长，**下半部不能无限循环**——会把调度器饿死、让别的进程得不到 CPU。这时内核的"泄洪"机制：

```bash
irq_exit()
  → __do_softirq()
    → 第 11 次循环还有 pending?
      → wakeup_softirqd()  ← 唤醒 ksoftirqd/n
        → ksoftirqd 在进程上下文执行 run_ksoftirqd()
          → 和 do_softirq 一样遍历 softirq_vec[]
          → 但可以被抢占、可以被调度器换下去
```

**`%soft` 高的时候看什么**：

```bash
# ksoftirqd 就是 %soft CPU 时间的来源
ps aux | grep ksoftirqd
# 查看每个核的 ksoftirqd
ls /proc/*/comm | grep ksoftirqd
# 查看网络软中断是否在 ksoftirqd 里跑
cat /proc/softirqs | grep NET_RX
```

---

## 七、一次网卡收包的完整中断流程（全部串起来）

```plantuml
@startuml
!pragma teoz true
skinparam backgroundColor #FAFAFA
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "网卡" as NIC
participant "CPU/上半部\n(硬中断 handler)" as TH
participant "下半部\n(NET_RX softirq)" as BH
participant "内核协议栈\n+ socket" as NET
participant "用户进程\n(阻塞在 recv)" as APP
NIC -[#37474F]-> TH : ① 收到包 → 发 IRQ 打断 CPU
TH -[#37474F]-> TH : ② 上半部:应答网卡、\n把包放进内存队列、\n触发 NET_RX 软中断,**立刻返回**
TH -[#37474F]-> BH : ③ 稍后(开中断环境)执行下半部
BH -[#37474F]-> NET : ④ 走协议栈:IP/TCP 处理、\n找到对应 socket、数据入接收缓冲
NET -[#37474F]-> APP : ⑤ 唤醒阻塞在 recv 的进程\n→ 标记它可运行,塞回运行队列
APP -[#37474F]-> APP : ⑥ 调度器择机让它跑,\nrecv 返回,拿到数据
note over TH #FFCDD2
关中断窗口极短(只搬数据+应答)
end note
note over BH,NET #C8E6C9
重活在这里,开着中断、可被打断
end note
@enduml
```

这串流程把本仓库几条线连了起来：**中断（本篇）** → 唤醒进程 → **加入运行队列、等调度（[scheduling.md](/concepts/process/scheduling.md)）** → 进程 `recv` 返回是一次**系统调用（[syscall.md](/concepts/process/syscall.md)）**。

---

## 八、多核与中断亲和性

- **中断默认落在某个核**：每个 IRQ 由某个（或某组）CPU 核处理。默认可能全压在 CPU0，导致 `mpstat` 里 CPU0 的 `%soft`/`%irq` 爆高、别的核闲着。
- **中断亲和性 `/proc/irq/<n>/smp_affinity`**：用位图指定某个 IRQ 允许被哪些核处理，把它**从关键业务核挪走**，别让软中断抢关键线程的 CPU（详见 [irq-affinity.md](/concepts/process/irq-affinity.md)，含 IO-APIC/MSI-X 硬件机制、irqbalance 原理、网卡多队列实践）。
- **网卡多队列（RSS）/RPS/RFS**：现代网卡有多个硬件队列，可把收包中断**分散到多个核**并行处理；软件层的 RPS/RFS 则在软中断阶段把包分给不同核。这是高吞吐网络消除"单核软中断瓶颈"的标准手段。
- **IPI（处理器间中断）**：一个核要通知别的核做事（如 TLB shootdown——改了页表要让别的核刷 TLB，见 [../cache/tlb.md](/concepts/cache/tlb.md)；或触发别的核重新调度）就发 IPI。

---

## 九、观测命令速查

```bash
cat /proc/interrupts          # 每个 IRQ 在每个 CPU 上的累计次数(看中断压在哪个核)
cat /proc/softirqs            # 每种软中断(NET_RX/NET_TX/TIMER/SCHED...)在每核的次数
mpstat -P ALL 1               # 逐核 %irq(硬中断)/%soft(软中断)占比 —— 看是否集中单核
cat /proc/irq/<n>/smp_affinity        # 查某 IRQ 的亲和性掩码(hex 位图)
echo 4 > /proc/irq/<n>/smp_affinity   # 把 IRQ n 绑到 CPU2(bit2=1),需 root
top                           # ksoftirqd/<n> 高 或 %si 高 → 软中断繁忙(多为网络)
vmstat 1                      # 'in' 列 = 每秒中断数,'cs' = 上下文切换
```

- **`%irq` 高**（硬中断）：设备中断频繁，可能中断风暴、驱动问题。
- **`%soft` 高**（软中断）：多为网络收发繁忙，`cat /proc/softirqs` 看 `NET_RX`/`NET_TX` 增长，考虑网卡多队列/RPS 分散。
- **`ksoftirqd` 吃 CPU**：软中断多到当场处理不完，被推给内核线程消化——网络风暴的典型信号。

---

## 十、和本仓库其他文档的关系

- **对照：系统调用**：[syscall.md](/concepts/process/syscall.md)——同样进内核态，但系统调用是进程主动同步陷入，中断是外设异步打断；老式 `int 0x80` 正是"软中断陷入"（本文 §1.3）。
- **中断之后**：[scheduling.md](/concepts/process/scheduling.md)——下半部唤醒进程 → 进运行队列 → 等调度；IPI 可触发重新调度。
- **中断上下文切换**：[context-switch.md](/concepts/process/context-switch.md) §4.3——'借壳执行'的关键特征总览与引用；详细分析见本文 §2.4（不能睡眠）、§3.1（IST 栈切换）、§3.2（运行时限制）、§3.3（中断返回与调度）。
- **中断亲和/多核**：[irq-affinity.md](/concepts/process/irq-affinity.md)（IRQ affinity 完整论述：硬件机制/smp_affinity/irqbalance/RSS 配合）、[thread-affinity.md](/concepts/process/thread-affinity.md)（线程绑核，与中断绑核互为补充）、[../../tools/cpu/mpstat.md](/tools/cpu/mpstat.md)（`%irq`/`%soft` 逐核）。
- **异常也是中断**：[../elf/mmap.md](/concepts/elf/mmap.md)/[../cache/tlb.md](/concepts/cache/tlb.md)（缺页异常走 IDT）、[../../crash/signals.md](/crash/signals.md)（SIGSEGV 源于缺页异常无法满足）。
- **DMA 靠中断通知**：[../io/dma.md](/concepts/io/dma.md)——DMA 传输完成后通过中断通知 CPU。
- **观测数据源**：[../../tools/proc/procfs.md](/tools/proc/procfs.md)（`/proc/interrupts`、`/proc/softirqs`）、[../../tools/cpu/vmstat.md](/tools/cpu/vmstat.md)（`in` 中断数）。
- **perf 用 NMI**：[../../tools/code/perf-internals.md](/tools/code/perf-internals.md)（PMU 采样中断 PMI 走 NMI）。

---

## 十一、一句话总结

> **中断是硬件异步打断 CPU、逼内核放下手头活去处理外设事件（网卡/磁盘/时钟）的机制。从分类上分为五类：外部中断（IRQ，异步可屏蔽）、异常（指令出错，同步不可屏蔽）、软中断陷入（系统调用入口，同步主动）、NMI（硬件紧急，不可屏蔽）、IPI（核间通信，异步）。内核面临五大设计矛盾——响应延迟 vs 处理工作量、单核集中 vs 多核分散、关中断保护临界区、中断上下文不能睡眠、中断风暴——分别通过上半部/下半部拆分、中断亲和性与多队列、spin_lock_irqsave、workqueue 进程上下文、NAPI 中断+轮询来解决。完整流程从硬件信号经 APIC→IDT→TSS.IST 栈切换→汇编入口→do_IRQ→上半部→下半部→iret 返回；IST 确保中断在 per-CPU 独立栈上运行，与 task 内核栈完全隔离；中断上下文因无 task_struct 而不可睡眠/不可调度，唯一的调度触发点在 iret 返回前检查 TIF_NEED_RESCHED。中断开销来自四部分：硬件响应、上下文保存恢复、缓存污染、TLB 冲刷，其中缓存污染最难量化但影响最大。**

