尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

DMA每日一问:跨平台随机脏数据与缓存一致性、内存屏障、IOMMU排查

DMA每日一问:跨平台随机脏数据与缓存一致性、内存屏障、IOMMU排查 这段 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_coherentdma_map_singlestreaming一致性通常保证 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 linestruct 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 去精确找而不是提前用一堆手工逻辑把代码搞复杂。这套东西说起来不算新但每次遇到换平台就随机坏数据的现场回头检查代码十有八九都能在这几条规矩里找到缺口。希望这次的拆解能帮你在下一次遇到类似问题时少花几个下午。
返回列表