同一段 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 还是 ARM,DMA 的工作流程其实都一样,一般分四步:第一步,分配一块能被 DMA 引擎访问的内存,拿到物理地址;第二步,把这笔传输的信息——源地址、目标地址、长度、方向、完成标志——写进描述符(Descriptor),描述符本身也放在内存里;第三步,按引擎要求的格式把描述符地址写进控制寄存器,或者通过门铃(Doorbell)通知 DMA 控制器开工;第四步,DMA 控制器自己搬运数据,搬完写一个完成标志或者发中断,CPU 接着处理后续逻辑。
为了性能,多数高端网卡和存储控制器用的是描述符环(Descriptor Ring):一段固定大小的环形区域,每个槽位里放着一条描述符。驱动在生产侧往 ring 里填描述符,DMA 引擎在消费侧按顺序取走执行,执行完再回写 owner 位。整个设计朴素高效,但它的正确性高度依赖一个前提——CPU 写入描述符的顺序,必须和 DMA 引擎看到的顺序一致。
举个例子帮你建立画面感。想象你是一个仓库主管(CPU),旁边有个快递员(DMA),你只需要把一百箱货的配送单子写好,放在前台桌子上(描述符),按一下呼叫铃(门铃),快递员自己拿着单子去装车、送货。送完他把回执盖个章放桌面(owner 位回写),再打个电话通知你(中断)。整个过程,CPU 不参与搬运,效率很高。
但这里有个致命假设:你放单子的时候,快递员默认“单子上的所有信息都是完整、最新的”。在 x86 上这个假设基本成立,换到 ARM 平台它就不成立了。为什么?下面这两节是全文的理论核心,值得多看两遍。
1.2 x86 的强内存序是一把“保护伞”
x86 的 CPU 从很早开始就保持了一个非常强力的内存序模型,叫 TSO(Total 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 按顺序写入四条 store,DMA 引擎读 owner 位为 1 时,前面的 addr、len、flags 早就可见了。但在 ARM 弱序平台上有两种风险:一是编译器优化把 store 重排,比如先写 owner 再写 len;二是 CPU 乱序执行,store buffer 还没刷出去,owner 已经写到内存,但 addr 还在 store buffer 里排队。无论哪种,DMA 引擎看到 owner=1 后立刻去读 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 在中断处理里看到 owner=1 后,需要再补一个 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 buffer:
struct 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 可以把分散内存映射成连续的 IOVA,ARM 平台的 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 打出来,再在中断处理或者回读时比对。如果发现 owner=1 时 addr、len 还是旧值,那就是屏障缺失,直接加 dma_wmb() 试一下。这个验证不需要任何高深工具,printk 加耐心就够了。
第二段验证缓存一致。在收包中断里,把 DMA 刚写的数据和 CPU cache 中的数据做校验,或者在 dma_unmap_page 前后各打印一次数据内容。如果 unmap 前读是坏的、unmap 后读是好的,那基本 100% 是缓存一致性问题。
如果你的环境跑的是 Linux,强烈建议打开 DMA API debug:
CONFIG_DMA_API_DEBUG=y CONFIG_DMA_API_DEBUG_SG=y它会在 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 属性,必须与驱动实现保持一致,不一致是“换平台随机坏”的最大来源之一。第六,发布前至少跑一遍跨架构 CI:x86 一份、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 这种“较真”的平台,所有模糊行为都会变成随机故障。很多所谓“玄学坏数据”,归根结底就是内存顺序、缓存一致性、对齐这三件事没做好。如果你现在也在被类似问题折磨,建议从这三件事查起,大概率能找到根因。