ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

DMA读取旧数据?三招解决Cache一致性问题

DMA读取旧数据?三招解决Cache一致性问题

搞嵌入式的朋友,十有八九都被同一个问题折磨过:明明内存里数据已经是新的了,DMA却像是在跟旧数据谈恋爱,怎么都读不到新的。我最早踩这个坑是在调一个网卡驱动的时候,几十毫秒就丢一次包,每次抓包发现都是DMA搬了一堆“考古数据”上来,当时还以为是硬件时序问题,排查了两天才意识到是Cache在中间搅局。这个问题如果你没捅破那层窗户纸,会觉得玄而又玄,懂了之后其实就那么点事。这篇就跟大家掰开揉碎讲清楚,DMA为什么总读旧数据,遇到Cache相关的问题到底怎么解,我总结了三招,每一招都有适用场景和代价,照着用基本能解决你手上的问题。

这个问题的核心其实是:CPU看到的内存和DMA看到的内存,根本不是同一个世界。CPU有Cache做中间层,DMA直接访问物理内存,两边看到的数据可能不一致。能解决什么问题呢?收发数据包、音视频采集、外设控制类驱动,凡是涉及大块数据搬运的场景,一旦遇到传输内容错乱、卡在旧数据、性能忽高忽低,先往Cache一致性上想,大概率能命中。适合谁来学呢?刚入门驱动开发没多久、正在调DMA相关功能的朋友,以及做过一段时间但一直没系统梳理过内存屏障和Cache维护话题的工程师,这篇都能给你参考。

1. 问题产生的根源:DMA和Cache为什么“各执一词”

1.1 先从Cache的基本工作方式说起

要理解DMA读旧数据这个问题,得先搞清楚Cache到底是个什么东西。打个比方:你去图书馆查资料,第一次从书架上拿了一本书回座位看,看完之后懒得还,把书直接放自己桌面上了。第二次你换了个方式查同样的资料,直接看桌面上这本书就行,速度快得多。这个桌面就是Cache,书架就是物理内存。

CPU读写内存时,不会每次都跑去找内存控制器,而是先在自己的Cache里找。找到了就叫命中,速度快得飞起;找不到才去内存里搬一块数据进Cache。问题来了,CPU改写了一个变量的时候,改的其实先是Cache里的副本,什么时候写回物理内存,那要看Cache的策略和替换时机。对于驱动开发来说,这意味着:CPU认为“数据已经写到内存了”,但在物理内存这个层面,旧值可能还蹲在原地。

具体到硬件层面,Arm架构的Cache通常分成D-Cache(数据Cache)和I-Cache(指令Cache),DMA传输用的都是数据缓冲区,所以问题基本都出在D-Cache上。Cache由很多Cache Line组成,每个Cache Line的大小一般是32字节到128字节,Arm64常见的是64字节。后面我们在说对齐问题、伪共享问题的时候,这个Cache Line大小是个关键参数。

1.2 DMA读到旧数据的完整链路

现在我们把DMA和Cache放在一起看,整个故障链路就清楚了。搞DMA传输的时候,驱动通常是这样做的:CPU准备一块缓冲区,往里填数据,然后告诉DMA控制器“从这块内存地址读N个字节发出去”。从CPU的角度,数据已经写好了,DMA随时可以搬走。但DMA不经过Cache,它直接走总线去读物理内存,而物理内存地址上的数据,很可能还是旧的。

比如你填了一个数据包到地址0x10000000,CPU写入时把数据留在Cache里,还没来得及写回内存。DMA控制器去0x10000000搬数据,搬回来的全是旧数据——上一次传输留下的残留,或者是这个地址的初始值。网络发出去的报文全是错的,校验过不了,对面设备直接丢弃。

反过来还有另一个方向的问题:DMA从外设收到数据,直接写进物理内存,这时CPU如果还持有这个地址的Cache副本,CPU读出来的就是Cache里的旧数据,看不到DMA刚写进来的新内容。这种情况在接收方向更容易让人抓狂,因为DMA确实把数据写进内存了,内存里确实有数据,可是CPU读到的却不是那批数据,隔着一层Cache就像隔着一堵墙。

1.3 为什么CPU视角有时看不见问题

