中断与 I/O
1. 为什么 CPU 不能"干等"外设
设想一个场景:CPU 想从磁盘读一个扇区。磁盘是机械部件——磁头要移动、盘片要旋转,一次读取就是几毫秒。而 CPU 的时钟周期以纳秒计,几毫秒相当于上百万个周期。
如果 CPU 发出读命令后就在原地等,那等于一台 3 GHz 的机器,为了等一个慢六个数量级的外设,把上百万个周期白白烧掉。这就是"CPU 与外设的速度鸿沟"——也是本章所有 I/O 机制的出发点。
本质一句话:I/O 的全部学问,就是想办法让慢速外设别把快速 CPU 拖死。
2. I/O 的三种基本方式
处理器与设备交换数据,历史上演化出三种方式:
| 方式 | 等待期间 CPU 在干嘛 | 每块数据代价 | 适用场景 |
|---|---|---|---|
| 轮询(polling) | 空转查状态位 | 等满整个设备延迟 | 极简单、极低速设备 |
| 中断(interrupt) | 干自己的正事,就绪才被"打断" | 一次上下文切换 | 中低速、事件驱动设备 |
| DMA | 完全不管,数据由控制器搬 | 只有开始/结束各一次中断 | 大块连续数据(磁盘、网卡、GPU) |
三种方式的差别,说白了就是一句话:等待数据的那段时间,CPU 到底在干什么。 下面依次把机制讲透。
3. 异常与中断:一张分类树
"中断"这个词在计算机系统里,其实泛指一切控制流的强制转移(trap)。按产生时机是否与当前指令同步,可以分成两大类:
- 同步异常(synchronous):由正在执行的指令直接引起,如系统调用(trap)、缺页(fault)、非法指令(fault)、硬件错误(abort)。
- 异步异常(asynchronous):与指令流无关,由外部事件触发,这才是通常所说的"中断"(interrupt),如键盘按键、网卡收包、定时器到期。
两类异常的恢复语义完全不同:
- fault(缺页、非法指令):处理完重新执行引起异常的指令;
- trap(系统调用、断点):处理完执行下一条指令;
- abort(硬件错误):通常无法恢复,直接终止进程;
- 中断(外部事件):与指令无关,处理完回到断点继续。
4. RISC-V 的中断机制
RISC-V 在 Machine 模式下用 4 个 CSR 寄存器 + 1 条指令实现完整的陷阱(trap)机制:
| 寄存器 / 指令 | 作用 |
|---|---|
mtvec |
陷阱入口地址(向量表基址),发生异常时硬件跳到这里 |
mepc |
被打断指令的 PC,mret 时从这里恢复执行 |
mcause |
异常原因:最高位 = 1 表示中断,低 12 位是编码 |
mstatus |
中断开关:MIE(当前使能)、MPIE(打断前的值)、MPP(特权级) |
mret |
陷阱返回指令:恢复 mepc/mstatus,并重新使能中断 |
一次典型的中断处理流程:
下面是一段 RISC-V(RV32,M 模式)陷阱入口的汇编,把"保存现场 → 查原因 → 处理 → 恢复现场 → 返回"完整走一遍:
1 | trap_vector: |
注意一个 RISC-V 的关键设计:MMIO(内存映射 I/O)。RISC-V 没有独立的 I/O 指令,设备寄存器就映射在普通地址空间里,lw/sw 一条指令就完成访问——所以上面读设备数据就是一次普通访存。
mcause 常见编码(M 模式):
| mcause | 含义 | mcause | 含义 |
|---|---|---|---|
| 0 | 指令地址未对齐 | 8 / 9 / 11 | 环境调用 ecall(U/S/M 模式) |
| 1 / 5 / 7 | 指令 / 取数 / 存数访问错误 | 12 / 13 / 15 | 指令 / 取数 / 存数页错误 |
| 2 | 非法指令 | 中断 3 | 机器软件中断 |
| 3 | 断点 | 中断 7 | 机器定时器中断 |
| 4 / 6 | 取数 / 存数地址未对齐 | 中断 11 | 机器外部中断(I/O 设备) |
5. C 演示:轮询 vs 中断
下面这个 C 程序用最朴素的计数器模拟两种 I/O:设备读一次需要 300 个"周期"。轮询版 CPU 被锁死在等待循环里;中断版主程序边干正事边等,设备就绪才被"打断":
1 |
|
输出(本机 gcc 编译运行结果,与 Python 镜像核验一致):
1 | [轮询] 读到 0x5A,CPU 全程锁死 300 个周期 |
两种方式拿到同样的数据,代价却完全不同:轮询把 300 个周期全部浪费在"查标志位"上;中断让主程序完整推进了 300 步,只在最后被"打断"一下。设备越慢,差距越大——真实磁盘要等几毫秒,轮询浪费的是几百万个周期。
6. DMA:大块搬运的正确姿势
中断也不是银弹:每处理一块数据就要一次上下文切换(保存/恢复现场),如果数据块小、频率高,中断本身的开销反而吃掉了 CPU。想象网卡每秒收到几十万个包,每个包都打断一次 CPU,那 CPU 就只剩下"保存现场"和"恢复现场"了。
于是有了 DMA(Direct Memory Access,直接存储器访问):一个专门的数据搬运引擎,把"外设 → 内存"的拷贝从 CPU 手上整个接过去。CPU 只需要配置好源地址、目的地址、长度,然后转身去干别的;整块数据搬完,DMA 控制器才发一次中断通知 CPU"完工"。
C++ / Java 对照:软件世界里到处是这三种方式的投影。Java 的阻塞式 InputStream.read() 代码最简单,但线程在等待时只能挂着(≈ 轮询);Linux epoll 的事件回调"就绪才通知"(≈ 中断);Java NIO 的 Channel + Buffer + mmap 让数据不经过用户态拷贝(≈ DMA)。C++ 里 std::condition_variable 的 wait/notify,也正是"保存 → 等待 → 被唤醒恢复"的同步版。
7. 对比表与小结
| 维度 | 轮询 | 中断 | DMA |
|---|---|---|---|
| 等待期 CPU | 空转 | 干正事 | 干正事(更彻底) |
| 每块数据开销 | 0(但全部浪费) | 1 次上下文切换 | 接近 0 |
| 适用数据量 | 极小 | 中 / 小块、事件驱动 | 大块连续数据 |
| 实现复杂度 | 最低 | 中 | 高(需要控制器) |
| 典型设备 | 状态寄存器、低速传感器 | 键盘、定时器、串口 | 磁盘、网卡、GPU |
🐾 小结:
- 轮询 = 拿 CPU 的时间换简单;中断 = 拿一次上下文切换换并行;DMA = 连切换都省掉。
- 同步异常(fault / trap / abort)由当前指令引起,异步异常(中断)由外部事件引起,恢复语义不同:fault 重执行、trap 跳下一条、中断回断点。
- RISC-V 用
mtvec/mepc/mcause/mstatus+mret五件套完成"保存 → 处理 → 恢复";MMIO 让设备寄存器直接参与普通访存。 - 选哪种 I/O,先问一句话:等待期间 CPU 能去做什么? 答不上来才用轮询。

