4. DMA内存与缓存一致性
DMA 性能问题中,最容易被误判为“驱动写得不够快”的,其实是缓存一致性。CPU 访问内存时会经过 cache,而 DMA 控制器通常直接访问物理内存(或经 IOMMU 映射后的地址)。当同一块缓冲区同时被 CPU 和 DMA 引擎读写时,如果 cache 中的副本与内存中的真实数据不一致,就会出现“数据看起来没更新”“DMA 读到旧数据”“传输完成但内容错乱”等典型故障。理解一致性映射、流式映射、cache flush/invalidate 以及 IOMMU/SMMU 的作用,是排查高吞吐 DMA 问题的基本功。
一、核心原理:为什么 DMA 会与 cache 冲突
CPU 的 load/store 默认走 cache,写操作可能停留在 write-back cache 中,尚未写回内存;读操作也可能命中 cache 中的旧副本。DMA 引擎不感知 CPU cache,它只认内存中的字节。于是产生两类方向性错误:
- DMA 读(设备读内存):CPU 刚写入缓冲区的数据还在 cache 里,内存中仍是旧值,DMA 读到旧数据。需要在 DMA 启动前把 cache 写回内存(flush/clean)。
- DMA 写(设备写内存):DMA 已把新数据写入内存,但 CPU cache 中仍保留旧副本,CPU 读到旧数据。需要在 DMA 完成后使 cache 失效(invalidate)。
因此,缓存一致性的本质是:在 CPU 与 DMA 交接缓冲区所有权的时刻,保证双方看到的是同一份数据。Linux 内核把这套机制抽象为 DMA API,分为一致性映射和流式映射两大类。
二、方法流程:两类映射的选型与操作顺序
一致性映射(coherent mapping)在分配时就建立“CPU 与设备始终一致”的语义,驱动不需要在每次传输前后做 cache 维护,代价是 CPU 访问该内存通常不走 cache,性能可能下降,且对内存连续性有要求。它适合生命周期长、频繁被双方访问的小对象,例如 DMA 描述符环、命令块、状态寄存器镜像。
流式映射(streaming mapping)针对“一段已有缓冲区、一次或一段时间内交给 DMA 使用”的场景。它不改变缓冲区的 cache 属性,而是要求驱动在所有权交接点显式维护 cache。典型顺序如下:
- 用 dma_map_single() 或 dma_map_sg() 映射缓冲区,得到 DMA 地址,并声明方向。
- 若方向为 DMA_TO_DEVICE,映射后启动 DMA 前,内核会保证 CPU 写入对设备可见(必要时 flush)。
- 启动 DMA,等待传输完成(中断或轮询)。
- 若方向为 DMA_FROM_DEVICE,在 CPU 读取数据前调用 dma_unmap_*(),内核会做 invalidate,使 CPU 看到设备写入的新数据。
- 不要在 map 与 unmap 之间让 CPU 访问缓冲区,否则可能破坏一致性语义。
/* 发送方向:CPU 写缓冲 -> 设备读 */
dma_addr_t tx_addr;
tx_addr = dma_map_single(dev, tx_buf, tx_len, DMA_TO_DEVICE);
if (dma_mapping_error(dev, tx_addr)) { /* 错误处理 */ }
writel(tx_addr, dev->regs + TX_DMA_ADDR);
writel(tx_len, dev->regs + TX_DMA_LEN);
writel(START, dev->regs + TX_CTRL);
/* 传输完成后 */
dma_unmap_single(dev, tx_addr, tx_len, DMA_TO_DEVICE);
/* 接收方向:设备写缓冲 -> CPU 读 */
rx_addr = dma_map_single(dev, rx_buf, rx_len, DMA_FROM_DEVICE);
writel(rx_addr, dev->regs + RX_DMA_ADDR);
writel(rx_len, dev->regs + RX_DMA_LEN);
writel(START, dev->regs + RX_CTRL);
/* 等待完成中断后 */
dma_unmap_single(dev, rx_addr, rx_len, DMA_FROM_DEVICE);
/* 此时 CPU 才能安全读取 rx_buf */
三、IOMMU 与 SMMU:地址翻译与隔离
在没有 IOMMU 的系统中,设备发出的 DMA 地址就是物理地址,驱动必须保证缓冲区物理连续,且设备可以访问任意物理内存,存在安全与碎片化问题。IOMMU(x86 常见)与 SMMU(ARM 架构的 IOMMU 实现)在设备与内存之间增加一层地址翻译:设备使用 IOVA(I/O 虚拟地址),IOMMU 将其翻译为物理地址。
这带来三个直接收益:其一,驱动可以用分散的物理页拼出连续的 IOVA,缓解大块连续内存分配压力;其二,设备被限制在映射范围内,越界访问会被拦截,提升隔离性;其三,配合 DMA API,映射与取消映射同时承担了 cache 维护和地址翻译表维护的职责。需要注意的是,IOMMU/SMMU 的页表遍历本身有开销,频繁 map/unmap 小缓冲区会显著拉低吞吐,应尽量使用 dma_map_sg() 批量映射或复用一致性缓冲区。
| 对比项 | 一致性映射 | 流式映射 |
|---|---|---|
| 典型 API | dma_alloc_coherent / dma_free_coherent | dma_map_single / dma_map_sg / dma_unmap_* |
| cache 维护 | 由分配属性保证,无需手动 | 由 map/unmap 在交接点维护 |
| CPU 访问性能 | 通常较慢(uncached/WC) | 保持原有 cache 性能 |
| 适用对象 | 描述符环、控制块、长期共享结构 | 大数据块、网络/存储收发缓冲 |
| 生命周期 | 长,随设备或通道存在 | 短,一次传输或一段窗口 |
| 常见错误 | 误当普通内存频繁读写 | map 后 CPU 仍访问缓冲区 |
四、案例与误区复盘
某千兆网卡驱动在压力测试中出现“偶发丢包且校验错误”。排查发现接收路径使用 dma_map_single(DMA_FROM_DEVICE) 后,在中断处理里先读 rx_buf 再调用 dma_unmap_single()。由于 CPU cache 中仍是上一次的旧数据,读到的是过期内容。把 unmap 提前到读取之前,问题消失。这个案例说明:invalidate 必须发生在 CPU 读取之前,而不是之后。
另一个常见误区是“用一致性映射分配大块数据缓冲”。一致性内存通常 uncached,CPU 逐字节处理时性能急剧下降,反而成为瓶颈。正确做法是:描述符用一致性映射,数据缓冲用流式映射,并在批量传输时用 dma_map_sg() 减少映射次数。
调试建议:开启 CONFIG_DMA_API_DEBUG 可检测映射泄漏、方向错误与重复 unmap;在 ARM64 平台可结合 SMMU 的 fault 日志确认设备是否访问了未映射的 IOVA。若怀疑 cache 一致性,可临时用 dma_alloc_coherent 替换流式缓冲做对照实验,快速定位问题方向。
记忆口诀:长期共享用一致,单次传输用流式;设备读先 flush,设备写后 invalidate;map 与 unmap 之间,CPU 不要碰缓冲。