有个现象很有意思:有的工程师写DMA驱动,从头到尾没做过任何Cache维护,程序也能跑,只是偶尔出错。为什么?因为CPU和内存之间还有一层写缓冲和缓存一致性协议在起作用。现代CPU的Cache一致性协议(比如MESI)管的是多核CPU之间的Cache同步,几个CPU核心看到的数据能保持一致。DMA控制器不在这个协议范围内,它不属于CPU核心,一致性协议管不到它,所以CPU和DMA之间的数据同步必须由软件显式维护。

还有种情况是硬件帮你擦了一部分屁股。有些SoC的DMA控制器支持Cache Coherent功能,或者说总线接口上有额外的硬件逻辑做一致性处理,这种情况你不管Cache也能跑,但这依赖具体平台,不能当成普遍规律。我在某芯片平台上就见过这样的硬件,DMA读数据之前硬件自动clean CPU的Cache。换了一个平台,同样的驱动代码直接把数据全读乱了。所以正确的态度是:默认DMA不具备缓存一致性能力,驱动里要做好显式维护,这样代码换个平台也能站得住。

2. 第一招:从源头治理,一次性申请一致性内存

2.1 一致性内存的核心思路

第一招的思路比较彻底:既然DMA和Cache会各执一词,那就让DMA用的那块内存直接绕开Cache,或者让硬件保证它的一致性。这种内存叫“一致性内存缓冲”,在Linux内核里对应的是dma_alloc_coherent这类接口分配的缓冲区。

注意,dma_alloc_coherent虽然名字里带coherent,但它不一定意味着物理上是Non-Cacheable,它只是给驱动一个承诺:这块内存在DMA与CPU之间保持一致。实现上有些平台确实是Non-Cacheable映射,CPU访问它不走Cache,每次直接读写内存,天然一致,但是慢;另一些平台做的是“DMA操作前后由硬件自动做Cache维护”,对软件是透明的。不管底层怎么实现,驱动层面的效果是一样的:CPU写数据后,DMA去读就能读到最新的;DMA写了数据,CPU也能立即看到。

这招解决的是“为数据交互专门开辟一个停车场,并确保这个停车场的安保由硬件负责”的问题,驱动代码写起来最干净,不用每次传输前做一堆维护操作。适合用在通信描述符、命令请求、控制信息这些小型高频交互数据上,这类数据的特点是:量小、频次高、每次交互都要求CPU和DMA看到完全一致的数据。

2.2 一致性内存分配的API与实操

Linux内核里分配一致性内存的标准接口是这一组:

dma_addr_t dma_handle; void *cpu_addr; cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL); if (!cpu_addr) { /* 处理分配失败 */ }

用完之后释放:

dma_free_coherent(dev, size, cpu_addr, dma_handle);

这里cpu_addr是CPU侧的虚拟地址,驱动用它读改写数据;dma_handle是DMA侧能用的总线地址,DMA控制器拿这个地址来搬运数据。两个地址对应的物理内存是同一块,但CPU和DMA各自用适合自己的地址形态去访问。

分配一致性内存的时机是在驱动probe阶段,一次性分配好,后续DMA操作反复使用。以网卡驱动为例,发送描述符环、接收描述符环、数据缓冲区,全都用dma_alloc_coherent分配。之后每个包收发的时候,只需要在描述符里更新索引和长度,不需要对每个包做Cache清理,这对高吞吐场景是很大的性能优势。

关于size参数有一个细节要给新入门的朋友提个醒:如果后面要做Cache维护相关的对齐操作,建议分配的size按Cache Line大小的整数倍来定,比如64字节对齐、128字节对齐。如果只是用dma_alloc_coherent,底层已经保证了对齐,不需要你操心,但你自己做池子管理、手动切分缓冲区的时候,就要小心处理对齐了。

2.3 一致性映射的代价与适用场景

没有免费的午餐。一致性映射虽然让Cache一致性问题“消失”了,代价是性能。如果底层实现是Non-Cacheable映射,CPU访问这块内存的每一次读和写,都要穿越总线到内存控制器,速度跟访问普通Cache内存比可能差一个数量级。虽然驱动访问控制结构体的次数比搬运数据少得多,但也不能随处滥用。

