中断与异常背后的故事

0 阅读8分钟

上学的时候,刚学《计算机组成原理》和《操作系统》,我被这两个词中断和异常折腾得够呛。

课本上总是喜欢画一个极其工整的表格,然后告诉我:

  • 中断(Interrupt) 是外部设备发出的,比如网卡来了个数据包,鼠标动了一下。它是异步的。
  • 异常(Exception) 是 CPU 自己执行代码时触发的,比如除以零、内存缺页(Page Fault)。它是同步的。

然后老师接着说:在 x86 体系结构里,不管你是中断还是异常,CPU 都是通过“中断描述符表(IDT)”去找对应的处理函数,保存上下文、切内核态、执行 Handler、然后恢复现场。

我第一次学到这里的时候,其实觉得非常自然——多么优雅统一的设计啊,管你是外部打扰还是内部出错,硬件统一接管,一套逻辑全部搞定。

直到后来做低延迟网络优化,看到了 DPDK(Data Plane Development Kit)的原理;再后来去翻 x86 的指令集演进史,我才猛然发现:

把“中断”和“异常”强行塞进同一套机制里,可能是计算机架构史上最无奈、也让后人付出代价最大的一次“强行统一”。

最早的计算机,既没有“中断”,也没有“异常”。

那时候的 CPU 就像个极其高傲且死板的流水线工人。它只管一条接一条地执行纸带上的指令。

如果它想从打印机或者磁带机里读数据,CPU 就必须停下手中的活,一遍又一遍地跑去问外设:“好了没?好了没?好了没?”

这叫轮询(Polling)。

当时计算机非常贵,CPU 每等外设一秒钟,都在烧掉大把的金钱。后来像 IBM 704 的工程师们实在受不了了,写了一份专利,核心思想就一句话:“别让 CPU 去问外设了,让外设准备好数据之后,拉一下 CPU 的门铃。”

这就是中断(Interrupt)。

中断诞生的唯一目的,是为了提高效率。它是外设和 CPU 之间的异步通信协议,本质上是“CPU 把等待的时间外包给了硬件电路”。

在这个时期,中断和操作系统安全、内存保护没有任何关系。

那异常(Exception)又是怎么来的?

异常的诞生,是为了解决另一个完全不同的危机:多任务与系统的尊严。

这时候问题来了:如果某个人写了一段死循环代码,或者写了一个去改别人内存的指令,CPU 怎么办?

在早期,CPU 碰到非法指令,直接就关机停机(HLT)了。但这可是多用户服务器啊,一个人写错代码,总不能让全公司的终端一起黑屏吧?

于是,硬件厂商不得不给 CPU 的 ALU(算术逻辑单元)和 MMU(内存管理单元)加上检测电路:只要发现除以零、越界访问、非法指令,CPU 绝不能崩溃,而是要瞬间剥夺当前进程的控制权,强行跳到操作系统预设的一段安全代码里,把这个坏进程杀掉或者挂起。

这就是异常(Exception)。

你看,如果站在历史的起点看:

  • 中断,是外设给 CPU 发的“通知”。
  • 异常,是 CPU 给坏代码开的“罚单”。

这两件事发起的源头不同、目的不同、甚至硬件电路的检测点都完全不同。

那为什么我们今天学到的知识里,它们俩长得像孪生兄弟一样?

答案在Intel 设计 8086 和后来的 80386 身上。

当年 Intel 设计微处理器的时候,工程师们面临着极其严苛的晶片面积和成本限制。

既然“中断”需要一套跳到内核地址、保存通用寄存器、执行处理函数、再返回(IRET)的电路;“异常”也需要跳到内核地址、保存上下文、执行 Handler、再返回。

Intel 的工程师想了想:干嘛要造两套轮子?不如把它们合并了吧!

于是,Intel 提出了“向量化中断/异常处理机制”(就是后来的 IDT)。他们给所有的事件编了号:0 ~ 31 留给 CPU 内部异常(如 0 是除零,14 是缺页);32 ~ 255 留给外部硬件中断和软件自定义中断。

从硬件电路的角度看,极其简洁优雅。

但这种工程上的偷懒,给后来的软件生态埋下了一个巨坑。

因为“异常”里有一个非常特殊的分类,叫Trap(陷阱)。

既然通过这套中断机制可以安全地从用户态(Ring 3)平滑切换到内核态(Ring 0),那操作系统的设计者们(包括 UNIX、Linux、Windows)很快就动了邪念:

