
做 AI Infra 的人迟早会被 DMA 上一课。这个 Day 12 的问题说起来特别典型一套在 x86 服务器上跑了很久的 DMA 驱动代码一行没改交叉编译到 RK3588 这类 ARM SoC 上数据就开始随机坏。不是每次都不对是那种跑十分钟出一次、校验和偶尔对不上的随机坏最磨人。今天就把这个问题的排查思路、底层原因和最终修复一次性讲透。这篇内容适合做 AI 基础设施底层、异构计算、驱动移植的读者。你不需要是 DMA 专家但如果你接手过“换平台就挂”的代码应该能从中找到共鸣。我会按自己的定位习惯来拆先复现再理解平台差异然后逐项排查根因最后给出可落地的编码清单。1. 问题现象与定位思路1.1 现象x86 正常ARM 上随机坏数据先描述一下我遇到的具体现象。x86 平台上同一段 DMA 环形缓冲区驱动跑高速数据流十几个小时校验和完全正确。换到 RK3588 之后同样的 Linux 内核、同样的设备树、同样的驱动源码数据开始不定时出错。出错的表现有几种收到的数据包里某几个字节被改掉但长度和大部分内容都正常一块完整的 4KB 数据中间混入了一小段上一个包的内容DMA 描述符被硬件写回的状态字段偶尔出现非法值设备直接卡住严重时整个 DMA ring 的内存被破坏驱动报 “failed to reset the dma” 之后才恢复。这些坏数据的概率很低有时候跑半小时才出现一次而且每次出错位置都不同。最坑的是用单步调试的时候它不出现一放开全速跑就会出现。1.2 先用最小复现锁定范围遇到这种问题我第一步不是去猜平台差异而是先复现并缩小范围。我习惯按这个顺序做关掉 CPU 调频、关闭多核调度的干扰把中断固定到某个核心把 DMA 搬运长度固定到一个值比如 512 字节循环搬运在 DMA 完成中断里对缓冲区做 pattern 校验出错了就 dump 完整现场把 Linux 内核的CONFIG_DMA_API_DEBUG打开它会帮我们检查 DMA API 使用是否合法。如果dmatest模块在 RK3588 上跑也出错那问题大概率在平台 DMA 控制器配置或者内核驱动对 DMA engine 的使用约定上如果dmatest完全正常问题就在我们自己的驱动里。我那次的结果是dmatest正常自带驱动出错。这就很明确了代码里一定存在某种依赖 x86 平台“运气”才能跑对的写法。2. DMA 在两种平台上的底层差异2.1 DMA 基本工作流程回顾先把 DMA 的工作方式用大白话过一遍。DMA 搬运数据时CPU 并不负责逐字节拷贝而是给 DMA 引擎下发一个“工单”这个工单在硬件层面通常叫描述符descriptor。描述符里一般包含几项关键信息源地址数据从哪个地址读出来目的地址数据写到哪个地址去长度需要搬运多少字节控制标志这次搬运要不要中断、要不要做地址累加状态标志硬件完成后写回的状态比如成功、失败、还有多少字节没搬完。CPU 把所有描述符放在一块内存里形成一个环形队列然后用一个“尾指针”告诉 DMA 引擎“新工单准备好了”。DMA 引擎按顺序消费描述符搬运完数据后触发中断通知 CPU。问题在于CPU 和 DMA 引擎是两个独立的执行单元它们同时在访问同一块内存而且谁先谁后不是绝对确定的。x86 能帮你遮住大部分顺序问题ARM 则直接把问题暴露出来。2.2 x86 上为什么不容易出问题x86 在 DMA 相关场景里确实更“省心”。原因有几个第一x86 的 PCIe 控制器和 CPU 之间的内存一致性协议相对成熟。大多数 x86 服务器平台在硬件层面提供了较强的 cache 一致性支持设备访问内存时硬件会做很多缓存同步工作。驱动即使没有显式做 cache flush很多情况下也能碰巧跑对。第二x86 采用强内存模型。CPU 写入内存的顺序在外部观察者看来基本和程序顺序一致。也就是说驱动先写描述符、再写 tail 指针DMA 引擎大概率能看到描述符先到、tail 后到顺序不会倒挂。第三很多 x86 平台的 IOMMU 默认关闭或者使用 identity mapping物理地址和总线地址一致。驱动如果把virt_to_phys()拿到的地址直接塞给设备也不会出错。这些“省心”其实掩盖了大量隐患。换到 ARM 平台同一个驱动就可能变成不定时炸弹。2.3 ARM 上为什么问题暴露ARM 平台和 x86 有几个显著差异正好会触发 DMA 驱动的隐藏 bug。最核心的是内存模型。ARM 是弱内存模型CPU 写指令 A、再写指令 B硬件不保证外部观察者看到 A 先于 B。打个比方你往一条传送带上放了两封信x86 的传送带会严格按顺序送到对面ARM 的传送带却可能把第二封信先送过去。DMA 驱动最怕这种情况。因为驱动通常是先写描述符内容再更新 tail 指针通知 DMA 引擎。在弱内存模型下DMA 引擎可能先看到 tail 被更新然后回头读描述符内容结果读到的是还没写完整的旧数据SDRAM 里自然就是坏数据。其次是 cache 一致性。DMA 引擎访问内存通常直接走总线不经过 CPU 的 cache。如果 CPU 刚写入的数据还停在 cache line 里没被清出去DMA 引擎从内存读到的就是旧数据。反过来DMA 写完内存后CPU 再访问同一地址可能读到 cache 里残留的旧数据。这个在 x86 上很多平台靠硬件保证ARM 上则必须由驱动显式处理。再加上 SMMU/IOMMU 的存在。ARM 平台设备访问内存时发起的地址是经过 SMMU 翻译的总线地址DMA address和 CPU 物理地址不是一回事。如果驱动把物理地址直接给设备设备可能访问到一块完全无关的内存坏数据就产生了。这三个差异叠加起来就是“x86 好好的换平台随机坏数据”的根本背景。3. 随机坏数据的五大根因与排查方法3.1 根因一DMA 缓冲区 Cache 一致性没处理好缓存一致性问题是最常见的根因几乎每个从 x86 移植到 ARM 的 DMA 驱动都会碰到。具体来说有两种情况DMA 读内存时CPU 之前写入的数据还留在 cache 里没有被写回。DMA 从内存拿到的是旧数据DMA 写内存后CPU 再读同一地址时hit 到 cache line 里的旧数据看不到 DMA 的新结果。我见过一个典型案例驱动用kmalloc()分配数据缓冲然后把虚拟地址写进描述符DMA 完成后CPU 直接访问这块内存数据偶尔不对。原因就是kmalloc()返回的是常规的可缓存内存DMA 引擎绕过 cache 读写后CPU cache 里的数据已经过期了。正确做法是区分两种内存类型一致性内存coherent memory用dma_alloc_coherent()分配这类内存在 DMA 和 CPU 之间天然保持一致但 CPU 访问速度可能稍慢流式内存streaming memory用dma_map_single()映射在每次 DMA 前后手动做dma_sync_single_for_device()和dma_sync_single_for_cpu()。建议描述符环形缓冲坚决用dma_alloc_coherent()数据缓冲可以根据性能要求选择流式映射加手动同步。我在 x86 上见过有人直接用kmalloc加flush也能跑但到 ARM 上就露馅。3.2 根因二描述符读写顺序缺少内存屏障这个根因非常隐蔽因为它不是每次都错而是取决于 DMA 引擎正好在哪个时间点去读描述符。标准流程是desc-src src_dma; desc-dst dst_dma; desc-len len; desc-flags FLAG_VALID; // 这里是关键必须保证上面的写入先被 DMA 看到 wmb(); // 或者 dma_wmb() ring-tail (ring-tail 1) % ring_size;在 x86 上即使不写wmb()由于强内存模型大概率没问题。但 ARM 上DMA 引擎可能先看到ring-tail被更新然后立刻去读desc-flags结果读到的是旧值它可能认为描述符非法或者读到一个半新半旧的状态。反过来也有问题。DMA 完成后驱动在中断里读取硬件写回的状态if (desc-status STATUS_DONE) { dma_rmb(); process_data(desc-buf); }如果没有dma_rmb()CPU 可能在看到 status 被改写之前先读取了缓冲区里的数据拿到的是上一轮的老数据。我修复这类问题时统一使用 Linux 内核提供的 DMA 屏障dma_wmb()适合 DMA 前保证描述符内容先于 taildma_rmb()适合 DMA 后保证 status 先于数据被 CPU 读取如果既需要写屏障又需要读屏障直接上dmb()或mb()。不要小看这一行很多随机坏数据问题加完屏障就再没出现过。3.3 根因三物理地址 vs 总线地址搞混了第三个根因是地址空间混淆。驱动的本意是告诉 DMA 引擎“去哪里搬数据”但设备实际访问的是总线地址不是 CPU 物理地址。在 x86 平台很多服务器没有开 IOMMU设备看内存就是物理地址物理地址等于总线地址。驱动如果用virt_to_phys()转换地址给设备碰巧能工作。但在 ARM 平台上SMMU 几乎都是开启的设备发起的地址会经过 SMMU 翻译如果你把物理地址传给设备设备访问的会被翻译到别的地址去。正确的做法是dma_addr_t dma_handle; void *cpu_addr; cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); // dma_handle 才是应该写进描述符的地址如果是流式映射dma_addr_t dma_addr; dma_addr dma_map_single(dev, cpu_ptr, len, DMA_FROM_DEVICE); // 描述符里写 dma_addr // DMA 完成后 dma_unmap_single(dev, dma_addr, len, DMA_FROM_DEVICE);此外还要注意dma_set_mask_and_coherent()的返回值。x86 上哪怕没检查DMA 掩码默认可能兼容ARM 上如果设备支持 32 位地址但内存超过 4GB没有配置 mask 会导致 DMA 地址分配失败或落到 bounce buffer间接引发问题。排查时重点看两点描述符里的地址是不是来自 DMA API 返回的dma_addr_tdma_alloc_coherent或dma_map_single的返回值有没有被错误截断。3.4 根因四分配类型和生命周期没控制好这部分属于“分配方式对了但释放时机不对”的典型。先说分配类型。x86 平台内存充足很多驱动习惯用kmalloc()分配描述符数组因为连续虚拟地址容易管理。但这种内存不一定保证物理连续。如果 DMA 引擎不支持 scatter-gather而你的描述符跨了多个物理页硬件可能只访问到第一段或者访问到错误位置。ARM 平台更敏感。某些 SoC 的 DMA 控制器要求描述符所在内存是连续的、甚至对齐到 64 字节。如果只用kmalloc()运气好时连续运气差时跨页随机坏数据就来了。描述符应该用dma_alloc_coherent()一次性分配一整块连续内存再把这块内存拆成多个描述符不要依赖虚拟地址连续假装物理连续。生命周期问题同样值得注意。DMA 是一种异步操作驱动把请求提交后硬件还在搬数据CPU 可能已经去处理别的事情。如果驱动在中断还没来之前就释放并重用了缓冲区DMA 引擎会继续往旧地址写数据这段内存可能已经被分配给别的模块造成随机坏数据。排查时打开CONFIG_SLUB_DEBUG和CONFIG_DEBUG_OBJECTS观察是否是 buffer 被提前释放。我自己遇到过一个案例驱动做了dma_unmap_single()之后数据还在 DMA 引擎的 FIFO 里没写完结果下一次分配复用同一块内存拿到旧数据。最后修法是等 DMA 状态机完全空闲后再 unmap问题才消失。3.5 根因五对齐和 burst 长度限制最后一个根因比较“硬”跟平台控制器强相关。很多 DMA 控制器对源地址、目的地址、传输长度有对齐要求。比如要求源地址、目的地址按 4 字节对齐也有的要求按 64 字节对齐跟 AXI bus 的 burst 配置有关。x86 的 PCIe 设备通常能容忍非对齐访问内部会帮你拆成多次传输但 ARM SoC 内置的 DMA 控制器可能就直接要求全对齐。我在 RK3588 上遇到过一个问题数据缓冲头部没有做对齐偏移了几个字节导致 DMA 从非对齐地址开始读取偶尔会出现半个 AXI burst 数据丢失表现出来就是某一小段数据全是 0xFF。另外 burst length 配置也有讲究。如果配置的 burst 长度超过设备 FIFO 能力硬件可能溢出并丢弃部分数据。这类问题比较靠硬件特性排查时建议参考 SoC 的 TRMTechnical Reference Manual确认 DMA 控制器的对齐约束和 burst 上限。排查方法很简单把缓冲区地址和长度都打印出来检查是否满足对齐要求。如果用户态传入的指针天然不保证对齐驱动层要自己做一个对齐的 bounce buffer不能直接把不齐的 buffer 丢给 DMA。4. 实操中的定位工具与复现技巧4.1 让随机问题变确定数据校验与循环压力“随机坏数据”最怕随机所以想办法把它变成确定性问题。我的做法是写一个最小压力程序申请一块 DMA 缓冲区填充固定 pattern然后让 DMA 在设备内部做回环或从一个数据源反复拷贝到缓冲区每完成一次就用crc32或简单的xor校验。出错后不要马上停先把出错附近的完整数据、描述符状态、tail 值都记录下来。如果设备本身不支持回环可以在驱动里构造一个“假 DMA”路径不经过硬件只验证 CPU 端的描述符提交和中断处理逻辑。这个路径能帮你区分问题到底出在硬件线路上还是出在驱动和 DMA engine 的交互上。我现场排查的时候会把压力次数从 1000 次拉到 10 万次直到能稳定复现。复现率提高之后再逐个打开和关闭平台特性SMMU 开/关、CPU 调频开/关、多核调度开/关观察出错概率变化就能精准定位影响因素。4.2 内核侧排查手段DMA API 调试、ftrace、IOMMU debugLinux 内核自带的调试工具非常值得依赖。我重点推荐这几个CONFIG_DMA_API_DEBUG这个选项打开后内核会检查 DMA API 的调用包括是否 double map、是否 unmap 了错误地址、是否跨页等。很多随机坏数据其实是 API 用错打开后系统会直接打印 warning。dmatest内核模块这是 DMA engine 子系统的自测工具可以用来验证平台 DMA 通道本身是否健康。如果它都出错基本可以排除驱动层直接查硬件或设备树。ftrace/kprobe用来追踪dma_alloc_coherent、dma_map_single、dma_unmap_single的调用路径确认每次映射的虚拟地址、DMA 地址和长度是否符合预期。IOMMU/SMMU debugfs在/sys/kernel/debug/下查看设备的 IOVA 映射区间确认设备能不能看到 DMA 地址。如果驱动写进去的是物理地址这里能明显看到不对。另外用trace-cmd record -e dma:* -e iommu:*抓一次完整的 DMA 事件流再配合trace-cmd report查看每个描述符提交和完成时间点能很快发现地址或顺序异常。4.3 硬件层的分析逻辑分析仪和总线抓包如果软件层排查完还是一头雾水那就需要考虑硬件层了。ARM SoC 内部通常是 AXI/AHB 总线DMA 控制器通过总线访问内存。有些开发板或仿真环境允许你外接逻辑分析仪抓取 DMA 发出的总线读写请求。这个过程虽然繁琐但能直接看到设备实际访问的地址、长度和 burst 类型。抓包时重点确认三个信息设备访问的地址是否落在预期的 DMA 缓冲区内地址是否跨越了 4KB 页边界burst 长度是否超过了设备 FIFO 配置。如果设备实际访问的地址和驱动想的不一样前面提的物理地址 vs 总线地址问题基本跑不掉。我在 RK3588 上排查 eth 驱动 DMA 问题时就靠抓总线信号发现 reset 期间还残留一次未完成的写请求导致后续 DMA 状态错乱最终表现为failed to reset the dma。5. 修复要点与编码规范5.1 最小改动方案先保证正确再谈性能如果你的代码已经比较成熟只为了快速移植到 ARM 平台最小改动方案我建议按三步走描述符内存全部改成dma_alloc_coherent()分配不要在描述符上省性能数据缓冲使用dma_map_single() 每次 DMA 前后的dma_sync_single_for_device()/dma_sync_single_for_cpu()在更新 tail 之前加dma_wmb()在读取完成状态之后加dma_rmb()。这里强调一点不要为了“避免 cache flush 开销”而省略 sync。ARM 上省略了 sync性能可能看起来没变化但坏数据风险会直线上升。先把正确性保住再考虑用dma_alloc_coherent和流式映射的混合策略来调优。5.2 可迁移的 DMA 编码清单我自己整理了一份 DMA 驱动移植 checklist每次新平台适配都逐条过所有传给设备的地址必须来自 DMA API禁止把virt_to_phys()的结果直接交给设备描述符内存和数据内存分开管理描述符用 consistency 更强的dma_alloc_coherentDMA 提交前更新完描述符后必须调用dma_wmb()再更新 tailDMA 完成后确认状态位后必须调用dma_rmb()再读取数据DMA mask 必须设置并检查返回值dma_set_mask_and_coherent()失败要返回错误每个dma_map_single()必须配对dma_unmap_single()尽量缩短 map 和 unmap 之间的生命周期如果设备复位或停止要等 DMA 状态机回到 idle 再释放内存不能直接 reset确认硬件对齐要求必要时在驱动里做 bounce buffer用CONFIG_DMA_API_DEBUG跑一轮完整测试确保没有任何 DMA API warning。5.3 性能权衡一致性内存 vs 流式映射不要一提dma_alloc_coherent就觉得性能差。对于数据量小、频率高的描述符来说一致性内存非常合适因为省去了每次手动 sync 的开销。数据缓冲区如果很大流式映射配合一次 sync 的开销在大多数场景下可以接受。x86 上很多驱动花费大量精力调优但换到 ARM 之后最危险的不是性能慢而是“看起来快但数据是错的”。我宁可先用一致性内存把功能跑通再用内核 perf 工具看热点决定哪些缓冲区改成流式映射优化。6. 常见问题速查表问题现象可能根因解决动作数据偶尔错几个字节无规律Cache 一致性使用dma_alloc_coherent或显式 sync描述符被硬件读成旧值/半新半旧缺少写屏障更新 tail 前调用dma_wmb()DMA 完成中断里读到旧数据缺读屏障读取 status 后调用dma_rmb()设备访问的地址错误物理地址被当成总线地址使用dma_map_single/dma_alloc_coherent的返回地址CONFIG_DMA_API_DEBUG报 mismatchmap/unmap 配对错误检查所有错误路径是否提前 unmapfailed to reset the dmareset 时序没等状态机停止先确认 idle再执行 reset加 barrier缓冲区跨页后数据乱物理不连续分配保证物理连续或用 IOMMU/SMMU 映射非对齐地址导致部分数据假错控制器对齐要求在驱动里做对齐 bounce bufferburst 后数据溢出burst 配置超过 FIFO查 TRM降低 burst length压力测试偶尔触发单步不触发弱内存序竞争全局检查屏障缺失和竞争条件最后再分享一个小技巧。做平台适配时别急着在主业务路径里找 bug。先写一个最小的 DMA 自环测试把描述符、数据缓冲、中断处理全部独立出来在这个小测试里把上述五项根因都过一遍。这个测试如果能在新平台上稳定跑 10 万次不出错再合回主业务后面能省下大把排查时间。我自己现在每次移植 DMA 相关代码都会先默认“ARM 上的顺序和 cache 一定有问题”然后逐行检查驱动里的地址、顺序、生命周期。这种心态虽然保守但确实能提前发现很多隐藏雷区避免被随机坏数据折磨到大半夜。