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,分为一致性映射和流式映射两大类。

DMA 与 CPU 缓存一致性:映射类型与数据流 CPU load / store 经 cache 访问 CPU Cache 可能持有脏行/旧副本 flush / invalidate 作用点 物理内存 DMA 直接读写 真实数据落点 DMA 引擎 不感知 cache 按总线地址访问 一致性映射 dma_alloc_coherent() • 分配时即保证 CPU 与 DMA 看到一致数据 • 通常映射为 uncached / write-combine • 无需手动 flush / invalidate • 适合长期共享的描述符环、控制块 • 代价:CPU 访问可能变慢,占用连续内存 • 释放:dma_free_coherent() 流式映射 dma_map_single/sg() • 一次性映射已有缓冲区,方向明确 • 需在交接点做 flush / invalidate • DMA_TO_DEVICE:先 flush 再启动 • DMA_FROM_DEVICE:完成后 invalidate • 适合大数据块、网络/存储收发缓冲 • 释放:dma_unmap_single/sg()

二、方法流程:两类映射的选型与操作顺序

一致性映射(coherent mapping)在分配时就建立“CPU 与设备始终一致”的语义,驱动不需要在每次传输前后做 cache 维护,代价是 CPU 访问该内存通常不走 cache,性能可能下降,且对内存连续性有要求。它适合生命周期长、频繁被双方访问的小对象,例如 DMA 描述符环、命令块、状态寄存器镜像。

流式映射(streaming mapping)针对“一段已有缓冲区、一次或一段时间内交给 DMA 使用”的场景。它不改变缓冲区的 cache 属性,而是要求驱动在所有权交接点显式维护 cache。典型顺序如下:

  1. 用 dma_map_single() 或 dma_map_sg() 映射缓冲区,得到 DMA 地址,并声明方向。
  2. 若方向为 DMA_TO_DEVICE,映射后启动 DMA 前,内核会保证 CPU 写入对设备可见(必要时 flush)。
  3. 启动 DMA,等待传输完成(中断或轮询)。
  4. 若方向为 DMA_FROM_DEVICE,在 CPU 读取数据前调用 dma_unmap_*(),内核会做 invalidate,使 CPU 看到设备写入的新数据。
  5. 不要在 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() 批量映射或复用一致性缓冲区。

对比项一致性映射流式映射
典型 APIdma_alloc_coherent / dma_free_coherentdma_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 不要碰缓冲。