ARM Cache 一致性:DMA 数据错了,先别骂外设
一、深度引言:DMA 问题常常不是 DMA 坏了
嵌入式调试里,DMA 传输完成但数据不对,是很典型的坑。很多人先怀疑外设寄存器配置、时钟分频、描述符链表、线缆接触,查了一圈都没问题,最后才发现是 Cache 一致性。CPU 看到的是 Data Cache 里的旧数据,DMA 外设写的是 DDR 内存里的新数据,双方视角都没错,但数据在系统里已经分叉了。
ARM Cortex-A 系列处理器默认开启 Data Cache,CPU 读写数据通常经过 Cache,DMA 外设直接访问 DDR 内存。这种双视角架构本身没问题,问题在于软件没有在正确时机做 Cache 维护操作。DMA 发送方向:CPU 写数据到 Cache 但没有写回内存,外设读到的是旧数据;DMA 接收方向:外设写新数据到内存但 CPU 还持有旧 Cache,CPU 读到的是旧缓存。更隐蔽的第三种场景:CPU 写了部分数据后 DMA 又写了新数据覆盖了同一地址,CPU 的 Cache 里是旧值、DDR 里是 DMA 写的新值、两者都"自认为正确"但实际已经分叉。
这种问题往往表现为偶发。低频传输、低负载时不复现,高频视频流、高温降频、内存压力大时才出错。遇到"偶尔错一帧"或"偶尔丢一个包",不要急着归类成外设不稳定——先查 Cache。更关键的判断依据:如果错误数据在重启设备后消失(Cache 自然清空),那几乎可以确定是 Cache 一致性问题。
工程结论:带 Cache 的 ARM 平台上,DMA buffer 必须认真处理一致性。这不是可选优化,是必须做的正确性保障。
二、原理剖析:Cache 维护指令与 dma_map_single 内幕
CPU 与外设的数据视角
flowchart TD A[CPU 写数据] --> B[Data Cache<br/>缓存最新值] B -->|Clean: 写回 DDR| C[DDR Memory] C --> D[DMA 外设读取] C -->|DMA 外设写入新数据| E[DDR Memory 已更新] E -->|Invalidate: 丢弃旧缓存| B B --> F[CPU 读取最新值]CPU 读写走 Cache,DMA 外设直走 DDR。Cache 和 DDR 之间的数据同步,必须由软件显式触发。ARMv8-A 提供了一组 Cache 维护指令:
- DCCMVAC(Data Cache Clean by Virtual Address to Point of Coherency):把指定地址范围的脏 Cache Line 写回 DDR 内存,但 Cache 中仍然保留副本。用于 CPU→外设方向:CPU 写完数据后,Clean 确保外设能读到最新值。
- DCCIMVAC(Data Cache Clean and Invalidate by Virtual Address to Point of Coherency):先写回脏数据到 DDR,再从 Cache 中删除副本。用于需要同时确保内存更新和 Cache 不再持有的场景。
- DCIVAC(Data Cache Invalidate by Virtual Address to Point of Unification):直接丢弃指定地址范围的 Cache Line,不写回脏数据。用于外设→CPU 方向:外设写完数据后,Invalidate 确保 CPU 重新从 DDR 读取。注意:如果 Cache 中有脏数据(CPU 之前写过但还没写回内存),Invalidate 会直接丢弃,导致数据丢失。所以 Invalidate 前必须确认 Cache Line 不是脏的。
dma_map_single 的内幕
Linux 内核的dma_map_single()和dma_unmap_single()是封装了 Cache 维护的标准 API。理解它的内幕,才能在裸机或 RTOS 上正确实现对应操作:
flowchart TD A[dma_map_single<br/>direction=DMA_TO_DEVICE] --> B[CPU→外设方向] B --> C[调用 DCCMVAC<br/>Clean 虚地址范围] C --> D[返回物理地址<br/>外设可安全读取] E[dma_map_single<br/>direction=DMA_FROM_DEVICE] --> F[外设→CPU方向] F --> G[调用 DCIVAC<br/>Invalidate 虚地址范围] G --> H[返回物理地址<br/>CPU 后续读取安全] I[dma_unmap_single<br/>direction=DMA_FROM_DEVICE] --> J[传输完成后] J --> K[再次调用 DCIVAC<br/>确保 CPU 读到最新数据]DMA_TO_DEVICE 方向(CPU 写数据给外设):dma_map_single只做 Clean,不做 Invalidate。Clean 把脏 Cache 写回 DDR,外设通过物理地址读 DDR 就能拿到最新值。Cache 中仍然保留副本,CPU 后续读还能命中 Cache,不影响性能。
DMA_FROM_DEVICE 方向(外设写数据给 CPU):dma_map_single先做 Invalidate,丢弃可能存在的旧 Cache。这样 DMA 传输期间,CPU 不会从旧 Cache 读到过期数据。传输完成后dma_unmap_single再次 Invalidate,确保 CPU 从 DDR 读到外设刚写入的新数据。
DMA_BIDIRECTIONAL 方向:先 Clean 再 Invalidate,最安全但性能最差。只在不确定数据方向时使用。
Cache Line 对齐的必要性
很多 ARM 平台要求 DMA buffer 地址和长度按 Cache Line(通常 64 字节)对齐。原因:Cache 维护指令的最小操作单位是整条 Cache Line。如果 buffer 起始地址不在 Cache Line 边界上,Clean 或 Invalidate 会波及相邻数据——可能把不属于 DMA buffer 的有效 Cache Line 也写回或丢弃,造成数据损坏。
dma_cache_rule: cpu_to_device: clean_before_dma # DCCMVAC device_to_cpu: invalidate_after_dma # DCIVAC require_cacheline_alignment: true # 地址和长度对齐 64 字节 never_invalidate_dirty_cache_line: true # 脏数据必须先 Clean 再 Invalidate这三条规则要写进驱动规范里,不要靠每个人记。
三、代码实现:方向不同,操作不同
裸机 / RTOS 环境下的 Cache 维护
// ===== Cache 维护操作的封装 ===== #define CACHE_LINE_SIZE 64 // ARMv8-A 通常 64 字节 // 对齐计算:地址向下对齐,长度向上对齐 // 必须覆盖完整 Cache Line,避免误伤相邻数据 static uintptr_t align_down(uintptr_t addr) { return addr & ~(CACHE_LINE_SIZE - 1); } static size_t align_up_size(uintptr_t addr, size_t len) { uintptr_t start = align_down(addr); uintptr_t end = (addr + len + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1); return end - start; } // ===== CPU → 外设方向:Clean Cache ===== // 场景:CPU 写完数据,DMA 外设要读取 void dma_prepare_tx(void *buf, size_t len) { uintptr_t start = align_down((uintptr_t)buf); size_t aligned_len = align_up_size((uintptr_t)buf, len); // DCCMVAC:把脏 Cache Line 写回 DDR // 外设通过物理地址读 DDR,拿到 CPU 写的最新数据 SCB_CleanDCache_by_Addr((uint32_t *)start, aligned_len); } // ===== 外设 → CPU 方向:Invalidate Cache ===== // 场景:DMA 外设写完数据,CPU 要读取 void dma_prepare_rx(void *buf, size_t len) { uintptr_t start = align_down((uintptr_t)buf); size_t aligned_len = align_up_size((uintptr_t)buf, len); // DCIVAC:丢弃旧 Cache Line,CPU 后续读会从 DDR 取新数据 // 注意:调用前必须确认这些 Cache Line 不是脏的 // 如果 CPU 之前写过这部分内存但没 Clean,Invalidate 会丢弃数据 SCB_InvalidateDCache_by_Addr((uint32_t *)start, aligned_len); } // ===== DMA buffer 结构体封装 ===== typedef struct { void *vaddr; // 虚地址,CPU 访问用 uintptr_t paddr; // 物地址,DMA 外设访问用 size_t len; // buffer 长度(已按 Cache Line 对齐) int direction; # DMA_TO_DEVICE / DMA_FROM_DEVICE / DMA_BIDIRECTIONAL } dma_buffer_t; // 分配时强制对齐,不要让业务层自己 malloc dma_buffer_t *dma_alloc_buffer(size_t size, int direction) { size_t aligned_size = (size + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1); void *vaddr = aligned_alloc(CACHE_LINE_SIZE, aligned_size); if (!vaddr) { printf("DMA buffer alloc failed, size=%d\n", aligned_size); return NULL; } uintptr_t paddr = (uintptr_t)vaddr; // 裸机环境下虚地址=物地址 // 如果有 MMU,需要通过页表转换 dma_buffer_t *buf = malloc(sizeof(dma_buffer_t)); buf->vaddr = vaddr; buf->paddr = paddr; buf->len = aligned_size; buf->direction = direction; return buf; }调试验证:递增模式校验
// ===== Cache 一致性快速验证 ===== // CPU 写入递增模式,DMA 读走校验;DMA 写入固定模式,CPU 读取校验 // 这个测试比跑完整业务链路更容易定位问题 void cache_coherency_test(void) { uint32_t *tx_buf = (uint32_t *)dma_alloc_buffer(256, DMA_TO_DEVICE); uint32_t *rx_buf = (uint32_t *)dma_alloc_buffer(256, DMA_FROM_DEVICE); // CPU 写递增模式 for (int i = 0; i < 64; i++) { tx_buf[i] = i; } dma_prepare_tx(tx_buf, 256); // DMA 传输并校验 tx_buf 内容 // DMA 写固定模式到 rx_buf // ... start_dma_rx(rx_buf, 256); dma_prepare_rx(rx_buf, 256); // CPU 校验 rx_buf 内容是否是外设写入的值 for (int i = 0; i < 64; i++) { if (rx_buf[i] != EXPECTED_PATTERN) { printf("Cache coherency error at index %d: got %08X, expected %08X\n", i, rx_buf[i], EXPECTED_PATTERN); } } }四、边界分析:偶发问题与保护区检测
偶发 Cache 问题的特征
Cache 一致性问题往往表现为偶发,因为只有当 Cache Line 状态刚好是"脏且未写回"或"旧且未失效"时才会出错。低频传输时 Cache Line 很可能已经被自然替换(LRU 算法),数据自然一致;高频传输时 Cache Line 持续被命中,新旧数据持续分叉。
以下场景更容易触发 Cache 一致性问题:
- 高频视频流 DMA:每秒 30 帧以上,帧 buffer 频繁在 CPU 和外设之间切换
- 高温降频:CPU 频率降低,Cache 替换策略更保守,脏 Line 存留时间更长
- 内存压力大:多路 DMA 同时传输,Cache 竞争加剧
- NPU 推理 + CPU 后处理并行:两者访问同一输出 buffer,Cache 状态交叉
遇到"偶尔错一帧",不要急着归类成外设不稳定。先在传输前后各打一次 Cache 状态日志,确认 Clean/Invalidate 是否在正确时机执行。
DMA buffer 保护区
调试时可以给 DMA buffer 加保护区。传输前后检查头尾 magic,如果 magic 被改写,说明可能存在长度错误或越界写。Cache 问题和越界问题经常混在一起,保护区能先排除一类故障:
#define DMA_MAGIC_HEAD 0xA55A1234 #define DMA_MAGIC_TAIL 0xDEAD5678 typedef struct { uint32_t head_magic; // 保护区头部 uint8_t payload[256]; // 实际 DMA 数据 uint32_t tail_magic; // 保护区尾部 } dma_protected_buffer_t; bool dma_verify_magic(dma_protected_buffer_t *buf) { if (buf->head_magic != DMA_MAGIC_HEAD) { printf("DMA head magic corrupted: %08X\n", buf->head_magic); return false; } if (buf->tail_magic != DMA_MAGIC_TAIL) { printf("DMA tail magic corrupted: %08X\n", buf->tail_magic); return false; } return true; }这种轻量自检不适合长期打开(保护区浪费内存),但在定位阶段很有用。
IOMMU 与 dma-mapping API
如果系统有 IOMMU(ARM SMMU)或 Linux dma-mapping API,应优先使用平台提供的接口。裸写 Cache 函数容易漏掉架构差异:ARMv7-A 的 Cache Line 是 32 字节,ARMv8-A 是 64 字节;某些 SoC 的 L2 Cache 是 PIPT(物理索引物理标签),某些是 VIPT(虚拟索引物理标签),维护指令的行为不同。移植到新 SoC 时,裸写 Cache 操作很容易出问题。
五、总结
ARM 平台 DMA 调试要先确认 Cache 一致性。DCCMVAC(Clean)用于 CPU→外设方向,确保 CPU 写的数据到达 DDR;DCIVAC(Invalidate)用于外设→CPU 方向,确保 CPU 不读旧 Cache。两者不能乱用,脏数据必须先 Clean 再 Invalidate。
DMA buffer 地址和长度必须按 Cache Line 对齐,否则维护操作会波及相邻数据。驱动层应封装 dma_buffer_t 结构体,统一分配对齐内存和 Cache 维护,不让业务层直接碰硬件一致性细节。
Linux 环境下优先使用 dma_map_single/dma_unmap_single,裸机环境下封装对齐计算和 SCB_CleanDCache/InvalidateDCache 调用。调试时用递增模式校验和保护区 magic 快速定位。
DMA 数据错了,不一定是外设坏。CPU、Cache 和内存之间的关系没处理好,数据就会在系统里分叉。