“我们要实现‘系统调用(Syscall)’,让用户程序请求操作系统打开文件。干脆也别重新设计机制了,直接用一个软件中断指令吧!”

在 Linux x86 上,这就是著名的 int 0x80。

我刚开始写 C 语言的时候,觉得 int 0x80 简直是神来之笔。一个指令就能从普通权限跳进内核核心区,多方便啊。

但是,如果你去翻翻硬件手册就会发现,通过 IDT 走传统的中断/异常流程,CPU 在后台到底干了多重的活:

  1. 检查权限级别(Ring 3 还是 Ring 0)。
  2. 查找 IDT 表,获取对应的段选择子和偏移量。
  3. 切换栈指针(ESP),把旧的 SS、ESP、EFLAGS、CS、EIP 一股脑挤进内核栈。
  4. 修改特权级,保存现场。

这一套流程走下来,在奔腾时代需要消耗几百个 CPU 时钟周期。

为了发起一次简单的 read() 系统调用,CPU 必须硬生生背上“通用硬件中断”的所有包袱。

这就好比你只是想去社区前台盖个章,前台却严格按照“接待外宾”的最高规格流程,查你的户口本、做全身安检、记录档案、安排专车……

到了 90 年代末,CPU 主频越来越高,而系统调用的开销却成了操作系统最大的性能瓶颈。

最终,Intel 和 AMD 实在忍不住了,不得不自己打自己的脸。

他们硬生生在 CPU 里加了全新的硬件指令:SYSENTER/SYSEXIT(Intel)和 SYSCALL/SYSRET(AMD)。

这些指令完全绕过了 IDT,绕过了传统的“中断/异常处理机制”,用一种极其粗暴且特化的硬件通道,直接把控制权扔进内核。

这场持续了二十年的“中断与异常强行统一”,最终以硬件厂商不得不单独为系统调用开后门而尴尬收场。

事情到了这里,还没完。

如果说“异常”通过 SYSCALL 重新找到了属于自己的高效通道,那“中断”呢?

中断的命运,在今天变得更加魔幻。

半个多世纪前,人们为了摆脱“轮询(Polling)”的低效,发明了中断。大家觉得让外设主动打扰 CPU 是计算机史上最伟大的进步。

但谁能想到,五十年后,我们又主动把中断给“杀”了。

在今天的 100Gbps 极速网络或者百万级 IOPS 的 NVMe 固态硬盘环境里,如果网卡每收到一个数据包就给 CPU 发一次硬件中断,CPU 将彻底陷入“中断风暴”(Interrupt Storm)。

CPU 所有的算力,都会被消耗在“响应中断 -> 保存上下文 -> 切换栈 -> 执行 Handler -> 恢复现场”的无尽循环里。

这时候,像 DPDK(数据平面开发套件)、SPDK,以及 Linux 内核最新的 io_uring,做了一件极其叛逆的事:

它们关掉了网卡和 NVMe 的中断,重新回到了 1950 年代最原始的“轮询模式(Poll Mode Driver)”。

让一个 CPU 核心绑定 100% 的占空比,什么别的事都不干,死循环去盯住网卡的接收队列。

当你把运行速度推到极致的时候,曾经为了“省算力”而发明的“中断机制”,反而成了最昂贵的负担。

后来越想越觉得,技术演进的逻辑真的很有意思。

我们今天教科书里写的“中断和异常”,看起来是一个和谐统一的抽象体系。

但在真实的历史演进里,它们是两股力量在不同诉求下的产物:

  • 异常 代表了操作系统对控制权的偏执。哪怕后来有了 SYSCALL,只要发生了缺页(Page Fault),操作系统依然要靠异常机制去织造那个“物理内存无限大”的伟大谎言。
  • 中断 代表了计算机对吞吐量与效率的妥协。它随着外设速度的低下而生,又随着外设速度的暴涨而被现代高性能框架抛弃。

计算机架构的发展,很少有那种在第一天就设计完美的“纯粹蓝图”。

绝大多数我们今天看到的“标准答案”,无非是当年硬件厂商为了节省几百个晶体管做出的权衡,或者是工程师们为了兼容上一代系统挂上的补丁。

理解了这些,下次你在内核源码里看到 IDT、看到 SYSCALL、或者在优化网络性能时看到 napi_poll,就不会觉得它们只是一堆冰冷无趣的代码了。

它们不过是几代工程师,在“控制”与“效率”的夹缝中,留下的打卡记录而已。

640.png