我见过一个同事图省事,把大数据缓冲区也用dma_alloc_coherent分配,结果吞吐量上不去,排查到最后就是Non-Cacheable映射导致DMA搬运前CPU填充缓冲区时,每次都直接写内存,耗时暴增。所以选第一招还是第二招,本质是在“代码简单”和“CPU访问快”之间做权衡。

适合走第一招的应用场景有这些:描述符环(descriptor ring)、命令通道、状态寄存器映射、每次DMA搬运前CPU需要频繁读写的控制结构。不适合走第一招的场景有:大块数据缓冲区,比如几MB的音视频帧、大批量网络报文,这类场景CPU往缓冲区写数据是有时间片可以做Cache加速的,数据写完之后再一次性做Cache维护更划算。

另外,dma_alloc_coherent在MMU开启的系统里可能返回的是非线性映射的地址,IOMMU场景下它还会帮你搞定地址映射关系,这块内容比较多,大家先在驱动层把它当成“给我一块CPU和DMA共用且一致的内存”就好。

3. 第二招:手动维护Cache,让数据在合适的时机归位

3.1 Cache维护三板斧核心操作方式

第二招不回避Cache,反过来主动管理Cache。要理解这一招的全部操作,先得记住三个核心动作的方向和差异:

  • Cache Clean,也叫Cache Writeback,把Cache里被修改过的数据写回物理内存,保证内存里是新的;
  • Cache Invalidate,把Cache里的数据标记为失效,之后CPU读这个地址时必须从物理内存重新加载,保证CPU看到的是内存里的最新值;
  • Clean + Invalidate,先写回,再失效,一个操作完成两件事。操作完之后,内存里有最新的数据,Cache是空的。

对应到Arm体系结构里,常见的维护接口大概长这样(不同平台API可能不同,但语义类似):

/* 将某个虚拟地址对应的Cache Line写回并失效 */ void dcache_clean_invalidate_poc(void *addr, size_t size); /* 只写回,不失效 */ void dcache_clean_poc(void *addr, size_t size); /* 只失效,不写回 */ void dcache_invalidate_poc(void *addr, size_t size);

POC(Point of Coherency)是一个很关键的概念,意思是维护操作外溢到的那个点。对DMA来说,这个点就是内存控制器,数据维护到内存控制器这一层,DMA就能看到正确的数据。

弄清楚DMA方向之后,操作选型就很好记了:

  • DMA从内存读数据发出去(发送方向):操作方向是CPU写数据到内存给DMA读,用Clean,必要时Clean+Invalidate;
  • DMA从外设收数据写到内存,CPU来读(接收方向):操作方向是DMA写数据到内存给CPU读,用Invalidate。

手动维护Cache这招,适合处理大数据块缓冲区,配合map/unmap接口用最顺,也是大多数驱动工程师日常接触最多的套路。

3.2 DMA发送前的Cache维护操作要点

以Linux内核的流式DMA映射接口为例,发送方向的标准做法是这样:

dma_addr_t buf_dma; buf_dma = dma_map_single(dev, buf_cpu, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, buf_dma)) { /* 处理错误 */ return -EFAULT; } /* 写DMA控制器寄存器,启动传输 */ writel(len, regs + DMA_LEN); writel(buf_dma, regs + DMA_ADDR); writel(1, regs + DMA_START);

调用dma_map_single并传入DMA_TO_DEVICE,内核在返回DMA地址之前,对这块缓冲区执行了Cache Clean,保证数据写回了内存。这里有几个关键点,稍微没注意就容易踩坑:

关键点一,必须在启动DMA之前完成map操作。map完成之后,你再去填空缓冲区数据,CPU改的还是Cache,DMA读到的依然可能是旧数据。正确顺序是:先填数据,再map,再启动DMA。顺序反了,map就等于白做。

关键点二,一次DMA操作的方向要在map到unmap之间保持一致。同一个缓冲区一会儿从CPU往DMA方向传,一会儿从DMA往CPU方向传,每次都要重新选择方向做维护,不能用一次map来回传。用完一次就dma_unmap_single,下一次用再重新map,这是最稳的做法。

