写定制驱动这半年,我发现自己经常干的一件事就是:打开调试日志,盯着 DDR 里的描述符,心里默念“你写的地址到底对不对”。尤其是在调 XDMA、GD32 网卡、以及 RK3588 的 ETH 这类外设时,一旦报出 “failed to reset the DMA” 或者接收描述符里出现乱码,十有八九是地址这个事没搞明白。今儿 Day 9,咱们就把这层窗户纸捅破:从 DMA 描述符里写的是什么地址,到设备眼里的内存究竟长什么样,用实际代码和踩坑记录把它讲透。
这个系列面向的是搞 AI Infra、写驱动、调定制板卡的同学。如果你正被 DMA 连续请求、RX 描述符刷屏、外设内存映射看着一头雾水,那么这篇内容大概率能让你少走几周的弯路。全文不和你兜圈子,直接上干货。
1. 先搞清楚:DMA 描述符里写的到底是什么地址
这个话题看着简单,但我在技术群里问了一圈,至少有一半人答不对。有人脱口而出“物理地址”,有人纠正说“应该是总线地址”,还有人直接说“反正把virt_to_phys得到的数写进去就对了”。这几个答案都有点意思,但又都不完整。
1.1 设备眼中的内存:没有进程,只有全局地址
你的 CPU 平时看到的地址,是经过 MMU 翻译之后的虚拟地址。每个进程都有自己的页表,你以为的“0x10000”在物理上可能是 DDR 的任何一个位置。但是对于 DMA 控制器和外设来说,事情简单粗暴得多:它们拿到一个地址,就直接往总线上发,去访问这个地址对应的内存。它眼里没有进程、没有页表、没有虚拟地址这回事。
打个比方:CPU 像是个拿着地图查路的人,地图上标注的“人民路 6 号”是虚拟地址,真正的位置还得靠门牌和路网翻译。DMA 设备不一样,它就像一个只看经纬度的快递无人机,你给它 31.23, 121.47,它就往那儿飞,没人和它谈门牌号的浪漫。描述符里写的地址,就是这种“经纬度坐标”。
所以第一层结论先定下来:描述符里绝对不写 CPU 虚拟地址,写了必崩。这算是所有 DMA 编程里最基础的禁令。
1.2 物理地址不是你以为的那个物理地址
那写物理地址行不行?行,但对一半。这里有个隐蔽的点:CPU 侧的“物理地址”和外设总线侧能直接寻址的地址,并不总是同一个数字。
大部分 ARM SoC 里,DMA 设备看到的内存地址空间,是一个被叫作“总线地址”(Bus Address)的概念。在没有 IOMMU/SMMU 的老方案里,总线地址等于物理地址,所以很多人早年写驱动直接virt_to_phys塞进去也能跑通。但一旦你上了带 SMMU 的平台,或者设备挂在 PCIe 桥后面,总线地址就和物理地址脱离了。设备报给驱动的地址,是经过 IOMMU 翻译之后的结果,驱动必须用dma_map_single或者dma_alloc_coherent这类 API 拿到的返回值,才是设备能认的地址。
这就能解释一个很典型的现象:你在调试 XDMA 时,描述符里明明填的是物理地址,可硬件就是死等状态,DMA 不搬数。原因往往就是你绕过了内核 DMA API,自己拿物理地址填了进去,而 SMMU 那头根本不认识这个数字。
统一一下概念,我建议你记住下面这张相当于“翻译对照”的表格:
| 地址类型 | 谁在用 | 怎么获得 | 能不能写进描述符 |
|---|---|---|---|
| CPU 虚拟地址 | CPU、进程 | 指针 | 绝不能写 |
| CPU 物理地址 | CPU 访问 / 内核页表 | virt_to_phys() | 无 SMMU 时可以,有 SMMU 时别用 |
| 总线地址 / DMA 地址 | DMA 控制器、外设 | dma_alloc_coherent/dma_map_single | 这才是最规范的写法 |
所以你问我“DMA 描述符里写的是什么地址”,标准的完整回答是:它写的是该设备所看到的、可用于 DMA 访问的地址,这个地址一般等于物理地址,但经过 IOMMU 后未必相等。
2. 描述符的完整长相与编排逻辑
说完了地址类型,咱们来看看描述符本身。很多做 AI Infra 的同学平时不接触这一层,只觉得“描述符就是一块内存,填好地址和长度就行”。但真到调板子时,描述符字段错一位,硬件就有可能把数据写到天王老子那边去。
2.1 一个典型描述符的字段结构
以比较常见的 DMA 控制器为例,一个描述符至少有下面这几个字段:
- 地址字段:存放 DMA 要搬运的目标内存地址,也就是咱们上面讨论的“总线地址”。
- 长度字段:本次传输的字节数。有一些控制器要求长度按 4/8/16/32 字节对齐,不满足直接报错。
- 控制字段:指定传输方向、中断使能、是否是链尾、是否要触发下一次描述符等。
- 状态字段:传输结束后,硬件回写完成标志、错误标志或者实际传输字节数。
拿网上经常搜到的“XDMA 描述符”来说,XDMA 有 H2C 和 C2H 两条搬运路径,描述符里除了地址和长度外,还会涉及一个关键字段:BTT(Bytes To Transfer)。这个字段在老版本驱动里出现过溢出问题,一旦赋错,DMA 一次搬运的字节就变了,数据错位症状特别像“描述符被随机改写过”。
你可以把描述符理解成外卖订单:地址是收货人地址,长度是点的菜量,控制字段是加辣/免辣/不放香菜,状态字段是骑手跑完单之后回的“已送达”。这套订单的格式是商家(驱动)和骑手(DMA 引擎)事先约定好的,谁都不能改格式。
2.2 环形队列:描述符不是单个用的
接口上见过的 DMA 描述符大多是梳状排列的,也就是一个环形队列(Ring)。硬件控制器有一个“当前正在处理”的指针,驱动把一批描述符写好之后,会更新控制器的 tail 或 head 指针,让它知道自己有新工作可做。
这个环形队列的设计原因并不复杂:DMA 引擎处理完一个描述符后,如果直接结束传输,那么高吞吐场景会频繁触发中断、频繁重新配置引擎,根本跑不满一块网卡或 NVMe SSD 的带宽。改成环形队列之后,驱动可以一次性交给硬件 N 个“任务”,硬件闷头把这一圈干完,再通知 CPU 处理结果。这也解释了为什么你在搜“DMA continuous requests”时,会看到有人为了连续 DMA 传输,会一次性铺几十上百个描述符。
这里有个容易犯的错:环形队列的每一项描述符都要占据一块物理连续的内存。如果你用kmalloc一个个分配,往往分配出来的地址不连续,环形队列就会出现“断裂”。所以驱动里通常的做法是一次性分配一整块描述符内存,再用 struct 数组去切分,绝对不要图省事搞多次kmalloc。
2.3 地址字段为什么要对齐 64 字节这类细节
描述符字段看着简单,但很多 DMA 控制器接收到的地址如果不对齐,会出现效率骤降或者直接触发Misaligned Address错误。之前在 GD32E230 的 ADC-DMA 采集上看到过类似问题:描述符指向的数据缓冲区地址是 3 字节对齐的,结果 DMA 搬运回来的数据隔几笔就乱一笔。
原因在于不少 DMA 引擎的读突发(burst)长度是按总线位宽或缓存线大小设计的。地址没按 32/64 字节对齐,硬件就需要用多个非对齐事务组合传输,某些实现会直接丢弃末尾几个字节数据。所以在分配 DMA 缓冲区时,最好带上ALIGN宏或者在设备树里声明dma-ranges时预留对齐余量。
我的个人建议是:分配 DMA 内存一律按 64 字节对齐打底,长度按 4 字节倍数尽量拉满到 32 倍数。这不是强迫症,是为了防止踩到不同硬件平台的对齐雷区。
3. 从驱动代码看 DMA 地址怎么一步步“写到”描述符
理论讲完,咱们直接上一段实际流程。这是我在调试一块 PCIe DMA 采集卡时的真实路径,完整的收发流程让很多朋友看了一头雾水,这里把它拆开揉碎。
3.1 内核给出的 DMA 地址是从哪来的
第一步,驱动里要准备一块专门给设备用的内存区。最常见的是dma_alloc_coherent:
dma_addr_t dma_handle; void *cpu_addr; cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);这个 API 一次给了两个地址:cpu_addr是 CPU 能访问的虚拟地址,dma_handle是设备能认的 DMA 地址。别小看这个函数,它背后做了不少事情:它可能会分配带 IOMMU 映射的页、可能敲门进入一致性内存池、可能铺好 cache 同步所需的底层钩子,唯一目的就是保证 CPU 写入的数据让设备 DMA 一方能看到。
到了这一步,你手里已经有了一个“设备认识的地址”,描述符里要填的就是dma_handle。
3.2 分配一致性映射与流式映射的区别
不过dma_alloc_coherent只适合长时间持有、反复用作 DMA 缓冲的场景。如果你只是把网络报文或者磁盘块映射给设备用一下,更推荐流式映射:
dma_addr_t addr; addr = dma_map_single(dev, virt_addr, len, DMA_TO_DEVICE); // 填描述符... // 传输完成后 dma_unmap_single(dev, addr, len, DMA_TO_DEVICE);这个操作的核心是拿到 DMA 地址并把缓存刷新到内存。很多新手困在一件事上:为什么我把数据写进缓冲区了,设备 DMA 读到的还是旧数据?原因多半是没有dma_map_single,或者没有在写入后调用dma_map_single申请地址/刷缓存。
用dma_alloc_coherent拿到的内存天然就是“一致映射”,不需要每次手动同步 cache,代价是分配开销大。流式映射则灵活,但要严格配对 map/unmap,顺序错了数据就花了。
3.3 一个完整的数据发送流程
这里以网卡 TX 方向为例,给大家展示一个最小可运行的伪代码流程:
struct eth_desc *desc = ring->desc_head + idx; desc->addr = dma_map_single(dev, skb->data, skb->len, DMA_TO_DEVICE); desc->len = skb->len; desc->ctrl = TX_DESC_CTRL_EN | (idx == ring->last ? TX_DESC_CTRL_END : 0); wmb(); // 保证前面的写入对设备可见 writel(tail_new_idx, ring->tail_reg);这段流程里有一个关键操作:wmb()写屏障。DMA 引擎通过总线访问内存时,可能因为 CPU 乱序执行,导致“地址已经写到描述符”的指令还没真正落到内存,硬件就开始读取描述符。多数 DMA 控制器需要在更新 tail 寄存器前加wmb(),确保描述符数据全部 reachable。
常见错误就是漏掉wmb(),症状表现为“跑了几十次才偶尔出错”,排查起来极其头大。
4. 我在实际项目里踩过的 DMA 地址坑
写再多的结构理论,都不如直接说几个真实场景让大家对照自查。下面这三个问题,我分别在 RK3588 平台、Xilinx XDMA 和 GD32 上遇到过,网络热词里也有人反复搜到了它们。
4.1 “failed to reset the DMA”到底是什么
关于“RK3588 ETH 报 failed to reset the DMA”,这个错误我第一次见时也愣了一下。它并不是说 DMA 控制器损坏,而是驱动在启动或链路复位时,往 DMA 控制寄存器写复位命令,硬件在规定时间内没有应答,或者复位后标志位没有回到预期值。
实际排查中,出现这个报错的前置条件通常有两个:一是描述符队列里有残留的异常地址,导致 DMA 状态机卡在某个非法状态;二是设备树里dma-ranges或者iommu-map配错,导致内核把一个无效的总线地址写进了 DMA 配置寄存器。
我那次解决的路径是:把驱动卸载后手工读取 DMA 状态寄存器,发现DMACSR里的RB(Receive Busy)位被卡住。清不掉。后来重新初始化描述符环形队列,并确保通知硬件的 tail 指针是 0,再触发软复位,问题就消失了。所以遇到这个报错别急着换硬件,先把描述符队列清干净再复位。
4.2 高地址内存与 32 位设备
老一点的外设,比如某些 32 位 PCIe 桥设备,DMA 地址只能支持 32 位范围,也就是 4GB 以下。但现在的服务器/开发板内存动辄 8GB/16GB,内核给驱动分配的dma_handle很可能落在 4GB 以上,直接填进描述符就会出问题。
这个场景下,驱动要做的是让设备侧知道自己的地址能力,比如在设备树里声明dma-ranges = <0x0 0x0 0x0 0x0 0x0 0xffffffff>;,或使用DMA_BIT_MASK(32)做掩码设置。
我当年在调一块 PCIe 数据采集卡时,发现有时 DMA 能跑,有时数据完全乱掉,查了两天才意识到系统插了 16GB 内存,默认 DMA 掩码是 64 位,但设备实际只能处理 32 位描述符地址。需要在驱动初始化阶段做一次 mask 检查:
if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))) { dev_err(dev, "device can't do 32-bit DMA\n"); return -EIO; }许多“偶发性”数据错误,根源就在这里。排查问题时可以优先确认一下。
4.3 不一致描述符里看到的全 0 地址或错乱数据
还有一种特别玄学的故障:打开调试寄存器,发现描述符地址字段确实是 0,但驱动明明设置了addr字段。后来发现是 cache 缓存没有回写。特别是dma_alloc_coherent的一致性映射只保证“内核帮你处理 cache 同步”,但如果你用自己的方式将这块内存同时也做了普通映射,然后往普通映射地址写入数据,就有可能出现缓存不同步。
这种情况在 GD32 这类单片机上尤其明显,因为它的 ART 缓存和各总线主设备对内存可见性要求非常苛刻。建议在往描述符写入关键字段后,无论是 ARM 还是 RISC-V,都要按体系结构要求加wmb()/dmb(),必要时调用dma_sync_single_for_device做一次显式同步。
5. 调试 DMA 地址问题的几条实用路径
最后一章,把我这么多年定位 DMA 问题的方法论分享给你。不搞一把梭,按顺序排查,通常都能在小时内锁定问题。
5.1 从 /proc/iomem 和内核日志确认地址空间
你写完驱动,第一件事不是上设备,而是先cat /proc/iomem确认目标外设映射的物理地址范围。然后在内核里打印dma_handle的实际值,确认它是否落在外设可寻址的范围之内。
如果设备树里配了iommu-map,还可以检查 SMMU 是否把设备侧地址重新映射到了另一个地址段,这样调试时心里就有底。
5.2 加打印与 ftrace 追踪 dma_map_ops
在驱动里加dev_info打印dma_handle、物理地址、虚拟地址三者之间的对应关系,是非常正常的操作。另外打开内核的CONFIG_FTRACE,可以跟踪dma_map_page、dma_unmap_page等调用过程,确认是不是在 map 阶段拿到异常地址。
哪怕是临时加几千行日志,也比盲目看逻辑靠谱。DMA 问题最怕的就是猜。
5.3 物理层抓包:DMA 数据验证
如果手上有逻辑分析仪或总线协议分析仪,可以挂在总线侧,观察描述符读取地址和数据搬运地址。很多 DMA 控制器会把描述符读事务和实际搬运事务在总线上以可识别的模式呈现,直接抓总线,你就能看到硬件到底在尝试访问哪个地址。这个手段最硬核,也最准。
没有总线分析仪的,也可以绕道用“数据验证”法:把 DMA 接收缓冲区填上特定模式的种子数据(比如 0xAA、0x55),然后让设备 DMA 写一块内存,写完后再 dump 出来检查目标地址区域里有没有对应模式。如果数据出现在了错误地址区域,那你描述符里的地址字段 100% 有问题。
我在实际调板时,还喜欢在驱动里加一个 debugfs 接口,用来随时 dump 描述符环形队列的内存内容。遇到问题,直接cat /sys/kernel/debug/dma_ring/desc,看一眼描述符里存着的地址对不对,再和dma_handle比对,基本一眼就能定位是驱动问题还是硬件问题。
最后分享一个排查小技巧
如果你经常调 DMA 相关驱动,一定要养成一个好习惯:在描述符初始化阶段,往每一个描述符的“填充区”写入一个魔术脚印,比如0xDEADBEEF。这样一旦硬件或软件改了描述符里的意外字段,后面 dump 时就能看到哪个描述符被动过、被谁动过的痕迹。这招帮我在一次多核竞争问题上少排查了两天。
DMA 描述符地址这件事,说白了就是“设备眼中的内存是一张没有分层的地图”。搞清物理地址、总线地址、虚拟地址三者之间的关系,再配合规范的内核 API 来管理 DMA 映射,这个领域的大部分问题都能迎刃而解。希望这些内容能帮你避开我当年踩过的坑,把更多的调试时间留给真正复杂的问题。