ARM Cache 一致性:DMA 数据错了,先别骂外设

📅 2026/7/4 14:49:59
ARM Cache 一致性:DMA 数据错了,先别骂外设
ARM Cache 一致性DMA 数据错了先别骂外设一、深度引言DMA 问题常常不是 DMA 坏了嵌入式调试里DMA 传输完成但数据不对是很典型的坑。很多人先怀疑外设寄存器配置、时钟分频、描述符链表、线缆接触查了一圈都没问题最后才发现是 Cache 一致性。CPU 看到的是 Data Cache 里的旧数据DMA 外设写的是 DDR 内存里的新数据双方视角都没错但数据在系统里已经分叉了。ARM Cortex-A 系列处理器默认开启 Data CacheCPU 读写数据通常经过 CacheDMA 外设直接访问 DDR 内存。这种双视角架构本身没问题问题在于软件没有在正确时机做 Cache 维护操作。DMA 发送方向CPU 写数据到 Cache 但没有写回内存外设读到的是旧数据DMA 接收方向外设写新数据到内存但 CPU 还持有旧 CacheCPU 读到的是旧缓存。更隐蔽的第三种场景CPU 写了部分数据后 DMA 又写了新数据覆盖了同一地址CPU 的 Cache 里是旧值、DDR 里是 DMA 写的新值、两者都自认为正确但实际已经分叉。这种问题往往表现为偶发。低频传输、低负载时不复现高频视频流、高温降频、内存压力大时才出错。遇到偶尔错一帧或偶尔丢一个包不要急着归类成外设不稳定——先查 Cache。更关键的判断依据如果错误数据在重启设备后消失Cache 自然清空那几乎可以确定是 Cache 一致性问题。工程结论带 Cache 的 ARM 平台上DMA buffer 必须认真处理一致性。这不是可选优化是必须做的正确性保障。二、原理剖析Cache 维护指令与 dma_map_single 内幕CPU 与外设的数据视角flowchart TD A[CPU 写数据] -- B[Data Cachebr/缓存最新值] B --|Clean: 写回 DDR| C[DDR Memory] C -- D[DMA 外设读取] C --|DMA 外设写入新数据| E[DDR Memory 已更新] E --|Invalidate: 丢弃旧缓存| B B -- F[CPU 读取最新值]CPU 读写走 CacheDMA 外设直走 DDR。Cache 和 DDR 之间的数据同步必须由软件显式触发。ARMv8-A 提供了一组 Cache 维护指令DCCMVACData Cache Clean by Virtual Address to Point of Coherency把指定地址范围的脏 Cache Line 写回 DDR 内存但 Cache 中仍然保留副本。用于 CPU→外设方向CPU 写完数据后Clean 确保外设能读到最新值。DCCIMVACData Cache Clean and Invalidate by Virtual Address to Point of Coherency先写回脏数据到 DDR再从 Cache 中删除副本。用于需要同时确保内存更新和 Cache 不再持有的场景。DCIVACData 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_singlebr/directionDMA_TO_DEVICE] -- B[CPU→外设方向] B -- C[调用 DCCMVACbr/Clean 虚地址范围] C -- D[返回物理地址br/外设可安全读取] E[dma_map_singlebr/directionDMA_FROM_DEVICE] -- F[外设→CPU方向] F -- G[调用 DCIVACbr/Invalidate 虚地址范围] G -- H[返回物理地址br/CPU 后续读取安全] I[dma_unmap_singlebr/directionDMA_FROM_DEVICE] -- J[传输完成后] J -- K[再次调用 DCIVACbr/确保 CPU 读到最新数据]DMA_TO_DEVICE 方向CPU 写数据给外设dma_map_single只做 Clean不做 Invalidate。Clean 把脏 Cache 写回 DDR外设通过物理地址读 DDR 就能拿到最新值。Cache 中仍然保留副本CPU 后续读还能命中 Cache不影响性能。DMA_FROM_DEVICE 方向外设写数据给 CPUdma_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 LineCPU 后续读会从 DDR 取新数据 // 注意调用前必须确认这些 Cache Line 不是脏的 // 如果 CPU 之前写过这部分内存但没 CleanInvalidate 会丢弃数据 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 后处理并行两者访问同一输出 bufferCache 状态交叉遇到偶尔错一帧不要急着归类成外设不稳定。先在传输前后各打一次 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如果系统有 IOMMUARM SMMU或 Linux dma-mapping API应优先使用平台提供的接口。裸写 Cache 函数容易漏掉架构差异ARMv7-A 的 Cache Line 是 32 字节ARMv8-A 是 64 字节某些 SoC 的 L2 Cache 是 PIPT物理索引物理标签某些是 VIPT虚拟索引物理标签维护指令的行为不同。移植到新 SoC 时裸写 Cache 操作很容易出问题。五、总结ARM 平台 DMA 调试要先确认 Cache 一致性。DCCMVACClean用于 CPU→外设方向确保 CPU 写的数据到达 DDRDCIVACInvalidate用于外设→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 和内存之间的关系没处理好数据就会在系统里分叉。