关键点三,传输完成后也不要忘了unmap。发送方向,DMA读完内存之后,如果不再用这块缓冲区,unmap做的事情相对轻,就是把映射关系解除。但如果传输完成后你还要修改缓冲区或者读取状态,就需要再执行dma_unmap_single(dev, buf_dma, len, DMA_TO_DEVICE),把整块区域重新变得Cache一致。

3.3 DMA接收后的Cache维护操作细节

接收方向比发送方向更容易出问题。DMA收到数据,写入物理内存,CPU随后读取这段内存,如果CPU的Cache里还保留着这块地址的旧副本,CPU读到的就是旧数据。

Linux内核map接口在接收方向的正确用法:

buf_dma = dma_map_single(dev, buf_cpu, len, DMA_FROM_DEVICE); if (dma_mapping_error(dev, buf_dma)) { /* 处理错误 */ return -EFAULT; } /* 启动DMA接收 */ writel(buf_dma, regs + DMA_ADDR); writel(len, regs + DMA_LEN); writel(1, regs + DMA_START); /* 等DMA完成中断 */ /* 处理数据前,必须执行unmap,内部做Cache Invalidate */ dma_unmap_single(dev, buf_dma, len, DMA_FROM_DEVICE); /* 此时CPU读取buf_cpu就是内存里DMA写好的新数据 */

注意一个关键区别:发送方向通常在map时做Cache维护,接收方向在unmap时做Cache维护。对应了数据流向:发送方向CPU写内存在前,所以要Clean;接收方向DMA写内存在前,CPU读内存在后,所以要Invalidate。很多刚接触的朋友在这里绕晕,其实你可以只用一条逻辑记:哪个角色先碰数据,后面的角色要确保能看到新值。

更底层的实现里,dma_unmap_single可能就是dma_sync_single_for_cpu的别名,操作上map_for_device和unmap_for_cpu是一对。内核里对两种情况还有更细的同步函数:

/* DMA读内存前调用(CPU写完数据、DMA还没开始读) */ dma_sync_single_for_device(dev, buf_dma, len, DMA_TO_DEVICE); /* DMA写内存后调用(CPU准备读数据) */ dma_sync_single_for_cpu(dev, buf_dma, len, DMA_FROM_DEVICE);

这套API的好处是可以复用同一块缓冲区反复收发,不用每次map/unmap,适合那些比较高吞吐的驱动路径。

3.4 整理Cache维护时的内存屏障问题

做Cache维护还有一个容易忽略的搭档:内存屏障。Cache维护只是保证Cache和内存之间的数据一致性,内存屏障保证的是访问顺序。举个例子,启动DMA传输前,你要写长度寄存器、地址寄存器、启动寄存器,这些MMIO寄存器操作的顺序,不能被CPU或编译器乱序执行乱掉。如果地址还没写进寄存器,启动寄存器就先被写了,DMA控制器拿到的是旧的地址或者无效的长度,出错的节奏就猜不透了。

Linux内核里有一组专门的DMA屏障,干什么用的呢?它保证所有在屏障之前的写操作(不管是普通内存还是MMIO寄存器)在屏障之后的操作执行前,对外设可见。实际驱动代码里,启动DMA前加一条:

wmb();

如果是Arm平台,也可以用更精确的屏障:

dsb(sy);

有些内核版本和平台提供的dma_wmb()是轻量一些的,适合DMA描述符已经被一致性内存保证有序的场景。如果在我的代码里,标准写法是这样的:写DMA描述符并做Cache维护之后,加一个dma_wmb(),确保描述符数据对外设可见;然后写寄存器启动传输,再加一个wmb(),确保寄存器的写操作在启动命令前完成。

想到一个很常见的坑:CPU往MMIO寄存器写数据时,写缓冲和总线桥可能合并写、乱序完成,这时候光靠编译器禁用重排是不够的,必须有CPU给出的内存屏障指令,让写操作以程序规定的顺序到达外设。这个问题不要等出了bug再去怀疑,驱动里但凡有“先准备数据,再通知硬件”的模式,屏障都应该随手加上。

4. 第三招:绕过Cache,从根上回避一致性问题

4.1 Non-Cacheable映射的原理

第三招和第一招有思想上的相近之处,但它更“物理”:把DMA缓冲区所在的页直接配置成Non-Cacheable,CPU访问该区域时完全跳过Cache,每一次读都去内存拿,每一次写都直接落到内存。

