中断:机器是怎么被打断的
系统调用是你主动下去。这一章讲另外两种——你被迫下去和你被打断。三个经常被混用的词(中断、陷阱、异常)在这里会被彻底分开,而分开它们的标准只有一条:处理完之后,怎么回来。
一个必须先解决的问题
先想清楚一件事,不然后面全是散点。
假设你写了这个程序,并且把它跑起来:
int main(void) {
while (1) { } // 什么也不干,就是不停
return 0;
}
它不调用任何系统调用,不访问任何非法内存,不做任何越界的事。它就是老老实实地占着 CPU 转圈。
问题来了:操作系统凭什么把 CPU 从它手里拿回来?
内核也是代码。代码要执行就得占着 CPU。而现在 CPU 正在执行这个死循环——内核根本没在运行,它拿什么去抢?
这个问题不解决,前面讲的一切都是空的。你没法调度,没法响应键盘,没法做任何事情。而在合作式多任务的年代(Windows 3.x、经典 Mac OS),这个问题确实没有解决——一个程序不主动让出,整台机器就一起卡死。你可能还记得那个年代。
既然软件抢不过软件,就让硬件来抢。
主板上有一个定时器,它按固定频率(通常 100–1000 Hz)向 CPU 发一个电信号。CPU 收到之后,无论正在执行什么,都会在当前指令结束后立即停下,跳到内核注册好的处理函数去。
这就是时钟中断。它不受你的代码控制,你也关不掉它——因为 cli 是特权指令(第 2 章)。
整个抢占式多任务,就建立在这一个硬件信号上。调度器不是「一直在运行、时刻监视着大家」——它平时根本没在跑,它是被时钟中断叫醒的。每次醒来它看一眼:这家伙的时间片用完了吗?用完了就换人,没用完就原样放回去。
所以第 1 章说的「你的循环每秒被打断上千次」,字面意义上就是这么回事。你的死循环确实在跑,只是每隔一毫秒左右,它就被硬生生冻住、状态存好、CPU 交给别人一会儿,然后再原样解冻。它自己毫无察觉。
三个词,三种回法
「中断」「陷阱」「异常」这三个词日常交流里经常混着用,但它们是三类不同的事件。区分它们的关键不在于是谁引发的,而在于——
- fault(故障):处理完,重新执行刚才那条指令。
- trap(陷阱):处理完,执行下一条指令。
- abort(中止):回不去了。
这三种返回语义决定了一切。尤其是第一条——它是虚拟内存能对你完全透明的唯一原因。
下面这台机器把八种事件摆出来。每挑一个,注意看表格最后一行「处理完怎么回来」——这一行才是分类的依据。特别留意「缺页」和「访问非法地址」这两个:它们是同一个异常号,走同一条路,结局却截然不同。
为什么 fault 的「重试」这么重要
这一节值得单独拿出来说,因为它是整个卷 III 的地基。
设想你执行这条指令:
mov (%rsi), %rax ; 从 rsi 指向的地址读一个值
而这个地址虽然合法(你确实 malloc 过),但内核还没真的给它分配物理内存(第 12 章会讲为什么)。于是 MMU 翻译失败,触发 #PF(缺页异常)。
接下来发生的事,完整地是:
- CPU 停在这条指令上——注意,是「停在」不是「跳过」。它把这条指令的地址记进
rip,把出错的那个地址记进CR2。 - 跳到内核的缺页处理函数。
- 内核查:这地址在这个进程的 vma 里吗?在。那就是合法的,只是还没分配。
- 内核分配一个物理页,填好页表项。
- 内核执行
iretq返回。因为#PF是 fault 类,rip仍然指向那条出错的指令。 - 那条指令重新执行一遍。这次 MMU 翻译成功,读到了值。
结果是:你的程序完全不知道刚才发生过什么。它执行了一条 mov,拿到了一个值,仅此而已。它不知道自己刚才去内核转了一圈、不知道内核刚给它分了一页内存,唯一的痕迹是这次访存花了 约 1.5 μs 而不是 约 100 ns。
如果 #PF 是 trap 类(返回后执行下一条指令),那这条 mov 就永远不会成功,rax 里是垃圾。整个「按需分页」的把戏就不可能存在——你必须在程序启动时就把所有内存准备好。
一个比特的返回语义差别,撑起了整个现代内存管理。
同一个异常号,两种结局
刚才 demo 里那个细节值得展开:「缺页」和「段错误」是同一个东西——都是 #PF,向量号 0x0E。
硬件层面它们无法区分。CPU 只知道「这个地址翻译不出来」,它不知道这是「应该有但还没准备好」还是「根本不该有」。
区分它们的是内核。内核拿到出错地址后,去查这个进程的 vma 链表(第 10 章那张地址空间地图):
| 内核查完发现 | 判定 | 结局 |
|---|---|---|
| 地址在某个 vma 里,只是没分配物理页 | minor fault | 分一页、填页表、重试。你毫无察觉 |
| 地址在 vma 里,但内容被换到 swap 了 | major fault | 从盘读回来、填页表、重试。你只觉得这次特别慢 |
| 地址在 vma 里,但你在写一个只读页(且是 COW 页) | 写时复制 | 复制一份、改页表、重试(第 13 章) |
| 地址不在任何 vma 里 | 非法 | SIGSEGV,进程死 |
| 地址在 vma 里,但你在写一个真正只读的段 | 越权 | SIGSEGV,进程死 |
五种情况,同一个入口。前三种你永远不会知道,后两种你会看到那行熟悉的报错。
这也是为什么第 11 章那台地址翻译器要把这几种情况都做进去——它们在硬件上是一回事,只有走到内核那一步才分道扬镳。
中断处理器的一条铁律:越快越好
中断处理函数运行的时候,通常同类中断是被屏蔽的(不然中断嵌套会把内核栈撑爆)。这意味着:你在中断处理器里待得越久,丢事件的风险越大。网卡收包中断处理慢了,后面的包就来不及收;时钟中断处理慢了,系统时间就不准。
所以内核把中断处理拆成两半,这是一个非常经典的设计:
| 上半部(hard IRQ) | 下半部(softirq / tasklet / workqueue) | |
|---|---|---|
| 什么时候跑 | 中断一来立刻跑 | 稍后,中断已经重新打开 |
| 能不能被打断 | 基本不能 | 能 |
| 能不能睡眠 | 绝对不能 | workqueue 可以 |
| 该干什么 | 只做「必须马上做」的:从设备把数据搬走、应答硬件、打个标记 | 剩下的所有活:协议栈解析、唤醒等待的进程 |
| 要求 | 微秒级 | 可以慢一点 |
网卡收包是最典型的例子:上半部只把包从网卡缓冲区搬到内存、然后标记一下就返回;剩下的 TCP/IP 协议栈处理全在下半部(NET_RX_SOFTIRQ)。
这个「快速响应 + 延后处理」的模式,你在应用层其实天天在用——Android 的主线程只处理事件分发、真正的活扔给协程或线程池,是同一个道理。约束相同,解法就会长得一样。
top 里有一列 si(softirq)。高并发网络服务器上它可能占到 20%–30%。
坑在于:这部分 CPU 不属于任何进程。你按进程看 CPU 占用,会发现「谁都不高,但机器就是满的」。这是第 23 章那些「玄学现象」里很典型的一个,遇到时记得先 top 按 1 看每个核的明细,再看 si 那一列。
早期 Linux 无论有没有事,时钟中断都以固定频率(HZ,常见 100/250/1000)打进来。一台完全空闲的机器,每秒也要被打断上千次。
笔记本时代这成了问题——CPU 刚要进深度睡眠省电,就被时钟中断叫醒,睡不踏实。于是 Linux 2.6.21 引入了 tickless(NO_HZ):没事干的时候干脆不打中断,直接把定时器设到「下一个真正需要唤醒的时刻」。
后来又有了 NO_HZ_FULL:只要某个 CPU 上只有一个可运行任务,连它的时钟中断也停掉——反正没人可换。这对高频交易、实时音频这类「一微秒都不想被打扰」的场景很关键。
有意思的是,这等于部分退回了合作式调度:既然只有你一个人,那就不抢了。抢占的成本,只在真的需要抢的时候才付。
「为什么 Android 上 UI 卡顿总是 16.6 ms 的整数倍?」
因为屏幕刷新(VSYNC)本质上就是一个周期性的硬件中断,60 Hz 屏就是每 16.6 ms 一次。渲染管线的每一步都挂在这个信号上。
关键在于它不等人:你的 onDraw 花了 17 ms,就是错过了这一帧的窗口,只能等下一个 VSYNC——用户看到的掉帧是 16.6 ms,不是 0.4 ms。这就是「掉帧总是整数倍」的来历。
这也解释了为什么 Choreographer 的存在如此重要:它让你的动画对齐到中断节奏,而不是自己拿 Handler.postDelayed 瞎猜时间。跟硬件节拍打架,你永远赢不了。
这一章的一句话
软件抢不过软件,所以抢占式多任务建立在一个硬件信号上——时钟中断,整个骗局的引擎。而中断、陷阱、异常的真正区别只有一条:处理完怎么回来。fault 重试那条指令,这一个语义撑起了整个虚拟内存。
卷 I 到此结束。你现在知道了那道线是什么、怎么跨过去、以及机器怎么被打断。接下来三卷,我们逐一拆开三重假象。先从时间开始——下一章,进程。