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),如键盘按键、网卡收包、定时器到期。
异常(trap)= 控制流被强制转移 异常 / 中断总入口 同步:由当前指令引起 异步:与指令流无关 trap:系统调用 ecall fault:缺页 / 非法指令 abort:硬件致命错误 外部 I/O 设备中断 定时器中断 软件中断(IPI 处理器间中断)

两类异常的恢复语义完全不同:

  • 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,并重新使能中断

一次典型的中断处理流程:

① 设备把数据放进寄存器,拉高 IRQ 线 外设"就绪"事件到达 CPU 引脚 ② CPU 完成当前指令,检查 MIE 使能位 一条指令的中间态不会被"撕开" ③ 硬件自动保存现场:PC 存入 mepc mstatus 保存到 MPIE 并关闭中断 ④ 按 mtvec 跳入口,执行 ISR(读设备、清中断) ISR 必须尽快返回,避免丢失其他中断 ⑤ mret:恢复 mepc / mstatus,回到断点继续 关键寄存器速查 mtvec = 入口地址 mepc = 断点 PC mcause = 异常编码 mstatus= 使能位/特权级 最高位=1 表示中断: 3 软件 / 7 定时器 11 外部 I/O 中断 (M 模式编码)

下面是一段 RISC-V(RV32,M 模式)陷阱入口的汇编,把"保存现场 → 查原因 → 处理 → 恢复现场 → 返回"完整走一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
trap_vector:
addi sp, sp, -32 # ① 现场保存:为 8 个寄存器腾出栈空间
sw ra, 28(sp) # 保存返回地址
sw t0, 24(sp)
sw t1, 20(sp)
sw a0, 16(sp)
csrr t0, mcause # ② 读异常原因
li t1, 11 # 中断编码 11 = 机器外部中断
bne t0, t1, .Lpanic # 不是外部中断 -> 走错误处理
lw t0, 0(x30) # ③ MMIO:读设备数据寄存器(地址 x30)
sw t0, 0(x31) # 写入内存缓冲区(地址 x31)
lw a0, 16(sp) # ④ 现场恢复(与保存顺序相反)
lw t1, 20(sp)
lw t0, 24(sp)
lw ra, 28(sp)
addi sp, sp, 32
mret # ⑤ 返回:PC 回到断点,继续执行

注意一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>

#define LATENCY 300 /* 设备读一次要等 300 个周期 */

static unsigned char dev_data = 0x5A; /* 设备数据寄存器(MMIO) */

/* 方式一:轮询读。CPU 从发起请求到拿到数据,被锁死在等待循环里 */
static unsigned char poll_read(void) {
int cycles = 0;
while (cycles < LATENCY) /* 等价于 while (!dev_ready) */
cycles++; /* 空转:每周期只做"检查"一件事 */
return dev_data;
}

/* 方式二:中断读。主程序不等设备,先干自己的事 */
static unsigned char isr_result;
static int main_work = 0;

static void isr_read(void) { /* 中断服务程序:从设备搬走数据 */
isr_result = dev_data;
}

int main(void) {
unsigned char a = poll_read();
printf("[轮询] 读到 0x%02X,CPU 全程锁死 %d 个周期\n", a, LATENCY);

for (; main_work < LATENCY; main_work++)
; /* 主程序边干正事边等设备(重叠) */
isr_read(); /* 设备就绪 -> 中断 -> 跳 ISR */
printf("[中断] 主程序推进 %d 步后才被打断,ISR 取到 0x%02X\n",
main_work, isr_result);
return 0;
}

输出(本机 gcc 编译运行结果,与 Python 镜像核验一致):

1
2
[轮询] 读到 0x5A,CPU 全程锁死 300 个周期
[中断] 主程序推进 300 步后才被打断,ISR 取到 0x5A

两种方式拿到同样的数据,代价却完全不同:轮询把 300 个周期全部浪费在"查标志位"上;中断让主程序完整推进了 300 步,只在最后被"打断"一下。设备越慢,差距越大——真实磁盘要等几毫秒,轮询浪费的是几百万个周期。

6. DMA:大块搬运的正确姿势

中断也不是银弹:每处理一块数据就要一次上下文切换(保存/恢复现场),如果数据块小、频率高,中断本身的开销反而吃掉了 CPU。想象网卡每秒收到几十万个包,每个包都打断一次 CPU,那 CPU 就只剩下"保存现场"和"恢复现场"了。

于是有了 DMA(Direct Memory Access,直接存储器访问):一个专门的数据搬运引擎,把"外设 → 内存"的拷贝从 CPU 手上整个接过去。CPU 只需要配置好源地址、目的地址、长度,然后转身去干别的;整块数据搬完,DMA 控制器才发一次中断通知 CPU"完工"。

三种 I/O 方式:数据到 CPU 的代价逐级下降 ① 轮询 polling CPU 每周期查状态位 设备 就绪后给数据 CPU(空转) 等待期间 CPU 被锁死,只能干瞪眼 ② 中断 interrupt 设备 就绪才发 IRQ CPU(干正事) 保存现场 ISR 处理 等待期间主程序照常推进,每块数据付一次上下文切换 ③ DMA 设备 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 能去做什么? 答不上来才用轮询。