DMA读旧数据这个问题我踩过不少次。设备跑起来之后要么第一帧数据是上一帧的要么CPU改了发送缓冲区外设那边收到的还是老内容代码翻来覆去查不出问题。后来发现真凶往往不是DMA配置出错而是Cache在中间捣乱。DMA拿到的地址和CPU眼中的地址本质上不是同一个“内存视图”旧数据就这么来的。这篇文章用实际排障的视角聊透DMA为什么总读旧数据Cache在里面扮演什么角色以及解决Cache问题够用的三招。搞驱动、做音视频采集、网络收包或者涉及多核共享内存的朋友基本都能直接套用。1. 问题根源DMA和CPU看到的不是同一份内存1.1 同一段地址两个“账本”现代CPU往外设或者内存写数据时并不是每次都会立刻落到物理内存上。CPU和内存之间隔着CacheCPU写操作往往会先写进Cache把这个Cache行标记成“脏”dirty等之后被替换或者主动刷回数据才真正落入DDR。这套机制对CPU来说是透明的性能也很香但DMA完全不认这套账。DMA是总线上的主设备发起传输时直接和内存控制器打交道读写的是物理内存那一侧的真实数据。它不经过CPU的Cache也看不到Cache里的脏数据。于是同一个物理地址就出现了两份内容CPU看的是Cache里的副本DMA看的是DRAM里的真身。两份内容一旦不同步读到的数据自然就是“旧”的。用生活类比来记的话就是两个账本一个摊在桌面上Cache一个锁在保险柜里内存。CPU习惯改桌面的那一份DMA却每次都去保险柜拿。CPU改了桌面账本但没及时抄回保险柜DMA拿到坏账DMA把保险柜里的账目更新了CPU又盯着桌面那份旧的看两边永远对不上。这里有两个方向值得记牢后面排查时会反复用到方向一CPU写数据DMA去读。典型场景是CPU填充发送缓冲区然后启动DMA把数据发给外设。可能出现的问题是Cache里的新数据还没回写内存DMA从内存读到旧数据。方向二DMA写数据CPU去读。典型场景是DMA接收数据到内存CPU后续去解析。可能出现的问题是内存已经是新数据但CPU命中Cache里的旧副本读出来还是旧内容。1.2 哪些场景最容易踩坑我总结了一下凡是DMA和CPU共享内存又没有做处理的地方基本都容易踩。最常见的是这四类第一网卡或者通信外设的收发包路径。网络数据进来DMA直接写到内存里的接收描述符和数据缓冲区CPU再来处理。数据量大、频率高漏一次Cache同步就是一次丢包或错包。第二音视频采集和图像传感器采集。摄像头或者采集卡的数据通过DMA持续搬到DDRCPU端取帧处理。这个场景最容易出现“画面永远差一帧”或者“首帧花屏”的老大难。第三多核异构系统里的共享内存。比如一个核跑控制逻辑另一个核通过DMA搬运计算结果两个CPU各自持有自己的Cache还需要额外的核间同步手段问题更隐蔽。第四FPGA或者外设处理器共享DDR的场合。FPGA通过DMA访问系统的内存CPU侧做计算任务类似“主设备和CPU并行访问同一内存”的结构也容易出现一致性问题。很多人排查这类问题时习惯性怀疑DMA寄存器配置、中断处理、缓冲区地址一查就是大半天。实际上DMA本身没有罪罪魁祸首是CPU把数据留在Cache里没给DMA一个交代。1.3 容易忽略的两类“假DMA问题”还有两类情况表面上看也像DMA读旧数据但根子不太一样。一类是Cache line的粒度问题。CPU读写Cache是按照Cache line来操作的常见的是32字节或者64字节。如果DMA缓冲区没有按Cache line对齐那么一次同步会影响相邻地址的数据甚至把旁边别的变量也一起刷掉。后续我会细说对齐的算法这里先埋个印象。另一类是DMA描述符的更新顺序。有些场景里CPU需要先配置好描述符里的地址、长度、标志位然后再写一个“启动”控制位。因为CPU可能会乱序执行如果没有加内存屏障DMA看到的描述符可能只更新了一半“旧描述符”就这么产生了。严格说这不是Cache一致性的锅而是CPU内存序的问题但在实际项目里它们经常一起出现排查时必须同步考虑。2. 三招解决Cache问题方案与选型一次讲清2.1 第一招干脆别缓存把内存映射成非缓存区域最简单的思路就是“一力降十会”这段内存对CPU关闭Cache保证CPU访问的就是物理内存本身。只要双向用的都是同一个账本就不存在对不上的问题。实现上不同的平台有各自的映射方式。有的系统提供类似“non-cacheable”的内存属性可以在页表里把这段地址配置成不走Cache如果在驱动里编写代码也常见到使用一致性内存分配接口的写法比如分配一段DMA专用缓冲区底层会直接映射成无缓存或者由硬件保证一致性的内存。这种方式写出来的代码最简单启动DMA前后都不需要手动刷Cache。代价也很直观CPU每次访问这段内存都会退化为外部总线事务花在等待上的时间比走Cache慢不少。所以这个方案比较适合数据量不大、CPU访问频率不高、但数据正确性要求极高的场景比如设备寄存器交互、控制块、描述符表。要是拿它搬运几兆字节的视频帧或者海量网络包CPU性能会被拖垮。我个人的经验是如果缓冲区小比如几十到几百字节用这个方案不会心疼如果缓冲区是K字节级别甚至M字节级别先别冲动继续看后面两招。2.2 第二招软件维护Cache一致性手动刷新这是大多数驱动开发者的主战场。思路不再是“不缓存”而是“让Cache和内存按照约定的时机关联起来”。具体来说需要按传输方向区分操作准备把数据交给DMA之前CPU要把Cache里脏的数据写回到内存也就是“flush”或者叫“clean”。这保证DMA读内存时能读到CPU的最新写入。在DMA把数据写进内存、CPU准备读取之前要把Cache里对应的旧行废掉也就是“invalidate”保证CPU下一次读取时不会命中旧副本。许多操作系统或者驱动框架里已经封装好了对应的接口。拿常见的DMA同步接口来说方向参数一般会区分“CPU到设备”对应DMA_TO_DEVICE和“设备到CPU”对应DMA_FROM_DEVICE。前者需要在启动DMA前执行一次“对设备方向同步”后者需要在DMA完成中断里、CPU读数据之前执行“对CPU方向同步”。方向一旦搞反代码看着正常跑起来还是错。这个方法的关键点在于调用时机。很多人只在初始化的时候做了一次同步后面每次传输都不做数据量小的时候可能侥幸不出错数据量一上来问题就显现。它的性能损耗可控因为只需要在传输边界做一次同步对大块数据来说是性价比很高的做法。2.3 第三招用硬件一致性机制兜底如果芯片足够新可能还带着系统级一致性机制比如IOMMU、系统总线上的监听机制或者处理器和外设之间直接支持一致性互联。这类硬件可以让DMA访问内存时自动去监听CPU的Cache也能让CPU读到DMA写入的数据时自动失效对应Cache行。用了这类硬件软件上就不需要频繁调用同步接口了看起来跟普通内存操作一样干净。大吞吐量的网卡、多核异构通信、GPU和CPU共享内存的场景如果芯片支持强烈建议优先考虑。但这个方案有两个门槛。一个是芯片得支持不是所有嵌入式SoC都带这套机制尤其一些低成本的入门级平台手册里根本没有相关章节。第二个是配置成本和问题排查成本高涉及控制器的初始化、设备地址转换、中断路由等出错时定位比单纯写软件同步更头疼。所以在选型的时候我会优先确认芯片能力再决定走硬件方案还是软件方案。2.4 三招怎么选把三个方案放在一起看基本就是一张表方案实现成本性能损耗适用场景非缓存映射低高CPU每次访问都走总线小缓冲区、寄存器交互、低频控制软件维护Cache同步中中只同步传输边界DMA搬运大块数据、性能敏感场景硬件一致性机制高需要芯片支持低或零高速网卡、多核异构共享、海量数据交互对大多数中小项目来说第二招“软件维护Cache”是性价比最高的也是我在实际项目里最常用的。后面的实操部分我会专门拿一个DMA循环采集场景来拆解每一步。3. 实操过程手把手修复一个DMA循环采集的旧数据问题3.1 场景设定采集板循环DMA数据差一帧先描述一个典型的现场。某图像采集板卡上传感器通过DMA持续把图像数据搬到DDR上指定缓冲区。CPU这边通过中断得知“新的一帧来了”然后把缓冲区里的数据拿去做算法处理。启动之后出现两个现象第一帧处理出来是花屏或者错乱后面大部分帧正常但偶尔又会出现画面回退像看到了上一秒的图。测试同事的第一反应是DMA中断写早了或者缓冲区地址配错了。但我翻代码之后发现DMA的中断触发时机、地址长度都没问题。问题出在中断里读数据前没有做任何Cache操作。传感器通过DMA把新帧写进了内存CPU端的Cache里还残留着上一次访问这段地址的旧数据。由于缓冲区地址每次循环复用CPU的中断处理函数在绝大多数时候读的是Cache里的旧帧看起来就是“差一帧”的诡异现象。这种情况特别容易发生在DMA循环采集里因为缓冲区是同一块地址一旦命中Cache旧行问题会反复出现。而且因为Cache命中很快程序跑起来速度并不慢很多人压根意识不到CPU读的是缓存副本。3.2 定位过程先验证“是不是Cache”再动手定位这种问题我不会一上来就加同步。那样即使解决了也不知道真正原因。我通常先做三个动作第一步保留原有逻辑在中断处理函数里把DMA写入缓冲区的时间戳或者帧号打印出来。如果发现CPU读到的帧号总是比外设刚刚写完的帧号小1那就基本锁定了读方向的一致性有疑虑。第二步做一次对照实验。把这块缓冲区临时改成非缓存属性分配相当于用第一招去验证。如果改成非缓存之后现象消失说明问题确实在Cache。这个方法比单靠人脑推理更可靠能快速排除DMA配置和中断逻辑的因素。第三步确认是Cache问题后再回退代码在正确位置上加入软件同步。注意加入的逻辑要覆盖两个方向如果后续还会用同一个缓冲区发送数据发送方向同样要维护不能只修接收。3.3 修复代码接收和发送两个方向的同步下面给一个简化但完整的例子。假设设备是一个字符设备驱动里分配了一块DMA缓冲区数据通过DMA写入这个缓冲区CPU读取处理处理完成之后CPU会把结果写回同一个缓冲区再由DMA发送出去。#define BUF_SIZE 4096 #define CACHE_LINE_SIZE 64 static unsigned char *dma_buf; static dma_addr_t dma_handle; static struct device *dev; /* 初始化时分配一致缓冲区或用同步接口管理普通缓冲区 */ static int buf_init(void) { dma_buf dma_alloc_coherent(dev, BUF_SIZE, dma_handle, GFP_KERNEL); if (!dma_buf) return -ENOMEM; /* 这里也可以考虑申请普通内存后手动map但一致性分配 在平台上更省心尤其小缓冲区用它更稳 */ return 0; } /* 中断处理DMA已经把新数据写入了dma_buf */ static irqreturn_t dma_rx_isr(int irq, void *arg) { size_t real_len actual_len; /* 读取DMA写入的数据之前让CPU侧Cache先失效 */ dma_sync_single_for_cpu(dev, dma_handle, real_len, DMA_FROM_DEVICE); process_data(dma_buf, real_len); /* 假设处理完还要原路发送回去进入发送前刷新Cache */ dma_sync_single_for_device(dev, dma_handle, real_len, DMA_TO_DEVICE); start_dma_tx(dma_handle, real_len); return IRQ_HANDLED; }不要急着复制这段到你的工程里先理解两个关键点。第一方向参数不是随便写的。DMA_FROM_DEVICE表示设备把数据写入了内存CPU要读所以操作先把Cache里的旧行失效DMA_TO_DEVICE表示CPU要写数据给外设读所以操作先把CPU写好的脏数据刷回内存。方向反了代码不报错但问题依然在。第二如果没有现成的同步接口需要自己实现千万记得对齐到Cache line。很多平台要求DMA缓冲区起始地址和同步长度都按Cache line对齐。推荐写法是static unsigned int align_len(unsigned int len) { return (len CACHE_LINE_SIZE - 1) ~(CACHE_LINE_SIZE - 1); }实际操作中我一般直接在分配缓冲区的时候就按对齐大小多分配一些然后把起始地址也做对齐。如果缓冲区长度小于Cache line对周边数据的误伤风险很大这种场景要么单独分配一块专用内存要么干脆用非缓存方案。3.4 容易漏掉的描述符屏障DMA读旧描述符的另一个真相修完数据方向的Cache同步之后项目已经跑正常了。但这里要提醒一个我在实践中经常遇到、也容易漏掉的点描述符或者控制块的更新顺序。有些外设的DMA不是直接把数据放内存而是通过一块描述符表来定义地址和长度CPU需要先更新描述符再写“启动”寄存器。由于CPU会乱序执行往描述符里写的地址、长度、标志位未必按照代码顺序落到物理内存。如果没有内存屏障DMA启动时可能读到半新的描述符于是又出现“DMA读旧数据”。这个问题的解法不是Cache同步而是写屏障desc-addr dma_handle; desc-len len; desc-flag READY; /* 保证上面几个写操作在启动DMA之前对内存可见 */ dma_wmb(); writel(1, dma_controller-start);这条dma_wmb()要放在写描述符之后、启动DMA之前。同样的如果CPU要从描述符里读取外设更新后的状态读取后往往还要配合读屏障使用确保读到的是顺序一致的数据。3.5 验证结果和性能取舍修复之后第一帧正常后续帧也不会回退了。但别高兴太早每次中断里做一次dma_sync_single_for_cpu和dma_sync_single_for_device是会产生额外开销的。尤其是每帧数据很大、中断频率又高的时候同步耗时可能占掉CPU不少时间片拉高中断处理延迟。实测下来如果每帧只有几百字节这种开销不算什么但如果每帧有数兆字节还整块做同步性能会明显下降。这时候可以考虑几个优化方向只在真正需要的地方做同步。比如数据量很大但处理时只需要头部可以只同步头部长度。使用多缓冲区交替。CPU处理缓冲A的同时DMA写入缓冲B天然错开也能降低同步等待的阻塞感。大块连续数据配上描述符分段把同步粒度细化到每一段避免一次同步整块大内存。最实在的建议是先保证正确再考虑性能。不要一上来就为了省那几十微秒不写同步等项目交付后出了偶发错帧返工成本远高于那点性能优化收益。4. 常见问题与排查技巧实录4.1 问题速查表把我在实际调试中遇到过的情况整理成一张速查表方便后面排查时直接对照。现象可能原因排查和处理建议数据永远差一帧CPU读前没有做invalidate或同步方向用反检查接收方向是否调用了CPU方向同步并用非缓存分配做对照实验偶发错帧、丢包频率不高漏了某次同步或描述符更新后缺写屏障全面检查每条DMA传输路径尤其是循环缓冲区复用时的同步点CPU改完数据DMA发出去外设收到旧内容发送方向没有flush或flush在启动DMA之后才执行确认DMA启动前已调用设备方向同步并核对dma_wmb()位置加了同步后性能大幅下降每次同步整块大缓冲区且没有对齐Cache line改成分段同步、双缓冲交替或评估非缓存映射是否更划算中断里看到DMA描述符状态是旧的DMA已更新内存但CPU Cache中还残留旧描述符读描述符前执行invalidate操作确保看到新状态多核间共享缓冲区数据错乱核间Cache一致性和DMA一致性混在一起先解决DMA方向同步再确认核间通信的内存屏障是否完整这张表里的前四行是我见过的高频现场。第五行容易被忽略尤其是很多DMA控制器会自己写回传输状态CPU侧如果直接去读那块共享描述符不做失效处理一样会遇到“假DMA问题”。4.2 一次描述符旧状态的排障复盘有一次做网络加速相关的外设驱动现象很折腾大量传输时偶发超时抓包看描述符状态总出现“外设明明已经处理完CPU看到的却是未完成”。测试同事一开始怀疑中断丢失反复检查DMA中断配置都没发现问题。后来我把焦点移到描述符上。CPU在轮询描述符状态时读到的其实是Cache里的副本。外设已经在内存里把状态字段更新成完成但CPU这段地址之前被访问过Cache里还留着“未完成”的旧状态。等Cache行被替换出去下一次轮询才看到新值于是表现为偶发超时。修复方式是在轮询前对描述符所在的内存区域做一次invalidate然后再读取状态。加了这一行之后偶发超时没有再出现。这个案例让我积累了很重要的一课不只是数据缓冲区要关注Cache描述符、控制结构、状态区只要是DMA和CPU共享的全都不能漏。4.3 几个只有实战才懂的细节第一中断上下文里做Cache同步要注意长度。中断处理函数追求短平快如果同步的数据区间特别大中断延迟就会飙高。建议把大块数据拆成若干段处理一段同步一段或者干脆把处理逻辑放到内核线程里中断里只做最小必要的描述符同步。第二不要用DMA同步接口代替核间同步。有些多核场景里一个核写缓冲区另一个核读缓冲区有人以为做一次Cache同步就够了。但DMA同步接口解决的是“CPU与设备之间的一致性”不是“CPU与CPU之间的一致性”。多核共享内存还需要操作系统提供的核间同步原语配合不然还是会有并发读写冲突。第三调试阶段利用“非缓存对照实验”很高效。遇到疑难DMA问题先把可疑缓冲区改成非缓存类型如果问题消失说明这里确实存在Cache一致性隐患然后再恢复成缓存类型并补上正确的同步代码。这种对照法能在最短时间内锁定问题比打印大法省力得多。关于DMA和Cache我想多说一句我现在写的每一条DMA传输路径都会先问一句“这段内存此刻是CPU在用还是DMA在用”。CPU写完要交给DMA先刷CacheDMA写完要交给CPU先失效Cache。这一句话背下来能省下很长一段排障时间。如果你手头也有这种“偶尔差一帧、偶尔读旧数据”的怪问题不妨先把代码里的同步点全部列一遍再用非缓存分配做一次对照大概率问题就浮出水面了。