☰
嵌入式DMA与Cache一致性问题的三种解决策略
2026/10/13 11:11:56 网站建设 项目流程

1. DMA读到旧数据,根子出在Cache一致性

做过嵌入式Linux驱动的人,多半都遇到过这种诡异场景:中断明明对了,DMA的描述符状态字也确实更新了,但CPU去读接收缓冲区时,拿到的却是上一次甚至更早的陈旧数据。发送方向也一样,数据明明通过CPU写进了buffer,外设收到的却是几个节拍之前的旧内容。排了半天指针错误、地址错位,最后才意识到是Cache在捣鬼。

把这句话摆在最前面:没有硬件Cache一致性机制的平台,DMA绕过CPU缓存直接访问物理内存,而CPU写数据时可能只落在Cache里还没写回内存,DMA读到的自然就是旧数据;DMA写进来的内容,也可能被CPU缓存里的旧副本挡住,让CPU看不到新数据。

这几乎是每个非一致性DMA系统都会踩的坑。这篇文章会先讲清楚为什么会这样,再给出我实际处理这类问题最常用的三招:正确使用DMA映射API自动刷Cache、手动Cache维护配合内存屏障、以及直接分配一致性DMA缓冲区。这套方法既支持Linux驱动,也适合裸机环境参考。内容偏ARM嵌入式方向,x86和其他自带IO一致性DMA的平台可以直接跳到后面看如何判断。

1.1 为什么硬件Cache会让DMA犯糊涂

现代CPU一般有L1/L2甚至L3 Cache。Cache的存在是为了让CPU快速访问高频使用的数据。但在DMA场景下,CPU和DMA是两个“看不见彼此”的访存发起方。

CPU日常读写内存时,往往不是在改物理内存,而是在改Cache。采用write-back策略的Cache,只有在Cache行被替换、手动Clean或硬件事件触发时,才会把脏数据回写到内存。也就是说,CPU说“我写好了”,数据的真正归宿可能还在Cache里躺着。

DMA控制器通常直接挂在内存总线上,它不知道CPU的Cache里有什么,也不会先看一眼Cache再去访问内存。于是在两个方向都出问题:

  • 发送场景(CPU → DMA → 外设):CPU往缓冲区写数据,Cache中产生脏行。DMA从物理内存直接读,读到的自然还是老数据。
  • 接收场景(外设 → DMA → CPU):DMA把新数据写进物理内存,但CPU之前可能读过这块缓冲区,Cache里保留了旧副本。CPU去读缓冲区,命中的是Cache里的旧数据,DMA刚写入的新内容被无视了。

用个粗糙但好懂的类比:CPU是秘书,手边有一堆“桌面草稿”(Cache),正式档案存放在“文件柜”(内存)。DMA是另一个部门,只认文件柜里的版本。秘书改了一版文件,只放在桌上没有归档,DMA来取的时候拿到的当然还是旧版;反过来,DMA把新版本放进文件柜,秘书却还盯着桌上的旧草稿,自然也对不上。

1.2 先确认你的SoC到底要不要处理Cache一致性

很多人一上来就常规性地刷Cache,其实第一步应该是查硬件的Cache一致性机制。

部分现代SoC,比如带CCI/CMN总线的ARM高性能平台,或者x86架构配合IOMMU、PCIe硬件Snoop,DMA访问已经做到了硬件级一致。也就是说,DMA操作同样会参与CPU Cache的一致性协议,硬件会自动做“监听”,不需要软件刷Cache。

在这种平台上,如果你还是傻乎乎地每次收发都做clean/invalidate,不仅白白损耗性能,还可能把状态搞坏。举个真实踩过的例子:某平台手册里写了DMA引擎能自动监听L2 Cache,驱动里却保留了早期的刷Cache逻辑,结果每次收发都会偶发“描述符状态异常”,去掉手动刷Cache之后一切恢复。问题不在刷的动作,而在于多余的操作破坏了硬件管理的一致性视图。

判断方法,一是查芯片参考手册里的Memory Coherency或DMA章节,二是看内核设备树里对应设备节点是否有dma-coherent属性。dma-coherent表示该设备DMA与Cache保持一致,Linux这一侧的通用DMA API会对应走捷径,驱动代码里不需要再做同步操作。

