高级 #hardware#io#dma

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/OUTx86 传统设备

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 ms0%
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 占用延迟适合设备单次传输量
轮询 PIO100% (忙等)最低极简单设备(状态寄存器)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 架构