中断与异常:内核如何处理突发事件
写程序时我们默认一件事:代码一行接一行往下执行。但真实的机器里,CPU 随时可能被打断——网卡来了个包、磁盘读完了数据、定时器到点了、你的程序访问了一个非法地址、甚至你主动调用了一个系统调用想读文件。这些事件不会乖乖排在你代码的下一行,它们随时插队。
操作系统怎么应对这种"随时可能插队"的世界?答案就是中断与异常机制。它是 CPU 从"只会埋头顺序执行"进化到"能响应外部世界、能处理内部故障"的关键一跃。可以说,没有中断,就没有多任务、没有 I/O 高效协作、没有现代操作系统。
这一篇我们先把机制讲透:一个中断/异常发生时,硬件和软件到底一步步做了什么——怎么暂停当前程序、怎么找到处理入口、怎么保存现场、处理完又怎么原样恢复,然后再讲为什么内核要这样设计,以及它在工程里怎么影响你。
一、先分清三个词:中断、异常、陷入
这三个词常被混用,但来源和语义不同,先把它们理清,后面才不会乱。它们统称为"控制流的非正常转移",按来源和是否同步可以这样划分:
- 中断(interrupt)/ 外部中断:来自 CPU 外部,和当前执行的指令无关、异步发生。典型来源是设备(网卡、磁盘、键盘)和定时器。比如你正在算一个循环,网卡突然收到包,发来中断——这件事跟你的循环毫无关系,纯属"外部插队";
- 异常(exception)/ 内部异常:由 CPU 执行某条指令时触发,同步发生(执行到那条指令必然触发)。又细分为:
- 故障(fault):可恢复的,处理完能重新执行那条指令。最典型的是缺页异常(page fault)——访问的页不在内存,处理程序把页调进来,再回去重新执行,程序毫无察觉;
- 陷阱(trap):有意触发的,处理完执行下一条指令。系统调用就是用陷阱实现的(程序主动执行一条特殊指令陷入内核);
- 终止(abort):严重错误,无法恢复,比如硬件故障,通常直接干掉进程。
一句话区分:中断是"别人来打扰你"(异步、外部),异常是"你自己这条指令出事了或主动求助"(同步、内部)。 缺页异常是"出事但能救",系统调用是"主动求助",除零是"出事且这条指令没法继续"。
理清这个分类很重要:因为缺页异常和系统调用根本不是"错误",而是操作系统正常运转的核心机制。把异常一概当成"程序崩溃"是初学者最大的误解。
二、中断向量表:CPU 怎么知道该跳到哪里处理
事件来了,CPU 第一个要解决的问题是:这是什么事件,该由哪段代码处理? 不可能为每种事件都用一堆 if-else 去判断——那太慢。硬件用的是一张查找表。
每一种中断和异常都被分配一个编号(中断号 / 异常号,也叫中断向量)。系统在内存里维护一张中断向量表(interrupt vector table),在 x86 上叫 IDT(中断描述符表):
- 表的下标就是中断/异常的编号;
- 表的每一项里存着对应处理程序的入口地址(以及特权级等信息)。
于是 CPU 的处理逻辑变得极其机械、极快:
- 事件发生,CPU 拿到它的编号(外部中断由中断控制器告知编号,异常的编号由 CPU 内部固定);
- 用编号作下标,去中断向量表里查出对应的处理程序入口地址;
- 跳到那个地址开始执行处理程序。
这套"编号 → 查表 → 跳转"的机制,和上一篇讲的分页里"页号查页表"、文件系统里"inode 号查 inode 表"是同一种思想——用编号查一张表,把判断变成 O(1) 的寻址。计算机系统里这种"查表代替判断"的套路无处不在。
中断向量表在系统启动早期就由内核设置好(每一项填上对应处理程序的地址),这是内核初始化的必做工作之一。
三、现场保存与恢复:处理完怎么"假装什么都没发生"
这是整个机制最精妙、也最容易被轻视的一环。中断处理完之后,被打断的程序必须能原封不动地继续跑,就像从没被打断过一样。要做到这一点,进入处理程序之前必须保存现场(context),处理完再恢复现场。
所谓"现场",是指被打断那一刻 CPU 的关键状态:
- 程序计数器(PC / RIP):被打断时执行到哪条指令了,恢复后要从这里接着跑;
- 状态寄存器(标志位):运算结果的标志等;
- 通用寄存器:处理程序自己也要用寄存器,不保存就会覆盖掉原程序的值。
完整的一次中断处理时间线是这样的:
拆解这条流程的分工:
- 硬件自动做的部分:CPU 检测到中断后,自动把最关键的现场(PC、状态寄存器)压栈,并切换到内核态、查表跳转。这部分必须由硬件做,因为软件还没开始跑、连保存自己的机会都没有;
- 软件(处理程序)做的部分:处理程序一开头就把它要用到的其他寄存器也保存起来,然后执行真正的处理逻辑,最后恢复这些寄存器;
- 返回:执行一条专门的中断返回指令(如 x86 的
iret),它会把硬件压栈的 PC 和状态寄存器弹回去,CPU 就回到了被打断的精确位置继续执行。
中断机制能成立的前提,是被打断的程序状态可以被完整保存和精确恢复。这也正是为什么进程切换(上下文切换)要保存那么多寄存器——它和中断现场保存是同一套底层能力,进程切换本身常常就发生在时钟中断的处理过程中。
四、上半部与下半部:中断处理为什么要"拆成两半"
这里有个关键约束:中断处理程序运行时,通常会关闭(或屏蔽)同级中断,而且它打断了正常的进程执行。所以它必须尽可能快。 如果一个中断处理程序磨磨蹭蹭跑很久,会带来两个严重后果:
- 其他中断被延迟响应,系统整体延迟飙升,甚至丢失中断(比如网卡缓冲区溢出丢包);
- 被打断的进程迟迟无法恢复。
但有些 I/O 完成后要做的事确实很多(比如收到网络包后要走完整个协议栈解析)。怎么办?Linux 的经典解法是把中断处理拆成两半:
- 上半部(top half / 硬中断):在中断处理程序里只做最紧急、最必要的事——比如应答硬件、把数据从设备缓冲区快速取走、记个标记,然后立刻返回。要快、要短;
- 下半部(bottom half / 软中断、tasklet、工作队列):把那些耗时但不紧急的处理(如协议栈解析、唤醒进程)推迟到稍后、在开中断的环境里执行。
这套"上半部火速应答、下半部从容处理"的拆分,核心目的是把"关中断"的时间窗压到最短,让系统既能及时响应硬件,又不被冗长的处理拖垮。理解它,你才看得懂网络高负载时
softirq占用 CPU 是怎么回事。
五、把机制连起来:缺页异常的完整一生
光看零件不够,我们用一个最重要的异常——缺页异常,把整套机制串一遍。它完美展示了"异常不是错误,而是核心机制":
- 程序访问一个虚拟地址,CPU 查页表,发现该页不在物理内存(有效位为 0);
- CPU 触发缺页异常(一个 fault),硬件保存现场(PC 指向出错的那条访存指令)、查异常向量表,跳进内核的缺页处理程序;
- 缺页处理程序判断这次缺页是合法的(地址在进程的合法地址空间内)还是非法的:
- 如果非法(比如访问了空指针、越界),内核给进程发
SIGSEGV信号——这就是著名的段错误(segmentation fault); - 如果合法(比如这页被换出到磁盘了,或是首次访问的新页),内核找一个空闲物理页框,把数据从磁盘/交换区读进来(这中间又会发起一次磁盘 I/O,用到中断和 DMA),更新页表;
- 如果非法(比如访问了空指针、越界),内核给进程发
- 处理完,因为缺页是 fault,CPU 返回后重新执行那条访存指令——这次页已经在内存里,访问成功,程序完全感知不到刚才发生过缺页。
看明白这条链路,你就理解了几件大事:虚拟内存的"按需调页"、mmap 的惰性加载、写时复制(COW),全都是建立在缺页异常这个机制之上的。 程序以为自己有一大片连续内存,实际上是内核靠缺页异常一页一页"骗"进来的。同时你也看清了段错误的本质——它就是一次被判定为非法的缺页异常。
六、为什么要这样设计:让 CPU 摆脱"只会顺序执行"的牢笼
机制讲透,回答"为什么"。如果 CPU 只会顺序执行指令,会怎样?
- 无法响应外部世界:设备完成了没法通知 CPU,只能靠 CPU 不停轮询,浪费算力(上一篇讲过轮询有多糟);
- 无法实现多任务:没有时钟中断定期打断当前进程,操作系统就永远夺不回 CPU 控制权,一个死循环的程序能让整台机器卡死;
- 无法优雅处理错误:除零、非法访问只能让整台机器停摆,没法只杀掉出错的那个进程;
- 无法实现按需的高级机制:虚拟内存的按需调页、写时复制都依赖缺页异常这个"陷阱"。
所以中断与异常的设计动机非常根本:
它给 CPU 装上了"被打断并安全返回"的能力,让操作系统能在任意时刻夺回控制权——无论是因为外部设备来事(中断)、当前指令出状况(异常),还是程序主动求助(系统调用)。这是操作系统能够"管理"一切的硬件支点。
系统调用用陷阱实现、进程调度靠时钟中断驱动、虚拟内存靠缺页异常支撑——可以说,操作系统最核心的几项能力,全都站在中断异常这块基石上。
七、和后端 / SRE 实践的联系
这套机制天天在你的线上系统里运转:
- 看懂
softirq/si高:top里%si(软中断)占用高,常见于网络高负载——大量网络包触发中断,下半部软中断忙于处理协议栈。这是排查网络性能问题的重要信号; - 理解中断亲和性(IRQ affinity):高性能网络场景常把网卡中断绑定到特定 CPU 核,避免中断在核间乱跳导致缓存失效。理解中断机制,才懂这个调优在做什么;
- 缺页与性能:
page fault分次缺页(minor,页在内存只是没建映射)和主缺页(major,要从磁盘读)。主缺页很贵(涉及磁盘 I/O),频繁主缺页意味着内存不足在频繁换页,是性能杀手。ps、/proc/<pid>/stat都能看缺页统计; - 段错误(SIGSEGV)排查:知道它是"非法缺页异常",你就明白排查方向是"程序访问了不该访问的地址"——空指针、越界、use-after-free,结合 core dump 和
dmesg定位; - 中断风暴:设备故障或配置问题可能导致中断疯狂触发,CPU 全耗在中断处理上,系统假死。这是 SRE 偶尔会遇到的硬件级故障。
八、动手做一个小实验:让异常和正常流程对照出现
写一段会触发异常的代码,和正常代码对比,亲眼看异常路径:
1 | // segv.c —— 故意访问非法地址,触发缺页异常 → SIGSEGV |
1 | gcc -g segv.c -o segv |
segv 触发的是被判为非法的缺页异常,内核发 SIGSEGV 杀掉它;而 time -v ls 里那些 minor page faults,则是程序正常启动时按需建立内存映射触发的合法缺页——同一套缺页异常机制,一个被判死刑、一个是日常运转。把这两者放在一起看,你对"异常不等于错误"就有了切身体会。
学习这一部分最容易踩的坑
1. 把异常一概当成"程序崩溃"
缺页异常、系统调用(陷阱)都是操作系统正常运转的核心机制,不是错误。只有非法访问、除零这类才是真的出错。
2. 混淆中断和异常的来源
中断是异步、外部(设备/定时器),和当前指令无关;异常是同步、内部,由当前指令触发。来源不同,处理语义也不同(fault 重新执行、trap 执行下一条)。
3. 忽视现场保存恢复的重要性
中断能"无感打断再继续",全靠完整保存和精确恢复现场。这也是上下文切换开销的来源。不理解这点,就想不通进程切换为什么有成本。
4. 以为中断处理程序可以慢慢跑
中断处理常在关中断环境下运行,必须短小,否则拉高全局延迟、甚至丢中断。这正是 Linux 把处理拆成上半部/下半部的原因。
总结
中断与异常是操作系统能够"管理"一切的硬件支点。核心要点:
- 中断是异步外部事件(设备/定时器),异常是同步内部事件(由指令触发),异常又分 fault(可恢复重执)、trap(系统调用)、abort(致命);
- 缺页异常和系统调用不是错误,而是核心机制——虚拟内存、
mmap、COW 都建立在缺页异常之上; - CPU 靠"中断号 → 查中断向量表 → 跳转处理程序"实现 O(1) 的事件分发;
- 现场保存与精确恢复让被打断的程序毫无察觉地继续运行,这也是上下文切换的底层能力;
- 中断处理拆成上半部(火速应答)和下半部(从容处理),目的是把关中断时间压到最短;
- 正是"能在任意时刻被打断并安全返回"这个能力,让操作系统得以夺回 CPU、实现多任务、按需调页和错误隔离。
把这条主线记牢,你再看 softirq 飙高、段错误排查、缺页性能问题,就都能落回到这套统一的机制上来解释。
参考资源:
- 《深入理解计算机系统》(CSAPP)异常控制流章节
- 《现代操作系统》(Andrew S. Tanenbaum)
- 《Linux 内核设计与实现》中断与中断处理章节
- The Linux Kernel Documentation - Interrupts