如果你确定板子硬件不具备一致性能力,再继续往下看,三招都基于“非一致性系统”这个前提。

2. 第一招:用DMA映射API按方向自动刷Cache

Linux内核的DMA API其实是把最标准的做法写进了规范里。只要在合适的时机、按正确的方向调用dma_map_single和dma_unmap_single,Cache维护这个脏活就交给内核去办了。

这也是我在驱动里优先推荐的方法。因为它不要求你自己动手写任何刷Cache的底层操作,只要守规矩,就能保证CPU和DMA双方都看到正确数据。

2.1 DMA_TO_DEVICE 和 DMA_FROM_DEVICE 的正确姿势

搞清楚方向最重要。内核DMA API用direction参数表达的是数据的“最终流向”:

  • DMA_TO_DEVICE:数据由CPU写入内存,然后由设备通过DMA读取。对应发送路径。
  • DMA_FROM_DEVICE:数据由设备通过DMA写入内存,然后由CPU读取。对应接收路径。

对应到Cache操作上:

方向传输前传输后说明
DMA_TO_DEVICE需要把CPU在Cache中写入的脏数据Clean回内存不需要额外操作,DMA读内存不会改内存关键是启动DMA前让数据真正落到RAM
DMA_FROM_DEVICE需要处理Cache中的旧数据,防止脏写回覆盖DMA内容需要Invalidate Cache,CPU再读时才会取内存中的新数据接收完成后必须让CPU的Cache失效

因此,dma_map_single在传输前调用,dma_unmap_single在传输完成后调用,内核会根据direction完成上述Cache维护。别小看API这套约定,很多问题恰恰是调用时机错了。

一个规范的发送流程是这样:

/* 发送 */ void *buffer = ctx->tx_buf; size_t len = packet_len; dma_addr_t dma_handle; memcpy(buffer, data, len); dma_handle = dma_map_single(ctx->dev, buffer, len, DMA_TO_DEVICE); if (dma_mapping_error(ctx->dev, dma_handle)) { dev_err(ctx->dev, "dma_map_single failed\n"); return -EIO; } /* 把dma_handle和len写到设备寄存器,然后启动传输 */ writel(len, ctx->regs + TX_LEN); writel(dma_handle, ctx->regs + TX_ADDR); writel(CTRL_START, ctx->regs + TX_CTRL); /* 记住dma_handle,传输完成中断里做unmap */ ctx->tx_dma = dma_handle; ctx->tx_len = len;
/* 发送完成中断 */ if (status & TX_DONE) { dma_unmap_single(ctx->dev, ctx->tx_dma, ctx->tx_len, DMA_TO_DEVICE); }

接收流程要区分两个阶段。启动接收时需要先把缓冲区map好,传输完成后在中断或tasklet里unmap并invalidate:

/* 接收前 */ dma_addr_t rx_dma; rx_dma = dma_map_single(ctx->dev, ctx->rx_buf, RX_BUF_SIZE, DMA_FROM_DEVICE); writel(ctx->rx_buf_len, ctx->regs + RX_LEN); writel(rx_dma, ctx->regs + RX_ADDR); writel(CTRL_START, ctx->regs + RX_CTRL); /* 接收完成中断 */ if (status & RX_DONE) { dma_unmap_single(ctx->dev, ctx->rx_dma, ctx->rx_len, DMA_FROM_DEVICE); /* 到这里,rx_buf里的数据才是DMA写进来的新数据 */ process_packet(ctx->rx_buf, ctx->rx_len); }

这里有个非常容易犯的错:在调用dma_map_single之后,到dma_unmap_single之前,CPU不能随意访问这块缓冲区。API建立的是一个“外设访问期”,这期间CPU再写缓冲区,内存里的数据是否最新就没人能保证了。有些新手在map后还往buffer里memcpy,结果DMA传出去的还是旧内容,排查半天发现是访问序违规。

2.2 高频传输时的替代方案:dma_sync_single_for_cpu/device

有人会问:每次收发都map/unmap,开销是不是大了点?对于高频小包场景,确实如此。尤其是有IOMMU的平台,map/unmap还涉及页表操作,频繁调用代价不可小觑。