传统ARM平台里,可以靠设置页表的属性位来实现。比如在旧一点的ARM32内核代码里,有人直接改pgprot_writecombine或者pgprot_noncached,重新映射缓冲区所在的物理页面。ARM64平台则是通过页表项里的属性字段配置,把某个区域的Cache属性设为Device或者Non-Cacheable。Linux内核里不同的平台API可能不一样,在设备树里也可以对某段内存区域做no-map或者打上dma-coherent等标签,让内核默认把这段区域按一致性的方式处理。

这一招的好处是彻底回避问题,不需要写代码在每次DMA传输前前后后调用Sync接口,也不需要在分配时操心dma_alloc_coherent的性能陷阱。坏处也很明显:CPU访问这块区域慢,写入数据时如果是一系列连续的写,还会因为每次都要走总线闹出额外的开销。用它之前要想清楚你的数据流量特征。

4.2 非缓存映射的实际落地路径

实际项目里,我看到好几个应用场景适合第三招:

一个是在开发板调试阶段,为了快速排除Cache一致性的嫌疑,临时把某段测试缓冲区的内存属性改成Non-Cacheable看看,如果问题消失了,就能断定是Cache问题,然后再换成正经的Cache维护方案。

另一个场景是在某些专用控制器场景,比如小型的显示控制器、特定的采集接口固件,数据流非常单一,硬件也不支持复杂的描述符交互。这时候给缓冲区做Non-Cacheable映射,省掉所有软件维护,整个数据通路变得极其朴素,开发和排错的成本都低。

还有一种是数据量小但是实时性要求高的场景,比如控制命令的交互。这种场景CPU每次写一个命令,DMA立刻要读到,如果被Cache截住缓存起来,命令可能一直发不出去。对这种高频小控制信息,Non-Cacheable映射其实是很合适的选择。

具体落地方式,如果是在Linux内核驱动里,可以直接借用已经封装好的内存属性接口,也可以在设计硬件的时候就把该缓冲区放在固定的物理段,并在页表里单独配属性。这里多说一嘴,非缓存映射不只是“不用Cache”,还涉及到总线上事务的合并、访问模式等,一时半会儿说不完,但代码层面对开发者最简单的理解就是:该段内存每次读写都直达内存控制器,谁都不挡道。

4.3 三招的横向对比与选型判断

这三招不是互相排斥的,一个正经驱动里通常会混着用。我整理了一下各方案的特点,方便大家对照自己手上的场景做选择:

方案核心机制代码复杂度CPU访问性能适用场景注意事项
一致性内存分配硬件自动维护或Non-Cacheable最低可能较慢描述符、控制块、高频小数据避免用在大块数据搬运热路径
手动Cache维护Clean/Invalidate按需操作中高大块数据收发缓冲注意操作方向、时机、对齐、屏障
Non-Cacheable映射CPU完全绕开Cache较低低小型专用缓冲区、调试验证注意性能损失,不推荐大块传输

选型有个简单的判断逻辑:先看数据结构的大小和访问频率。如果是控制面结构,每次传输都要CPU改、DMA读、CPU再改,那用一致性内存分配最稳;如果是数据面,CPU填一大块数据,填完让DMA搬走,这样用第二招手动维护Cache省下的Cache加速时间非常可观;如果平台本身对一致性支持有缺陷,又不想深究细节,第三招至少能让验证流程跑起来。

还有一个要注意的是,CPU访问性能和DMA整体吞吐不是一回事。CPU访问Non-Cacheable内存慢,但DMA搬数据它用的是内存带宽,影响可能没你想的大。所以做选型判断时,一定要看瓶颈在你代码的哪个位置,而不是凭着性能经验拍脑袋。

5. 常见问题与排查技巧实录

5.1 DMA读到旧数据的场景速查表

做驱动这几年,我把自己遇到的和身边同事遇到的典型问题整理过一张速查表,排查时可以对着看:

现象猜测原因验证手段解决方向
发送数据总是重复上一个包的内容DMA读内存时Cache里是旧数据,还没Clean改成每次传输前强制Clean,看是否恢复发送方向补充Cache维护,注意map时机
接收数据前面一段是新的,后面一段是旧的缓存行对齐问题,跨Cache Line的缓冲后半部分没失效检查缓冲区地址是否Cache Line对齐,size是否对齐对齐缓冲区,按Cache Line粒度做Invalidate
随机性偶发错误,概率不大于1%内存屏障缺失,寄存器写的顺序被打乱抓总线时序或加详细log对比时间戳在启动DMA前补充wmb/dsb屏障
某些平台正常,换平台就出错原平台DMA带硬件一致性,新平台没有换模拟平台或者总线分析验证统一做显式Cache维护,不依赖硬件特性
通过一致性内存分配后CPU访问变慢Non-Cacheable映射导致CPU直达内存测量CPU填充耗时和吞吐量换大块缓冲区为普通内存+手动Cache维护
DMA描述符更新了CPU却不执行描述符停在Cache里,DMA看到旧命令确认描述符区域是否一致内存描述符用一致性内存分配,必要时再补Clean

这张表对应的每一种问题,我在实际项目里都见过至少一次,特别是“发送数据重复上一个包”这种,简直可以说是DMA驱动的“新手勋章”。遇到一次之后,你就会记住Cache维护的必要性。

5.2 排查工具与定位方法

排查Cache/DMA一致性问题,光靠眼睛看代码有时候效率太低,我推荐几个比较实用的定位手段。

第一个手段是代码审查加日志。先把自己驱动里所有DMA方向的代码理一遍:分配缓冲区用的什么接口,map的方向对不对,传输前后的Sync调用有没有对称,启动DMA之前有没有做屏障。这一套流程查下来能定位掉70%的问题。注意检查边界情况时,特别要看接收方向异常时你还在读缓冲区的那段路径有没有被Cache挡住。

第二个手段是实验对照。临时把缓冲区的内存属性改成Non-Cacheable,跑一下复现场景,看看问题是否消失。如果问题消失,说明确实是Cache一致性问题,接下来再优化成性能更好的Cache维护方案。这个手段最直接,不需要仔仔细细去数每一个调用的时机。

第三个手段是开内核或平台相关的调试工具。比如在Linux下开CONFIG_DEBUG_OBJECTS_DMA,或者使能内核的DMA API debug功能,它会帮你检查map与unmap的配对、方向是否错用等常见违规。内核日志会非常直白地告诉你“DMA-API: device driver maps memory but never unmaps”这类线索。还有CONFIG_DMA_API_DEBUG_SG是专门查SG表问题的,遇到分散聚合缓冲的时候也很有用。

第四个手段是直接看汇编或硬件日志。如果问题非常隐蔽,怀疑编译器或CPU对访问顺序做了重排,可以看编译器生成的汇编,确认关键写操作的顺序。硬件逻辑分析仪或者总线trace工具可以抓DMA控制器的实际发起地址和数据,比对Cache维护后的内存值,但这类工具部署成本高,一般是前三个手段都用完才上。

5.3 我踩过的几个值得展开的坑

第一个坑是“缓存行对齐”。有一次调一个音频采集驱动,DMA每帧写1920字节到缓冲区,CPU总是读到前半帧新的、后半帧旧的。查来查去,发现缓冲区是临时在驱动里按kmalloc分配的,起始地址不是64字节对齐,DMA写完1920字节后,CPU做Invalidate的代码写的是按size做的区间失效,但因为地址没对齐,前半部分和后半部分各跨了一个Cache Line,有一部分旧数据一直没被失效掉。从那以后,凡是kmalloc缓冲区做DMA,我都要先做对齐检查,至少确认PAGE_ALIGNED或者ARCH_KMALLOC_MINALIGN符合要求。不给DMA缓冲区专门做对齐是很常见的误区,不要觉得只有一致性内存才需要对齐。

第二个坑是“描述符里的字段顺序”。电压控制器驱动的描述符里有addr、len、flags三个字段,我最初用普通内存放描述符,改完描述符后调用dma_sync_single_for_device做Clean,但忘了加内存屏障。结果是描述符的数据写回了内存,但DMA启动寄存器的写操作先于描述符的写完成到达控制器,控制器读描述符时拿到的还是旧命令。后来调整成:改描述符,做Clean,加wmb(),再写启动寄存器。这个问题表面看是DMA读旧数据,根子上是访问顺序乱了,所以在“Cache维护”和“寄存器通知”之间,永远要给屏障留位置。

