这段 DMA 代码在我 x86 开发机上跑了三个月,一次问题都没出过,搬到另一块板子上,十次里就有两次收到的数据是脏的——长度对不上、校验和偶尔错、重启之后又奇迹般恢复正常。如果你也在做 AI Infra 底层这块,大概对这种问题不陌生:它不给你一个干脆的报错,只用低概率的脏数据慢慢折磨你。热词里"DMA 每日一问"这类讨论最近特别多,核心争议其实就一句话——同一段代码,凭什么在 x86 上好好的,换个平台就随机坏。这篇文章,我想把这背后的几层原因一层层剥开:从缓存一致性模型,到内存屏障,再到 IOMMU 地址翻译和对齐细节,每一层我都会给出可复现的判断方法、修复代码和实测经验。不管你是刚接触驱动的新手,还是已经写过几个 DMA 通道的老兵,看完应该都能对照自己的代码查一遍。
1. DMA 到底在为什么工作,平台差异又为什么这么致命
1.1 先把 CPU、内存、设备这三方关系摆正
DMA 全称 Direct Memory Access,直译就是"直接内存访问"。它要解决的问题非常朴素:外设要搬一大块数据的时候,不想让 CPU 一个字节一个字节地搬运。于是硬件里弄出一个独立的总线主控,让设备自己通过内存控制器去读写内存。CPU 只负责配置起点、长度、方向,然后就可以去干别的活。这是几乎所有高性能外设(网卡、NVMe、高速串口、SPI、PCIe 设备)都绕不开的机制。
理解这个模型有个关键点:真正的"数据通路"是设备 → 内存控制器 → 内存颗粒,CPU 和它的 cache 根本不在这条路上。你在代码里写的那几行寄存器配置,处理的是"控制面";真正跑数据的是"数据面"。绝大多数 DMA 玄学问题,都出在这两个面的认知错位上——你以为你写的数据设备一定能看到,其实并没有。
所以当有人问"为什么换个平台就坏",本质问的是:这台机器的 CPU cache、内存序模型、地址翻译单元,跟设备侧看到的内存,到底是不是同一个视图。x86 帮你把很多东西藏了起来,换个平台,这些被藏起来的东西全部暴露出来。
1.2 x86 的"侥幸"从哪儿来
很多 x86 平台让你产生"这代码没问题"的错觉,是因为硬件帮你兜了不少底。在典型 x86 上,DMA 通常被当作 cache-coherent 的:设备对内存的写会经过 cache 一致性协议,CPU 之后读到的就是新数据。同时 x86 的强内存序模型(TSO)让普通内存写基本保持顺序,你"先写描述符再敲门铃"这种写法,在 x86 上大概率是天然有序的。
注意我用的是"大概率"。DMA 相关 bug 的典型特征就是概率性:跑一百次错两次,跑一千次错一次。x86 把这些概率压到了很低很低,你觉得"跑了三个月没事",其实只是样本量还不够。有同行跟我说过,他们线上跑了一年才在一个大流量场景下暴露出 DMA 脏读,排查了半个月——因为本地复现不出来。
这种问题最难的地方在于它不每次都错。于是很多人的第一反应是硬件坏了、线接错了、时钟不稳、供电有问题。方向一开始就偏了,后面全是白费功夫。想快速判断是不是软件层面的 DMA 问题,我一般先看一个特征:错误是否与数据量、负载、内存地址相关。如果只在大传输、高负载、特定对齐的缓冲上出现,八成是软件问题。
2. 头号嫌疑:缓存一致性模型,x86 替你兜了多少
2.1 从"两个内存视图"说起
在带 cache 的 CPU 上,内存其实有两个物理视图。CPU 侧看到的是"带 cache 的视图",你写一个变量,可能只是写进了 L1/L2,还没落到内存颗粒;设备侧看到的是"裸内存视图",它不认识 cache,只能读物理内存里的真实字节。如果这两者没有硬件机制去同步,就会出现经典的错位——CPU 以为写进去了,设备却读到旧值。
x86 缓解这个问题的办法是把 DMA 纳入 cache 一致性域的硬件协议里。但在 ARM 平台上,情况分化得更细:有的外设端口是 coherent 的,有的不是;即使是同一个 SoC,不同总线上的设备一致性属性也可能不同。一旦落在 non-coherent 域,cache 和内存之间的同步就完全交给软件,也就是你的驱动代码。
Cortex-M7 上有一个经典例子:它带 D-Cache,但它的 DMA 控制器不参与 cache 一致性。CPU 往缓冲区填数据,数据留在 D-Cache 没写出,DMA 直接去内存搬,搬走的是一堆旧字节;反过来 DMA 往内存写数据,CPU 去读时命中了自己 cache 里的旧行,读到的还是旧数据。这就是完整的 non-coherent 场景。
2.2 遗漏 sync 会造成的具体现象
我见过最隐蔽的一类 bug 是这样的:CPU 把发送描述符写进一个 streaming 映射的缓冲区,没做 sync 就去敲设备的门铃。设备按它读到的旧描述符去某个地址搬数据,那个地址可能是上一轮的、已经释放的、甚至被别的模块复用的内存。结果就是随机踩内存,现象千奇百怪:有时候是数据错,有时候是校验失败,有时候直接把内核搞崩。
还有一个更迷惑的现象:同样的代码在调试版本下是对的,在发布版本下会错。原因是编译器优化改变了写内存的顺序或者把某些写优化掉了。所以这类问题常常让人觉得"时好时坏",其实就是概率问题。
判断方法很简单,把这段 DMA 缓冲改成dma_alloc_coherent分配(非缓存或硬件一致),如果问题消失,基本可以确诊是 cache 不一致。这是我最常用的"五分钟分诊"手段,代价是性能,但用来定位非常有效。
2.3 dma_alloc_coherent 与流式映射到底怎么选
新手最容易混淆的就是"什么时候用 coherent 映射,什么时候用 streaming 映射"。我这里给一张对照表,都是在实际项目里总结出来的:
| 维度 | dma_alloc_coherent | dma_map_single(streaming) |
|---|---|---|
| 一致性 | 通常保证 coherent,无需手动 sync | 不保证 coherent,需要手动 sync |
| 分配开销 | 分配慢,性能可能受限 | 复用现有内存,分配快 |
| 访问速度 | 可能因为非缓存而变慢 | CPU 访问正常速度 |
| 生命周期 | 常驻,长期存在 | map/unmap 成对出现,用完即弃 |
| 典型用途 | 描述符环、控制结构、寄存器镜像 | 大块一次性数据传输 |
| 常见错误 | 忘了 free,泄漏 | 忘了 sync,脏数据 |
选择的逻辑其实很清楚:长期存在、需要 CPU 频繁改写的控制结构用 coherent;一次性搬运的大块数据用 streaming。描述符环这种要被 CPU 和设备反复读写的结构,用 coherent 能省掉大量 sync 逻辑,虽然访问慢一点,但胜在稳定、不容易出错。大数据传输用 streaming,因为它能复用普通内存,吞吐更高。
我个人的原则是:先把功能写对,再谈性能。在开发阶段,如果一段 DMA 逻辑我还没完全想清楚谁写谁读,就先全用 coherent,等验证通过后再逐步替换成 streaming 并补上 sync,每替换一处就压力测试一轮。这样出问题的时候能立刻定位是哪次替换引入的,比一次性全上 streaming 再排查要省心得多。
3. 二号嫌疑:内存屏障缺位,设备读到的永远是旧数据
3.1 编译器重排和 CPU 乱序是两件独立的事
很多人把内存屏障理解成"防止 CPU 乱序执行",其实这只说对了一半。乱序有两个来源,而且它们会在不同层面上各自捣乱。
第一个来源是编译器重排。编译器在优化时,如果它认为两条语句没有数据依赖,就可能调换它们的顺序。你写的"先填描述符,再写门铃寄存器",在编译器眼里可能只是两段无关的写操作,它完全可能把门铃那句提前。这个重排在 x86 和 ARM 上都可能发生,跟架构无关,是编译器级别的。
第二个来源是CPU 乱序执行。即使编译后的指令顺序是对的,处理器也可能为了填满流水线而乱序执行内存操作。x86 的 TSO 模型下,store-store 基本有序,所以普通内存写之后紧跟 MMIO 写,通常能保持顺序。但 ARM 是弱内存序,普通内存写和 MMIO 写之间没有任何天然的顺序保证,CPU 完全可能让门铃先于描述符到达设备。
两个来源叠加,结果就是设备在描述符还没写好的时候就被通知了。
3.2 一个描述符写完就敲门的经典案例
我把这个场景写成代码,你一眼就能看出问题在哪:
/* 有问题的写法:看似顺序执行,实则两处都可能乱序 */ desc->len = 1024; desc->addr = buf_phys; /* 这里没有任何屏障 */ writel(DOORBELL, reg_base + DOORBELL_OFF);在这个片段中,desc->len和desc->addr是普通内存写,writel是 MMIO 写。编译器可能把 MMIO 写的指令提前到描述符赋值之前,ARM 的 CPU 也可能让这个写先被设备看到。设备收到门铃后去读描述符,读到的是未初始化或者上一轮的内容,于是要么搬错数据,要么搬去错误的地址。
正确的做法是在两段之间插入一个写屏障:
/* 正确写法:用 dma_wmb 保证描述符先可见 */ desc->len = 1024; desc->addr = buf_phys; dma_wmb(); /* 保证前面的写先于后面的写可见 */ writel(DOORBELL, reg_base + DOORBELL_OFF);dma_wmb()是 Linux 提供的 DMA 专用写屏障,语义是"屏障之前的所有写,必须对设备可见之后,屏障之后的写才能可见"。它同时约束编译器和 CPU,所以两处乱序都能挡住。与之对应的还有dma_rmb(),用在读方向——比如中断里先读设备状态寄存器,再读数据描述符,防止 CPU 把数据读提前到状态判定之前。
3.3 屏障该放在哪,才不算浪费性能
屏障不是越多越好。全屏障(mb())开销很大,会把流水线里所有内存操作都卡住,用在热路径上会明显掉性能。我的经验是遵循几个原则:
- 写方向用
dma_wmb(),读方向用dma_rmb(),只在确实需要跨读写边界的场景才用dma_mb()。 - 屏障要放在依赖关系的边界上:描述符和门铃之间、状态寄存器和数据描述符之间、多个需要严格顺序的 MMIO 之间。
- 高频热路径上,能靠描述符环的一次性 map + 单次屏障解决,就别每包都屏障。比如批量提交一批描述符后只敲一次门铃,屏障也就只需要一次。
我在一个高速串口 DMA 项目里做过对比:最初每个数据包都加全屏障,吞吐掉了大概百分之十几;改成批量提交加单次dma_wmb()之后,吞吐恢复到原来的水平,稳定性也没受影响。所以屏障的关键是找准边界,而不是无脑堆。
4. 三号嫌疑:cache line 对齐和伪共享
4.1 cache line 尺寸不是全球统一标准
做 cache 维护的时候,一个很容易被忽略的前提是:cache line 大小在不同平台上并不一样。x86 上常见的是 64 字节,ARM 上有 32 字节的、64 字节的、128 字节的,RISC-V 的实现也各式各样。如果你的代码里硬编码了 64 这个数字,换到 cache line 是 128 的平台,flush 或者 invalidate 的范围就不对了。
不对齐的后果有两种。一种叫"多刷":你只想刷一个描述符,结果把同一 cache line 里隔壁的描述符也刷出去了,如果隔壁描述符正在被设备或另一个核使用,就可能丢更新。另一种叫"漏刷":要刷的数据跨越了两个 cache line,你按一个边界刷,第二个 line 的尾部没被刷到,设备的读就带上了旧数据。
正确做法是别硬编码,用系统提供的常量,比如内核里的cache_line_size()或者用 DMA API 自带的 cache 维护函数。如果确实需要手动做,就按运行时查询到的行大小来算,并且把缓冲区首地址按行大小对齐。
4.2 环形缓冲描述符的对齐实践
DMA 描述符环是最容易踩对齐坑的地方。假设你的发送描述符结构体大小是 24 字节,两个描述符挤在一个 64 字节 cache line 里。当你为了提交一个描述符去做 cache 维护时,同 line 里另一个描述符的更新可能被一起 invalidate 掉,设备读到的就是旧值。同一个 line 被两个独立操作的对象共享,这就是伪共享在 DMA 场景下的体现。
解决方式很简单,让每个描述符独占一个 cache line:
struct tx_desc { u64 addr; u32 len; u32 flags; } __attribute__((aligned(64)));这里为了跨平台,aligned(64)里的 64 最好换成运行时确定的最坏情况行大小,或者在编译期用平台宏来选择。代价是每个描述符浪费一点内存,但换来的是不会因为伪共享产生随机错误。对描述符环这种小结构来说,这点浪费完全值得。
我踩过的一个具体坑:在 Cortex-A 平台上一开始用aligned(64),后来换到某个 cache line 是 128 字节的平台上,问题又开始出现,原因就是两个描述符仍然可能共享一个 128 字节行。改成对齐到 128 之后就稳定了。所以对齐值要按目标平台上最大可能的行大小来给,而不是照抄别人的代码。
4.3 什么时候必须手动 flush 和 invalidate
用 coherent 映射时,这些动作硬件帮你做了,你基本不用管。但只要用到 streaming 映射,就必须自己处理方向性的 cache 维护。这里有个口诀我经常提醒新版同事:写方向 flush,读方向 invalidate。
- 设备要读的数据(CPU 写、设备读,比如发送缓冲):在敲门铃前对缓冲区做一次 flush,把 cache 里的最新内容压到内存。
- 设备要写的数据(设备写、CPU 读,比如接收缓冲):在设备写完之后、CPU 读之前,对缓冲区做一次 invalidate,让 CPU 丢弃 cache 里的旧行,重新从内存读。
这两个动作的方向搞反,就会出现"数据看着像被改过,其实没生效"或者"明明是旧数据却被当成新数据"这类迷惑现象。尤其在双向传输的场景里,同一个缓冲区既被读又被写,就必须在每次方向切换的时候重新维护,不能想当然。
5. 四号嫌疑:IOMMU 和地址翻译带来的隐形错位
5.1 x86 上 IOMMU 的直通模式掩盖了问题
x86 平台上有一个很容易被忽视的因素:很多场景下 IOMMU 是可以关闭或者配置成直通模式的,设备看到的就是物理地址。你在驱动里用virt_to_phys()把虚拟地址换算成物理地址,直接填进描述符,居然能跑得起。这给人一种错觉,好像"物理地址可以直接用"。
本质上这是 x86 把你惯坏了。设备的地址翻译策略是平台相关的,一旦换到 IOMMU 默认开启的平台,设备看到的是经过翻译的 I/O 虚拟地址(IOVA),跟你以为的物理地址完全不是一回事。你继续传物理地址,设备就可能读到完全无关的内存区域——轻则数据错,重则踩到别人的缓冲。
5.2 ARM SMMU 默认开启时会发生什么
ARM 平台上的 SMMU 在很多配置下是默认启用的。这意味着设备发出的地址要先经过 SMMU 翻译,才能落到真正的物理内存上。这个翻译需要驱动通过 DMA API 去建立映射,返回一个dma_addr_t给设备用。
如果你绕过 DMA API,自己用virt_to_phys()算一个物理地址填进去,SMMU 会把这个"物理地址"当成 IOVA 再翻译一遍,落到一个谁也说不清的物理地址上。现象就是设备读写完全跑偏,而且因为翻译本身不一定报错,所以你不一定能从日志里看到端倪。
我有一次在调试一个 PCIe 设备驱动时,就因为这个原因浪费了大半天。现象是设备能正常初始化,寄存器读写全对,但只要跑数据传输就丢包。后来用内核自带的 DMA debug 功能打开之后,立刻报了"设备访问了未映射的地址",一眼就定位了。
5.3 永远用 DMA API 返回的地址,别自己算
结论非常明确:永远用dma_map_single()或者dma_alloc_coherent()返回的dma_addr_t去填设备描述符,绝对不要用virt_to_phys()的结果去糊。前者是经过平台 DMA 层处理的、设备真正能用的地址,后者只是 CPU 视角的物理地址,两者在开启 IOMMU 或 SMMU 的平台上根本不是同一个东西。
只有在你完全确认平台没有 IOMMU、并且 DMA 层本身也是透传的极少数场景下,才可以考虑直接传物理地址。但即使这样,我也建议用dma_map_*系列,因为它是可移植的,未来平台变了不需要改代码。这次的教训就是"看着能跑"和"正确"之间的差别,可能只是一个平台配置的开关。
6. 一套可复用的定位流程和速查表
6.1 第一步先分诊:到底是没写进去还是没读出来
面对随机坏数据,先别急着改代码,先做分诊。核心就一个问题:错误发生在设备读方向,还是设备写方向。判断方法是把设备端的收发都停掉,用一个简单的模式:CPU 往缓冲里填一个已知序列,启动设备"读"这段内存;再用设备"写"一段已知序列进内存,CPU 去读。哪一边错,就把问题范围缩小一半。
如果是发送方向错(CPU 写、设备读),嫌疑集中在:cache flush 漏了、写屏障缺了、描述符对齐有问题。如果是接收方向错(设备写、CPU 读),嫌疑集中在:cache invalidate 漏了、读屏障缺了、缓冲区被踩。分诊清楚了再动手,能省掉大量盲目尝试。
6.2 用"降级法"快速缩小范围
我常用的第二招是降级验证:把 streaming 映射换成 coherent 映射,把描述符对齐加大到最坏情况,把屏障全加上。如果这样一切正常,就说明问题一定在上面这几个点里;然后一个一个往回改,看哪一处改回去问题复现,就锁定了根因。
这个方法的逻辑在于:先用最保守、最不容易出错的配置保证功能正确,再逐步优化到高性能配置。它不一定能一步找到根因,但能保证你不会在错误的假设上反复试错。尤其是在没有专用调试工具的平台上,这几乎是效率最高的做法。
6.3 常见问题速查表
下面这张表是我这些年攒下来的,可以直接对照排查:
| 现象 | 最可能原因 | 处理方向 |
|---|---|---|
| 偶发脏数据,重启后消失 | 漏了 cache sync | 补dma_sync_single_for_* |
| 只有大传输才错 | 描述符跨界、对齐问题 | 按 cache line 对齐;检查长度边界 |
| 换平台后才错 | IOMMU/SMMU 地址翻译 | 只用 DMA API 返回的地址 |
| 高负载下才错 | 描述符环回绕竞争 | 加锁、双缓冲、内存屏障 |
| 调试版对、发布版错 | 编译器重排 | 加dma_wmb/dma_rmb |
| 只有某几个缓冲区错 | 缓冲区被复用或踩内存 | 检查生命周期、加保护 |
| 数据顺序偶尔错乱 | 多描述符提交顺序问题 | 提交边界加屏障、批量提交 |
6.4 我踩过的两个坑
第一个坑是"重启就好"的假象。早期遇到偶发脏数据,重启之后恢复正常,我就以为硬件问题,没深究。后来发现其实是上一次异常退出留下的 cache 状态问题,重启只是把状态清了。重启能好不代表是硬件问题,这点一定要记住。
第二个坑是"只在客户环境里错"。本地跑得好,客户现场出问题,往往是因为客户环境的 IOMMU 配置、cache line 大小、负载特征跟本地不一样。所以我后来养成了一个习惯:把 DMA 相关的关键配置打印出来,包括 IOMMU 状态、cache line 大小、对齐值、缓冲区物理地址范围,出问题时第一时间对比两端,比盲猜高效得多。
7. 可移植 DMA 代码的几条硬规矩
7.1 让内存所有权和方向始终清晰
写 DMA 代码,最重要的不是技巧,而是纪律。我的第一条硬规矩是:每一块 DMA 缓冲,都要明确"任何时刻谁在写、谁在读、什么时候切换"。一个缓冲如果既被 CPU 写又被设备写,却不做同步,那无论怎么调屏障和对齐都不可能稳定。所以在设计阶段就把缓冲按方向分类:发送缓冲只由 CPU 写、设备读;接收缓冲只由设备写、CPU 读;需要双向的一定要有明确的交接点。
这条规矩看着简单,但实际项目里出问题的,十有八九是所有权不清晰导致的。有人图省事让 CPU 和设备同时写一个缓冲,只在末端加个标志位,结果在高负载下标志位的可见性又成了新的问题。与其事后补漏洞,不如一开始就把边界划清楚。
7.2 不要依赖任何隐式的屏障行为
x86 的强内存序、硬件的 cache 一致性、SMMU 的某些透传配置,这些都会给你"代码天然正确"的错觉。可移植代码的核心思路是:假设平台不给任何隐式保证,该加的屏障一个不少,该做的 sync 一次不漏。多加点屏障带来的性能损失,远小于在客户现场出随机错误带来的成本。
具体到代码里,就是所有描述符写完到门铃之间加dma_wmb,所有状态寄存器读完到数据描述符读之间加dma_rmb,所有的 streaming 缓冲在方向切换时做对应的 sync。这些可能在某些平台上确实多余,但多余的屏障不影响正确性,缺失的屏障却可能致命。
7.3 统一走 DMA API,别自己造轮子
最后一条也是最省心的:统一用内核的 DMA API。它帮你处理了地址翻译、cache 维护、对齐差异这些平台相关的细节,你只需要调用对应的接口,代码就有可移植性。自己用virt_to_phys手动换算、自己硬编码 cache line 大小、自己判断要不要 flush,这些在换平台时全是隐患。项目里如果有这类"手工优化"的代码,我一般会在重构时统一替换掉,哪怕短期看性能稍微降一点。
我个人的经验是,DMA API 提供的接口在绝大多数场景下性能完全够用,真正需要绕过它的极端场景非常少见。与其为了百分之几的性能去冒随机错误的险,不如先把系统跑稳,性能瓶颈再用 profiling 去精确找,而不是提前用一堆手工逻辑把代码搞复杂。
这套东西说起来不算新,但每次遇到"换平台就随机坏数据"的现场,回头检查代码,十有八九都能在这几条规矩里找到缺口。希望这次的拆解能帮你在下一次遇到类似问题时,少花几个下午。