这时可以用同步API替代反复map/unmap。思想是:把map/unmap只看作“建立/释放设备对内存访问权限”的过程,而把Cache维护用dma_sync_single_for_cpu和dma_sync_single_for_device单独完成。缓冲区只映射一次,之后每次收发只做同步。

接收路径典型写法:

/* 初始化时映射一次 */ ctx->rx_dma = dma_map_single(ctx->dev, ctx->rx_buf, RX_BUF_SIZE, DMA_FROM_DEVICE); /* 每次启动接收 */ static void arm_rx(struct my_ctx *ctx) { dma_sync_single_for_device(ctx->dev, ctx->rx_dma, RX_BUF_SIZE, DMA_FROM_DEVICE); writel(RX_BUF_SIZE, ctx->regs + RX_LEN); writel(ctx->rx_dma, ctx->regs + RX_ADDR); writel(CTRL_START, ctx->regs + RX_CTRL); } /* 接收完成 */ static void rx_complete(struct my_ctx *ctx) { dma_sync_single_for_cpu(ctx->dev, ctx->rx_dma, RX_BUF_SIZE, DMA_FROM_DEVICE); dma_rmb(); process_packet(ctx->rx_buf, ctx->rx_len); arm_rx(ctx); }

注意,dma_sync_single_for_device和dma_sync_single_for_cpu只是Cache维护手段,并不会像map/unmap那样建立或释放IOMMU映射。如果你的设备没有IOMMU或者驱动不需要反复映射,这个方法很适合。

这套API最大价值是让“刷Cache”这件事变得标准化。但它不是万能钥匙:CPU访问期和DMA访问期必须严格交替,互不重叠。一旦有一个CPU线程在DMA传输过程中插入访问,崩溃和脏数据只是一瞬间的事。

3. 第二招:手动Cache维护加内存屏障,自己掌控全局

Linux API固然方便,但有些场景绕不开手动操作:老版本内核、裸机驱动、或者需要极细致控制Cache维护时机的地方。另外,面试或者排查问题时,懂底层Cache指令也能省很多力气。

这一招的核心是两件事:Cache Clean/Invalidate操作,以及配套的内存屏障。很多只刷Cache不加屏障的驱动也能跑,但属于“运气好”,换个CPU、换个总线,问题立刻现形。

3.1 底层Cache操作接口与调用位置

在ARMv7/ARMv8裸机环境下,Cache维护通常通过协处理器指令或者CPI段中的Cache操作by MVA完成。例如:

  • Clean Cache Line:DCCMVAC,把脏Cache行回写内存。
  • Invalidate Cache Line:DCIMVAC,丢弃Cache中的副本。
  • Clean and Invalidate:DCCIMVAC。

在Linux内核里,不直接依赖具体指令,而是使用封装好的底层接口,比如dmac_map_area、dmac_unmap_area。但更推荐的做法还是用dma_sync_single_*,因为它在不同架构间有可移植性。这里讲手动方式,是为了让你理解API背后发生了什么。

映射到实际操作位置:

发送方向,在DMA启动前对缓冲区做Clean:

clean_cache_mva(buffer, len); dma_wmb(); writel(CTRL_START, dev->regs + TX_CTRL);

接收方向,在DMA完成中断里对缓冲区做Invalidate:

dma_rmb(); invalidate_cache_mva(buffer, len); dma_rmb(); process_packet(buffer, len);

很多人会把Clean和Invalidate的顺序搞反。发送前该做的是Clean而不是Invalidate;接收完成后该做的是Invalidate,同时对旧的脏行要先Clean再Invalidate,否则脏行数据可能还没来得及回写就被扬了。

3.2 为什么还必须配内存屏障

内存屏障管的是“访问顺序”,它和Cache维护是两回事。我见过太多人把内存屏障当成刷Cache的替代品,加了dma_rmb却不做invalidate,数据照样错,然后百思不得其解。

屏障解决的是乱序问题:处理器、编译器、总线都可能对内存访问重新排序。典型例子是发送方向:

/* 错误示范:只写数据不写屏障 */ memcpy(buffer, data, len); clean_cache_mva(buffer, len); writel(CTRL_START, dev->regs + CTRL);

