ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DMA与Cache一致性难题:三种解决方案与实战排查

DMA与Cache一致性难题:三种解决方案与实战排查 1. 从一个让人抓狂的现象说起搞嵌入式或者底层驱动开发的朋友大概率都遇到过这种让人血压飙升的场景你明明用DMA把一段新数据搬到了内存里CPU去读的时候却发现读到的还是上一次的旧值或者反过来CPU刚改完一块缓冲区的内容启动DMA往外发结果发出去的还是老数据。代码逻辑翻来覆去检查了十几遍寄存器配置也没问题DMA传输完成标志位也置起来了可数据就是不对。这个问题的根源十有八九出在Cache上。DMA和Cache之间的数据一致性问题是每个做底层的人都绕不过去的坎。它不是什么高深的理论难题但如果你不理解背后的机制排查起来能让你怀疑人生。我自己在这个坑里前前后后摔过好几次从早期的ARM9平台到后来的Cortex-A系列、Cortex-M7每次平台换了、Cache策略变了都得重新踩一遍。这篇内容就是把我这些年处理DMA与Cache一致性问题的经验整理出来核心讲清楚三件事为什么会出现数据不一致、三种主流解决方案各自适合什么场景、以及实际项目中怎么选怎么用。不管你是刚接触DMA的新手还是已经调过几个项目但总觉得理解不够透彻的老手应该都能从中找到有用的东西。文章会涉及具体的代码操作、寄存器配置、内存属性设置也会分享一些文档里不会写的实操心得和排查技巧。2. DMA和Cache为什么会打架2.1 先搞清楚两者各自的工作方式要理解冲突的根源得先把DMA和Cache各自的行为模式理清楚。CPU访问内存的时候并不是每次都直接跑到DRAM去读写。现代处理器为了弥补CPU速度和内存速度之间的巨大差距在中间加了一层或多层Cache。CPU读数据时先看Cache里有没有有就直接拿没有就从内存加载一份到Cache里再返回。CPU写数据时如果采用写回策略数据先写到Cache里标记为脏等Cache行被替换时才真正写回内存。整个过程CPU只跟Cache打交道内存对CPU来说反而是幕后的。DMA则完全不同。DMA控制器是一个独立于CPU的外设它搬运数据时直接访问物理内存根本不经过Cache。外设到内存的传输DMA把数据从外设FIFO直接写到DRAM的指定地址内存到外设的传输DMA从DRAM读取数据直接推给外设。对DMA来说Cache是不存在的。这两套机制各自工作都没问题但一旦它们操作同一块内存区域问题就来了。2.2 三种典型的不一致场景具体来说数据不一致会以三种形式出现每一种的表现和成因都不太一样。第一种DMA写入内存后CPU读到旧数据。这是最常见的情况。假设CPU之前读过这块缓冲区Cache里存了一份旧数据的副本。现在DMA把新数据写到了DRAM但Cache里的旧副本并没有被更新。CPU再去读的时候Cache命中直接返回了旧副本根本不知道DRAM里的数据已经变了。这就是标题里说的DMA总读旧数据的典型表现。第二种CPU写入数据后DMA发出旧数据。CPU修改了发送缓冲区的内容但这些修改还停留在Cache里没有写回DRAM。此时启动DMA发送DMA从DRAM读到的还是修改前的老数据。这种情况在通信协议栈里特别容易出问题比如你更新了要发送的报文内容结果对端收到的还是上一帧的数据。第三种DMA传输过程中Cache回写造成数据覆盖。这种最隐蔽。DMA正在往某块内存写数据与此同时Cache因为行替换或者主动回写把之前缓存的脏数据写回了同一块内存区域直接把DMA刚写进去的数据覆盖掉了。这种问题往往表现为数据偶尔出错、概率性出现排查难度极大。2.3 为什么有的平台没问题有的平台必现有人可能会问我在某个平台上用DMA好好的从来没遇到过数据不一致的问题是不是这个问题被夸大了这取决于几个因素。如果那块内存区域被配置为不可缓存Non-cacheable那CPU访问它时根本不走Cache自然不存在一致性问题。如果Cache策略是写直达Write-through而不是写回Write-backCPU的写操作会同时更新Cache和内存DMA读到旧数据的概率也会大大降低。另外如果DMA传输的数据量很小恰好都在同一个Cache行内且CPU在DMA传输前后没有访问过这块内存也可能碰巧不出问题。但这些都是碰巧不是正确。一旦平台换了、Cache策略改了、代码执行时序变了问题就会暴露出来。所以正确的做法是理解原理主动处理而不是靠运气。3. 三招解决Cache一致性问题3.1 第一招把内存区域配置为不可缓存这是最直接、最省心的方案。既然问题的根源是Cache和DMA看到的数据不一致那干脆让CPU访问这块内存时不走Cache大家都直接操作DRAM就不存在一致性问题了。在ARM架构下通常通过MMU的页表属性来设置。以ARMv7-A为例页表项中有一个TEXType Extension字段和C、B两个位组合起来决定内存的缓存策略。要设置为Non-cacheable通常配置为TEX0b000, C0, B0。在ARMv8-A中则通过MAIRMemory Attribute Indirection Register来定义内存属性再在页表项中引用对应的属性索引。在Linux内核中可以通过dma_alloc_coherent()来分配一致性内存内核会保证这块内存对CPU和DMA都是一致的。在裸机或RTOS环境下则需要在链接脚本或者MMU配置代码中手动指定某段内存区域的属性。/* 以ARMv7-A页表配置为例设置某段内存为Non-cacheable */ /* 假设使用一级页表描述符 */ #define SECTION_DESC_NON_CACHEABLE (0x00000002 | (0 12) | (0 2) | (0 3)) /* bit11表示Section描述符TEX0b000, C0, B0 */ void set_non_cacheable(uint32_t vaddr, uint32_t paddr, uint32_t size) { uint32_t *pgtable (uint32_t *)TTB_BASE; uint32_t index vaddr 20; /* 1MB Section对齐 */ uint32_t count size 20; for (uint32_t i 0; i count; i) { pgtable[index i] (paddr (i 20)) | SECTION_DESC_NON_CACHEABLE; } /* 刷新TLB */ __asm volatile(mcr p15, 0, %0, c8, c7, 0 : : r(0)); }这个方案的优点是简单粗暴配置好之后就不用管了CPU和DMA怎么读写都不会出问题。但缺点也很明显性能损失。CPU每次访问这块内存都要直接读写DRAM延迟比Cache命中高一到两个数量级。如果这块内存访问频繁比如是网络收发包的缓冲区那性能影响会非常显著。所以这个方案适合的场景是DMA传输不频繁、数据量不大、对性能不敏感的控制类应用。比如串口收发缓冲区、ADC采样缓冲区这类用Non-cacheable完全没问题。注意有些平台把内存配置为Non-cacheable后还需要考虑写缓冲Write Buffer的影响。即使C0如果B1写操作仍然可能被缓冲导致DMA读到的数据滞后。所以严格来说要配置为Non-cacheable且Non-bufferable即C0, B0。3.2 第二招手动维护Cache一致性如果性能要求高不能接受Non-cacheable带来的开销那就得用手动维护的方式。核心思路是在DMA传输前后由软件显式地执行Cache操作保证DMA和CPU看到的数据是一致的。具体来说分两个方向DMA写入内存前外设到内存方向在启动DMA之前对目标缓冲区执行Cache Invalidate操作把Cache里可能存在的旧数据副本标记为无效。这样DMA写完之后CPU去读时Cache不命中会从DRAM重新加载最新数据。DMA读取内存前内存到外设方向在启动DMA之前对源缓冲区执行Cache Clean操作把Cache里的脏数据写回DRAM。这样DMA从DRAM读到的就是最新数据。在ARM架构下对应的指令操作如下/* Cache Clean将指定地址范围的Cache行写回内存 */ void cache_clean_range(void *addr, uint32_t size) { uint32_t start (uint32_t)addr ~(CACHE_LINE_SIZE - 1); uint32_t end ((uint32_t)addr size CACHE_LINE_SIZE - 1) ~(CACHE_LINE_SIZE - 1); for (uint32_t p start; p end; p CACHE_LINE_SIZE) { __asm volatile(mcr p15, 0, %0, c7, c10, 1 : : r(p)); /* DCCMVAC: Data Cache Clean by MVA to PoC */ } dsb(); /* 确保Clean操作完成 */ } /* Cache Invalidate将指定地址范围的Cache行标记为无效 */ void cache_invalidate_range(void *addr, uint32_t size) { uint32_t start (uint32_t)addr ~(CACHE_LINE_SIZE - 1); uint32_t end ((uint32_t)addr size CACHE_LINE_SIZE - 1) ~(CACHE_LINE_SIZE - 1); for (uint32_t p start; p end; p CACHE_LINE_SIZE) { __asm volatile(mcr p15, 0, %0, c7, c6, 1 : : r(p)); /* DCIMVAC: Data Cache Invalidate by MVA to PoC */ } dsb(); isb(); }这里有几个关键细节必须注意Cache行对齐问题。Cache操作的最小单位是Cache行通常是32字节或64字节。如果你要Invalidate的缓冲区起始地址没有对齐到Cache行边界那么Invalidate操作会把包含该地址的整个Cache行都无效掉。如果这个Cache行里恰好有相邻变量的脏数据还没写回那就丢了。所以做Invalidate之前要么确保缓冲区按Cache行对齐要么先对头部和尾部的不对齐部分做Clean操作。Clean和Invalidate的顺序。对于外设到内存的方向正确的顺序是先Invalidate再启动DMA。但如果你不确定缓冲区里有没有脏数据更安全的做法是先Clean再Invalidate。因为直接Invalidate会丢弃脏数据而CleanInvalidate保证数据先写回再无效不会丢。屏障指令不能省。Cache维护指令执行后需要DSBData Synchronization Barrier确保操作真正完成ISBInstruction Synchronization Barrier确保后续指令看到更新后的状态。省掉屏障指令在乱序执行的处理器上可能出问题。这个方案的优点是性能好只在DMA传输前后做一次Cache操作传输过程中CPU访问缓冲区仍然享受Cache加速。缺点是软件复杂度高每一处DMA传输都要记得加Cache维护代码漏掉一处就出bug。而且Cache维护操作本身也有开销如果DMA传输非常频繁、每次传输的数据量又很小那Cache维护的开销可能比传输本身还大。3.3 第三招使用硬件一致性接口如果SoC支持硬件Cache一致性Hardware Cache Coherency那问题就简单了。硬件一致性由总线上的一致性管理器如ARM的CCI、CCN自动维护CPU和DMA访问同一块内存时硬件保证它们看到的数据是一致的软件不需要做任何额外的Cache维护操作。在ARM生态中支持ACEAXI Coherency Extensions接口的处理器和互联总线可以实现硬件一致性。DMA控制器如果也连接到一致性互联上那它访问内存时硬件会自动处理Cache的查询和更新。使用硬件一致性的好处是软件完全无感性能也好。但代价是硬件成本高需要SoC设计时就把一致性互联做进去而且DMA控制器本身也要支持一致性协议。在中低端芯片上这个方案往往不可用。在Linux内核中dma_alloc_coherent()在支持硬件一致性的平台上会直接返回一致性内存不需要软件做Cache维护在不支持的平台上内核会通过保留一块Non-cacheable内存或者软件维护的方式来模拟。设备树中的dma-coherent属性就是用来声明某个设备支持硬件一致性的。/* 设备树中声明DMA一致性 */ my_dma_device: dma-controller12340000 { compatible vendor,dma-controller; reg 0x12340000 0x1000; dma-coherent; /* 声明支持硬件一致性 */ #dma-cells 1; };3.4 三种方案怎么选把三种方案放在一起对比一下对比维度Non-cacheable手动Cache维护硬件一致性软件复杂度低配置一次即可高每处DMA都要处理最低软件无感CPU访问性能差无Cache加速好传输间隙可用Cache好DMA传输性能好好好硬件要求无特殊要求无特殊要求需要一致性互联适用场景低频、小数据量高频、大数据量高端SoC出错概率低高容易漏极低实际项目中我的选择策略是这样的如果DMA传输频率低于每秒几百次数据量也不大直接用Non-cacheable省心。如果是网络、存储这类高频大数据量的场景用手动Cache维护但一定要封装好接口避免散落在各处。如果芯片支持硬件一致性那当然优先用但要注意确认DMA控制器是否真的连接到了一致性互联上有些芯片虽然CPU支持ACE但DMA仍然走的是非一致性路径。4. 实操中怎么落地这三招4.1 裸机环境下的完整配置流程以Cortex-A9裸机环境为例走一遍完整的配置流程。第一步在MMU页表中划分内存区域。通常把内存分成三块普通可缓存区域给代码和数据用Non-cacheable区域给DMA缓冲区用设备区域给外设寄存器用。/* 内存布局示例 */ #define DDR_BASE 0x00000000 #define DDR_SIZE 0x40000000 /* 1GB */ #define DMA_BUF_BASE 0x30000000 #define DMA_BUF_SIZE 0x01000000 /* 16MBNon-cacheable */ #define DEVICE_BASE 0x10000000 #define DEVICE_SIZE 0x00100000 void mmu_init(void) { /* 普通内存Cacheable, Write-back, Write-allocate */ set_section_attr(DDR_BASE, DDR_BASE, DMA_BUF_BASE - DDR_BASE, ATTR_NORMAL_WBWA); /* DMA缓冲区Non-cacheable, Non-bufferable */ set_section_attr(DMA_BUF_BASE, DMA_BUF_BASE, DMA_BUF_SIZE, ATTR_NON_CACHEABLE); /* 设备区域Device, Non-cacheable */ set_section_attr(DEVICE_BASE, DEVICE_BASE, DEVICE_SIZE, ATTR_DEVICE); /* 使能MMU */ enable_mmu(); }第二步如果选择手动Cache维护方案封装Cache操作接口。关键是接口要清晰让调用者不容易搞错方向。/* 封装DMA缓冲区操作接口 */ typedef enum { DMA_DIR_DEV_TO_MEM, /* 外设到内存 */ DMA_DIR_MEM_TO_DEV, /* 内存到外设 */ } dma_direction_t; void dma_prepare_buffer(void *buf, uint32_t size, dma_direction_t dir) { if (dir DMA_DIR_DEV_TO_MEM) { /* 外设到内存先Clean再Invalidate确保不丢脏数据 */ cache_clean_range(buf, size); cache_invalidate_range(buf, size); } else { /* 内存到外设Clean把脏数据写回DRAM */ cache_clean_range(buf, size); } } void dma_finish_buffer(void *buf, uint32_t size, dma_direction_t dir) { if (dir DMA_DIR_DEV_TO_MEM) { /* 传输完成后Invalidate掉可能被预取的旧数据 */ cache_invalidate_range(buf, size); } /* 内存到外设方向传输完成后不需要额外操作 */ }第三步在DMA传输的启动和完成回调中调用这些接口。/* DMA传输示例 */ void uart_dma_receive(void *buf, uint32_t len) { /* 传输前Invalidate目标缓冲区 */ dma_prepare_buffer(buf, len, DMA_DIR_DEV_TO_MEM); /* 配置DMA控制器 */ dma_set_src(UART_RX_FIFO_ADDR); dma_set_dst((uint32_t)buf); dma_set_len(len); dma_set_dir(DMA_DEV_TO_MEM); /* 启动传输 */ dma_start(); } /* DMA传输完成中断处理 */ void dma_irq_handler(void) { void *buf dma_get_current_buf(); uint32_t len dma_get_current_len(); /* 传输后Invalidate确保CPU读到最新数据 */ dma_finish_buffer(buf, len, DMA_DIR_DEV_TO_MEM); /* 通知上层处理数据 */ notify_data_ready(buf, len); }4.2 Linux驱动中的处理方式在Linux内核中DMA一致性问题的处理有一套成熟的API。核心原则是使用DMA API来映射缓冲区而不是直接用物理地址。对于一致性DMA缓冲区用dma_alloc_coherent()分配/* 分配一致性DMA缓冲区 */ dma_addr_t dma_handle; void *cpu_addr; size_t size 4096; cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); if (!cpu_addr) { dev_err(dev, Failed to allocate coherent DMA buffer\n); return -ENOMEM; } /* cpu_addr给CPU访问dma_handle给DMA控制器使用 */ /* 两者访问同一块物理内存硬件保证一致性 */对于流式DMA映射用dma_map_single()或dma_map_sg()/* 流式DMA映射 - 内存到外设方向 */ dma_addr_t dma_addr; dma_addr dma_map_single(dev, buf, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_addr)) { dev_err(dev, DMA mapping failed\n); return -EIO; } /* 把dma_addr配置给DMA控制器 */ /* ... 启动DMA传输 ... */ /* 传输完成后解除映射 */ dma_unmap_single(dev, dma_addr, len, DMA_TO_DEVICE);dma_map_single()内部会根据平台是否支持硬件一致性来决定是否需要执行Cache维护操作。在不支持硬件一致性的平台上内核会调用架构相关的Cache维护函数在支持的平台上这些操作会被跳过。提示流式DMA映射的方向参数非常重要。DMA_TO_DEVICE表示内存到外设内核会执行Cache CleanDMA_FROM_DEVICE表示外设到内存内核会执行Cache InvalidateDMA_BIDIRECTIONAL则两者都做。方向搞错了Cache维护操作也就错了数据必然出问题。4.3 一个真实的排查案例说一个我印象最深的案例。某项目用DMA做SPI屏幕刷新屏幕偶尔会出现花屏概率大概百分之几。一开始怀疑是SPI时序问题调了时钟相位和极性没用。又怀疑是DMA传输长度配置错误检查了也没问题。最后用逻辑分析仪抓SPI数据线发现DMA发出的数据里偶尔会夹杂几个字节的旧数据。问题定位到Cache上。代码里帧缓冲区用的是普通可缓存内存每次刷新前调用了cache_clean_range()。看起来没问题但仔细看代码发现cache_clean_range()的实现里只用了DCCMVAC指令没有加DSB屏障。在Cortex-A7这种乱序执行的核上Cache Clean指令发出后可能还没真正完成DMA就已经启动了导致部分脏数据还没写回DRAM。加上DSB之后问题消失。这个案例告诉我Cache维护指令后面的屏障不是可选项是必须的。很多参考代码里省略了屏障在简单核上可能碰巧能跑但换到乱序核上就出问题。5. 常见问题与排查技巧5.1 问题速查表现象可能原因排查方法解决方案DMA写入后CPU读到旧数据Cache未Invalidate在DMA完成后加Invalidate操作传输后执行Cache InvalidateCPU写入后DMA发出旧数据Cache未Clean在DMA启动前加Clean操作传输前执行Cache Clean数据偶尔出错概率性Cache回写覆盖DMA数据检查是否有并发访问用Non-cacheable或加锁只有部分数据不对Cache行对齐问题检查缓冲区地址对齐按Cache行对齐或处理头尾换平台后问题消失/出现Cache策略不同对比两个平台的MMU配置统一Cache策略加打印后问题消失时序变化用屏障指令替代打印加DSB/ISB屏障5.2 几个容易踩的坑坑一Invalidate之前忘了Clean。很多人知道外设到内存方向要做Invalidate但忽略了如果缓冲区里之前有CPU写入的脏数据直接Invalidate会把这些脏数据丢掉。正确的做法是先Clean再Invalidate或者确保缓冲区在DMA写入前没有脏数据。坑二Cache行对齐没处理。假设Cache行是64字节你要Invalidate的缓冲区起始地址是0x1004长度是100字节。Invalidate操作会从0x1000开始到0x1080结束多无效了0x1000到0x1004这4个字节以及0x1064到0x1080这28个字节。如果这些区域有别的变量的脏数据就丢了。解决办法是缓冲区按Cache行对齐分配或者对头尾不对齐部分单独处理。坑三DMA传输过程中CPU访问了缓冲区。即使你在传输前后都做了Cache维护如果传输过程中CPU又去读了缓冲区Cache里可能又缓存了中间状态的数据。等DMA传输完成Cache里的数据是过时的。所以DMA传输期间CPU不应该访问正在传输的缓冲区或者访问后要重新Invalidate。坑四多核环境下的Cache一致性。单核的问题好解决多核就复杂了。Core0做了Cache CleanCore1的Cache里可能还有旧数据。多核环境下需要用核间中断或者共享内存加屏障来协调或者直接用硬件一致性。如果SoC不支持硬件一致性多核DMA的Cache维护会非常麻烦建议直接用Non-cacheable内存。坑五编译器优化导致Cache操作被重排。编译器可能把Cache维护函数调用和DMA寄存器写入重排导致实际执行顺序和代码顺序不一致。解决办法是在Cache操作和DMA操作之间加编译器屏障barrier()或者把Cache操作函数标记为noinline并加内存屏障。5.3 调试手段排查Cache一致性问题有几个实用的手段。用Non-cacheable内存做对照实验。如果怀疑是Cache问题把缓冲区改成Non-cacheable如果问题消失那就确认是Cache一致性导致的。这是最快的定位方法。在Cache操作前后读寄存器确认。有些处理器有Cache维护操作的性能计数器可以通过读计数器确认操作是否真正执行了。用DSB内存屏障做保守处理。如果怀疑是屏障问题在所有Cache操作后加DSB在所有DMA寄存器写入前加DSB先保证功能正确再逐步优化。打印DMA描述符和实际数据。在DMA完成中断里打印DMA描述符里的目标地址和实际读到的数据跟预期对比能快速判断是DMA没搬对还是Cache读错了。6. 我个人的一些经验体会做了这么多年底层关于DMA和Cache一致性我最大的体会是不要试图靠小心来避免问题要靠机制来保证正确。什么意思如果你选择手动Cache维护方案那就要把Cache操作封装成跟DMA传输绑定的接口让调用者不可能漏掉。比如我现在的做法是所有DMA传输都走一个统一的dma_transfer()函数这个函数内部根据方向自动做Cache维护外部调用者根本不需要知道Cache的存在。这样即使新人来写代码也不会因为忘记加Cache操作而出bug。另一个体会是文档里写的和实际芯片的行为可能有差异。有些芯片的DMA控制器声称支持一致性但实际上只对某些方向、某些地址范围有效。有些芯片的Cache行大小文档写的是32字节实际可能是64字节。这些差异只能通过实测来确认。我的习惯是在项目初期就写一个Cache一致性的测试用例把各种方向、各种对齐情况都跑一遍确认平台的实际行为。还有一点性能优化要建立在正确性之上。我见过有人为了省几个Cache维护操作的开销把代码改得极其复杂结果引入了更难排查的偶发bug。Cache维护的开销跟DMA传输本身的开销比起来通常是可以接受的。如果确实成为瓶颈再考虑用Non-cacheable或者硬件一致性来优化但前提是功能已经正确了。最后分享一个小技巧如果你不确定某块内存该用什么Cache策略可以先全部用Non-cacheable把功能跑通然后再逐步把性能敏感的区域改成Cacheable手动维护每改一处就做一轮压力测试。这样能把问题定位到最小的改动范围比一上来就全用Cacheable然后到处救火要高效得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进