
同一段 DMA 代码x86 上稳如老狗换个 ARM 平台就时不时给你坏几笔数据这种问题在 AI Infra 的异构基础设施改造里太常见了。我们当时在做的是统一数据通路给后端存储和网络加速用的x86 服务器上跑了几个月TSAN、KASAN 都扫过干净。后来要把同一套逻辑搬到 ARM 节点顺便让智能网卡上的轻核也复用结果噩梦开始了大流量下随机出现 payload 错乱不是在收包路径而是在我们自己管理的 DMA ring 搬运链路里。最邪门的是错的不是整个包是包中间某几个字节变成旧页面的内容像被幽灵改过一样。这篇文章就用这个 case 做引子把 DMA 在 x86/ARM 上行为不一致的根本原因、排查路线、标准修法完整拆一遍。如果你正在做驱动移植、DPDK/SPDK 跨平台适配或者嵌入式 Linux 的数据搬运开发建议把“内存序”“缓存一致”这两个关键词刻在脑子里早晚用得上。1. 先还原现场同一段 DMA 代码到底差在哪1.1 再捋一遍 DMA 的完整生命周期无论 x86 还是 ARMDMA 的工作流程其实都一样一般分四步第一步分配一块能被 DMA 引擎访问的内存拿到物理地址第二步把这笔传输的信息——源地址、目标地址、长度、方向、完成标志——写进描述符Descriptor描述符本身也放在内存里第三步按引擎要求的格式把描述符地址写进控制寄存器或者通过门铃Doorbell通知 DMA 控制器开工第四步DMA 控制器自己搬运数据搬完写一个完成标志或者发中断CPU 接着处理后续逻辑。为了性能多数高端网卡和存储控制器用的是描述符环Descriptor Ring一段固定大小的环形区域每个槽位里放着一条描述符。驱动在生产侧往 ring 里填描述符DMA 引擎在消费侧按顺序取走执行执行完再回写 owner 位。整个设计朴素高效但它的正确性高度依赖一个前提——CPU 写入描述符的顺序必须和 DMA 引擎看到的顺序一致。举个例子帮你建立画面感。想象你是一个仓库主管CPU旁边有个快递员DMA你只需要把一百箱货的配送单子写好放在前台桌子上描述符按一下呼叫铃门铃快递员自己拿着单子去装车、送货。送完他把回执盖个章放桌面owner 位回写再打个电话通知你中断。整个过程CPU 不参与搬运效率很高。但这里有个致命假设你放单子的时候快递员默认“单子上的所有信息都是完整、最新的”。在 x86 上这个假设基本成立换到 ARM 平台它就不成立了。为什么下面这两节是全文的理论核心值得多看两遍。1.2 x86 的强内存序是一把“保护伞”x86 的 CPU 从很早开始就保持了一个非常强力的内存序模型叫 TSOTotal Store Order。它保证普通写指令按顺序对外可见CPU 写地址 A再写地址 B其他观察者包括 DMA 引擎看到 B 的时候A 一定也已经是新值了。这对 DMA 驱动意味着什么意味着你往描述符里填地址、长度、标志字段时硬件天然能看到完整的新值不需要你做任何额外动作。另外还有一个被很多人忽略的点x86 平台上的 DMA 访问在绝大多数 PCIe 场景下由硬件保证了 cache coherence。也就是说CPU 写过的 cache 行会在 DMA 读取前自动回写DMA 写过的行在 CPU 读的时候也会自动失效。驱动里把描述符 ring 当成普通系统内存来用基本不会踩缓存坑。这种“隐藏保护”让很多驱动开发者在 x86 上养成了大胆的习惯直接 kmalloc 一段内存当 DMA 缓冲直接 memset 描述符写完就通知硬件。在 x86 上这些写法确实大多能工作。但架构迁移之后保护伞没了问题才集中爆发。说白了x86 不是“标准答案”它只是替你兜底了很多“你忘了写”的细节。1.3 ARM 的弱内存序和非一致缓存才是真实世界ARM 架构这里主要指 ARMv7-A、ARMv8-A 这类应用处理器采用的是弱内存序Weak Memory Ordering。CPU 可以在程序顺序之外重排普通内存访问编译器在优化时也不保证源代码顺序对应最终机器码顺序。再加上很多 SoC 的 DMA 控制器没有把自己的访问接入缓存一致性协议——也就是 non-coherent DMA。两个变化叠加结果就很刺激第一你往描述符里写字段时编译器可能把你对 owner 位的写提前导致 DMA 引擎在 addr、len 还没写好的时候就拿去执行第二DMA 往内存写数据时数据可能直接落在 DRAM但 CPU 的 cache 里还留着同一地址的旧数据之后 CPU 读到的还是旧值第三CPU 读描述符的完成标志可能已经拿到新值了但后续再读描述符里的其他字段又读到了旧数据。这三个问题在 x86 上几乎不需要显式处理到了 ARM 上全部要显式化——这就是“同一个 DMA 代码坏数据随机出现”的根子。提示理解“强序/弱序”可以用一个餐厅比喻。x86 像一家规矩严格的餐厅服务员必须按点单的顺序上菜ARM 像一家追求效率的快餐厅厨师可以先做耗时长的菜只要最后顾客那桌的菜齐了就行。但对“顾客”DMA 引擎来说先上的菜可能不完整——它可能先拿到标注最后写完的“汤”结果其他“菜”还没下锅。2. 三个最常见根因屏障缺失、缓存不一致、对齐与内存属性2.1 根因一描述符的“最后写 word”顺序被重排先看一个典型的 TX 描述符初始化代码很多驱动里长这样struct tx_desc { uint32_t addr; uint32_t len; uint32_t flags; uint32_t owner; /* 1 hardware owns this desc */ }; void prep_tx_desc(struct tx_desc *desc, dma_addr_t addr, uint32_t len) { desc-addr addr; desc-len len; desc-flags TX_FIFO | TX_LAST; desc-owner 1; /* 必须最后写 */ }在 x86 上这段代码的正确性没有任何悬念CPU 按顺序写入四条 storeDMA 引擎读 owner 位为 1 时前面的 addr、len、flags 早就可见了。但在 ARM 弱序平台上有两种风险一是编译器优化把 store 重排比如先写 owner 再写 len二是 CPU 乱序执行store buffer 还没刷出去owner 已经写到内存但 addr 还在 store buffer 里排队。无论哪种DMA 引擎看到 owner1 后立刻去读 addr 和 len都可能读到旧值或者全零然后搬运了错误地址的数据。修复方式是在写 owner 位之前加一个“写屏障”保证前面所有 store 对 DMA 控制器可见后owner 位的写才能发生void prep_tx_desc(struct tx_desc *desc, dma_addr_t addr, uint32_t len) { desc-addr addr; desc-len len; desc-flags TX_FIFO | TX_LAST; dma_wmb(); /* 保证上面三行对 DMA 可见后再写 owner */ WRITE_ONCE(desc-owner, 1); }Linux 内核里 dma_wmb() 在不同架构下实现不同x86 上它经常退化成空操作因为强序ARM 上会展开成 dsb(ishst) 或 dmb(ishst)。这恰好解释了为什么 x86 不写也正常ARM 不写就翻车——你缺的屏障在 x86 上是 no-op所以写不写你都发现不了。读取侧同理DMA 引擎回写 owner 表示完成CPU 在中断处理里看到 owner1 后需要再补一个 dma_rmb()确保描述符里其他数据完整读入if (READ_ONCE(desc-owner) ! 1) return; dma_rmb(); /* 此时再读 desc-addr / len / status 都是完成后的新值 */注意WRITE_ONCE/READ_ONCE 解决的是编译器重排和撕裂读写内存屏障解决的是 CPU 乱序和可见性二者缺一不可。2.2 根因二CPU 缓存里的旧数据DMA 回写不进去这个更隐蔽。DMA 要把数据从网卡搬到内存假设驱动分配了一个普通内存页当 RX bufferstruct page *page alloc_page(GFP_KERNEL); dma_addr dma_map_page(dev, page, 0, PAGE_SIZE, DMA_FROM_DEVICE);在 x86 平台上dma_map_page 基本不会实际做太多事情因为硬件缓存一致DMA 写入的数据 CPU 直接读就能看到。但在 non-coherent 的 ARM 平台上dma_map_page 必须做缓存维护DMA_FROM_DEVICE 方向会 invalidate 对应 cache 行DMA_TO_DEVICE 方向会 clean 回写。如果驱动没有正确调用 dma_map/dma_unmap或者图省事绕过了 DMA API 直接把物理地址给了硬件就会出现经典症状CPU cache 里保存着这个页面的旧数据比如上次收到的包DMA 控制器把新数据写到了物理 DRAM 里CPU 之后读内存命中的还是 cache 里的旧页面于是“坏数据”出现——而且往往是一段新的、一段旧的混杂酷似内存内容被随机改动。这类问题最典型的现象是错的数据其实不是垃圾而是某个老包的内容时间戳、序号都对不上有时甚至能还原出几个包之前的数据。我在实际排查中见过最迷惑的一个案例就是收包线程读到的 payload 里混着三个不同包的片段乍一看以为是内存被踩最后发现是 cache 一致性没维护。修复的核心是统一使用 DMA API 管理缓冲生命周期struct page *page alloc_page(GFP_KERNEL); dma_addr dma_map_page(dev, page, 0, PAGE_SIZE, DMA_FROM_DEVICE); /* ... 提交给硬件 ... 等中断完成 ... */ dma_unmap_page(dev, dma_addr, PAGE_SIZE, DMA_FROM_DEVICE); /* 此时再从 page 里读取数据保证看到的是 DMA 写完后的值 */描述符 ring 本身也不要图省事用 kmalloc 加默认映射。Linux 推荐用 dma_alloc_coherent() 分配它能保证 CPU 侧和 DMA 侧看到同一个一致视图。在 x86 上它往往退回普通内存但在 ARM 上内核会自动选择 non-cached 或带维护的映射方式struct tx_desc *ring; dma_addr_t ring_dma; ring dma_alloc_coherent(dev, RING_SIZE, ring_dma, GFP_KERNEL);很多嵌入式平台的 DMA 错误比如部分 RK3588 平台在网络驱动里报 failed to reset the dma实际场景里就见过是描述符 ring 用错了内存类型或者 DMA 引擎在复位时访问到了一块尚未就绪的 memory。这类问题在 x86 上基本不会出现一到 ARM SoC 上就密集暴露。当然reset 失败还可能混合了软件时序问题但优先级最高的检查项一定是内存属性和映射方式。2.3 根因三对齐要求、内存属性和 IOMMU 行为差异第三个坑是平台对 DMA 内存属性与对齐要求不一致。x86 上的 PCIe 设备普遍对描述符和缓冲区对齐比较宽容4 字节对齐基本都能跑。ARM 平台上的 SoC 内部 DMA 控制器比如 RK3588 的 GMAC、STM32 的 DMA 控制器、各种 AI 加速器的片内 DMA往往有非常严格的要求描述符必须按 16、32 甚至 64 字节对齐每笔传输的地址和长度必须按突发长度burst对齐内存属性要匹配设备树里的 dma-coherent 声明。如果设备树写了 dma-coherent说明这个设备访问内存是缓存一致的驱动不需要额外做 cache 维护反过来如果没写说明 DMA 不会自动维持一致必须做缓存维护。这点写错轻则性能下降重则随机坏数据。设备树示例gmac { dma-coherent; /* 表示该设备 DMA 是缓存一致的 */ ... };还有 IOMMU/SMMU 的差异。x86 上的 VT-d 可以把分散内存映射成连续的 IOVAARM 平台的 SMMU 同样能但默认行为、页表项对 cache 属性的影响各不相同。如果你的驱动假设 IOMMU 一定存在或一定不存在跨平台就可能出问题在 x86 上 IOMMU 开启后一切正常在 ARM 上没配 SMMU 属性导致 DMA 访问了错误物理地址也会被误判成“内存损坏”。甚至有些平台的 DMA 引擎不是完全 64 位地址安全x86 上 IOMMU 帮你做了重映射ARM 上如果 SMMU 没使能高 32 位地址被裁剪表现同样是随机坏数据。这块没有捷径拿到平台 memory map 和 DMA 控制器的 TRM 逐项核对才是正路。3. 实操排查从“随机坏数据”定位到根因3.1 稳定复现随机问题要人为制造“确定性”随机 bug 最怕不好复现。我的做法是三步。第一步压大流量。坏数据概率往往和数据量线性相关把多队列全开、用大包压链路把现象概率从“偶尔一次”变成“几分钟一次”。第二步缩 batching。如果驱动有批量提交描述符的功能把一次提交从 64 条减到 1 条。批量提交会掩盖描述符时序问题因为 CPU 连续写入多个描述符时中间自然会有较长的时间间隔即使有重排也可能被某种串行化掩盖单条提交时屏障缺失会被瞬间放大。第三步两侧对拍。用一台 x86 机器做对端向 ARM 板卡发带标记的 payload比如 payload 里带上递增序号和 CRC这样坏数据发生时能立刻判断是“版本旧”还是“内容损坏”。这套方法看着简单但实际卡住的人很多。大家习惯性地一头扎进代码审查翻来覆去找“谁踩了内存”却忘了先回答两个基本问题坏数据是 DMA 写入方向的问题还是 CPU 读取方向的问题两种问题对应的修法完全不同不分清楚就乱改代码大概率是把问题从一个位置挪到另一个位置。3.2 分阶段打点判定是写侧问题还是读侧问题拿到稳定复现后用二分法把问题分成两段DMA 引擎从描述符拿到的参数对不对DMA 写回的数据 CPU 读到没有。先验证第一段。在 prep_tx_desc 里写完 owner 之前把 addr、len、flags 打出来再在中断处理或者回读时比对。如果发现 owner1 时 addr、len 还是旧值那就是屏障缺失直接加 dma_wmb() 试一下。这个验证不需要任何高深工具printk 加耐心就够了。第二段验证缓存一致。在收包中断里把 DMA 刚写的数据和 CPU cache 中的数据做校验或者在 dma_unmap_page 前后各打印一次数据内容。如果 unmap 前读是坏的、unmap 后读是好的那基本 100% 是缓存一致性问题。如果你的环境跑的是 Linux强烈建议打开 DMA API debugCONFIG_DMA_API_DEBUGy CONFIG_DMA_API_DEBUG_SGy它会在 dma_map/unmap 误用时打出大量有效提示比如 map/unmap 次数不匹配、方向错误、越界访问等。x86 上这些错误可能被各种硬件机制掩盖DMA API debug 在 ARM 上往往直接抓到现场。3.3 关键修复示例一版跨平台安全的描述符提交把上面几类问题综合成一个完整的修法代码长这样static void submit_tx(struct net_device *ndev, struct sk_buff *skb) { struct xdev *xdev netdev_priv(ndev); struct tx_desc *desc xdev-ring[xdev-prod_idx]; dma_addr_t addr; addr dma_map_single(xdev-dev, skb-data, skb-len, DMA_TO_DEVICE); if (dma_mapping_error(xdev-dev, addr)) goto drop; desc-addr addr; desc-len skb-len; desc-flags TX_INT | TX_LAST; dma_wmb(); /* 保证 addr/len/flags 可见 */ WRITE_ONCE(desc-owner, 1); /* 现在才敲门铃让 DMA 引擎去取 */ writel(1, xdev-doorbell); }几个容易被忽略的细节门铃寄存器用 writel() 而不是普通赋值对 Device 类型内存的访问writel/readl 天然带串行化语义普通指针赋值没有。描述符 ring 本身是 dma_alloc_coherent() 分配的所以不需要在每笔提交前后做 cache 维护但 dma_wmb() 依然必须有因为它管的是访问顺序而不是缓存一致性。中断侧也要对称处理irqreturn_t xdev_irq(int irq, void *data) { while (READ_ONCE(rx_desc-owner) 0) { /* DMA 引擎回写 owner */ dma_rmb(); /* 处理 rx_desc-addr / len 指向的数据 */ ... dma_unmap_page(dev, addr, len, DMA_FROM_DEVICE); /* 重新挂一个新的 buffer再清 owner 归还给 DMA */ WRITE_ONCE(rx_desc-owner, 0); } return IRQ_HANDLED; }这套模式在 Linux 内核的网络驱动里遍地都是e1000e、igb、stmmac 都是这个套路。你去看它们的源码一定找得到 dma_wmb() 和 dma_rmb()几乎每个都写在 owner 标志位的两侧。这就是业界的标准答案没有玄学。3.4 缓存一致性问题的系统级验证手段如果怀疑是缓存一致除了代码审查还有几个快速验证手段可以参考。第一在 ARM 上临时把设备树的 dma-coherent 属性去掉或者加上观察问题是否改变方向比如从不坏变成必坏借此确认设备是否真的支持一致。第二用内核的 debugfs 或者 /sys/kernel/debug/ 下 DMA 相关接口检查映射记录。第三在 DMA 写完成中断里对收到的数据做一次与原始 manifest 的对拍能够在毫秒级确认内容新旧。第四如果条件允许用逻辑分析仪或者硬件侧抓取总线上的读写地址对比寄存器里配置的 DMA 地址是否一致这能直接排除 SMMU 地址翻译问题。提示很多嵌入式平台的 DMA 引擎并不完全 64 位地址安全。x86 上 IOMMU 帮你做了 DMA 地址重映射ARM 上如果 SMMU 没有使能高 32 位地址可能被裁剪产生“随机坏数据”其实是地址被截断。排查时看一下平台是否开启 SMMU不是坏事。4. 常见问题排查技巧与长期防范4.1 快速定位速查表下面的表整理了几种典型现象和对应的排查方向亲测有效现象x86 平台表现ARM 平台表现最可能根因验证手段数据内容错乱但结构完整极少出现常见缓存一致性问题unmap 前后对比数据DMA 引擎拿到错误参数几乎不会常见缺少写屏障打印 owner 置位前后字段owner 已置位但数据是旧包罕见常见CPU cache 旧数据残留增加 dma_rmb 或 invalidate性能骤降加偶发错误少见常见对齐或突发长度查 TRM 中对齐要求CRC、校验随机失败少见常见内存属性错误核对设备树 dma-coherent地址看起来被截断不明显明显SMMU/IOMMU 未使能查 dmesg 中 DMA 地址打印这张表不能替代分析但能帮你快速缩小排查范围。我见过不少人在“缓存一致性”“屏障缺失”“对齐错误”三个方向反复横跳最后发现其实是三个问题同时存在——所以逐项排查时每修一个点都要重新压测确认单一变量。4.2 设计跨平台 DMA 代码的六条经验这些经验是我踩坑踩出来的不是从书上看来的每一条背后都有真实的故障记录。第一统一走内核 DMA API不要裸操作内存当 DMA 缓冲。哪怕是 x86 上能跑到 ARM 必炸。第二描述符的“最后写位”单独放在一个 32 位 word 上前面字段写完立即 dma_wmb()最后用 WRITE_ONCE 写所有者位。第三所有者位必须用 WRITE_ONCE/READ_ONCE 读写防止编译器撕裂和重排。第四中断处理里看到 owner 翻转后马上 dma_rmb()再读其他字段。第五设备树或 ACPI 里的 dma-coherent 属性必须与驱动实现保持一致不一致是“换平台随机坏”的最大来源之一。第六发布前至少跑一遍跨架构 CIx86 一份、ARM 一份、启用 IOMMU/SMMU 一份DMA path 必须在多平台压测过。尤其是第一条很多人觉得在 x86 上直接拿物理地址给硬件“又快又简单”但到了 ARM 平台你省掉的那一次 dma_map 呼叫就是随机坏数据的起点。4.3 其他值得留意的坑再补充几个容易中招的细节。第一“碰巧通过”的修复很危险。比如在 owner 前加了一个 mb() 后问题消失你以为是屏障解决了问题实际上可能只是时序变了或者 Cache 维护动作凑巧进来了。一定要把修复落在确定语义上搞清楚自己是在修“顺序”还是“一致性”。第二DMA 完成中断里不要做耗时操作。很多平台用 threaded IRQ 加锁后描述符 ring 被 CPU 和 DMA 同时访问又会引入新的竞争条件如果在中断里直接做 cache clean/invalidate效率损失更大。第三不要把问题都归给软件。有些“坏数据”最终查出来是 ECC 错误或者信号完整性问题特别是高速接口、视频接口场景。遇到怎么也复现不了的别排除用 memtester 这类工具扫一遍硬件也看看平台的 ECC 日志。即使是 STM32、GD32 这类 MCU 上的 DMA 乱数据问题本质上也跑不出今天说的这几类原因外设寄存器配置顺序、总线仲裁、缓冲区一致性。只是 MCU 没有 Linux 内核那套 DMA API 帮你兜底所以写裸机驱动时更要自己把关。4.4 回到 AI Infra 视角的教训回到“AI Infra 每日一问”这个场景。AI Infra 环境有个特殊点很多团队把网卡卸载、存储卸载、DPU 数据面的代码在 x86 上验证完就直接发版到了 ARM 异构节点才出问题。DMA 代码是其中一个最典型的“防不胜防”点但它背后代表的是同一类问题——跨架构时x86 与生俱来的强一致、强序、统一 IOMMU 的保护在 ARM 上都不存在。所以做 AI Infra 驱动层或者系统软件我个人的纪律是把 barrier、cache 维护写成显式代码不要依赖平台惯性每次迁移架构时先把 DMA 相关代码做一次逐行 barrier 审计再谈性能优化。这比事后排查省太多时间。回顾这个 case我最大的体会是x86 不是“标准答案”它更像一个极其宽容的上司帮你兜底了所有“你忘了写”的错误一旦换到 ARM 这种“较真”的平台所有模糊行为都会变成随机故障。很多所谓“玄学坏数据”归根结底就是内存顺序、缓存一致性、对齐这三件事没做好。如果你现在也在被类似问题折磨建议从这三件事查起大概率能找到根因。