中断处理完整流程:从 IRQ 信号到 handler 返回
这是 interrupts(内核中断处理总纲)的拆分篇之一,专门讲"一次中断从硬件信号到处理完毕的完整旅程"。配套拆分篇:分类体系与设计考量 · 上半部 / 下半部机制与内部实现 · 开销量化。
以下以 x86-64 架构为例,追踪一次外部中断从硬件信号到软件处理完毕的完整路径:

流程逐步拆解:
| 步骤 | 谁在做 | 做什么 | 耗时量级 |
|---|---|---|---|
| ①→② | 外设 + 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内核线程。
IST 栈切换机制 —— 硬件如何为中断准备独立运行环境
x86-64 提供了 IST(Interrupt Stack Table) 机制,在 TSS(Task State Segment)中为每种中断类型预设独立的栈指针。当中断发生时,CPU 根据 IDT 条目的 IST 字段,直接从 TSS.IST[n] 加载 RSP,一次性切到独立中断栈——task 的内核栈完全不被触及。
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] → (保留) │
└──────────────────────────┘为什么需要独立中断栈:
- 防止栈溢出:如果中断直接借被打断线程的内核栈,而该栈已经接近用完(深度递归、大的局部变量),中断 handler 再压栈 → 栈溢出 → 静默损坏相邻数据 → 难排查的随机崩溃。
- 隔离故障:独立栈把中断处理和被中断的 task 隔离开,栈损坏不会互相影响。
- NMI 尤其需要:NMI 可能在任何时候到达(包括在内核栈即将耗尽时),它必须有自己可靠的栈才能完成诊断/panic 日志记录。
下面以 IDT entry 配置了 IST=5 的设备中断为例,分两种场景追踪硬件栈切换的完整时序。
情况一:Task A 在用户态时被中断
CPU 发现 CPL 改变(3→0),直接从 TSS.IST[5] 读栈指针,所有状态保存 + handler 执行都在 IST 栈上完成——Task A 的内核栈完全不参与:

情况二:Task A 在内核态时被中断
CPL 不变(0→0),同样直接切到 IST 栈。硬件只压 3 个值(无需保存 SS/RSP);iret 返回后内核需要显式恢复 RSP 回到内核栈继续执行:

两者完整对比:
| 用户态被中断 | 内核态被中断 | |
|---|---|---|
| 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 的内核栈 | 完全不参与 | 返回前恢复 |
关键认知:
- IST 是一次性直接切换:只要 IDT entry 的 IST ≠ 0,CPU 就直接从 TSS.IST[n] 加载 RSP,不存在"先切到内核栈、再切到 IST 栈"的两段切换。
- Task A 的内核栈在情况一中完全不被触及——这正是 IST 的核心价值:彻底隔离,中断处理不会污染被中断 task 的栈。
- 情况一的完整往返: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 足够。
中断上下文的运行时限制
理解了 IST 栈切换后,还需要理解中断 handler 运行时受到的各种"与众不同"的约束。这些约束不是特权不够,而是上下文缺失导致的。
没有 task_struct —— 中断是一个没有"身份证"的内核代码路径
中断处理程序没有 task_struct,不能用 ps 看到它、调度器也感知不到它。内核用 preempt_count 字段中的 bit 来标记当前 CPU 是否在中断上下文中:
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) 这种事。详见 下半部机制。
不切换页表 —— 为什么能又不安全
中断处理程序不切换 CR3,地址空间仍然是被打断线程的。这在带来便利的同时也有深刻的设计隐患。
为什么不换是可行的:内核地址空间在所有进程中是共享的——无论被打断的是进程 A 还是 B,内核代码段、内核数据段、内核堆栈、设备 MMIO 映射都在同一块虚拟地址区域内(x86-64 的 0xffff800000000000 ~ 0xffffffffffffffff)。中断 handler 和它要访问的内核数据结构都在这个共享区域内,CR3 指向谁不重要。
虚拟地址空间布局(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 失效要轻。
全程内核态 —— Ring 0 的特权与限制
中断处理代码全程在 CPL=0(Ring 0 / 内核态) 运行。这意味着:
能做的事:
- 执行特权指令(
cli/sti开关中断、mov cr3改页表、写 MSR 寄存器、访问 IO 端口)。 - 访问全部内核虚拟地址空间,包括设备 MMIO 区域。
- 修改所有内核数据结构(但要持锁)。
不能做的事——并非特权不够,而是上下文缺失:
- 不能调度(
schedule()):没有合法的 task_struct 可切换,原因见 分类体系与设计考量。 - 不能触碰用户态地址:
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 标志——这是衔接"中断上下文"和"真实进程调度"的关键点。
中断返回时的调度触发 —— 何时真正发生进程切换
中断本身不涉及调度,但中断返回是触发进程调度最频繁的入口。整个过程如下:

关键时间线总结:
中断到达 → 硬件保存/切栈/查 IDT(几十周期) → 上半部(几微秒)
→ softirq(可选,几微秒~几十微秒) → iret 返回前检查 need_resched
→ 若需调度 → context_switch(1-5μs) → 新 task 运行核心结论:中断上下文中的"处理"(上半部+下半部)全程不发生进程调度,唤醒操作只是改了 task 的状态位和队列。真正的进程切换发生在
iret返回前的TIF_NEED_RESCHED检查点。这也是为什么使用pidstat -w看到的nvcswch高——那些非自愿切换的触发源十有八九是中断唤醒了更高优先级任务,而切换本身发生在中断返回路径上,不是中断 handler 内部。
完整示例:磁盘 DMA 完成中断的全过程
以下将本篇所有概念串联到一个真实场景中:
初始状态: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检查点触发的。这两者的时序不要混淆。
中断的入口基础设施:IDT(中断描述符表)
IDT 是上面整个流程的"查表入口",单独拿出来讲。
IDT 是什么
IDT(Interrupt Descriptor Table)是一张系统级表,将中断向量号(0~255)映射到对应的 handler 入口地址和特权级。CPU 收到中断向量号后,用它乘以 16(每个 gate descriptor 16 字节)索引 IDT,读出 handler 信息,跳转执行。
中断向量号 × 16 → IDT 基址 + 偏移 → Gate Descriptor → Handler 地址 + IST/DPL 信息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,期间必须能响应磁盘完成中断。
前 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/内存/总线) |