DMA读旧数据这个问题,我踩过不少次。设备跑起来之后,要么第一帧数据是上一帧的,要么CPU改了发送缓冲区,外设那边收到的还是老内容,代码翻来覆去查不出问题。后来发现,真凶往往不是DMA配置出错,而是Cache在中间捣乱。DMA拿到的地址和CPU眼中的地址,本质上不是同一个“内存视图”,旧数据就这么来的。这篇文章用实际排障的视角,聊透DMA为什么总读旧数据,Cache在里面扮演什么角色,以及解决Cache问题够用的三招。搞驱动、做音视频采集、网络收包,或者涉及多核共享内存的朋友,基本都能直接套用。
1. 问题根源:DMA和CPU看到的不是同一份内存
1.1 同一段地址,两个“账本”
现代CPU往外设或者内存写数据时,并不是每次都会立刻落到物理内存上。CPU和内存之间隔着Cache,CPU写操作往往会先写进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持续搬到DDR,CPU端取帧处理。这个场景最容易出现“画面永远差一帧”或者“首帧花屏”的老大难。
第三,多核异构系统里的共享内存。比如一个核跑控制逻辑,另一个核通过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,先刷Cache;DMA写完要交给CPU,先失效Cache。这一句话背下来,能省下很长一段排障时间。如果你手头也有这种“偶尔差一帧、偶尔读旧数据”的怪问题,不妨先把代码里的同步点全部列一遍,再用非缓存分配做一次对照,大概率问题就浮出水面了。