如果writel(CTRL_START)被CPU乱序提前执行,DMA控制器可能在你还没Clean完成、甚至数据还没完全写入缓冲区时就开始传输。结果就是设备读到了不完整的包,或者读到了旧值。

正确做法是在数据准备和启动DMA之间插入写屏障:

memcpy(buffer, data, len); clean_cache_mva(buffer, len); dma_wmb(); /* 保证clean_cache以及buffer写入都对DMA引擎可见 */ writel(CTRL_START, dev->regs + CTRL);

接收方向,DMA完成中断里需要读屏障:

if (readl(dev->regs + IRQ_STATUS) & RX_DONE) { dma_rmb(); /* 确保DMA写入内存的动作已经完成且有序可见 */ invalidate_cache_mva(rx_buf, len); dma_rmb(); /* 确保invalidate之后CPU再读buffer */ process_packet(rx_buf, len); }

为什么中断里还要加dma_rmb?因为DMA控制器写入内存是外设侧的主设备操作,CPU观察到中断时,DMA的写如果还没完全抵达内存或总线上仍有排队写,后续读取可能读到半新不旧的数据。屏障可以约束CPU端读操作的顺序,这也是内核文档里明确建议的用法。

3.3 手动Cache维护时的描述符Ring示例

在裸机网络驱动里,我们经常用描述符Ring管理收发。描述符本身也是DMA要访问的内存,同样存在Cache一致性问题。

一个经典做法是:描述符数组放在非Cacheable内存区域,这样CPU每次读写描述符都直接访问RAM,不需要维护Cache。但如果你偏要把描述符放在Cacheable内存里,就得按下面这种模式处理:

/* 写发送描述符,然后启动DMA */ static void prepare_tx_desc(struct my_dev *ctx, int idx, dma_addr_t payload_dma, u32 len) { volatile struct tx_desc *desc = &ctx->tx_ring[idx]; desc->addr = payload_dma; desc->len = len; desc->ctrl = DESC_VALID | DESC_LAST; /* 确保描述符内容先写入内存 */ clean_cache_mva((void *)desc, sizeof(*desc)); dma_wmb(); /* 更新生产者索引让DMA引擎开始搬运 */ writel((idx + 1) % RING_SIZE, ctx->regs + TX_PRODUCER); }

接收完成同理:

static void handle_rx_desc(struct my_dev *ctx, int idx) { volatile struct rx_desc *desc = &ctx->rx_ring[idx]; u32 len; dma_rmb(); invalidate_cache_mva((void *)desc, sizeof(*desc)); dma_rmb(); len = desc->actual_len; if (desc->ctrl & DESC_DONE) { invalidate_cache_mva(ctx->rx_payload[idx], len); process_packet(ctx->rx_payload[idx], len); } }

这样搞确实繁琐,所以即使裸机驱动,很多老手的做法依然是:描述符放Non-Cacheable区,Payload缓存用Cacheable区,只在Payload上做Cache操作。只有非常追求性能、Payload也不大时,才把环形缓冲整体放到手动管理里反复折腾。

4. 第三招:直接分配一致性DMA缓冲区,从根上解决

如果每次收发都要刷Cache、加屏障,总还是太累了。有没有一种办法,让CPU和DMA操作同一块内存时,硬件或软件映射机制自动保证双方看到的内容一致?

有。这就是一致性DMA映射。

Linux下对应接口是dma_alloc_coherent和dma_free_coherent。用这个API分配出来的缓冲区,CPU和设备不用再做Cache维护,直接读,直接写,两边看到的结果都是同步的。

4.1 dma_alloc_coherent 到底干了什么

它的核心思路是:把分配给DMA控制器使用的物理内存区域,在CPU页表里映射为“不具备一般Cache缓存属性”的内存。通常是不允许Write-Back Cache缓存,或者直接映射为保持“一致性”的硬件域。这样CPU访问该缓冲区时,数据不只是停留在Cache,而是直接落到内存;DMA访问时,自然也能拿到最新内容。

在某些支持硬件总线一致性的平台上,dma_alloc_coherent可能分配的仍是Cacheable内存,但硬件监听机制保证了DMA和CPU共同看到同一份数据。无论哪种实现,对驱动代码来说效果一样:不需要手动Clean/Invalidate。

典型用法:

