DMA:绕过 CPU 的数据搬运¶
日期:2026-07-21 标签:dma, hardware, uart, nic, cache, pio, descriptor, starryos
来源:StarryOS UART 驱动实践 + 16550 规范 + 网卡路线分析。 范围:DMA 概念、与已有中断驱动 PIO 方案的对比、适用边界。
背景:从 PIO 谈起¶
StarryOS 当前的异步 UART 驱动使用 PIO(Programmed I/O,程控输入输出)。发送一个字节的流程:
每一步数据搬运都走 CPU。push 和 pop 等效于 memcpy,写 THR 是 write_volatile MMIO 操作。所有字节都是 CPU 亲自从一处搬到另一处。
115200 bps(~14.4 KB/s)下这笔开销不大。但如果面对 100 MB/s 的网卡或 500 MB/s 的 NVMe 磁盘,CPU 全部时间都消耗在搬运字节上,无法做其他事。
类比:PIO 是自己一块一块搬砖;DMA 是雇司机开卡车,只告诉他仓库地址、工地地址、多少块砖。
DMA 是什么¶
DMA(Direct Memory Access,直接存储器访问)允许外设直接读写系统内存,不需要 CPU 逐字节参与。CPU 只下达命令:源地址、目标地址、长度、方向。剩下的搬运由 DMA 控制器(DMAC)完成。
三个角色:
┌──────────────────────────────┐
│ 系统总线 │
│ │
┌────┐ │ ┌──────────┐ ┌─────────┐ │
│CPU │ │ │ DMAC │ │ 设备 │ │
│ │ │ │ │ │(UART/NIC│ │
│ │ │ │ │ │ /Disk) │ │
└─┬──┘ │ └────┬─────┘ └────┬────┘ │
│ │ │ │ │
│ ①配 DMAC │ │ │
│──────────▶│ │ │
│ │ │ ② 总线读写 │ │
│ │ │◀────────────▶│ │
│ ③完成中断 │ │ │
│◀──────────│ │ │
│ │ │ │ │
└──────────────────────────────┘
- CPU:配置 DMAC,启动传输,然后被中断通知完成
- DMAC:执行搬运的专用硬件,直接操作总线
- 设备:数据的源或目的地
DMAC 的硬件角色¶
DMAC 是总线主设备(Bus Master),能独立发起总线读写事务。普通外设(UART)只是从设备,只能被 CPU 访问。这正是 DMAC 可以绕过 CPU 的根本原因 — 它有操作总线的能力。
DMAC 内部是一个状态机,工作循环:
while 还有字节要搬:
1. 从 src 发起总线读请求 → 得到数据
2. 向 dst 发起总线写请求 → 写入数据
3. src += 步长
4. len -= 1
完成 → 产生中断通知 CPU
PIO vs DMA:4 KB 传输对比¶
以发送 4 KB 数据到 UART 为例。
PIO 方式:
DMA 方式:
时间 →
CPU: [配置DMAC(~50条指令)]──[去干别的事了]────[处理完成中断(~20条)]
DMAC: [读→写]×4096 次总线事务
UART: [收字节][发字节][收字节]...
CPU 从 ~8192 条指令降到 ~70 条,释放约 99% 的 CPU 时间。
UART 115200 bps 下这个收益不明显(PIO 本身只占约 0.1% CPU),但 1 Gbps 网卡(~125 MB/s)或 NVMe SSD(~3 GB/s)下是无法用 PIO 跟上的 — CPU 指令吞吐根本不够,DMA 是唯一选项。
描述符(Descriptor)¶
单个 buffer 的简单搬运只是入门。实际 DMA 控制器使用描述符环(Descriptor Ring)支持 scatter-gather(分散-聚集)。
一个典型描述符结构:
struct DmaDescriptor {
src: u32, // 源物理地址
dst: u32, // 目标物理地址
len: u16, // 本段字节数
flags: u16, // OWN 位 + 链式 + 中断使能
next: u32, // 下一个描述符地址
}
flags 中三个关键位:
- OWN(所有权位):1 = DMAC 拥有此描述符(可读可写),0 = CPU 拥有(DMAC 不碰)。这是 CPU 和 DMAC 之间交接所有权的唯一机制。
- 中断位:此描述符完成传输后是否产生中断。
- 链式位:是否跳转到 next 地址继续取下一个描述符。
所有权状态机¶
这和 StarryOS 已有的 tx_copier_active(AtomicBool)是同一模式 — 只是消费者从"软件 copier 任务"变成了"DMA 硬件引擎"。
软件填描述符 → 设 OWN=1 → 硬件取描述符并搬运 → 完成后设 OWN=0 → 软件回收
↑ │
└─────────────────────────────────────────────────────────────┘
描述符环和你已有的 RingBufTx 同属环形缓冲区,区别在于:
RingBufTx里存的是原始字节数据- DMA descriptor ring 里存的是地址和长度,数据本身在别处
- 所有权交接从 CPU↔CPU(软件任务间)变成 CPU↔DMAC(软件↔硬件)
完整传输周期(四阶段)¶
以 NIC 的 TX descriptor 为例:
阶段 1:提交。软件填充 TX descriptor,设 OWN=1,写 doorbell 寄存器通知硬件有新任务。
阶段 2:DMA 读。硬件通过总线读 descriptor 内容,验证 OWN=1,读取 payload buffer。
阶段 3:DMA 传输。硬件将 payload 写入设备内部 FIFO,设备发送到外部(如网络)。
阶段 4:完成。硬件将 descriptor 的 OWN 设回 0,可选地产生中断。软件在 ISR 中扫描 descriptor ring,回收 OWN=0 的项。
与 StarryOS UART 组件的对应¶
| StarryOS UART 组件 | DMA 对应概念 | 相同点 | 不同点 |
|---|---|---|---|
RingBufTx(SPSC ring) |
descriptor ring | 环形缓冲区 + 所有权模型 | DMA ring 存地址+长度,不是数据本身 |
tx_copier_active(AtomicBool) |
descriptor OWN 位 | "谁在拥有这个资源"的状态 | CPU 原子变量 vs 硬件可见的内存位 |
| copier task(pop→写 THR) | DMAC 引擎(读内存→写设备) | "搬运工消费 ring"的拓扑 | 软件任务轮询 vs 硬件总线事务 |
AtomicWaker + poll 注册 |
DMA 完成中断 | "完成后通知上层" | 中断直接触发 waker 唤醒 |
TxCompletion::is_drained() |
descriptor 回收确认 | "确认传输完毕"的多阶段检查 | 外加 cache coherence 语义 |
| NAPI 中断合并 | descriptor chain 一次中断覆盖多个描述符 | 批量处理减少中断频率 | 软件实现 vs 硬件原生支持 |
UART 为什么不一定需要 DMA¶
UART 115200 bps 下 DMA 的固定开销可能超过 PIO。
- StarryOS D1 真板 PIO 已达物理线速 95.3–99.1%
- DMA 配置一次需要:刷 cache、填描述符、写 doorbell → 几十到上百条指令
- UART TX FIFO 只有 16 字节(
FIFO_SIZE = 16),DMA 单次传输 ≤ 16 字节 - 16 字节 PIO 搬运只需要 ~32 条指令,DMA 固定开销更大
DMA 的真正价值出现在高速设备上。网卡 125 MB/s、NVMe 3 GB/s — CPU 指令吞吐无法靠 PIO 跟上。
DMA 引入的三个新问题¶
PIO 不需要关心的事,DMA 必须处理。
Cache 一致性¶
CPU 写 buffer[0] = 0x41 可能只在 L1 cache 里,还没到 DRAM。DMAC 读 DRAM 会拿到旧值。
解决:
- DMA 传输前:cache clean(flush)— 把 CPU cache 的脏数据强制写回内存
- DMA 传输后:cache invalidate — 丢弃 CPU 旧 cache line,下次访问重新从内存读
PIO 不需要这个是因为 write_volatile 到 MMIO 寄存器绕过了 cache。
物理地址 vs 虚拟地址¶
DMAC 工作在物理地址空间。如果 OS 启用了 MMU/页表,CPU 看到的虚拟地址对 DMAC 没有意义。每个描述符中的地址必须做 virt→phys 转换。这就是 DMA 编程中 DMAInfo { cpu_addr, bus_addr } 二元组的来源。
IOMMU 与安全¶
设备 DMA 不经过 CPU 的 MMU,恶意或出错的设备可能 DMA 写入任意内核内存。IOMMU(I/O 内存管理单元)提供 DMA 侧的地址翻译和权限隔离,限制设备只能访问授权区域。
与邻近概念的区分¶
| 概念 | 一句话 | 与 UART 驱动的关系 |
|---|---|---|
| PIO | CPU 逐指令搬运 | 当前 copier 写 THR 就是 PIO |
| DMA | 专用硬件绕过 CPU 搬运 | 无实现,等待硬件评估 |
| MMIO | CPU 通过内存地址访问设备寄存器 | PIO 和 DMA 配置都用到 MMIO |
| 中断 | 设备通知 CPU 的机制 | PIO 用 THRE 中断,DMA 用传输完成中断 |
| NAPI / 中断合并 | ISR 中批量处理减少中断次数 | 当前 ISR 中连续 pop/push 实现;DMA 用 descriptor chain |
代码对照¶
当前 PIO TX copier(简化)¶
// CPU 亲自写 MMIO — 这就是 PIO
async fn tx_copier_task(driver: Arc<AsyncUartDriver>) {
loop {
wait_for_ring_not_empty().await;
while let Some(byte) = driver.tx_ring.pop() {
driver.uart_port.write(THR, byte);
}
wait_for_thre_interrupt().await;
}
}
假设的 DMA TX manager¶
// CPU 不碰数据 — DMAC 做搬运
async fn dma_tx_manager(
driver: Arc<AsyncDmaUartDriver>,
dma_ch: DmaChannel,
) {
let descs: &mut [DmaDescriptor; N] = /* 预分配 */;
let mut head = 0; // 软件填充位置
let mut tail = 0; // 硬件完成位置
loop {
// 1. 回收已完成描述符(OWN=0)
while descs[tail].is_completed() {
descs[tail].mark_free();
tail = (tail + 1) % N;
}
// 2. 填充新描述符
while head != (tail - 1) % N {
if let Some((buf_ptr, len)) = get_pending_buffer() {
cache_clean_range(buf_ptr, len); // ← PIO 不需要这行
descs[head] = DmaDescriptor {
src: phys_addr(buf_ptr), // ← 物理地址,不是虚拟地址
dst: UART_THR_PHYS_ADDR,
len, flags: OWN | CHAIN,
};
head = (head + 1) % N;
} else { break; }
}
// 3. 通知硬件
dma_ch.kick(); // 写 doorbell
// 4. 等待完成中断
wait_for_dma_completion().await;
}
}
关键变化:
| 变化 | PIO(现状) | DMA(假设) |
|---|---|---|
| 搬运工 | copier task(软件) | DMAC(硬件) |
| ring 里存什么 | 原始字节 | 描述符(地址+长度) |
| CPU 碰数据吗 | 是(pop → write THR) | 否(只管理描述符 ring) |
| memory barrier | Acquire/Release 原子变量 | cache clean/invalidate + fence |
| 地址类型 | 虚拟地址(MMIO 直接可访问) | 必须物理地址 |
常见误解¶
| 误解 | 事实 |
|---|---|
| "DMA 比中断驱动快" | 不一定。DMA 快在吞吐(大数据量),不在延迟。对小数据量,PIO 延迟更低。 |
| "DMA 不需要中断" | 需要。DMA 完成后用中断通知 CPU。只是中断频率降为每次传输一次,而非每字节一次。 |
| "DMA 完全消除 CPU 参与" | 消除的是数据搬运时的 CPU 参与。CPU 仍需配置 DMAC、处理完成中断、管理 descriptor ring。 |
| "有 DMA 就不需要 ring buffer" | 仍需。Descriptor ring 就是 DMA 版 ring buffer,所有权从软件间变成软硬件间。 |
动手问题¶
根据 StarryOS 现有代码,思考:
-
UART FIFO 只有 16 字节。DMA 每次传输 ≤ 16 字节,固定开销(配置 DMAC + 中断处理)可能比 PIO 16 次 MMIO wriite 还大。什么条件下 DMA 才划算?
-
当前 NAPI 中断合并在 ISR 中连续轮询 FIFO 实现批量搬运。DMA 用 descriptor chain 一次中断覆盖多个描述符。两者本质相同的思想在软件层和硬件层的不同实现。能映射出对应关系吗?
-
如果 DMAC 配置了
src=0xDEAD_BEEF(无效地址),会发生什么?和 PIO copier 访问无效 MMIO 地址的行为有什么不同?