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

资讯详情

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

DMA描述符里写的是什么地址?虚拟地址、物理地址与总线地址解析

DMA描述符里写的是什么地址?虚拟地址、物理地址与总线地址解析 DMA描述符里到底写的是什么地址这个问题看着基础却是我在调 AI Infra 底层网络驱动、以及自己折腾 RK3588 和 XDMA 的时候反复踩坑的点。今天 Day 9 就聊一个很硬核但特别容易被忽略的问题设备眼里的内存长什么样以及我们在代码里填进描述符的那个地址究竟是谁的地址。很多人写驱动、调 FPGA、搞 DMA 传输第一反应都是“我要在内存里准备一块缓冲区然后把地址交给设备”但设备拿到的那个地址跟 CPU 在代码里看到的指针很多时候根本不是同一个东西。把地址填错了轻则数据错乱重则 DMA 直接 fault系统死给你看。这篇文章就用我实际调试过的场景把这套地址体系彻底拆开适合做 Linux 驱动、嵌入式开发、以及搞 AI 基础设施底层加速的同学收藏。1. 先把三套地址讲清楚1.1 三种地址虚拟地址、物理地址、总线地址要聊清楚“DMA 描述符里写的是什么地址”第一步必须把地址这件事拆成三个独立的概念。这三者经常被混着叫但它们在硬件层面的含义完全不同。第一是 CPU 虚拟地址也就是应用程序里指针指向的那个地址。这背后是 CPU 的 MMU 在做翻译每个进程有自己的页表虚拟地址经过页表查出来以后才能得到物理地址。第二是系统物理地址它是整块内存条上、DDR 控制器视角下的地址编号。CPU 访问内存的时候经过 MMU 翻译之后拿到的那串地址就是系统物理地址。在大多数架构上物理地址会直接放到地址总线上让内存控制器去取数。第三是总线地址也就是设备比如网卡、GPU、DMA 控制器、FPGA发起 DMA 时写在自己的地址引脚或者总线事务里那个地址。总线地址经过了总线桥、IOMMU/SMMU 之后最终才会映射到系统物理地址上。这三者的关系可以用一个生活类比来说。虚拟地址是人名物理地址是身份证号总线地址是企业内部的工牌号。你自己知道“张三”是谁但门卫不认识张三门卫只认工牌。设备就是那个门卫你要让设备去取数据就得给它看它认识的工牌号。1.2 没有 IOMMU 时设备看到的是什么在绝大多数没有 IOMMU/SMMU 的嵌入式小系统上比如 STM32、GD32、普通 ARM Cortex-M 平台总线地址和物理地址是同一个东西。DMA 控制器发起的访问走的就是系统物理地址总线所有地址都是全局裸奔的。这个时候事情最简单你在内存地址 0x20001000 放了一块数据缓冲区DMA 描述符里就写 0x20001000设备就能直接访问到。因为整个系统只有一个地址视图CPU 能看到的物理地址设备也能看到。很多嵌入式工程师长期在这种环境下写代码就养成了一个习惯认为 DMA 地址就是物理地址。这个习惯在简单系统里没毛病但一旦上了带 MMU、带 IOMMU 的高端平台就开始出问题了。1.3 有 IOMMU 时设备看到的是什么到了服务器、AI 加速平台、以及像 RK3588 这种带 SMMU 的高端 SoC 上情况就完全不同了。系统里多了一个叫 IOMMUIntel 叫 VT-dAMD 叫 IOMMUARM 平台叫 SMMU的硬件单元它专门负责把设备发起的访问翻译成系统物理地址。IOMMU 的存在让设备发起 DMA 时的地址空间变成了一个独立的“设备地址空间”我们常叫它 IOVAI/O Virtual Address或者总线地址。设备以为自己在访问一块连续的内存实际上 IOMMU 可以把这些连续的总线地址映射到物理上不连续的内存页上。这对驱动的直接影响是当你在代码里调用dma_alloc_coherent或者dma_map_single拿到一个dma_addr_t类型的地址时这个地址本身是给设备看的不是给你 CPU 代码里直接解引用用的。CPU 要访问这块缓冲区得用另一个虚拟地址设备要用这块缓冲区得用dma_addr_t这个总线地址。我见过不少刚从嵌入式转到服务器驱动开发的同学拿到dma_handle之后顺手就把它当成物理地址填到描述符里运气好没开 IOMMU 的时候能跑开了 IOMMU 就各种随机性故障。这个习惯越早纠正越好。2. DMA 描述符里的地址到底是哪一种2.1 描述符是一种“CPU 写给设备看的便签”DMA 描述符本质上是一个数据结构放在内存里由 CPU 填写内容设备通过 DMA 总线去读取。你可以把它理解成 CPU 给设备留的便签我现在要往哪个地址写数据、写入多少字节、你还剩多少空间、缓冲区准备好没有。以最常见的网卡驱动为例。网卡要收包驱动先在内存里准备好一个环形描述符队列每个描述符里写着“我为你准备了一块缓冲区地址是 XX长度是 YY”。网卡收到数据包时就会根据描述符里的地址把数据 DMA 进那块缓冲区然后更新描述符里的状态字段告诉 CPU“数据已经放好了”。描述符里最关键的两个字段一个是缓冲区地址buffer address一个是缓冲区长度。其他还有状态、控制位、下一个描述符指针等。所有的地址字段填的都必须是设备能理解的总线地址不是 CPU 虚拟地址也不是简单的裸物理地址。这里特别想强调的是描述符本身也是一段内存设备要读描述符所以描述符自己所在的地址同样必须是总线地址。也就是说驱动分配描述符内存的时候也要用 DMA 分配接口拿到它的总线地址并把总线地址告诉设备通常通过写设备寄存器来告诉它描述符队列的起始地址。2.2 填进描述符的地址为什么不能是 CPU 虚拟地址这个问题是我面试别人时特别喜欢问的为什么 DMA 描述符不直接写 CPU 的虚拟地址原因分两层。第一设备本身没有 MMU它不会做虚拟地址到物理地址的翻译。虚拟地址是 CPU 进程私有的概念离开了 CPU 的页表机制虚拟地址就是个无意义的数字。你把一个用户态指针填给设备设备只会把它当成一个物理地址去访问结果大概率是访问到完全错误的内存区域。第二即使设备有 MMU那是也 IOMMU不是 CPU 的 MMU两者页表格式不一样。设备做地址翻译靠的是 IOMMU。在开了 IOMMU 的系统上设备发起 DMA 时地址先经过 IOMMU 查表查不到就要报 DMA fault。IOMMU 页表里维护的映射关系是总线地址到物理地址跟 CPU 进程页表一毛钱关系都没有。所以填进描述符的地址要么是物理地址无 IOMMU 场景要么是总线地址/IOVA有 IOMMU 场景。在 Linux 驱动开发中用dma_addr_t统一表示到了设备手里就是那张“门禁工牌”。2.3 描述符本身的地址和描述符里的地址要区分开这个点我特别想单独拿出来讲因为很多人踩过坑但没有意识到坑在哪。假设你定义了一个描述符结构struct desc你把它分配好之后自然会产生两个地址概念一个是“描述符这个结构体存放在哪里”另一个是“描述符里面指向的 buffer 地址放在哪个字段里”。设备要找到你的描述符需要你告诉它“描述符队列的起始地址”。这个地址写在设备的基地址寄存器里面同样必须是总线地址。举个例子Xilinx XDMA 的驱动中会有类似xdev-desc_bus_addr这样的变量就是用来存放描述符队列总线地址的。在初始化时驱动会把这个总线地址写入到 XDMA 的寄存器里设备才知道去内存哪里找描述符。而描述符里面的buf_addr字段是告诉设备“实际数据缓冲区在哪里”。这两个地址不一样而且都要是总线地址。我调试过一个很有意思的 bug同事把描述符结构体的 CPU 虚拟地址当成描述符地址写进 XDMA 寄存器结果描述符队列里全是乱码。设备顺着那个“假地址”去内存里读读到的自然不是我们填好的描述符而是一堆随机的数据。后来我把代码里所有和地址相关的变量都加了_cpu和_dma后缀这类问题才明显减少。3. 设备眼里的内存平铺的、带编号的、能访问的那一段3.1 嵌入式小系统DMA 就是直接访问内存从设备的视角看过去内存是一段平铺的、有编号的地址空间访问哪块区域、能访问哪块区域取决于系统的地址映射关系。最简单的嵌入式小系统里设备DMA 控制器眼里的内存就是物理内存本身。你给它地址 0x20001000它就访问 0x20001000中间没有任何翻译。这也是为什么 STM32 的 DMA 编程中内存地址寄存器DMACxCMAR直接填的就是内存缓冲区地址外设地址寄存器DMACxCPAR填的是外设的数据寄存器地址。GD32、STM32 上做 DMA 收发本质上就是设置源地址、目的地址、传输长度然后启动 DMA。这个过程中没有虚拟地址的概念CPU 访问外设也是通过固定地址映射后的物理地址。对设备来说内存就是一片连续编号的大数组它只管把数据从外设寄存器搬到内存指定的编号位置或者反向搬回去。3.2 服务器/AI InfraIOMMU 映射下的设备内存视图但到了 AI Infra 或者服务器环境设备眼里的内存就不是实打实的物理内存了而是 IOMMU 给它“捏造”出来的一块虚拟内存空间。以一台带 128G 内存、开了 IOMMU 的服务器为例网卡发起 DMA 时它以为自己访问的是总线地址 0x3000实际上 IOMMU 查表发现这个总线地址被映射到了物理地址 0x7f000000。设备完全感知不到这层映射它只知道自己访问的是 0x3000并且这个访问“看起来成功了”。这个设计最妙的地方在于设备可以拥有一段连续的总线地址空间即使背后的物理页面实际上是东一块西一块的。驱动把物理上不连续的多个页面用 IOMMU 映射成连续的总线地址设备就能一次性做一个大块 DMA不需要做 scatter-gather 拆散传输。这也让大块 DMA 缓冲区分配变得更容易系统不需要提前预留物理连续的大内存块。AI 场景里这个还挺重要。模型推理或者训练框架动辄想分配几百 MB 的连续 DMA 缓冲区用于 GPU 和 CPU 之间的数据交换没有 IOMMU 的服务器上这种分配很容易失败有 IOMMU 之后系统可以先把不连续的物理页映射成连续 IOVA满足硬件对连续性的偏执要求。3.3 连续与不连续为什么我们总要聊物理连续围绕 DMA 聊天绕不开“连续”这个词。我做 AI Infra 性能优化那阵子总有人问DMA 缓冲区到底需不需要物理连续答案取决于两个条件一是硬件是否支持 scatter-gather二是是否开了 IOMMU。如果设备不支持 SGDMA 描述符一次只能描述一块连续区域那么驱动就必须保证这块缓冲区在物理地址上是连续的。这也是老网卡驱动和很多 FPGA 板卡驱动的痛点一个巨大的连续 DMA 缓冲区在内存碎片化严重的系统里很难申请。如果设备支持 SG驱动就可以把分散在多个物理页面上的数据用多个描述符串起来设备会依次访问这些地址。这种模式下对物理连续性的要求就降低了每个描述符只需要指向一个页面。如果开了 IOMMU那更简单物理不连续也可以被映射成连续 IOVA设备看起来自己访问的还是一块连续内存。SG 描述符更多的是为了减少 IOVA 映射开销或者做协议层面的拆分。回到搜索热词里大家常查的 “dma continuous requests”很多时候的疑问其实是“为什么我的 DMA 请求老是失败”排查下来往往是系统内存碎片严重申请不到大块连续内存导致的。对应的解决办法也很固定用 SG 模式、开 IOMMU、或者封装一层连续内存池。4. 驱动里拿地址的完整实践4.1 dma_alloc_coherent把 CPU 地址和总线地址一次拿到理解了概念就要落到代码上了。Linux 驱动里最常用的分配 DMA 缓冲区接口是dma_alloc_coherent它的作用是分配一块内存同时保证这块内存在 CPU 和 DMA 设备两侧都能正常访问并且帮你处理了缓存一致性。#include linux/dma-mapping.h struct device *dev pdev-dev; dma_addr_t dma_handle; size_t size 4096; void *cpu_addr; cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); if (!cpu_addr) { dev_err(dev, dma_alloc_coherent failed\n); return -ENOMEM; } /* cpu_addr 是 CPU 访问这块缓冲区用的虚拟地址 */ memset(cpu_addr, 0, size); /* dma_handle 是设备 DMA 时应该使用的总线地址 */ writeq(dma_handle, priv-regs REG_DESC_BASE);这里有个经典的误解。很多初学者以为dma_alloc_coherent返回的cpu_addr可以直接转换成物理地址然后填到描述符里。在开了 IOMMU 的系统里这就是错的。cpu_addr是虚拟地址只有dma_handle才是给设备用的。在无 IOMMU 的嵌入式系统里dma_handle通常就等于物理地址。但这只是一种巧合它是架构相关的行为而不是接口的语义。写驱动时永远不要假设dma_handle和物理地址相等统一把它当作“给设备用的不透明地址”就行。4.2 dma_map_single临时映射与缓存同步如果不想用dma_alloc_coherent提前分配一块固定的 DMA 缓冲区而是想直接把已有的内核缓冲区交给设备那就用dma_map_single。void *buf kmalloc(size, GFP_KERNEL); dma_addr_t dma_addr dma_map_single(dev, buf, size, DMA_FROM_DEVICE); if (dma_mapping_error(dev, dma_addr)) { dev_err(dev, dma_map_single failed\n); kfree(buf); return -EIO; } /* 把 dma_addr 填进描述符 */ desc-buf_addr lower_32_bits(dma_addr); desc-buf_addr_hi upper_32_bits(dma_addr); /* 传输完成后 */ dma_unmap_single(dev, dma_addr, size, DMA_FROM_DEVICE);这里的DMA_FROM_DEVICE表示本次 DMA 的方向是设备往内存写数据。如果搞反了方向dma_unmap_single的时候缓存同步做错了就会出现“设备确实写了数据但 CPU 读到的还是旧数据”的灵异问题。这个缓存一致性问题是 DMA 调试的头号噩梦。CPU 有 cache设备通过 DMA 直接访问内存如果没有做同步或者没有用一致性映射CPU 会一直读到 cache 里的旧值。dma_alloc_coherent之所以叫 coherent就是因为它保证这块内存对 CPU 和设备是“一致”的不需要手动同步。而dma_map_single则要求在 DMA 之前和之后调用对应的 map/unmap本质上是帮你做 cache invalidate 或者 clean。4.3 我见过最典型的三种填地址错误在实际代码 review 里DMA 地址相关的错误出现频率非常高我归纳成三类大家对照看看自己有没有踩过。第一种直接拿 CPU 虚拟地址填描述符。这种错误在开了 IOMMU 的内核线程上下文里最容易出事。你kmalloc一块内存然后把kmalloc返回的指针直接丢给设备。设备拿着这个虚拟地址去 DMA访问到的内存完全是错的。现代内核开启CONFIG_IOMMU_DMA后这种错误通常表现为 DMA 传输 timeout 或者 IOMMU page fault。第二种拿到了正确的dma_addr_t但用错了位数。某些设备只有 32 位 DMA 寻址能力而系统可能分配了超过 4G 的总线地址。如果描述符里只填低 32 位高位直接丢掉设备就会访问到一个错误的地址。正确做法是用dma_set_mask_and_coherent告诉内核设备能支持多大的 DMA 地址范围然后在填描述符时分别填充高低 32 位。第三种描述符的环形队列指针指错了。环形描述符里通常有个“next descriptor pointer”字段用来告诉设备下一个描述符在哪。这个字段也是地址也必须填总线地址。很多人在初始化时把下一个描述符的 CPU 虚拟地址填进去了结果设备在处理好第一个描述符之后跳到了一个完全不存在的地址上去读下一个描述符直接卡死整个 DMA 队列。5. 实操写一个最简单的 DMA 描述符环5.1 环形描述符的结构设计理论讲了一堆不如直接动手写一个最小可用的 DMA 描述符环形队列。假设我们要为一个虚拟网络设备实现收包功能硬件支持 64 位地址描述符结构设计如下。struct rx_desc { u32 buf_addr_low; // 数据缓冲区地址低 32 位 u32 buf_addr_high; // 数据缓冲区地址高 32 位 u32 len; // 缓冲区长度 u32 status; // 状态OWN 位表示设备是否拥有 };环形队列里描述符总共 16 个每个描述符指向一个独立的 DMA 缓冲区。初始化时把 16 个描述符全部设为 CPU 写好的初始状态然后把描述符队列的总线地址告诉设备设备会从第一个描述符开始处理。这里最核心的概念是 OWN 位。初始化时 OWN 位设置为 1表示“这块缓冲区属于设备设备可以往里写数据”。CPU 收到 DMA 完成中断后会检查描述符的 OWN 位如果变成 0说明设备已经把数据放进了缓冲区CPU 可以读取了。读取完毕之后CPU 把 OWN 位置回 1重新把这块缓冲区还给设备。5.2 初始化流程与关键代码下面是一段经过简化的初始化代码重点放在地址的处理上。struct rx_ring *ring kzalloc(sizeof(*ring), GFP_KERNEL); if (!ring) return -ENOMEM; /* 分配描述符数组的 DMA 内存拿到总线地址 ring-desc_dma */ ring-desc dma_alloc_coherent(dev, RING_SIZE * sizeof(struct rx_desc), ring-desc_dma, GFP_KERNEL); if (!ring-desc) { dev_err(dev, alloc desc failed\n); kfree(ring); return -ENOMEM; } /* 分配每个描述符对应的数据缓冲区 */ for (i 0; i RING_SIZE; i) { struct rx_desc *desc ring-desc[i]; dma_addr_t buf_dma; ring-buf[i] dma_alloc_coherent(dev, BUF_SIZE, buf_dma, GFP_KERNEL); if (!ring-buf[i]) { /* 回滚处理省略 */ return -ENOMEM; } desc-buf_addr_low lower_32_bits(buf_dma); desc-buf_addr_high upper_32_bits(buf_dma); desc-len BUF_SIZE; desc-status cpu_to_le32(OWN_BIT); /* 描述符归设备 */ } /* 把描述符队列的总线地址写入设备寄存器 */ writel(lower_32_bits(ring-desc_dma), priv-base REG_DESC_BASE_LOW); writel(upper_32_bits(ring-desc_dma), priv-base REG_DESC_BASE_HIGH); /* 写 doorbell通知设备描述符已经准备好 */ writel(0, priv-base REG_DOORBELL);请注意一点desc-buf_addr_low和desc-buf_addr_high里存的是buf_dma也就是缓冲区在设备视角的地址不是 CPU 访问缓冲区用的虚拟地址。同时REG_DESC_BASE寄存器里写的是ring-desc_dma也就是描述符数组本身所在的设备视角地址。这两组地址用的是同一个dma_alloc_coherent接口返回的dma_addr_t。把它们区分清楚整个环形队列的地址链路就通了。5.3 调试 DMA 地址问题的手段调试这类问题首先要学会确认设备到底有没有读到正确地址。我在实际工作中总结出了三个层次的排查手段。第一层读设备寄存器。大多数 DMA 控制器会有寄存器记录当前描述符指针或缓冲区地址。初始化之后把设备寄存器里的描述符基地址读出来和驱动里记录的ring-desc_dma做对比。如果不一样说明 CPU 写入设备寄存器的地址不对或者字节序有误。第二层利用 Linux 内核的 trace 事件。现代内核里dma_api相关的 tracepoint 能打印每次 DMA 映射的地址信息。在debugfs里确认dma_handle是否正确。特别注意设备树或 ACPI 里配置的dma-ranges它决定了设备的 DMA 地址空间范围。如果设备的dma_handle落在dma-ranges范围之外就要怀疑是映射配置错了。第三层也是我最常用的土办法在初始化阶段故意把一个描述符的缓冲区填成特征数据 0xAA然后把设备寄存器里读到的地址直接cat /proc/iomem比对。如果设备看到的地址和驱动想给它的地址不一致这个土办法能很快定位到是地址翻译问题还是缓存一致性问题。6. 常见问题与排查经验速查6.1 高频问题速查表这些年帮同事排查过的 DMA 问题可以列一个很长的清单我挑出几个高频的整理成表格方便大家按图索骥。现象大概率原因排查方向DMA 传输超时/无中断描述符地址或缓冲区地址写错对比寄存器中的地址与 dma_handle数据随机错乱缓存一致性未处理检查是否用 dma_alloc_coherent 或正确调用 dma_map/unmapIOMMU page fault填入了虚拟地址或未正确映射确认地址来自 dma_map 系列接口设备看到了旧数据DMA_FROM_DEVICE 方向填错检查 dma_map_single 的 direction 参数老平台报 failed to reset the dmaDMA 控制器复位时序异常检查复位引脚/门控时钟/初始化顺序ADC/DMA 数据紊乱缓冲区索引错位或 FIFO 溢出检查环形队列 head/tail 更新顺序描述符环卡死next 描述符指针填了虚拟地址确认每个指针都填总线地址大块 DMA 分配失败内存碎片导致无连续物理页开启 IOMMU 或使用 SG 模式6.2 几个真实案例复盘案例一是 RK3588 上的以太网控制器报 “failed to reset the dma”。很多人以为是 DMA 描述符配置问题其实在我遇到的情况里这个问题更多是 DMA 控制器的复位时序问题。RK3588 的 GMAC 需要先保证时钟稳定再释放 DMA 的软复位如果驱动在时钟没准备好的时候就去做复位操作硬件就会返回失败。排查方式是先看 clk_enable 是否成功再调复位操作的顺序跟描述符里的地址没有直接关系但很容易被误诊。案例二是我在一块 XDMA 板卡上遇到的描述符异常。现象是 FPGA 侧能收到 DMA 请求但数据写入的位置完全不对。后来通过抓 PCIe trace 发现描述符里的 buffer 地址被 FPGA 解读成高 64 位和低 64 位两个字段而驱动的结构体里恰好把低 32 位放在偏移 0高 32 位放在偏移 4看起来对但字节序处理错了。解决方法是统一用 le32_to_cpu/cpu_to_le32 做转换并在结构体定义处注明寄存器手册里的字节序要求。案例三是在一块 MIPS 平台的网卡驱动里收包总是出现偶发乱序。用 printk 打了许久没有头绪最后是用dma_unmap_single的时机做修正才解决。驱动为了性能把 unmap 放在了 NAPI 处理完之后但硬件在极少数情况下还会在中断处理完后再写几个字节导致缓冲区被 unmap 之后又被 DMA 写入。这类问题用静态分析不太容易发现最好的排查方式是开启内核的 DMA API debug 选项它能在 unmap 之后检测到重复访问。6.3 我的几个避坑习惯这几年调 DMA 相关代码我慢慢总结出了一套固定习惯分享出来供参考。第一个习惯所有地址变量都明确加后缀。cpu_addr、dma_addr、phys_addr三个后缀在代码里严格区分禁止把一个 DMA 地址直接赋值给普通指针。代码可读性提升是一方面更重要的是能从类型上杜绝“虚拟地址填描述符”这种错误。第二个习惯初始化描述符环时用特征值先自测。我会在缓冲区里依次填入0xDEADBEEF这种特征值然后在设备完成第一笔 DMA 之后把写入位置的数据和预期值做对比。这样能第一时间判断地址映射是否正确而不是等整套数据通路跑起来之后再去大海捞针。第三个习惯凡是用到 DMA 的设备驱动第一件事就是检查dma_set_mask_and_coherent是否被正确调用。很多设备默认支持 32 位 DMA如果内核分配的缓冲区地址超过了 4GDMA 就会失败。显式设置 DMA mask 之后内核会尽量分配 4G 以下的内存避免这类问题。第四个习惯是把描述符相关的寄存器读取逻辑做成 debugfs 接口。调试期间可以通过读写文件的方式直接查看设备当前的描述符指针和状态位不用每次改代码重新编译。这个习惯在长期维护的驱动项目里价值巨大能省下很多和硬件联调的沟通成本。我个人的体会是DMA 地址这件事难不在代码难在建立起“设备有自己的地址世界观”这个意识。你习惯了 CPU 的虚拟地址视角就很容易忽略设备视角里那个平铺的、需要翻译的地址空间。只要把三套地址的边界理清楚写 DMA 描述符就跟填快递单一样简单收件人是谁就填谁的地址千万别替快递公司决定用哪套地址系统。
返回列表