struct desc_ring { void *virt_base; dma_addr_t dma_base; u32 n_desc; }; static int alloc_desc_ring(struct my_ctx *ctx, struct desc_ring *ring) { ring->virt_base = dma_alloc_coherent(ctx->dev, ring->n_desc * sizeof(struct desc), &ring->dma_base, GFP_KERNEL); if (!ring->virt_base) return -ENOMEM; memset(ring->virt_base, 0, ring->n_desc * sizeof(struct desc)); return 0; } static void free_desc_ring(struct my_ctx *ctx, struct desc_ring *ring) { dma_free_coherent(ctx->dev, ring->n_desc * sizeof(struct desc), ring->virt_base, ring->dma_base); }

分配成功后,virt_base是CPU使用的虚拟地址,dma_base是DMA引擎使用的物理地址。CPU操作virt_base,DMA操作dma_base,共享同一块物理内存。

这类内存最适合放DMA描述符、环形队列索引、硬件状态字,以及体积不大但访问频繁的控制信息块。下面这段描述符Ring的消费端,就用得干干净净:

static bool rx_ring_has_packet(struct my_ctx *ctx) { struct rx_desc *desc = &ctx->rx_ring_desc[idx]; dma_rmb(); return (desc->ctrl & DESC_DONE) != 0; }

这里还需要dma_rmb吗?需要。一致性内存解决的是Cache一致性问题,但没解决总线上的读写排序问题。DMA引擎写完描述符后,CPU并不保证马上就能看到,中间还有总线缓冲、写缓冲。用一次dma_rmb确保读到的是DMA写入后的最新状态。这是很多人的知识盲区,我见过有人把一致性内存当成完全隔离,结果还是读到旧描述符状态。

4.2 哪些场景适合用一致性内存,哪些并不适合

一致性内存并不总是优于普通内存。它的主要代价是CPU访问性能。如果CPU每次读写都直接绕过Cache冲到内存,那么对于大数据量Payload的解析和填充,速度会明显下降。

所以实际驱动里最常见的组合是:

  • 描述符Ring、状态字、生产者/消费者索引这类“控制面”数据,放一致性内存。
  • 真正的网络包Payload、大文件缓冲区这类“数据面”,用普通Cacheable内存,通过dma_map_single在收发瞬间做Cache维护。

举个例子,某网络驱动里,环形描述符共256项,每项64字节,用dma_alloc_coherent分配;每个包的数据缓冲区单独申请,用dma_map_single做映射。这样描述符的读写频率非常高,放到一致性内存能省下每包都Clean/Invalidate描述符的开销;而Payload动辄几KB,CPU解析时访存压力大,放Cacheable内存再手动管理Cache,收益更明显。

有一个容易踩的坑:dma_alloc_coherent分配大块连续内存并不总是成功。它拿的是物理连续页面,还要满足一致性内存池或CMA分配器的限制。大块分配失败时,驱动回退方案要么是改用dma_map_single配合分散页,要么用sg表。所以要分配几MB甚至几十MB的视频帧缓冲,别指望一致性内存在所有平台都能轻松给你。

5. 实操避坑指南:缓存行对齐与调试验证

很多Cache相关的问题不是方向搞错,而是对齐问题。Cache维护的基本单位是Cache Line,不是字节。x86常见64字节,ARM Cortex-A系列常见64字节,老一些的ARM可能是32字节,也有个别平台是128字节。如果你的DMA缓冲区和其他变量共用了一个Cache Line,那麻烦就大了。

5.1 缓存行对齐——最容易翻车的隐藏规则

假设分配了一个缓冲区buf,从0x1000到0x1010,只有16字节,而紧跟着地址0x1010到0x1020里放了一个相邻变量flag。Cache Line大小64字节,也就是说0x1000到0x1040属于同一行。CPU之前访问过flag,这个Line被缓存起来了。

DMA往buf写入新数据,然后CPU对buf做invalidate操作。invalidate是以整个Cache Line为单位的,0x1000到0x1040这一行会被直接丢弃。如果flag的数据还没来得及回写,或者CPU后面再从内存读flag,内存里flag可能是旧值,也可能因为脏行丢失而状态错乱。更恶心的是,这种问题只在特定地址布局、特定时序下出现,看起来完全随机,非常难复现。

