前阵子帮一个朋友排查问题,现象很有意思:一套在x86服务器上稳定跑了几个月的DMA驱动代码,原封不动交叉编译到ARM64平台上,结果网络吞吐一上来就开始随机坏包,跑着跑着还会偶发系统崩溃。更气人的是,单独测某个功能都正常,压力一来就翻车。这种事在AI Infra的异构平台上尤其常见——训练卡、推理卡、FPGA加速卡、智能网卡,大量DMA驱动代码的起点都是x86原型,然后往ARM管理平台或不同SoC上一迁移,就轮到“随机坏数据”来教育人了。
所谓“随机坏数据”,往往不是因为数据本身被算错,而是数据到达的先后和位置不对。x86上能跑,不代表代码正确,只是平台把错误掩盖了。这篇文章就从一次真实排查说起,把DMA跨平台坏数据背后的cache一致性、地址映射、内存屏障和DMA完成语义这些关键差异拆开讲透,再加上我这些年踩过的一些坑和处理套路,给正在搞驱动移植、固件开发、AI基础设施的同学一个可以直接照做的排查框架。
1. 表面现象背后的两个平台差异
1.1 随机坏数据的真相是顺序问题
坏数据是“随机”的,但随机不代表没有规律。任何一个数据搬运链条上,只要某个环节的“先后顺序”失去约束,就可能出现有时对、有时错的结果。x86能稳定运行,并不意味着代码在x86上完全正确,只能说明x86的硬件替你兜了很多底。
理解这个问题可以类比去餐厅取餐。你按顺序点单,后厨按理说也要按顺序出菜。x86的店规是“你点的单必须按顺序出”——CPU写数据的顺序、DMA读数据的顺序,都被硬件严格约束住了。ARM的店规宽松许多:写操作可以被重新排序,cache里的数据什么时候落到内存,对外设来说完全不是你能肉眼观察到的。如果你的代码没有一个强制的“取餐确认”机制,那在x86永远能拿到对的菜,在ARM上可能拿到以前的剩菜,或者半成品。
这背后有两个关键点。第一,DMA引擎读到描述符的时间和它真正搬运数据的时间,并不一定是同一个时刻。第二,CPU把数据写进cache之后,如果没人强制让cache写回内存,那内存里可能还是旧数据。两者叠加,就出现了“描述符说这里有新数据,但内存里还是旧内容”的经典翻车现场。
1.2 Cache一致性:谁负责把数据同步到内存
在x86平台上做PCIe设备DMA时,硬件层面做了总线窥探,设备可以直接感知CPU cache里的最新内容,驱动代码基本不用额外操心cache一致性。这也是为什么很多从x86起步的驱动作者,完全没有缓存同步这个概念。
ARM平台不一样。SoC内部的分立DMA控制器(比如PL330)和很多外设自带的DMA引擎,默认并不具备完整的总线窥探能力。就算某些硬件路径上做了snoop,软件依然要按“可能不一致”来写代码。数据从CPU写给DMA读,CPU侧要clean cache;DMA写数据给CPU读,CPU侧要invalidate cache。clean是把脏数据写回内存,invalidate是让后续CPU读操作不再命中旧的cache行。
这两个操作一旦漏掉,最典型的故障画面是:发送方向,DMA搬出去的总是旧数据;接收方向,CPU读完DMA写到内存的数据后,拿到的仍是cache里过去的内容。现象在压力测试下会非常随机,因为cache的替换策略和写回时机是动态的,不一定每次都踩中同一个环节,这也是很多人面对这类问题第一反应是“内存条坏了”的原因。
1.3 三种地址:CPU虚拟地址、物理地址、总线地址
DMA代码跨平台翻车的第二个大坑,是地址空间的切换。CPU访问内存用虚拟地址,最终落到物理地址;而DMA引擎看到的是它自己的总线地址。x86在没开VT-d的情况下,很多老驱动用virt_to_phys()一把梭,把物理地址直接填进DMA描述符就能工作,因为x86传统平台的物理地址和总线地址映射非常直接。
ARM上这套不总是成立。一个SoC里,外设看到的地址空间和CPU物理地址空间可能是两套地址。有些SoC把外设DMA映射到一个固定窗口,地址要做翻译才能访问内存;还有开启了SMMU的场景,驱动应该使用IOVA而不是物理地址。如果你的代码直接转物理地址、绕过DMA API,在x86上可能都没事,在ARM上大概率随机传错位置,偶尔还能传对——又是一种特别“玄学”的故障。
真正安全的做法是使用内核DMA API,比如dma_map_single()/dma_unmap_single()、dma_alloc_coherent()。API内部会处理平台差异:该走IOVA走IOVA,该走物理地址走物理地址,同时隐式插入必要的cache同步和内存屏障。很多跨平台坏数据问题的根子,就是代码没有用这套API,自己拿物理地址去填描述符。
2. 四个最容易踩的坑
2.1 没有用DMA API管理buffer
最常见的情形是:驱动用kmalloc()或kzalloc()分配一块内存,然后virt_to_phys()算出物理地址,写到DMA描述符里就启动传输。x86上,运气好,跑通了;到ARM上,buffer数据还在CPU cache里,DMA读到的是旧内存,坏数据随之而来。
正确的做法是,凡是给DMA用的buffer,就必须用DMA API去映射:
- 发送方向:dma_map_single(dev, buf, len, DMA_TO_DEVICE),传输完成后dma_unmap_single。
- 接收方向:dma_map_single(dev, buf, len, DMA_FROM_DEVICE),DMA完成后、CPU读数据前,要dma_unmap_single或者dma_sync_single_for_cpu做invalidate。
- 一致性内存:描述符表、控制结构这类CPU和DMA都会频繁访问的小块内存,用dma_alloc_coherent()分配,它天然保证双方看到一致的内容。
这里有一个性能权衡需要多说一句:dma_alloc_coherent()返回的内存通常是不带cache的一致性映射,读写性能比带cache的内存差,做大块数据搬移会拖累吞吐,所以建议只用于描述符和管理结构。高带宽数据buffer还是用普通内存配合dma_map_single(),再用dma_sync_*接口管理cache同步。
2.2 内存屏障缺失
跨平台DMA代码里最隐蔽的坑,是“CPU写入的顺序”和“DMA引擎看到的顺序”不一致。典型逻辑是:CPU先向buffer里写入数据,再更新DMA描述符中的标志位,然后写一个寄存器“启动DMA”。在x86上,CPU写操作是强顺序,这个逻辑没问题。ARM是弱内存序,编译器和CPU都可能把两次写入乱序,导致DMA先看到了“描述符更新完毕”的标志位,然后去读数据时,数据还没真正落到位。
解决手段是在关键写操作之间插内存屏障。下面这个错误用法在x86上可能一直没事,但换到ARM上就会偶发坏数据:
/* 错误示范:x86可能不会出问题,ARM上随机翻车 */ memcpy(desc->buf, data, len); desc->len = len; desc->ready = 1; writel(1, dma_reg_base + REG_START);正确的写法是把写入顺序显式约束住:
memcpy(desc->buf, data, len); dma_wmb(); /* 确保数据先于描述符更新可见 */ desc->len = len; dma_wmb(); /* 确保描述符更新先于启动寄存器写入 */ desc->ready = 1; writel(1, dma_reg_base + REG_START);dma_wmb()在x86上多数情况下会被编译成空操作或很轻的屏障,不影响性能;在ARM上负责生成屏障指令,保证写顺序。很多在x86上养成的惯性思维,到了ARM上就是隐患,这一条是我见过最多、也最容易忽略的。
2.3 对齐和传输长度限制不同
x86平台上的PCIe设备通常对源/目的地址对齐要求没那么苛刻,很多驱动里甚至能看到没做对齐处理就传上去的buffer。ARM SoC里的DMA控制器则各不相同:有的要求源地址按32字节对齐,有的限制burst长度不能超过某个值,有的block长度超了就出错。还有一种隐藏问题出现在cache line边界:如果DMA buffer和一个被CPU频繁修改的变量挤在同一个cache line里,invalidate操作可能把别人对这个变量的修改也一起抹掉。
所以,在做跨平台DMA移植时,建议先翻一下目标平台DMA controller的手册,把对齐要求和传输粒度上限写成一个明确定义的常量,不要在代码里到处硬编码。给buffer分配时直接做cache line对齐,比如用ALIGN或PAGE_ALIGN,宁可多浪费一点内存,也能省掉很多玄学排查时间。
2.4 完成中断不等于数据已落地
“DMA完成中断”这个语义,在不同平台上有细微差别。有的DMA控制器在最后一个数据到达外设接口时触发中断,有的在描述符里的字节被总线读取时就触发。如果你的代码在x86上收到中断就立刻读buffer,恰好x86路径上数据已经完整到位;换到另一个平台,中断来了但总线上还有几个拍的数据没传完,你马上读,可能读到半截数据或者旧数据。
排查这类问题,我的习惯是去看中断处理程序和DMA控制器状态寄存器里有没有“剩余传输计数”之类的字段。若平台提供了,就在中断处理里确认计数归零再访问数据;若没有,宁可加一次mb()和很小的delay,也别赌“中断到了数据肯定齐了”。另外,多队列或多通道DMA场景下,还要仔细区分“本通道的中断标志”和“全局DMA中断标志”,读错状态位会导致并发改数据,这种问题比单纯时序问题更难查。
3. 一次真实排查:从“x86正常、ARM随机坏包”到修复
3.1 案例背景与现象
我参与过一块AI推理加速卡的DMA驱动移植。原型代码在x86服务器上验证了三个月,各种压力测试都过了。后来把驱动交叉编译到ARM64管理平台上,刚开始小流量测试也看不出来,等跑到5Gbps以上,网络侧就开始收到大量checksum错误的包,甚至偶发系统死机。死机时dmesg里能看到DMA timeout,而同等负载在x86上完全正常。
这个案例非常典型:同一个二进制逻辑,两个平台表现天差地别。有同事第一反应是编译器优化出了问题,还有人怀疑是内存条坏了。最后耽误了两天,才回到代码里逐行排查DMA路径。
3.2 排查步骤
我自己排DMA问题有一套固定思路,先看API使用是否规范,再上工具,最后才怀疑硬件。
第一步,开内核的DMA_API_DEBUG。在kernel cmdline里加“dma_debug=1”,或者在内核配置里打开CONFIG_DMA_API_DEBUG,重启后跑压力测试。这套机制能捕获dma_map/unmap不匹配、方向错误、超出map范围等问题。第一次开完,系统在压力下刷了一堆警告,快速锁定了几个可疑点:某个发送路径的buffer没有dma_unmap就释放了,还有个接收路径方向标错了。
第二步,打开ftrace里的dma相关event,看每个DMA请求的complete中断的实际时序。把function_graph挂在网卡的中断处理函数上,结果发现一个关键线索:驱动在complete中断里拿到状态位后就立刻去读接收数据,但函数追踪显示从状态位置位到数据真正到达内存之间,还有几次总线事件没完成。
第三步,用cache操作辅助验证。临时在complete中断里加了一句dma_sync_single_for_cpu(),坏包率立刻从千分之一降到几乎为零。这一步基本确认了问题核心:接收方向上没有在CPU读buffer前做invalidate,同时发送方向上没有在kick off前做clean和wmb。
3.3 修改要点与代码片段
修复方案并不复杂,把DMA路径上的cache同步和屏障补齐。
接收路径,DMA完成中断里:
/* 从描述符拿到buf_dma_addr和len之后,先同步给CPU */ dma_sync_single_for_cpu(dev, buf_dma_addr, len, DMA_FROM_DEVICE); /* 再往上递交数据 */ skb_reserve(skb, headroom); skb_put_data(skb, buf_cpu_addr, len); /* buffer重新挂回DMA ring前,再同步回设备 */ dma_sync_single_for_device(dev, buf_dma_addr, len, DMA_FROM_DEVICE);发送路径,在kick DMA之前:
dma_sync_single_for_device(dev, buf_dma_addr, len, DMA_TO_DEVICE); dma_wmb(); /* 保证数据对DMA可见之后,再写启动寄存器 */ writel(desc_dma_addr, dma_reg_base + REG_DMA_START);这两段代码看起来不起眼,却把“x86认为理所当然、ARM需要显式表达”的同步语义都补上了。改完后在ARM平台跑同样的压力测试,稳定运行了72小时,坏包为零。
3.4 为什么DMA_API_DEBUG那么重要
我一直推荐所有DMA驱动开发者,在开发阶段就把CONFIG_DMA_API_DEBUG打开。它不是性能工具,而是把驱动里“违规操作”全部记录下来的审计工具。开启它跑一轮压力测试,比自己翻代码省力得多。跨平台移植时,这个开关能帮你快速区分:问题是因为API用错,还是因为平台间的同步语义差异。
需要注意,DMA_API_DEBUG会记录所有map/unmap信息,内存开销不小,生产环境不建议开。开发阶段开一次,跑完定位问题后就关掉。
4. MCU场景里的DMA坑:STM32/GD32/串口/CAN
4.1 GD32E230的ADC DMA数据紊乱
GD32E230这类MCU里,ADC多通道扫描+DMA搬运是非常常见的组合。很多人照着例程抄,结果发现某个通道的值偶尔跳到别的通道的范围,或者整帧数据错位。原因多半是触发时机和DMA配置不匹配。ADC可能在DMA配置完成前就已经启动转换,导致第一批数据还没被DMA取走就被新数据覆盖;或者DMA传输大小和ADC数据寄存器宽度不一致,造成错位。
解决办法是保证启动顺序:先配置DMA(方向、外设地址、内存地址、传输个数),再启动ADC连续转换;并且DMA传输大小要与ADC精度严格一致。比如12位ADC取16bit数据,DMA内存宽度就要配成HalfWord,不能配成Byte。对于数据错位,我还会在DMA buffer头部放一个固定同步字,让校验代码第一件事就检查对齐,一旦错位全部丢弃重新同步。
4.2 串口DMA加空闲中断:不定长接收万能套路
MCU上做不定长串口接收,最常用的是DMA + IDLE中断。思路是:DMA一直开着,接收数据自动进内存;串口总线空闲一段时间后触发IDLE中断,中断里从DMA读取当前传输计数器,算出这一段收到的字节数。这个套路成熟,但坑也不少。
最大的坑是DMA循环模式下的数据处理。如果DMA配置为循环模式,数据会被不断写入同一块buffer,如果不在半满和全满中断里及时搬走数据,就会被新数据覆盖。另一个坑是:IDLE中断触发时,DMA可能还剩几个字节没搬完,你立刻读计数器会少算;正确的做法是在IDLE中断里先关闭DMA接收(或禁用该通道),等计数器稳定后读出长度,完成数据处理后再重新启动DMA。这一步很重要,不然数据丢失和重叠就是家常便饭。
4.3 CAN总线到底用中断还是DMA
这个问题经常在技术论坛被问。我的判断是:对绝大多数CAN应用,中断接收远远好于DMA。CAN报文本身短(经典CAN最多8字节,CAN-FD也才64字节),一个报文就是一次完整的中断事件,DMA在这个场景下省不了多少CPU开销,反而带来缓冲管理、数据同步、DMA配置的复杂度。如果你要做的是一台整车数据记录仪,每秒几千帧报文,那可以上DMA直接把报文批量灌进内存环形缓冲,减轻中断频率。但这种场景一般也不直接让DMA去驱动CAN控制器,而是让CAN控制器的FIFO做中断,中断里成批搬运到内存,再交给应用层。这比DMA直读控制器寄存器简单得多,也更容易调稳。
5. 跨平台DMA代码的自检清单与速查表
5.1 移植前必查的七个问题
每次跨平台移植DMA代码,我建议先回答七个问题,答不上来就先去查资料:
- buffer是谁分配的?普通内存还是一致性内存?
- 每个DMA方向是否调用了正确的map/unmap接口?
- 数据交给设备前,有没有做向设备方向的同步?
- 设备写完后,CPU读取前,有没有做向CPU方向的同步?
- 启动DMA前,内存屏障是否就位?
- buffer地址和长度是否满足目标DMA控制器的对齐要求?
- 完成中断的语义是“数据落地”还是“描述符已读”?
如果你能明确回答这七个问题,跨平台移植的DMA代码基本不会出大问题。如果答不上来,就回去把API手册和SoC datasheet翻出来,而不是先跑测试。
5.2 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 发送数据总是旧内容 | 数据还在CPU cache,未clean | 发送前dma_sync_single_for_device |
| 接收数据出现在前/旧值 | 未invalidate cache | DMA完成后dma_sync_single_for_cpu |
| 压力一大就坏包 | 描述符写入顺序和启动顺序乱 | 插入dma_wmb()、mb() |
| 地址错乱,偶发访问违规 | 直接用了物理地址绕过DMA API | 改用dma_map_single/dma_alloc_coherent |
| 大量DMA_API debug警告 | map/unmap不匹配或方向错误 | 按警告逐条修 |
| 中断来了数据不完整 | 完成中断语义是“描述符读了”而非“数据落地” | 读取剩余传输计数或加同步 |
5.3 线上定位工具
如果代码已经跑起来才出问题,工具闭环很重要。Linux下我自己常用的组合是:
- dmesg + DMA_API_DEBUG:看有没有API层面警告
- /sys/kernel/debug/dma-api/:看map/unmap计数和泄漏条目
- ftrace:挂dma_alloc/dma_free/dma_complete相关event,看时序
- /sys/kernel/iommu_groups/:看SMMU/IOMMU映射,定位IOVA和fault
这些工具相比“加打印、碰运气”,能大幅缩短定位时间,特别是当你面对“只能跑几千次才出错一次”的随机性问题时,有明确的日志和统计才能把玄学变成工程问题。
6. 后续扩展:DMA问题不只是驱动的问题
DMA跨平台代码最膈应人的地方,就是它往往在x86上所有测试都过了,然后在你最没准备的时刻,用“随机坏数据”给你上一课。我自己的经验是:编写DMA代码的第一天,就把平台差异放在心上。如果你今天在x86上写,想象一下这块buffer换到ARM上、换到有SMMU的平台上,这段代码还站不站得住。用DMA API管理buffer、在正确位置加同步和屏障、把对齐要求写成平台常量,这些习惯平时看着繁琐,真到异构平台迁移时,能帮你省下几天的排障时间。
最后再分享一个小技巧:如果你遇到“看起来随机”的坏数据,先别急着去debug上层逻辑,回头把DMA这条路径的map/unmap、sync、屏障、对齐四个环节全部过一遍。这四个环节在x86上经常被掩盖,也恰恰是跨平台出问题的重灾区。把这四个环节看清楚,90%的“玄学坏数据”都能落地成明确的代码缺陷。
再往大了说,DMA只是数据传输的一半,真正的复杂度在于一致性模型和平台差异的组合。今天你在一段DMA代码上踩的坑,明天很可能就会出现在网卡驱动、存储控制器、AI加速卡固件里。把这些基础问题看透,后面再做异构平台迁移、性能调优,会顺手很多。