I/O 接口(DMA、内存映射 I/O)
I/O 接口是 CPU 与外部设备通信的桥梁——从简单的轮询到中断驱动,再到 DMA 直接内存访问,每种方式都在"CPU 参与度"和"数据传输效率"之间做权衡
CPU 怎么和”外面”通信?
CPU 再强大,也只能处理内存里的数据。但数据和指令需要从外部世界进来——键盘输入、硬盘读取、网络数据包、显示器输出……
这些都需要 I/O 接口(Input/Output Interface) 来连接 CPU 和外设。
类比:快递收发
- 轮询(Polling) = 你每隔 5 分钟去门口看看快递来了没——简单但浪费你的时间
- 中断(Interrupt) = 快递员到了打电话叫你——你忙你的,到了通知你
- DMA = 快递员直接把包裹放进你的仓库,不用你动手——最省事
I/O 设备访问方式
方式一:内存映射 I/O(Memory-Mapped I/O)
把设备寄存器”映射”到内存地址空间——CPU 用普通的 LOAD/STORE 指令访问设备:
内存地址空间:
┌──────────────────────┐ 0x00000000
│ 程序内存 │
│ RAM(主存) │
├──────────────────────┤
│ 设备寄存器 │
│ 键盘状态寄存器 ─→ 0xFF00 │
│ 键盘数据寄存器 ─→ 0xFF04 │
│ 显示缓冲区 ─→ 0xF000 │
│ 硬盘数据寄存器 ─→ 0xE000 │
└──────────────────────┘ 0xFFFFFFFF
// 假设键盘的数据寄存器被映射到地址 0xFF04
#define KEYBOARD_DATA ((volatile unsigned char*)0xFF04)
#define KEYBOARD_STATUS ((volatile unsigned char*)0xFF00)
#define KEY_READY_BIT 0x01
// 轮询读取键盘输入
char read_keyboard_polling() {
while (!(*KEYBOARD_STATUS & KEY_READY_BIT)) {
// 等待,什么也不做(忙等待)
}
return *KEYBOARD_DATA; // 读到的就是当前按下的键
}
🔑 使用
volatile关键字确保每次读取都真正访问设备,而不是从缓存读——设备寄存器的值可能随时变化。
方式二:独立 I/O 端口(Port-Mapped I/O)
x86 架构有独立的 I/O 地址空间,需要用专用的 IN/OUT 指令访问:
内存地址空间: I/O 地址空间:
┌────────────────┐ ┌────────────────┐
│ │ │ 0x60 键盘数据 │
│ 普通 RAM │ │ 0x64 键盘状态 │
│ │ │ 0x3F8 COM1 │
│ │ │ 0x378 并口 │
└────────────────┘ └────────────────┘
; x86 的 IN/OUT 指令
IN AL, 0x60 ; 从 I/O 端口 0x60(键盘)读取一个字节
OUT 0x378, AL ; 将 AL 的值写入 I/O 端口 0x378(并口)
| 方式 | 优点 | 缺点 | 使用场景 |
|---|---|---|---|
| 内存映射 I/O | 使用普通访存指令,编程简单 | 占用内存地址空间 | ARM、RISC-V |
| 独立 I/O 端口 | 不占用内存地址 | 需要专用指令 IN/OUT | x86 传统设备 |
I/O 传输方式
方式一:程序控制 I/O(Programmed I/O, PIO)
CPU 主动参与每一次数据传输——轮询或中断驱动:
轮询(Polling)
// CPU 反复检查设备状态,直到设备就绪
void read_sector_pio(int sector, char* buffer) {
// 1. 告诉硬盘我们要读哪个扇区
outb(0x1F2, 1); // 扇区数 = 1
outb(0x1F3, sector); // 扇区号
outb(0x1F7, 0x20); // 命令:读扇区
// 2. 等待硬盘就绪(轮询)
while (!(inb(0x1F7) & 0x08)) {
// CPU 忙等!不能做其他事
}
// 3. 读取数据(每次 2 字节,共 256 次)
for (int i = 0; i < 256; i++) {
((unsigned short*)buffer)[i] = inw(0x1F0);
}
}
问题:读取期间 CPU 不能做任何其他事——浪费!对于高速设备(如千兆网卡),轮询几乎占满 CPU。
中断驱动 I/O
// 中断驱动版本——CPU 在等待期间可以干别的
volatile char* g_keyboard_buffer;
volatile int g_keyboard_ready = 0;
// 键盘中断处理程序
void keyboard_isr() {
*g_keyboard_buffer = inb(0x60); // 读键盘数据
g_keyboard_ready = 1;
send_eoi(); // 发送中断结束信号
}
// 主程序
void main() {
register_isr(IRQ1, keyboard_isr); // 注册中断处理程序
while (1) {
// CPU 可以做其他事!
if (g_keyboard_ready) {
process_key(*g_keyboard_buffer);
g_keyboard_ready = 0;
}
// ... 其他任务 ...
}
}
优点:CPU 不用忙等,设备就绪时再通知 CPU。 缺点:每次传输少量数据仍会触发中断——高速设备会产生中断风暴。
方式二:DMA(Direct Memory Access,直接存储器访问)
DMA 是一个专门的硬件控制器,可以在不经过 CPU 的情况下,在外设和内存之间直接传输数据:
不带 DMA(PIO):
硬盘 ──→ CPU ──→ 内存
↑
CPU 全程参与,逐字节搬数据
带 DMA:
硬盘 ──→ DMA 控制器 ──→ 内存
↑
CPU 只需设置参数,然后做自己的事
↓
传输完成后 DMA 发一个中断通知 CPU
DMA 的工作流程
① CPU 设置 DMA 控制器:
- 源地址(硬盘缓冲区地址)
- 目标地址(内存地址)
- 传输长度(N 字节)
- 方向(硬盘 → 内存)
② CPU 告诉硬盘:"开始传输"
③ DMA 控制器直接控制总线,把数据从硬盘搬到内存
CPU 可以继续执行程序(如果数据不经过 CPU 路径)
④ 传输完成 → DMA 控制器发送中断通知 CPU
⑤ CPU 中断处理程序:数据已经在内存中了,可以直接使用
时间线对比:
PIO 方式:
CPU: ████ 设置 → ████ 传输 → ████ 传输 → ████ 传输 → ████ 完成
(CPU 被完全占用,不能做别的)
DMA 方式:
CPU: ██ 设置 ████████████████████████████████ ██ 中断处理
↑ ↑ ↑ ↑
设置DMA CPU 可执行其他程序 传输完成 处理完成
DMA 在后台默默搬数据 发中断
DMA 的性能收益
读 1 MB 数据从硬盘到内存:
| 方式 | CPU 占用 | 时间 | CPU 可用率 |
|---|---|---|---|
| PIO(轮询) | 100% | ~5 ms | 0% |
| PIO(中断) | 每次传输都中断 | ~5 ms + 中断开销 | ~30% |
| DMA | 仅设置 + 中断处理 | ~5 ms | ~97% |
💡 高速设备(千兆网卡、NVMe 固态硬盘、显卡)无一例外使用 DMA。没有 DMA,网络传输速率超过几百 Mbps 时 CPU 就会被完全占满。
DMA 的两种模式
| 模式 | 工作方式 | 适用场景 |
|---|---|---|
| 块传输(Burst Mode) | DMA 占用总线传输一整块数据,期间 CPU 无法访问总线 | 高速大批量传输 |
| 周期窃取(Cycle Stealing) | DMA 每次只传 1 个字,然后释放总线,交替进行 | 少量但频繁的传输 |
块传输模式:
总线占用:████████████████████████████████████████
DMA 搬数据 CPU 等
周期窃取模式:
总线占用:██ ██ ██ ██ ██ ██ ██ ██ ██ ██
DMA CPU DMA CPU DMA CPU DMA CPU DMA CPU
实际设备例子
键盘(低速设备)
方式:中断驱动 I/O
每次按键 → 一个中断 → CPU 读取一个字节
完全不需要 DMA(每次只有 1 字节数据)
硬盘(高速设备)
方式:DMA
读取 4 KB 数据块 → DMA 控制器搬 → 一次中断通知完成
如果用 PIO 每次读 2 字节,4 KB = 2048 次中断
用 DMA 只需要 1 次中断!
网卡(极高速度设备)
方式:DMA + 环形缓冲区(Ring Buffer)
网卡收到数据包 → DMA 直接写入内存缓冲区 → 中断通知 CPU
现代网卡甚至支持多队列 DMA——多个 CPU 核心同时处理不同队列
// 网卡的 DMA 环形缓冲区(简化)
struct dma_ring {
char* buffer[RX_RING_SIZE]; // 数据缓冲区指针数组
uint32_t length[RX_RING_SIZE]; // 每个缓冲区的长度
volatile uint32_t head; // 生产者索引(网卡写入)
volatile uint32_t tail; // 消费者索引(CPU 读取)
};
// 网卡收到包时,自动 DMA 到 head 指向的缓冲区
// 然后 head++,发中断
// CPU 处理 tail 指向的缓冲区,然后 tail++
// 这就是一个无锁的生产者-消费者队列!
I/O 性能对比
| 传输方式 | CPU 占用 | 延迟 | 适合设备 | 单次传输量 |
|---|---|---|---|---|
| 轮询 PIO | 100% (忙等) | 最低 | 极简单设备(状态寄存器) | 1 字节 |
| 中断 PIO | 每次中断少量 | 中等 | 键盘、鼠标 | 1-16 字节 |
| DMA | 仅设置+结束 | 较高(设置开销) | 硬盘、网卡、GPU | ≥ 512 字节 |
🔑 选择原则:传输量大 → 用 DMA;传输量小但频繁 → 用中断;极其简单且实时性要求高 → 轮询。
小结
I/O 接口是计算机与外部世界交互的桥梁:
| 概念 | 要点 |
|---|---|
| 内存映射 I/O | 设备寄存器映射到内存地址空间,用 LOAD/STORE 访问 |
| 独立 I/O 端口 | 独立地址空间,用 IN/OUT 访问(x86) |
| 轮询(Polling) | CPU 不断检查设备状态——简单但浪费 |
| 中断驱动 I/O | 设备就绪时通知 CPU——效率高,但高速设备有中断风暴 |
| DMA | 硬件控制器直接搬数据——CPU 几乎零参与 |
为什么这很重要? I/O 是计算机性能的终极瓶颈之一——CPU 可以每秒执行几十亿条指令,但如果数据进出的通道不够快,CPU 只能空转。DMA 让 CPU 从数据搬运的苦活中解放出来,是现代计算机高性能 I/O 的基石。
接下来,你将结合前面学到的所有知识,了解现代 CPU 的完整面貌——多核、乱序执行、超线程等:现代 CPU 架构。