1. 问题现场:x86 一切正常,换平台就随机坏数据
做 AI Infra 的同学,大概率都经历过这种“鬼故事”:一段 DMA 驱动代码,在 x86 的测试机上跑了好几天,压力测试、稳定性测试全过,怎么看都没问题。结果一交叉编译,部署到 ARM 盒子或者 RISC-V 开发板上,就开始随机抽风。有时候跑一两个小时才出一次异常,有时候开机几分钟就崩,而且现象千奇百怪——有的缓冲区里多了几个字节的垃圾数据,有的描述符环直接转飞了,有的干脆 DMA 传输完成中断都不触发,整块数据静默丢失。
我最早遇到这个问题是在做 AI 加速卡和主控之间的通信模块。x86 侧是标准 PCIe 拓扑,DMA 搬运数据稳稳当当;可当我把同一套逻辑移植到 RK3588 这类 ARM 平台时,网络包时不时就出现 CRC 错误,用 ethtool 查到的 rx_errors 忽高忽低,甚至有时候出现“failed to reset the dma”这类报错,卡死在驱动的 reset 流程里。
你问这是什么感受?就像同一个演员,在北京的剧组里怎么演都对,换到横店就成了跑龙套,台词还总说错。问题的根源当然是出了 bug,但真正让人头疼的是:为什么代码没变,行为却大变?
这个问题的答案,比大多数人想象得要深。它不是简单的“某个寄存器没配对”,而是 x86 和 ARM/RISC-V 在整个 DMA 相关的软硬件契约上,存在系统性差异。x86 在很多环节帮你把脏活累活兜底了,而换到别的平台上,这份“善意”突然消失,长期被掩盖的问题才暴露出来。
这篇文章我打算顺着这个随机坏数据的问题,把 DMA 在跨平台场景下容易踩的坑、背后原理、排查手段,一条一条掰开揉碎讲清楚。适合正在做嵌入式、异构计算、AI 加速器驱动,或者任何涉及设备搬数据到内存这套活儿的读者参考。
2. DMA 为什么天生容易出“灵异事件”
2.1 先回到原点:DMA 到底是什么
DMA 全称 Direct Memory Access,直译就是“直接内存访问”。它的核心思路很简单:数据搬运这件事,不要再麻烦 CPU 了,外设自己通过总线把数据塞进内存,或者从内存里取走。为什么要有它?因为 CPU 去搬数据太贵了,一次 memcpy 就是几十上百个时钟周期,而网卡一秒钟能收几十万个包,要是每个包都让 CPU 来搬,CPU 就别干别的了。
DMA 的典型工作模式是:驱动在内存中准备一块缓冲区,把这块缓冲区的物理地址、长度等信息写进 DMA 描述符,然后告诉外设“你可以开始了”。外设拿到地址后,通过总线自己读写内存,完成后通过中断通知 CPU。整个过程看似简单,但隐藏的问题非常多。因为一旦外设开始动内存,这时的数据路径就已经绕过了 CPU 的所有保护机制,它直接操作的是物理内存地址。
在 x86 上,这件事尤其“顺滑”。因为 x86 自打 486 时代开始,就在芯片组层面实现了 cache-coherent DMA——通俗点说,外设读写内存时,CPU 的缓存和外设看到的内存视图是基本一致的,硬件帮你把一致性同步做了。驱动开发者往往不需要关心缓存同步问题,只要调用标准 API,一切都水到渠成。
2.2 为什么平台迁移后“随机”才是常态
随机坏数据这件事,最让人抓狂的就是它的随机性。但随机性恰恰是这类 bug 的必然特征。为什么?
首先,随机性来自缓存命中和缓存未命中的时机差异。CPU 访问某块内存时,如果数据还在 cache 里,直接读缓存就能得到最新值;如果被换出了,得从内存重新加载。而 DMA 设备读写的是内存,不经过 CPU cache。当 CPU 和 DMA 设备访问同一块缓冲区时,谁先谁后、缓存是否命中等不确定因素,决定了最终数据是否一致。这一层不确定性,天然就是“随机”的源头。
其次,随机性来自调度时序。驱动代码并不是一直独占 CPU 的。中断来了要处理,内核线程要被调度器切换,DMA 描述符的写入和设备的启动命令之间,可能隔着几百微秒,也可能隔着几毫秒。在这段窗口里,别人碰没碰过相关内存、cache 是否被冲刷,都可能导致结果不同。
再一个,随机性来自编译器优化和 CPU 乱序执行。代码里的“先后顺序”只是逻辑顺序,编译器在优化时可能重新排列指令,CPU 执行时也可能乱序执行。这些优化在大多数情况下是安全的,但只要有一处内存屏障缺失,就可能在某些特殊时序下触发数据错乱。
所以“随机坏了”不是 bug 本身,它只是冰山一角。真正的 bug 是某条一致性契约被破坏,而 x86 的硬件恰好帮你掩盖了这条契约的失效。
3. 横在 x86 和其他平台之间的四道坎
3.1 缓存一致性:x86 在兜底,ARM 在摆烂
缓存一致性是 x86 和其他平台最本质的区别所在,也是我见过最多移植踩坑的根源。
x86 平台的 DMA 一致性,很大程度上是由硬件保证的。现代 x86 体系结构上,内存控制器、PCIe Root Complex 和 CPU 之间有一套完整的缓存一致性协议(基于 MESI 的延伸协议)。当 DMA 设备写入某块内存时,如果该内存对应的 cache line 在 CPU 缓存中是脏的,硬件会自动介入,把这个 cache line 写回或者作废,保证 DMA 读到的不是旧数据。驱动开发者通常不需要显式处理 cache sync。
ARM 平台则完全不同。ARM 架构手册中对 DMA 的一致性并没有做出和 x86 同等的保证——很多 ARM SoC 的 DMA 控制器访问内存时,根本不会自动维护 CPU cache 的一致性。也就是说,CPU 写了一段数据到内存,想把这块数据交给 DMA 设备去读,如果这行数据还留在 CPU 的 cache 里没写回,DMA 设备读到的极可能是内存中陈旧的副本,而不是你刚刚写入的数据。
偏偏这种不一致不会每次都发生。比如你写完数据后,恰好 cache 压力大被替换写回了,DMA 就能读到正确数据;又或者缓冲区恰好不是 cache line 对齐的,情况就更复杂。于是表现就是时好时坏。这就是“x86 正常、ARM 随机坏”的第一个核心原因。
解决手段业界早就标准化了:DMA API。Linux 内核提供dma_map_single/dma_unmap_single、dma_alloc_coherent等接口来管理一致性。dma_alloc_coherent分配的内存是 cache-coherent 的,通常实现上会关闭该内存区域的 cache 属性(或通过硬件方式保证一致性);而dma_map_single则在高层次帮你做了 cache invalidate 或 clean 操作,确保内存视图一致。
但很多从 x86 移植过来的代码,恰恰在这些接口使用上偷了懒。有些老驱动直接用kmalloc分配缓冲区,然后拿virt_to_phys算出物理地址填进描述符。这在 x86 上可能跑得通,但到了 ARM 平台上,就是随时可能爆炸的雷。
3.2 内存屏障:x86 的另一层“温柔”
第二个大坑是内存屏障。
事情要从 CPU 乱序执行和编译器优化说起。现代 CPU 为了提升性能,执行指令时并不保证严格按程序顺序执行,而是通过重排序缓冲区在保证单线程语义的前提下尽可能乱序。对单核单线程而言,这没问题;但对多核、对外设交互的场景,顺序可能就变了。
x86 因为 TS(Total Store Order,总存储顺序)内存模型,在写写操作上基本保持顺序,很多情况下程序员感觉不到乱序问题。尤其驱动场景里,往描述符里填几个字段,然后写一个“启动”寄存器,x86 上通常不会乱到哪去。于是写出来的代码可能就是:
ring->desc[0].addr = dma_addr; ring->desc[0].len = len; ring->desc[0].flag = FLAG_READY; writel(CTL_START, ring->ctrl_reg);这种代码在 x86 上跑起来很稳,但在 ARM 上就可能出问题。ARM 是典型的弱内存模型,flag的写入和writel(CTL_START, ...)的写入,实际可能被乱序执行——“启动 DMA”的信号先发出去了,而flag还没真正落到内存。DMA 控制器一看 flag 不对,要么拒绝执行,要么读到旧值,数据就坏了。
这类 bug 的经典解法是加屏障:
ring->desc[0].addr = dma_addr; ring->desc[0].len = len; wmb(); /* 确保描述符写入完成后再触发启动 */ ring->desc[0].flag = FLAG_READY; writel(CTL_START, ring->ctrl_reg);Linux 内核里有dma_wmb()这个专用屏障,语义就是保证 DMA 描述符的写入顺序。但很多从 x86 移植来的代码根本没想到还有这回事,毕竟原平台没加也跑得好好的。这正是“换平台就挂”的第二个典型原因。
3.3 地址映射:谁都以为自己拿到的是物理地址
第三个坎,是对“地址”的理解不同。
x86 上很多驱动写久了,会形成一个思维定式:我拿到一个内核指针,转成物理地址,填给 DMA 设备,就行了。至少在没有 IOMMU 的情况下,PCIe 设备看到的就是物理地址空间,CPU 的物理地址和设备的 Bus Address 一一对应。
但 ARM 平台上,地址空间的结构普遍更复杂。很多 SoC 内部有多个地址窗口,DMA 设备所在的总线域和 CPU 的物理地址域并不完全一致。更常见的坑是:你用的是virt_to_phys()得到的物理地址,但 DMA 设备要的是总线地址。在没有 IOMMU 的情况下两者往往一致,但一旦引入 IOMMU/SMMU,情况就完全不同了——设备访问的是经过 IOMMU 映射后的地址,而不是 CPU 物理地址。
还有 32 位 vs 64 位的问题。老的 DMA 设备可能只支持 32 位地址,在 x86 32 位内核里大家都没事;换到 64 位 ARM 平台,如果分配的缓冲区落到了 4G 以上,又没设置 DMA mask,DMA 设备的高位地址会被截断,数据直接写到错误的地方。内核里常见dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))的使用,但很多移植代码忽略了这个步骤,导致在 64 位平台上出现诡异的写坏内存问题。
3.4 对齐与描述符格式:看似无关,实则致命
第四个坎看起来最不起眼,但杀伤力同样很大:缓冲区对齐和描述符格式的假设。
DMA 描述符往往有严格的地址对齐要求。比如网卡环形描述符,要求 16 字节对齐;DMA 缓冲区要求 cache line 对齐(ARM 上通常是 64 字节,x86 上可能是 64 字节或 128 字节)。如果你的代码里只是用kmalloc随意分配,x86 平台因为 SLUB 分配器默认对齐相对宽松,可能一直没触发问题;但 ARM 平台上 cache line 大小和对齐策略不同,缓冲区如果跨了 cache line 边界,缓存同步操作会波及相邻数据,产生“你的数据没坏,隔壁的数据坏了”的诡异现象。
描述符格式的字节序同理。x86 是小端架构,ARM 既支持小端也支持大端。如果你的 DMA 设备寄存器或描述符字段需要按大端解释,代码里有没有正确使用cpu_to_le32这类转换函数,就成了决定成败的细节。在 x86 上因为都是小端,转换函数和不转换完全等价;换到跑大端模式的 ARM 平台,数据解释全乱套。
4. 实操排查:从“随机坏”到“破案”的四步走
4.1 让随机问题现形:复现条件控制
随机 bug 最大的敌人是“不能稳定复现”。排查的第一步,是让随机变必然。怎么加速复现?根据我的经验,有几个行之有效的手段。
第一个手段是加强内存压力。用stress-ng或memtester类工具压内存,迫使 cache 频繁换入换出,增加 DMA 缓冲区被逐出的概率。很多时候,bug 会在内存压力测试跑起来后几分钟内出现。第二个手段是降低缓存命中率,比如把缓冲区故意做成非 cache line 对齐,或使用更大的缓存块来测试边界条件。第三个手段是打乱调度节奏,绑定 CPU、设置 CPU 亲和性到不同核上反复跑,或者频繁开关中断,制造时序竞争。
如果这么做仍然复现不出来,那就得祭出静态代码审查。把 DMA 相关的代码逐行过一遍,对照 API 规范检查:用了kmalloc而不是dma_alloc_coherent?漏了dma_map_single?漏了dma_unmap_single?忘记dma_wmb()了?这些代码审查法比瞎试更高效,我后面会再系统展开。
4.2 第一嫌疑:检查 cache 一致性处理
在从 x86 移植的 DMA 代码里,cache 一致性问题约占到 60% 以上的案例。所以排查时,这项应当放在最优先的位置。
先把分配缓冲区的 API 列出来看一遍:
- 如果用的是
kmalloc+virt_to_phys,基本可以断定有隐患。应该改造成dma_alloc_coherent,或者至少用dma_map_single做一次映射。 - 如果用的是
dma_alloc_coherent,还要确认它的一致性属性是否真的生效。有些平台上dma_alloc_coherent走的是 uncached 映射,性能会打折;有些平台走的是 hardware-coherent 路径(如带CCI/CCIX的 SoC),性能还行,但前提是硬件真支持。 dma_map_single的匹配问题也常见:map 了一次,unmap 方向用错;或者 map 之后忘记 unmap,导致 cache 一致性问题残留到下一次传输。
举个典型例子。某 ARM SoC 的网卡驱动里,收包缓冲区用kmalloc申请,收完包直接skb_copy_to_linear_data拷贝数据。x86 上一切正常,因为 cache-coherent 硬件兜底了;ARM 平台上,DMA 写入的数据可能还在总线缓冲区或 CPU cache 里,skb_copy读到的却是旧值。修复方案就是把缓冲区改为dma_alloc_coherent,或者在 DMA 搬运前调用dma_map_single并正确设置方向。
针对 cache 一致性,我建议在调试阶段直接做一个“暴力验证”:把所有涉及 DMA 的缓冲区全部改成dma_alloc_coherent分配,再跑测试。如果坏数据现象消失,那基本实锤是 cache 问题。这种二分法的排查效率很高。
4.3 第二嫌疑:检查内存屏障完整性
如果 cache 一致性验证做完没发现问题,下一个重点就是内存屏障。
对着代码找漏洞的时候,我习惯把 DMA 操作抽象成两个关键顺序:第一个顺序是“描述符/数据准备好 → 启动DMA”;第二个顺序是“DMA完成中断 → 驱动程序读取结果”。这两个顺序在没有屏障保护时都可能在弱内存模型下被打破。
详情看代码。这是第一个顺序出问题的场景:
/* 错误示范:x86上侥幸能跑,ARM上随机炸 */ static void submit_desc(struct dma_ring *ring, struct desc *d) { d->addr = dma_map_single(dev, buf, len, DMA_TO_DEVICE); d->len = len; d->ctrl = DESC_VALID; writel(REGS_START, ring->reg_base); // 启动DMA }修复方案在 3.2 节已经给出过,核心是wmb()或dma_wmb()。注意wmb()和dma_wmb()的区别是:dma_wmb()是wmb()的轻量版,在支持它的平台上开销更低,用于保证 DMA 描述符写入的全局可见性。驱动里建议优先用dma_wmb()。
第二个顺序的坑则是反向的。中断处理函数中读 DMA 完成状态后,需要对 DMA 写入内存的数据做一次dma_map_single(dev, buf, len, DMA_FROM_DEVICE)再读数据,或者使用dma_sync_single_for_cpu做同步;同时在读状态寄存器前加rmb()。如果不加,CPU 可能先读了数据,后读到状态寄存器;即便顺序正好对了一次,下次又反了,就又变成随机问题。
4.4 第三嫌疑:检查地址域与对齐衰减
排除了前两类,剩下的怀疑对象就集中到地址和初始化的时序上。
地址域这块,排查步骤比较直接。先确认设备支持多大的 DMA 地址范围,再检查驱动里有没有正确设置 DMA mask。常用检查语句是:
if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(64))) { dev_err(dev, "DMA mask setup failed\n"); return -EIO; }如果设备只支持 32 位 DMA,就想办法把缓冲区分配到 4G 以下;如果支持 64 位,那重点就在于确认dma_addr_t没有截断。可以在描述符填充前后各打印一次dma_addr,对照实际覆盖的物理地址范围是否发生了高位截断。
对齐衰减这个点,我分享一个实用技巧:DMA 缓冲区的首地址应至少对齐到 cache line 大小,DMA_BIT_MASK设好了,描述符本身最好用ALIGN宏对齐。ARM64 上常见 cache line 是 64 字节,个别平台是 128 字节,dma_alloc_coherent分配的内存天然满足对齐要求,但如果你自己管理内存池,就必须检查对齐边界。
还有一个小细节很多新手忽略:亲和性和 NUMA。x86 多路服务器上,DMA 缓冲区如果跨 NUMA 节点分配,性能和一致性都可能受影响;但你如果只是从 x86 单机移植到 ARM 板卡,这个问题不突出,不过留着意识没坏处。
5. 各类平台的踩坑实录与争议点辨析
5.1 x86 并非“永远正确”,只是更容易掩盖问题
我在前面说了 x86 兜底能力更强,但必须强调:x86 并不代表“没问题”,它只是把很多问题掩盖在硬件细节里。
举个例子。x86 平台如果直接用kmalloc分配 DMA 缓冲区而不用dma_alloc_coherent,也可能会出问题,只是概率低很多。因为 x86 的 IOMMU(VT-d)如果被 BIOS 开启,DMA 时会经过 IOMMU 做地址翻译,同样存在一致性同步问题;而 BIOS 没开 IOMMU 时,DMA 的 cache-coherent 行为又能掩盖驱动本身的疏漏。换句话说,x86 不是没有错,而是错得不明显。
所以有一种说法是:“x86 上没问题的 DMA 代码,不能保证正确;但 ARM 上没问题的 DMA 代码,大概率在 x86 上也没问题。”这句话是有道理的,因为 ARM 少了硬件兜底,驱动如果能在 ARM 上稳定运行,往往意味着代码已经做好了必要的 cache 同步和屏障。
5.2 RK3588 上“failed to reset the dma”是怎么来的
可能有读者好奇这个报错本身。rk3588eth failed to reset the dma实际上是 RK3588 平台上以太网 DMA 控制器相关报错。它通常意味着驱动在软复位 DMA 时,DMA 引擎没能响应复位完成信号。这个问题的诱因,往往是 DMA 描述符仍处于活跃状态,或者 DMA 正在搬运的数据还没落定,就强制发起 reset。迁移代码中,如果复位逻辑没有先检查 DMA 空闲状态、没有等足够长的超时,就可能在弱内存模型的平台上提前下发复位指令,引发这种错误。
处理思路是:复位前确保 DMA 引擎已经停止,必要时轮询状态寄存器直到 idle,再发 reset;如果之后仍然报错,再检查 DMA 时钟、电源域配置是否完整。
5.3 “DMA 通道详解”类代码里常被忽略的 bug
热词里出现了“bat32mcu 的 DMA 通道详解以及 bug”“spi 需要两个 DMA 吗”“GD32E230 ADC DMA 数据紊乱”等,这些都是典型 MCU/DSP 场景的 DMA 问题。它们背后的核心逻辑,和 ARM64 平台上是相通的。
像 GD32E230 ADC DMA 数据紊乱,常见原因是 ADC 转换完成事件和 DMA 请求的触发时机匹配不当,缓冲区长度和 DMA 传输长度不一致,或转换数据宽度配置为半字/字节时,DMA 通道配置没有同步调整——本质还是描述符/配置和实际传输不匹配。ST 系 MCU 的 SPI DMA 也是同理:发送用 DMA、接收用 DMA,再加上中断处理,一旦 RX 和 TX 的 DMA 通道使用同一优先级或未关闭不必要的 FIFO,就可能出现字节错位。
这些 MCU 级问题虽然和 AI Infra 的服务器级场景不同,但从 DMA 本质来看,仍然是地址、宽度、长度、时序四件套的博弈。你在 ARM 平台上排查的思路,迁移到 MCU 上一样适用。
6. 一份可以直接“抄作业”的跨平台 DMA 开发检查清单
这一节我总结一下这些年做跨平台 DMA 移植和排错时反复使用的一份检查清单。我把它贴在这里,读者可以直接复制成自己团队的 code review 模板。
缓冲区分配
- 使用
dma_alloc_coherent或dma_pool,尽量避免kmalloc+virt_to_phys的方式。 - 如果必须用流式映射,确保
dma_map_single/dma_unmap_single严格配对,方向参数真实反映读写方向。 - 检查缓冲区对齐是否满足 cache line 大小,必要时
ALIGN。 - 设置合理的 DMA mask,确认设备地址位宽和实际分配地址匹配。
描述符提交顺序
- 描述符字段全部就绪后,必须使用
dma_wmb()或更强的屏障。 - 触发 DMA 启动的寄存器写操作,应放在所有描述符写入完成之后。
- 多队列场景下,每个队列的启动/停止操作也要设屏障,避免跨队列串扰。
完成中断处理
- 读取 DMA 完成标志/状态寄存器前,使用
rmb()/dma_rmb()。 - 读取 DMA 写入的数据前,调用
dma_sync_single_for_cpu或依赖dma_alloc_coherent的一致性保证。 - 如果使用
dma_unmap_single,注意在unmap之后再访问数据,还是先访问再unmap的次序,不同场景要求不同,别只抄一种模板。
Reset 与时序
- 任何 DMA 复位操作前,先确认 DMA 引擎已进入空闲状态,必要时轮询状态寄存器并设置超时。
- 软复位、时钟切换、电源门控之间留足延迟,不要依赖 x86 上那种“写完立刻生效”的假象。
- 如果平台支持 IOMMU/SMMU,确认设备访问经过 IOMMU 后的地址域是否和代码预期一致。
通用建议
- 遇到随机坏数据,优先怀疑 cache 一致性,其次内存屏障,再次地址对齐,最次才是硬件本身。
- 在同一块板上用“暴力二分法”——全改 coherent 分配,或全加屏障——去验证假设,是效率最高的调试法。
- 收尾前跑一轮内存压力测试和长时间稳定性测试,没有这两个测试的 DMA 代码,不能算发布就绪。
7. 结语:别再心疼那几行“多余”的屏障和映射
从 x86 到 ARM/RISC-V,最让我感慨的不是技术细节本身,而是思维方式的差异。写 x86 平台代码久了,容易形成“我写的就是对的”的错觉,因为平台用自己的硬件容错替你遮了丑。换到不带这些兜底机制的新平台,所有欠下的技术债集中兑现,表现为随机坏数据、偶发复位失败、神秘的描述符错乱。
面对这种问题,我的建议很简单:不要试图用 x86 的思维方式在 ARM 上写 DMA 代码。多花几分钟调用标准 API,多写两行内存屏障,多检查一次地址对齐,是在省未来的排查时间。宁可现在多写几行“看似冗余”的代码,也别在线上环境里面对随机坏数据通宵流泪。
我个人的处理习惯是:每次提交 DMA 相关代码前,强制自己走一遍这份检查清单,再找一位同事做一次 mirror-review。说真的,做过几十次之后,我现在看到一段没有dma_map_single就直接搬数据的驱动代码,第一反应不是“这是 bug”,而是“这人的平台还没换过,等他换了就懂了”。希望你看完这篇之后,不用亲自等那个“换了”的时刻才明白这个道理。