嵌入式DMA与Cache一致性:现象、根因与三招可落地的解法

发布时间:2026/10/13 2:24:17
嵌入式DMA与Cache一致性:现象、根因与三招可落地的解法
做嵌入式驱动开发的朋友大概率都遇到过这种诡异现象DMA明明已经把新数据写进了内存CPU去读的时候却拿到上一次的旧数据或者CPU把要发送的数据往缓冲区一放DMA再从内存往外搬搬出去的竟然是缓冲区初始化时的全零。我当年第一次碰上这个问题折腾了整整一个通宵一度怀疑是DMA控制器坏了换了块板子还是一样最后才发现根子在CPU的Cache身上。这篇文章把DMA和Cache一致性问题的来龙去脉讲透并给出三招可落地的解法软件刷Cache、修改内存映射属性、利用描述符协议与硬件一致性机制兜底。适合正在做网卡、串口、SDIO、音视频采集这类外设驱动或者用DMA搬运数据的嵌入式开发者。无论是裸机、RTOS还是Linux平台思路都通用只是换成各自平台的接口而已。1. DMA和Cache之间的信息差1.1 先看现象第一次永远拿到旧数据我遇到的场景是给一块带局域网控制器的板子写驱动。CPU通过DMA把收到的以太网帧搬到内存缓冲区中断来了以后CPU再从缓冲区里把数据读出来处理。诡异的是每次上电后接收到的第一帧数据永远是零填充的初始值第二帧才是真正的第一帧更离谱的是如果连续接收缓冲区里总有一个旧帧残留就好像数据永远慢了一拍。当时我打印缓冲区地址、检查DMA描述符全都正常百思不得其解。发送方向的症状更隐蔽。我把一包新数据memcpy到一个DMA发送缓冲区启动DMA抓包工具看到的却是上次发送的老内容。因为CPU认为数据已经写好了实际上那些字节只是躺在了Cache里主存里还是老版本。换句话说CPU的写操作被Cache截胡了DMA读到的自然就是旧数据。这个方向正是标题里DMA读旧数据的直译场景。为什么第一次最容易暴露因为CPU在初始化缓冲区的时候不管是清零还是填充魔数都会先访问这块内存Cache里已经缓存了这块地址对应的行。DMA把新数据写到主存之后Cache里的那一行并不会自动失效。中断来了CPU去读缓冲区Cache一看我有这行数据直接命中返回——里面还是初始化时的值。这就是第一次读旧数据的真相。1.2 根因Write-Back Cache的工作方式CPU和内存之间的速度差距太大了。现代处理器会把最近访问过的地址放在Cache里读写都优先跟Cache打交道。关键在写策略上很多处理器默认是写回Write-Back模式。CPU写一个地址先只修改Cache里对应的行标记为脏Dirty并不会立刻同步到主存。什么时候才写回可能是这个Cache行即将被淘汰的时候也可能是在执行某个同步指令强制触发的时候。这意味着从CPU写数据到数据真正出现在主存里中间存在一个时间窗口。在这个窗口内DMA去访问主存拿到的自然是旧值。反过来也一样DMA把数据写进主存CPU的Cache里还保留着旧的副本CPU下次读的时候一旦Cache命中也会得到旧值。一句话概括DMA访问的是内存CPU优先访问的是Cache两边对同一个地址的印象产生了分歧。打个比方Cache就是CPU手边的一张草稿纸它先把计算结果写在草稿纸上还没誊到正经账本里。DMA是另外一个查账的人他只看正经账本所以总以为账目没更新。问题不是账本坏了而是CPU的誊写动作没有同步。1.3 两个方向一个都不能漏Cache一致性问题必须分两个方向对待CPU写、DMA读典型场景是网络发包、SD卡写入、串口发送。CPU修改了数据数据还留在Cache里DMA去主存读拿到旧数据。解决方式是清理Clean把Cache里的脏行强制写回主存。DMA写、CPU读典型场景是网络收包、音频采样、ADC采集。DMA把新数据写进了主存CPU的Cache里却还是旧副本。解决方式是失效Invalidate让Cache中对应地址的行作废下次CPU访问时被迫从主存重新加载。很多新手只做了其中一个方向或者两个方向搞反了数据要么还是旧的要么直接被损坏。方向判断其实很简单谁最后修改了这块内存就围绕谁做文章。CPU最后改的交给DMA之前做CleanDMA最后改的CPU读取之前做Invalidate。双方向复用的缓冲区两个操作都得做。另外补一句别指望volatile能解决这个问题。volatile只是告诉编译器不要对这个变量的访问做优化但CPU硬件的Cache行为它管不了。DMA写入主存后CPU读变量命中Cache照样返回旧值volatile在这里完全帮不上忙。这个误区很常见我见过有人为了修这个bug把整个结构体所有字段都加上volatile最后还是没用。2. 第一招软件维护Cache一致性2.1 什么时候Clean什么时候Invalidate先记住两句话发射之前Clean接收之后Invalidate。所谓发射就是CPU把数据交给DMA之前必须保证你的修改已经对主存可见所以执行Clean所谓接收就是DMA把数据写进内存之后、CPU开始读之前必须强制CPU从主存重新取所以执行Invalidate。如果你有一块缓冲区需要同时做收发比如双工的串口DMA或者乒乓缓冲最稳妥的做法是准备接收时Invalidate准备发送时Clean并且在每次DMA完成中断里再做一次对应的操作。宁可多做一次操作也不要漏Cache行级操作本身开销不大但漏一次问题就非常难查。这里还要澄清一个常见误区有人以为Clean和Invalidate是同一件事或者Clean之后缓存行就失效了。其实Clean只是把脏数据写回Cache行仍然保留有效副本Invalidate只是作废缓存行。对刚Clean过的行执行Invalidate没问题但反过来就危险了发送方向如果错误地用了Invalidate等于把还没写回主存的修改直接丢掉了数据必然会坏。2.2 实操代码示例以带Cache的ARM内核为例多数M内核处理器的SDK会提供CMSIS风格的函数SCB_CleanDCache_by_Addr用于清理指定地址区间SCB_InvalidateDCache_by_Addr用于失效指定地址区间。典型发送流程像这样/* 1. CPU把待发送数据拷到DMA缓冲区 */ memcpy(tx_buf, packet, packet_len); /* 2. 交给DMA之前把该区间的脏行写回主存 */ SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, packet_len); /* 3. 启动DMA传输 */ DMA_Start_TX(tx_buf, packet_len);接收完成的中断里处理流程是这样/* 1. DMA已经完成数据在主存里 */ DMA_Stop_RX(); /* 2. CPU读取之前作废该区间的Cache行强制从主存重读 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, rx_len); /* 3. 现在读到的一定是DMA写入的新数据 */ process_packet(rx_buf, rx_len);如果你在Linux下写驱动内核已经封装好了对应的接口。分配时可以这样dma_addr_t dma_handle; void *buf dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL);如果用的是普通内存映射则需要每次搬运前后做同步dma_map_single(dev, buf, len, DMA_TO_DEVICE); /* 发送前内部等价Clean */ dma_map_single(dev, buf, len, DMA_FROM_DEVICE); /* 接收前内部等价Invalidate */ dma_unmap_single(dev, dma_handle, len, DMA_FROM_DEVICE); /* CPU读前再失效一次 */这些接口本质上就是在Cache操作之上加了必要的内存屏障并且考虑了IOMMU等场景比我手动操作寄存器和汇编指令可靠得多。能用上层接口就不要自己顺手写汇编除非是在裸机或RTOS环境下不得不手写。2.3 Cache Line对齐和内存屏障两个隐形坑第一个隐形坑是Cache Line对齐。Cache操作的基本单位是一个Cache Line常见大小是32或64字节具体看CPU手册。如果你只操作中间某一段地址需要把首尾所在的整个Cache Line包含进来。很多CMSIS函数会做内部对齐但如果你直接操作寄存器或者写精简实现就必须自己处理边界。#define CACHE_LINE_SIZE 64 void clean_range_aligned(uintptr_t addr, size_t len) { uintptr_t start addr ~(CACHE_LINE_SIZE - 1); uintptr_t end (addr len CACHE_LINE_SIZE - 1) ~(CACHE_LINE_SIZE - 1); /* 对start到end之间的每一行执行Clean */ }如果不对齐会带来两种结果。要么首尾数据的修改没有写回主存DMA发出去的还是旧内容要么把Cache Line里不属于你的邻居数据也一起写回或者失效导致别的变量、别的缓冲区被莫名其妙破坏。这两种现象都极具迷惑性特别是后者会让你一度怀疑有人在后台乱改你的内存。第二个坑是内存屏障。Cache操作指令执行本身需要时间CPU流水线可能不等它完成就继续往后跑。如果Clean指令还没真正生效DMA就已经被启动数据传输照样使用旧数据。正确的做法是在Cache操作和启动DMA之间插入数据同步屏障ARM上常用DSB保证之前所有缓存维护操作完成后再继续执行。Linux API里这些屏障已经被包含但裸机代码里很多人忘了加。你可以在代码里这么写SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); __DSB(); /* 确保Clean完成 */ DMA_Start_TX(tx_buf, len);编译器优化层面也要注意。如果你开了-O3并且缓存维护是通过内联汇编或直接写寄存器完成的要防止编译器把写缓冲区的代码挪到Cache操作之后。使用内联汇编时加上内存clobber描述或者干脆把相关操作封装成一个编译屏障函数避免乱序。3. 第二招从映射层面解决Non-Cacheable内存区间3.1 原理干脆不缓存这块内存软件刷Cache虽然能解决问题但每次收发都要做代码容易被方向、对齐、屏障各种细节纠缠。性能敏感的场景还要担心频繁Cache操作影响吞吐。第二个思路更彻底把DMA缓冲区所在的物理内存映射成不可缓存或写透传Write-Through让CPU和DMA看到的始终是同一个主存视图。Non-Cacheable不可缓存CPU每次读写都直接访问主存Cache完全不参与。没有任何一致性问题但CPU对这块区域的读写延迟明显变高。Write-Through写透传CPU写数据时同时更新Cache和主存读的时候仍然可以利用Cache。一致性较好写性能略差。Write-Back写回默认模式性能最好但必须手动维护一致性对应第一招的内容。DMA缓冲区的特点是数据体量大但CPU访问次数相对少访问模式很规律所以绝大多数情况下把DMA缓冲区设成Non-Cacheable是划算的。代价只是CPU往这块区域写入时每次都直接落到主存而数据本来就是要给DMA的这个代价基本等于提前完成了写回。3.2 Linux驱动里的做法Linux平台不用自己手动画页表。内核提供两个常用途径dma_alloc_coherent分配一块一致性内存内部保证CPU和DMA看到的内存是一致的本质就是Non-Cacheable映射或者在支持硬件一致性的SoC上利用总线监听。返回的虚拟地址可以直接用dma_handle就是给DMA用的物理地址。dma_map_single / dma_unmap_single针对已有的普通内存在每一次DMA传输前后做同步内部封装了Clean/Invalidate操作相当于把第一招封装成标准接口。对应的方向语义整理成表内核接口方向内部动作DMA_TO_DEVICECPU写、DMA读CleanDMA_FROM_DEVICEDMA写、CPU读InvalidateDMA_BIDIRECTIONAL双向Clean Invalidate在驱动初始化时也可以设置固定的DMA内存池通过dma_pool_create管理大小固定的缓冲区配合dma_alloc_from_coherent使用性能比反复分配一致性内存更好特别适合描述符这类小对象。描述符和数据缓冲区的缓存策略我建议分开描述符用不可缓存数据缓冲区按需用Cacheable加软件同步。这样可靠性粒度更细也不会牺牲太多性能。3.3 没有MMU时的MCU方案MPU配置很多带Cache的MCU内核比如Cortex-M7支持MPU而非MMU但思路是一样的。可以配置一个MPU Region把DMA缓冲区的起始地址和大小覆盖进去属性设为Normal Memory、Non-Cacheable优先级设为最高。之后CPU访问这个区域不会进CacheDMA读写也直接面对主存软件里针对这个缓冲区的Clean/Invalidate都可以删掉。配置伪代码大致是这样/* 选择Region编号 */ REGION_NUM 8; REGION_BASE DMA_BUF_ADDR; /* 起始地址按Region大小对齐 */ REGION_SIZE REGION_32KB; REGION_ATTR NORMAL_MEM_NON_CACHEABLE; REGION_ENABLE 1;要注意Region大小必须是2的幂次起始地址要对齐到Region大小不能随手填一个大约的尺寸。MPU配置一旦出错轻则内存访问异常重则系统直接跑飞所以配置前先把起始地址和大小算清楚并且用调试器确认Region生效。我见过有人图省事直接把整片RAM都设成Non-Cacheable。这样做绝对安全但CPU频繁访问的变量、栈、堆全都没有了缓存加速程序性能肉眼可见地下降实测某些场景整体吞吐比原来慢了一半以上。折中做法是DMA缓冲区单独圈一块Non-Cacheable区间其余RAM保持Cacheable同时注意数据不要跨多个Region访问否则又要面对半缓存半不缓存的尴尬状态。4. 第三招硬件一致性与描述符协议设计4.1 硬件一致性总线CCI/ACE在多核处理器或带GPU的应用处理器级SoC上纯粹靠软件维护Cache太痛苦了。厂商引入了Cache一致性互连总线像CCI、ACE这类名字。简单说CPU的Cache和系统总线之间加了一个监听过滤器Snoop Filter。DMA发起读写时总线会替它检查各个CPU的CacheDMA要读的地址在某个CPU的Cache里是脏行总线要求该CPU先把数据写回主存DMA再读取于是DMA拿到的一定是最新值。DMA向主存写了新数据总线会把CPU Cache中对应的行标记为失效CPU下次访问时被迫从主存重新加载。于是CPU和DMA看到的内存天然一致软件里那些Clean/Invalidate根本不用写。硬件一致性的好处是DMA缓冲区可以用普通Cacheable内存CPU访问性能不受影响驱动代码也简洁很多。代价是IP成本高、总线功耗大一般只在应用处理器级别SoC上出现普通MCU基本别想。如果你的平台支持硬件一致性但驱动里还是出现旧数据多半不是总线问题而是访问描述符和状态位时缺了内存屏障或者没有使用专门的一致性接口去触发屏障。别急着把缓冲区全改成Non-Cacheable先确认描述符的状态同步是否完整。4.2 描述符同步只刷数据缓冲区是不够的这一步是很多人的盲区。DMA传输的发起往往不是靠直接写数据区而是靠更新内存里的描述符地址、长度、控制位DMA完全按描述符执行。如果描述符本身也被Cache缓存着CPU更新完描述符后不做同步DMA去读描述符拿到的可能是旧地址、旧长度甚至旧的控制标志位传输过程直接乱套。反过来DMA处理完传输后会把完成状态DD位、中断标志写进描述符或者独立的状态区。CPU在中断里读这个状态时如果不做Invalidate可能反复读到任务还未完成中断一进就卡住或者轮询永远等不到完成。我的建议是小数据结构描述符、状态字、门铃标志和大量数据体分开处理。描述符区在上层配置阶段直接做成Non-Cacheable或者每次更新描述符后做一次精确的Clean数据区为了性能继续保持Cacheable到真正搬运前后再做行级Cache操作。这种分层一致性策略在网卡驱动里特别常用。下面是一个简化的发送描述符同步流程struct dma_desc { uint32_t buf_addr; uint32_t len; uint32_t ctrl; uint32_t status; /* DD位由DMA写 */ }; /* CPU填写描述符 */ desc-buf_addr dma_handle; desc-len len; desc-ctrl TX_ENABLE; /* 关键写完后先把描述符刷出去再发门铃通知DMA */ clean_descriptor_range(desc); write_doorbell(desc_index);4.3 协议设计来兜底门铃、状态轮询与校验硬件一致性是平台红利不是每个环境都有。软件层面上即使没有硬件一致性也可以通过协议设计把一致性风险限制在可控范围。核心思想是把所有谁改了什么、谁来看的时序变成显式的握手。门铃机制CPU把工作描述符写完后再写一个固定的硬件寄存器门铃通知DMA描述符好了可以来取。DMA只有看到门铃之后才读描述符从协议上保证CPU对描述符的修改先于DMA读取。状态轮询DMA完成传输时在约定的状态字里写完成标记CPU轮询这个标记读到标记才访问数据缓冲区。轮询本身是一种同步但配合Cache操作才可靠。校验字段在描述符和关键数据头部加CRC或奇偶校验CPU和DMA各自维护校验值。一旦出现因为一致性导致的半新半旧数据校验一定对不上至少能快速暴露问题而不是产生无法解释的脏数据。这三招组合起来就是在没有硬件一致性平台的条件下用软件把读旧数据的概率压到最低。最理想的状态是描述符区Non-Cacheable配合门铃中断数据区按需做Clean/Invalidate状态字带完整校验。你会发现这其实就是很多商用网卡驱动已经采用的套路只是它们把这些逻辑封装到了HAL层里。5. 常见问题与排查技巧实录5.1 我踩过的几个典型坑踩坑一Clean和Invalidate方向用反。发送方向错误地用了Invalidate结果CPU刚memcpy的数据还没写回主存就被当垃圾丢了抓包发现发出去的全是乱码和上一包残留。排查方法很简单把发送缓冲区的数据用DMA读回来和原始缓冲区对比如果前半段是新的后半段是旧的基本就是Cache维护做了一半或者方向错了。踩坑二地址没有对齐到Cache Line。我当时自封装的缓存操作函数偷懒只按传入的起始地址处理没有把首尾行补齐。结果每次发送的前32字节永远正确后面64字节偶尔串数据。后来才发现Clean操作只覆盖了我给的地址区间首尾Cache Line里属于数据包的那部分字节没有一并写回。踩坑三描述符忘同步。数据缓冲区做了全套Clean/Invalidate但描述符更新完没有刷。DMA偶尔读到一个旧的描述符把数据长度搞成0驱动读不到包状态卡死在等待。这个问题最气人因为它不定期出现完全取决于Cache行什么时候被淘汰。踩坑四关了Cache一切正常开了Cache就坏。这是最经典的判断信号基本锁定就是Cache一致性问题。不要慌也不要直接关Cache交付应该先确认方向、对齐、描述符三件事全部排除后再看是不是硬件一致性接口没有生效。5.2 问题速查表现象可能原因处理建议DMA发送总发出旧包或全零包CPU写的数据还在Cache里没有Clean发送前对缓冲区执行Clean或改用Non-Cacheable映射接收数据总是旧帧或初始化值CPU的Cache保留旧副本没有Invalidate接收完成中断里先Invalidate再读数据数据时好时坏传输长度不对描述符Cache未同步描述符区独立设为Non-Cacheable或更新后Clean发送首尾数据偶尔错乱Cache操作没有对齐到Cache Line按Cache Line扩展首尾区间关闭Cache后正常开启后异常典型Cache一致性冲突按方向、对齐、屏障、描述符顺序排查Linux下偶尔丢数据忘记unmap或方向参数错误检查map/unmap是否一一对应方向是否匹配5.3 排查思路总结遇到诡异数据问题我现在的排查路径很固定先临时把DMA缓冲区或整个DCache关掉确认问题是否消失如果消失就基本锁定了一致性问题。然后回答三个问题这组数据是CPU写还是DMA写谁最后改的谁先读答案清晰之后选择对应的Cache操作。再检查Cache Line对齐和内存屏障。最后单独检查描述符这类小对象的同步状态。这套思路适用于裸机、RTOS、Linux区别只是换成对应平台的Cache操作接口。我个人的体会是90%的神秘旧数据问题在回答完谁最后改的那一刻就已经有了答案。剩下10%基本都在描述符没同步这个点上兜圈子。下次再遇到DMA读到旧数据别急着怀疑硬件先从Cache下手三招按顺序过一遍问题大概率当场现形。