1. 先搞懂一件事:设备眼里的内存不是CPU眼里的内存
1.1 CPU视角的内存:那套复杂的虚拟地址体系
做AI Infra的人,每天打交道最多的除了算力卡就是内存。CPU视角下的内存,早就是一个被页表管理得服服帖帖的虚拟地址空间。用户态进程看到的是用户虚拟地址,内核看到的是内核虚拟地址,中间隔着一层MMU和页表缓存。在64位系统上,一个进程理论上能用的地址空间大到不需要去考虑“物理内存条插了多少”,因为虚拟地址和物理地址之间是一套复杂的映射关系。
但DMA控制器这种设备,压根不吃这一套。CPU可以通过页表随便映射、顺便还搞个swap换页,设备却不能。设备没有MMU来做虚拟地址到物理地址的翻译,除非平台额外部署了IOMMU或SMMU,否则设备发起DMA读写时,它拿出来的那个地址,直接就是送上总线、去访问物理内存位置的门牌号。换句话说,CPU眼里的内存是“带着导航系统的城市地图”,设备眼里的内存是一个没有路名的街区,只有坐标编号。
1.2 设备视角的内存:一个“扁平”的物理世界
如果把DMA引擎想象成一个勤快的搬运工,它不关心进程、不关心页表、不理解虚拟内存。它做的事情很简单:拿到一个源地址、一个目标地址、一段长度,然后启动搬运。搬运工手里的地址,必须是一个它能够直接访问的地址。在绝大多数不带IOMMU的场景下,这个地址就是物理地址。在带有IOMMU/PCIE总线域的场景下,这个地址是IOMMU映射出来的“设备地址”或叫IOVA。
所以,当你问“DMA描述符里写的是什么地址”,答案的核心是:设备发起DMA时能用、能访问的地址,而不是CPU虚拟地址。很多刚接触网卡驱动、NVMe驱动、GPU驱动和AI加速卡驱动的人,最容易犯的一个错就是:把内核里拿到的一个void *指针强行转成dma_addr_t填进描述符。这在某些平台碰巧能跑,因为DMA地址和物理地址恰好一致,但在有SMMU/IOMMU的ARM64平台、PCIE设备场景下,这样写直接就是内存踩踏、总线错误甚至内核崩溃。
我再打个比方。CPU像是一个人会看地图,知道“人民路123号”怎么走。设备则是一个认死理的货车司机,他的导航屏幕上只能输入拼音门牌号,你给他一个“人民公园东南角第三个长椅左转”这种描述,他就罢工或者乱开。DMA描述符就是司机手里的送货单,上面必须写门牌号,而不是描述性路线。这里有个容易被忽略的细节:即使门牌号(dma_addr_t)看起来像物理地址的数字,它也未必等于CPU的物理地址,因为中间还有IOMMU这层“门牌重编号系统”。
2. DMA描述符里写的地址,到底是什么地址
2.1 CPU物理地址、总线地址与IOVA的三角关系
要彻底搞清楚描述符里写什么,必须把三个概念摆在一起说清楚。
CPU物理地址:这是内存控制器层面看到的真实地址。在内核里,你需要通过virt_to_phys()或者page_to_phys()这种接口才能拿到。凡是做底层驱动的人,对这个都不会陌生。
总线地址/设备地址:这是设备在发起DMA时,放到总线事务里的地址。在没有IOMMU的简单系统里,总线地址就等于物理地址。但在PCIE RC模式、ARM SMMU开启的场景下,总线地址和物理地址可能完全是两码事。设备以为自己在读地址0x50000000,实际上IOMMU把它映射到了内存控制器上的0x90000000处。
IOVA:IOMMU给你设备分配的一个虚拟地址窗口。设备看到的是连续的窗口,实际背后可能是一堆不连续的物理页。这个机制和CPU的虚拟内存非常像,只不过服务对象是设备。
描述符里该填的地址,永远是那个“设备发起DMA时实际使用”的地址。在内核代码里,这个地址一般情况下等于dma_map_single()或dma_map_sg()返回的dma_addr_t。请务必把这个dma_addr_t当作一个不透明的胶囊来用,不要想当然地去猜测它是物理地址还是IOVA。在支持IOMMU的平台,同一个dma_addr_t对CPU来说就是一段不可直接解引用的数字,你硬要拿它当内核指针去访问,大概率是访问异常。
2.2 描述符的四个核心字段,到底该怎么填
以现在主流网卡、加速卡、以及高端SoC片内DMA控制器为例,一个典型的DMA描述符通常包含以下核心字段:地址指针、长度、控制字、状态字。地址指针填的就是上面说的dma_addr_t,长度用字节为单位,控制字决定方向、中断是否使能、是否刷新缓存等,状态字则由设备硬件回写,驱动轮询或中断时读取。
举一个比较有代表性的结构体,取自常见网络控制器描述符格式:
struct dma_desc { u32 addr; /* DMA地址低32位 */ u32 addr_hi; /* 高32位,用于64位DMA */ u32 length; /* 数据长度 */ u32 control; /* 控制字段:OWN位、中断位、方向等 */ u32 status; /* 状态字段:由DMA引擎回写,表示传输完成 */ u32 reserved[3]; /* 对齐保留,有时用于扩展元数据 */ };看到“控制字里的OWN位”没有?这个在多数硬件里都存在。OWN位表示描述符当前归谁所有:置1时归硬件所有,硬件可以读取并处理这个描述符;将描述符填充好之后,驱动把OWN位从0改成1,通知硬件可以拿走。硬件处理完,会把OWN位清0,并更新状态字。这个机制避免了驱动和硬件同时访问同一个描述符导致竞态。
这里要特别提醒一句:描述符本身也存在内存里,硬件也需要去读它。因此描述符所在的内存,必须让设备可访问。更关键的是,不能有缓存一致性风险。一般做法就是使用dma_alloc_coherent()一次性把描述符所在的缓冲区分配出来。这个函数返回两个值:一个是内核虚拟地址CPU可以用,一个是dma_addr_t给设备用。设备用这个dma_addr_t去读描述符,CPU用虚拟地址去写描述符,两边靠硬件机制保证看到一致的内容。
2.3 描述符地址“门牌号”的对齐要求
很多新手填描述符时只看“地址”两个字,却忽略了对齐要求。几乎所有DMA控制器都要求描述符起始地址按32字节或64字节对齐,有些甚至要求按Cache Line对齐。如果对齐不对,可能出现极诡异的现象:设备偶尔工作正常,偶尔读到半个描述符,然后总线报错或者干脆设备挂死。
我在实际项目里就遇到过一次类似问题。一块自带DMA的AI加速卡,驱动初始化时描述符环用的是普通kmalloc内存,手动做了dma_map_single()之后没检查对齐。结果就是同一个版本固件,在x86上稳定运行,在ARM SMMU开启的平台上一加载就报SMMU fault,日志里地址指针看起来完全正常。最后查了三天,发现是描述符基地址没做64字节对齐,IOMMU把一次跨越边界的事务拆成了两个,其中一个翻译成的物理地址完全不对。从那以后,凡是涉及描述符的内存分配,我都会在代码里加一句强制对齐:
struct dma_desc *desc_ring = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL); desc_ring = PTR_ALIGN(desc_ring, 64);很多人可能觉得PTR_ALIGN之后还要重新算dma_handle,麻烦,干脆放弃对齐检查。但DMA描述符对齐这个事,多花十分钟写对,能省下后面三天的调试时间。
3. 从环形描述符到真正跑起来的数据通路
3.1 经典Tx/Rx Ring是怎么工作的
单独一个描述符可以完成一次DMA传输,但实际业务里,驱动很少每次发一个数据包才去准备一个描述符,太慢了。常用的做法是环形描述符,也叫Ring Buffer。驱动预先分配一块连续内存,里面排满描述符,形成一个首尾相连的环。然后准备两个寄存器指针,一个叫Head,一个叫Tail。
驱动往环里填充新描述符时,移动Tail指针,写寄存器告诉硬件“你得处理到Tail了”。硬件处理完一个,内部移动Head指针,通过中断或状态位告诉驱动“我干到这儿了”。驱动回收时,只需要从自己的Ring上维护的head开始处理,到硬件更新的位置结束,中间的那部分描述符就是已经完成的。这种机制的好处是批量处理和异步处理都方便,网卡和高性能NVMe驱动基本都是这个套路。
描述符环本身占用的是一整块连续的物理内存,或者至少是IOMMU映射后呈现为连续的IOVA。这意味着在配置DMA时,驱动要完成几个步骤:分配内存、转成DMA地址、按顺序初始化每个描述符、把环的基地址和设备DMA地址写入硬件寄存器、最后写Tail踢一下硬件。
3.2 初始化描述符的完整代码流程
我写一份简化的初始化流程,可以当作模板来看:
/* 步骤1:分配描述符环,保证一致性映射 */ int nr_desc = 128; size_t ring_sz = nr_desc * sizeof(struct dma_desc); dma_addr_t ring_dma; struct dma_desc *ring = dma_alloc_coherent(dev, ring_sz, &ring_dma, GFP_KERNEL); if (!ring) return -ENOMEM; /* 步骤2:逐项初始化描述符 */ for (int i = 0; i < nr_desc; i++) { /* 准备数据缓冲区(这里从SKB或预分配池拿) */ struct page *page = alloc_page(GFP_DMA32 | GFP_KERNEL); dma_addr_t buf_dma = dma_map_page(dev, page, 0, PAGE_SIZE, DMA_TO_DEVICE); ring[i].addr = lower_32_bits(buf_dma); ring[i].addr_hi = upper_32_bits(buf_dma); ring[i].length = skb->len; /* 数据长度,单位字节 */ ring[i].control = DESC_OWN_BIT | DESC_IRQ_EN | DESC_DIR_TX; /* OWN=1,表示交给硬件 */ ring[i].status = 0; } /* 步骤3:把描述符环基地址写给设备 */ writel(ring_dma, dev_base + DMA_RING_BASE); writel(nr_desc, dev_base + DMA_RING_LEN); /* 步骤4:踢一下尾指针,触发传输 */ writel(nr_desc, dev_base + DMA_TAIL);这段代码要注意一个重点:ring是CPU访问描述符的虚拟地址,ring_dma是硬件访问描述符所用的DMA地址,两个都必须用到。ring[i].addr存的是数据缓冲区的DMA地址,不是虚拟地址,也不是物理地址。正因为dma_alloc_coherent()返回的双地址天然满足缓存一致性,描述符环才可以直接被硬件读取,不需要每填一项就刷一次Cache。
代码里我用了DMA_TO_DEVICE方向标志,发送方向。接收方向正好反过来,映射方向要改成DMA_FROM_DEVICE,在接收完成之后,驱动要从dma_map_page对应的缓冲区里取数据,取完还要做dma_unmap_page。这一步遗漏会带来缓存一致性问题,后面第4节会专门讲。
3.3 为什么描述符环的位置本身要选对
还有一个基础问题容易被忽略:描述符环放在哪个内存区域。早期PC系统里,设备DMA有时只能访问24位或32位地址空间,对应到内核里就有GFP_DMA这类标志。到了现代ARM SoC和高性能PCIE设备上,64位DMA已经很普及,但仍有少数外设不支持64位,或者只支持有限的地址范围。驱动初始化时应该用dma_set_mask_and_coherent()告诉内核设备支持多大的地址范围,内核DMA API层会据此决定你的缓冲区落在哪里。
我看过不少驱动直接把dma_set_mask这个函数注释掉,觉得“64位内存大势所趋没必要限制”。结果就是有些外设在内存高于一定物理位置时,DMA地址写得不对,数据搬到半路丢进黑洞,而驱动层面完全不报错,直到应用层发现读回来的数据是垃圾。排查这类问题既痛苦又耗时,所以初始化时别偷懒,把DMA掩码设置好,也是对硬件规格的尊重。
4. 我在实际项目中踩过的DMA描述符地址坑
4.1 缓存一致性:为什么数据总是“差一拍”
在ARM平台做DMA,缓存一致性问题是一个绕不开的坎。DMA引擎搬数据时,它直接写内存,不会管CPU的Cache。CPU读数据的时候,如果Cache里还留着旧值,它就不会真的去内存拿新的,于是你看到的现象就是:设备明明已经把数据搬完了,中断也来了,驱动去读缓冲区,却发现内容还是老样子。
如果描述符里的地址字段因为缓存不一致被硬件读错,现象还要更难受。硬件可能读到一条旧描述符,里面指向一个已经被释放的缓冲区,DMA写进去,直接把内核内存踩烂。这种随机内存破坏的Bug最难过。这时候先别急着上调试器,先把DMA API是否用对检查一遍。
在内核里,标准做法是:
- 用
dma_alloc_coherent()分配的区域,CPU和DMA之间天然一致; - 用普通
kmalloc()或者栈里的内存,需要自己调用dma_map_single()并指定方向;传输完毕要用dma_unmap_single(),这中间CPU想修改数据,还需要调用dma_sync_single_for_cpu()和dma_sync_single_for_device()。
实践里最省心的方案就是:描述符环用dma_alloc_coherent(),数据缓冲区如果是高频收发,就干脆用dma_pool预分配固定大小的缓存块,全程走专用DMA内存池,免去手动同步的内存屏障操作。这个方案在物联网级别的SoC和高性能服务器网卡驱动里都通用。
4.2 连续内存与CMA:大块分配失败的背后
DMA需要物理连续内存,这是老生常谈。在长时间运行的服务器上,内存碎片化严重时,即使系统还有几个GB的可用内存,也可能分配不出一块8MB连续的DMA缓冲区。为此内核做了CMA(Contiguous Memory Allocator)机制,系统启动时预留一部分内存专门用于大块连续分配。通常你在内核cmdline里能看到cma=128M之类的参数,这就是预留的连续内存池。
驱动层面,如果dma_alloc_coherent()分配大块内存失败,日志里多半能看到什么“resource temporarily unavailable”或者“allocation failed”。我自己曾经在跑AI推理服务的时候遇到过类似问题:推理引擎在初始化时给加速卡申请DMA缓冲区,连续分配4块128MB,长期运行后某个边缘节点突然起不来,日志里全是CMA: cma_alloc: failed。最后是把摄像头、音视频驱动里那些占着CMA不撒手的模块重启释放了内存,才缓过来。这件事给我的教训有两个:CMA内存是公共资源,驱动用完之后要立刻释放;大块DMA缓冲区的分配策略最好做成动态伸缩,而不是一上来就占满。
在网络热词里有一条“dma continuous requests”,说的就是连续DMA请求的问题。很多驱动在初始化时喜欢一下子把所有描述符环和缓冲区都分配好,遇到碎片化系统就失败。更好的做法是运行时按需增长,比如网卡只在队列激活时分配描述符环,数据缓冲区用page pool按需预取。
4.3 复位与描述符所有权:那些“failed to reset the dma”的瞬间
热词里有一条“rk3588eth报failed to reset the dma”,这是嵌入式开发圈子里比较常见的问题。设备在初始化或复位时,DMA引擎卡在一个未完成的事务上,硬件复位命令发出后迟迟不回状态。这个问题的原因往往不在DMA本身,而在于:
- 时钟或电源域没有先稳定下来,导致DMA复位时序不满足要求;
- 硬件正在搬运数据,复位命令被DMA事务阻塞;
- 描述符环里还悬着未清理的OWN位,硬件复位后突然抓到一条脏描述符。
排查思路其实有章法,首先确认复位前后时钟和电源状态,其次在复位前把DMA控制寄存器里的使能位全部清零,再等待状态寄存器里的busy位变低,最后才去操作复位位。顺序反了,大概率碰到reset失败。读完日志再想解决方案,比一个劲儿重试要高效得多。
还有一次我在一块内部集成了USB和SDIO控制器的SoC上调试,DMA复位失败,原因是某个中断没有被正确清掉,DMA引擎一直认为自己处于busy状态。解决办法就是在复位流程里先把对应中断状态寄存器挨个读一遍,并写1清除,然后再执行复位。所以如果看到reset失败,先别怀疑硬件,把中断状态、busy状态、时钟状态三个寄存器的值都抓出来看看。
4.4 错误地使用物理地址代替dma_addr_t
这个坑在开启IOMMU/SMMU的平台上特别明显。有些驱动是从老平台移植过来的,过去没有IOMMU,总线地址等于物理地址,于是驱动里直接用了virt_to_phys()把缓冲区地址填进描述符。换到新平台,IOMMU一开,设备实际使用的是IOVA,物理地址直接写进描述符里,设备尝试访问这个地址时,IOMMU不认,事务被终结,驱动看到的是dma_mapping_error或者bus error。
这个问题的隐蔽之处在于:设备有时候会假定一个默认映射区域,这个区域的IOVA可能和物理地址区间高度重合(尤其内存起始地址在0附近时),于是大多数时候居然能正常工作。只是某一次缓冲区落到一个IOMMU未映射的物理地址,系统就突然崩了。所以排查这种问题,最简单的做法就是把IOMMU/SMMU暂时关掉,重新跑一遍DMA流程,如果不再报错,基本可以确认是地址翻译层的假设出了问题。然后把驱动里所有硬编码的物理地址转换换成标准的DMA API,才能从根本上解决。
5. 排查DMA描述符问题的实用方法
5.1 从报错信息反推问题点
DMA相关的问题,日志信息往往非常有指向性。SMMU fault或DMAR fault基本说明设备发起DMA事务时IOMMU翻译失败,优先级是检查dma_addr_t是否填对、DMA掩码是否设置正确、映射是否已完成。reset timeout或者failed to reset the dma说明DMA引擎状态机卡住了,优先级是检查时钟电源、中断状态、busy位。descriptor error这类字眼,通常就是设备读描述符时格式或者对齐不对,优先级是检查描述符结构体定义是否和硬件手册一致。如果日志里只有事件,没有明确报错,那就要走总线上的trace工具了。
5.2 我做DMA调试时的几条心得
先说结论:打印要全,胆子要大,但改代码要谨慎。我在调DMA的时候,会先加一套调试函数,把每个描述符的addr、length、control、status完整打印出来,同时把设备寄存器里的Ring基地址、尾指针全部打一遍,和预期值做对照。光这一套动作,就能解决一半问题,因为很多描述符错误是驱动作弊导致的结构体错位。
第二点心得是关于性能问题的定位。如果DMA可以工作但吞吐量上不去,别先急着怀疑描述符环太短。可以用Linux的perf和ftrace看DMA中断的频率和抖动,如果中断频率正常但吞吐量低,问题多半出在缓存一致性的同步开销上。Linux内核里,/sys/kernel/debug/dma-api/下的调试接口能帮你统计dma_alloc_coherent的调用次数和峰值,观察峰值的波动很有价值。
第三点心得是:永远在驱动初始化阶段写一个“自检描述符”,就是在分配完描述符环之后,手动构造一个只含一个描述符的DMA传输,把某个缓冲区内填充成固定模式,搬完后对比内容和状态字。这个“冒烟测试”看着笨,但能快速分出“驱动问题”还是“硬件问题”,尤其在新的硬件平台带上电调试时,能省掉很多来回猜的时间。
我还想强调一点:调DMA的时候,不要怕用devmem直接读硬件内存。/dev/mem加devmem2工具,可以把描述符环里的数据以二进制方式读出来,确认设备实际访问的地址到底是什么。我之前调一块PCIE加速卡时,就是用devmem把描述符环整个dump下来,才发现在地址填充里有一个高8位被bug覆盖成0xDEADBEEF的现象——这种问题光靠代码review很难发现,但用工具直接看内存一眼就懂了。
最后再分享一个小技巧:在调试DMA描述符地址问题时,建议先关掉IOMMU,然后在驱动里打印所有dma_addr_t,把这些地址按顺序排出来,和物理页分配器给出的页号记录做对比。两边如果吻合,说明系统没有IOMMU参与或映射是恒等映射;两边不吻合,就说明你的地址经过了翻译层,那么一切对地址的猜测都要以IOVA为准。理解了设备眼里的那套地址,DMA调试才算真正入门。