第三个坑是“长期持有的映射没有释放”。还有一个接收方向的高频场景:驱动每收一个包就dma_map_single然后立刻dma_unmap_single,这块缓冲区没有被释放,只是反复使用。后来在高负载下出现间歇性丢包,查内核日志时DMA-API警告说某设备经常长期占有映射。原因是代码里某一条错误路径提前返回了,没走unmap,导致缓冲区映射一直被占用,后续DMA操作拿到的DMA地址是旧的映射关系。修好路径平衡之后问题消失。这也提醒我们,驱动代码的错误处理路径里,map和unmap的配对关系一定要像记账一样把平。

第四个坑是“跨Cache Line的伪共享”。这块其实不算DMA控制器本身的Cache一致性问题,但是在多核系统上,如果两个CPU核心各自处理不同方向的DMA描述符,描述符又在同一个Cache Line里,一个核心改动会引发另一个核心的失效震荡,导致性能急剧下降。解决方案是把不同方向的描述符分开放到不同的Cache Line里,甚至用____cacheline_aligned让它们天然分离。这类问题排查时性能指标会异常,但数据一致性的表象是好的,容易被误判成频率问题或者中断问题。

6. 这套知识在真实项目里的应用方式

我刚入行的那个阶段,对Cache理解得比较浅,特别容易迷信“DMA控制器很智能,什么都是自动的”。现在每做一个DMA相关的驱动,我会强制自己把这三步做成清单:

第一,确认目标平台DMA有没有硬件一致性支持。如果硬件手册里明确写了“DMA支持Cache Coherent”,那驱动里确实可以少做很多维护,但代码依然建议保留维护逻辑或至少注释清楚。为什么不那么信任硬件特性?因为平台升级、总线时钟调整、外设复用,都可能让这个“支持”在实际场景里打个折扣。

第二,梳理数据通路方向图。每块缓冲区在每个时刻的数据流方向要说清楚:是CPU写DMA读,还是DMA写CPU读。方向图确定下来,Cache维护操作就顺理成章:用Clean还是Invalidate就一目了然。

第三,设计缓冲区分配策略和操作时机。是描述符还是大数据块?是一次性分配还是反复使用?数据传输完成之后CPU是否会立即访问数据?这些问题回答完,在一致性内存分配、手动Cache维护、Non-Cacheable映射里做选择就有了依据。

我还发现一个很好用的思路:在驱动开发初期,先用最笨的方式把功能跑通——所有缓冲区都走一致性内存分配,虽然性能可能不是最优,但至少能排除掉Cache一致性问题这个变量,先把DMA数据通路本身调通。功能正常之后,再用性能分析工具看热点在哪里,决定哪些路径需要优化成手动Cache维护。这个“先一致性,后优化”的开发顺序很适合复杂的驱动模块,可以避免多个问题叠加在一起时根本没法定位。

写驱动的过程本质上是在跟硬件“约法三章”。DMA控制器是一根筋的工人,你告诉它地址它就去搬,搬回来的东西是不是好的,它不负责;而你告诉它一个被Cache挡住的地址,它只会傻傻地去搬旧数据。理解到这一层,你就会明白Cache维护不是为了“玄学正确”,而是为了让硬件真的看到你内存里那份最新的数据。只要时刻记得“CPU眼中的数据和DMA眼中的数据不是同一个世界”这句话,再遇到奇怪的收发问题,你就能把排查方向迅速拉到正确的轨道上。

最后再分享一个调试时的习惯:任何DMA相关的驱动,我都会在硬件寄存器层留一个调试接口,让中断处理或者定时器能触发执行一次完整的“clean+invalidate”操作。开发阶段一旦怀疑是Cache问题,就先打开这个调试开关,如果问题消失,再针对性地把维护代码落实到该去的地方。不需要每次都编译一遍内核再试错。这套方法陪着我在好几个项目上少熬了大夜,也希望能在你下次深夜调DMA的时候,帮你少掉几根头发。

RELATED READING

延伸阅读

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