解决办法:

  • DMA缓冲区首地址对齐到Cache Line大小。
  • 缓冲区长度向上对齐到Cache Line整数倍。
  • 不要让DMA缓冲区和普通变量共享同一Cache Line区域。
  • 如果DMA只需要操作一段很小的数据,你依然要给整个Cache Line“买全单”,该操作整行就操作整行。

Linux内核里,DMA映射API要求buffer地址按ARCH_DMA_MINALIGN对齐。自定义驱动里,可以结合平台信息用dma_get_cache_line_size取到Cache Line大小,然后对缓冲做对齐分配。裸机环境,直接用编译期常量ALIGN即可。

一个简单分配代码:

#define CACHE_LINE_SIZE 64 static unsigned char dma_buf[2 * CACHE_LINE_SIZE]; static unsigned char *aligned_buf; aligned_buf = (unsigned char *)ALIGN((unsigned long)dma_buf, CACHE_LINE_SIZE);

这里的冗余分配,就是为了保证即使数组起始不对齐,也能找到一段从Cache Line边界开始的空间。

5.2 打开内核DMA调试开关,让问题自己浮出来

Linux提供了一套严格检测DMA映射使用错误的机制,内核配置项叫CONFIG_DMA_API_DBUG,编进内核后,通过启动参数或sysfs观察。

打开之后,它会记录每次dma_map_*和dma_unmap_*的调用,检测:

  • map/unmap次数不匹配
  • 在映射未释放时反复map同一地址
  • 使用未映射的地址访问设备
  • 越界访问
  • 使用了错误的direction

排查Cache问题时,我第一件事就是先开它。它能排除掉“API调用本身写错”的可能,避免在错误的方向上浪费大量时间。debug信息通常在/sys/kernel/debug/dma-api目录下。内核也会在检测到问题时打印详细堆栈。

在设备驱动模型里,还可以通过dma_debug_driver=<驱动名>只跟踪特定设备的DMA操作。这个过滤在大型系统里很有用,否则log会被大量无关DMA操作淹没。

5.3 常见症状速查表

现象大概率原因排查方向
发送数据偶尔变成旧内容,重启后第一次正常DMA启动前Cache没Clean,或者map后又写buffer检查DMA启动前的clean操作,检查访问时序
接收数据永远是上一次的内容DMA完成后没有Invalidate,CPU命中旧Cache行确认接收路径invalidate是否执行
接收数据偶尔部分正确部分乱Cache Line部分被invalidate,殃及相邻变量检查缓冲对齐、DMA与变量是否共享Cache Line
加了内存屏障还是出错屏障不是刷Cache,Cache问题没解决先确保Clean/Invalidate正确,再加屏障
平台宣称非一致性,但怎么刷都还是旧数据可能是设备树标了dma-coherent,或IOMMU配置导致查设备树和IOMMU配置
换了CPU或总线频率后错误复现频率变化乱序窗口被放大,缺少内存屏障在DMA启动/完成路径补全屏障

最直接的验证方法是:把DMA缓冲区临时映射为Non-Cacheable或Strongly-Ordered,如果问题消失,基本坐实是Cache一致性问题。不过要注意,这只是排查手段,正式方案仍应回到上面三招里选型。

6. 写在最后:我的几点体会

这类问题真正难的地方,不是“刷Cache”这个动作,而是你不知道什么时候该刷、哪个方向刷、刷多大范围。我个人的经验是,每接手一个新平台的DMA驱动,第一件事就是看SoC手册里DMA Controller和CPU Cache之间有没有硬件一致机制,确认之后再决定用哪一招。

另外,强烈建议在项目早期做一次最简单的DMA Cache一致性自测:分配一块Cacheable内存,CPU先写入一个特征字节,然后启动DMA去读;再看DMA写回的特征字节,CPU能否立即读到。这个测试不到半天就能写好,但能省下后面几个月的排错时间,尤其是硬件和驱动同时Debug的阶段。

如果非要总结一条最实用的心法,那就是:CPU和DMA是两套完全独立的内存视图,你的驱动代码就是它们之间的翻译官。翻译出错,数据自然错乱。把方向、时机、对齐这三件事刻在脑子里,Cache问题再诡异,也能一步步拆解到源头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询