DMA映射这层窗户纸捅破了就那么简单。先说说我为什么想写这个主题。做嵌入式Linux驱动开发迟早要跟DMA打交道而大部分人在第一次接触把外设要用的内存地址告诉硬件这件事时都会卡在CPU地址和设备地址这两个概念上。每台设备都有自己的地址空间当你把一片内存交给网卡、USB控制器或者多媒体加速器去读写时必须告诉内核我要把这片内存映射给某个设备用这就是DMA mapping操作。如果你是刚接手一个用DMA传输数据的驱动项目或者被dma_alloc_coherent、dma_map_single这些API搞得一头雾水这篇博文就是写给你的。我会从最底层的一步步讲清楚Arm Linux下DMA映射的完整链路包括设备树里怎么配、驱动代码怎么写、cache一致性怎么处理、IOMMU介入后有什么变化以及我实际调试中踩过的坑。花十分钟读完你至少能自己写一套能跑通的DMA传输逻辑。1. 整体设计思路为什么DMA映射要把地址分两套1.1 两个地址空间的概念铺垫DMA是Direct Memory Access让外设绕过CPU直接读写内存。问题在于CPU看到的地址和外设看到的地址不是同一个东西。CPU侧我们有一个物理地址PA经过MMU一级页表转换成虚拟地址VA给驱动里的指针用外设侧设备也有自己的一套地址空间设备发出的地址总线信号经过总线系统可能还有IOMMU转换后才能落到实际的物理内存上。在没有IOMMU的简单系统里设备地址空间和物理地址空间基本是一一对应的做DMA映射其实就是在检查地址合法性处理cache一致性两件事上。但当系统里加了IOMMUArm平台常见的SMMU之后设备看到的地址就变成I/O虚拟地址IOVA这个IOVA和物理地址之间还需要一个页表来翻译。无论哪种情况驱动都不能直接把CPU的虚拟地址丢给硬件用必须经过一个明确的映射过程。我这里用一个生活化的类比DMA映射就像是帮外设指路。你CPU把数据放在客厅的一堆文件盒子里外设是个不认识你家门牌号的快递员你需要给他画一张从仓库大门到对应文件盒的路线图设备地址同时确保盒子里东西的最新版本是摊开放在明面的cache刷新而不是锁在保险柜里的旧副本。1.2 两种映射模型一致性映射和流式映射Linux内核提供的DMA API设计了两个层级的使用场景这是理解DMA mapping的钥匙。第一种是一致性DMA映射coherent DMA mapping对应的API是dma_alloc_coherent和dma_free_coherent。它解决的痛点是有些外设需要持续、反复地访问一块内存比如网卡接收描述符环形缓冲区、USB控制传输缓冲区这块内存在整个驱动生命周期里都可能被硬件访问。内核会把这片内存映射成pgprot_dmacoherent属性在Arm Linux里实际效果就是该内存页被标记为uncached或使用特殊的cache策略CPU和硬件看到的始终是同一个值不需要软件做flush操作。代价是分配和使用的效率相对低一些因为每次CPU访问都要直接穿透到内存。第二种是流式DMA映射streaming DMA mapping对应dma_map_single、dma_map_sg、dma_unmap_single、dma_unmap_sg这一组API。这类映射针对的是一次性或者少数几次的传输比如某次命令下发、某块数据接收。它复用普通的kmalloc内存性能高不用专门占用为一致性而特殊处理的页但必须在传输前后手动维护cache的一致性送数据出去之前要把CPU cache里的脏数据写回内存clean收数据之前要把cache里的旧数据作废invalidate。很多驱动开发新手在这一步翻车最常见的就是忘了在dma_map_single之后、发起DMA操作之前做相应的cache维护或者方向搞反了。1.3 为什么不用直接给物理地址的方式早期的简单驱动确实有人直接通过phys_to_virt拿到物理地址然后用virt_to_phys转换后塞给设备的DMA寄存器。这在小系统里偶尔能跑但隐患极大第一没有检查内存是否超出设备的寻址范围很多设备只能访问32位地址第二没有处理内存的cache一致性第三没有针对IOMMU做地址翻译时整个流程会直接崩。内核提供一套统一的DMA mapping API就是让驱动从这些底层脏活里解脱出来同时也让代码可以在不同总线、不同架构之间移植。这套API直观好用dma_alloc_coherent返回两个值CPU侧的虚拟地址给软件用DMA地址给硬件用。dma_map_single输入一个CPU的虚拟地址和长度返回设备用的DMA地址。我认为理解这套封装的关键在于不要纠结于内部每一步的实现细节不同ARM平台实现有所差异而要抓住驱动开发者的使用契约什么时候调用、调用后软件侧对内存的访问规则有什么变化。2. 设备树配置与DMA能力描述2.1 设备节点中的dma相关属性在设备树DT里一个设备要正确使用DMA至少要有几个属性做支撑。首先是dma-ranges属性它描述的是父设备通常是总线控制器或者IOMMU节点的父节点能访问的内存窗口。举个例子如果一个设备节点下写了dma-ranges 0x0 0x0 0x0 0x0 0x0 0x100000000意思是该设备能够访问从物理地址0开始、长度为4GB的一片连续的地址空间。这个属性不是给驱动直接读的而是内核在初始化设备时用来检查后续dma_map_single出来的设备地址是否落在合法范围内的依据。其次是dma-coherent属性。如果设备本身是cache-coherent的也就是说硬件访问内存时能自动去查CPU cache并保持一致那么在设备节点里标上dma-coherent内核就知道对该设备的DMA操作无需软件去flush cache。这个标志在实际情况里很关键如果设备不挂这个属性而驱动又用了dma_alloc_coherent内核会用一种特殊的内存映射保证即使不做cache维护也能拿到一致的数据代价是访问效率变低。反过来如果设备实际是coherent的但驱动没告诉内核它就会做很多额外的invalidate/clean操作性能白白浪费。再有一个是IOMMU相关的当系统使用SMMU时设备和SMMU之间通过iommus属性关联。举个例子iommus smmu0 0x100就表示该设备连接到id为0x100的SMMU stream ID上。没有iommus属性的设备默认不走SMMU直接访问物理地址。如果部分设备走了SMMU、部分设备没走驱动和内核都要区分清楚因为映射的API行为会非常不一样走了SMMU的设备dma_map得到的地址是IOVA而不是物理地址。2.2 查看系统级的DMA窗口范围有时候设备树里的信息还是比较隐晦驱动运行时要真正知道我能不能访问这片内存得看内核暴露出来的/sys/kernel/debug/devices_debug或者通过驱动里的dma_get_mask函数来查询。设备掩码DMA mask是一个重要概念比如32位设备支持的最大地址是0xFFFFFFFF64位设备则可以到0xFFFFFFFFFFFFFFFF。驱动里通过dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(32))设置设备的能力如果设备树里的dma-ranges限制更小内核会以两者较小的值作为最终约束。我建议做驱动开发时第一时间把设备的dma mask打印出来因为这直接影响后续映射是否成功。曾经在一个项目上遇到过设备明明支持64位地址但驱动没有调用dma_set_mask_and_coherent导致内核默认采用32位寻址一旦DMA缓冲区的物理地址超过4GB映射直接失败。后来加上64位mask设置问题就消失了。这个排查方式几乎适用于所有平台值得养成习惯。2.3 DMA池与内存区域选择的影响dma_alloc_coherent在底层分配内存时会选择适合DMA操作的物理连续内存。内核里维护了一个Per-CPU的DMA内存池当驱动请求的大小超过一定阈值时会走__dma_direct_alloc_pages这种大页分配路径或者通过CMA连续内存分配器来获取物理连续的区域。设备树里的reserved-memory子节点可以指定一块预留内存专门给某个设备做DMA用例如reserved-memory { #address-cells 2; #size-cells 2; ranges; dma_buffer0: dma_buffer80000000 { compatible shared-dma-pool; reg 0x0 0x80000000 0x0 0x10000000; no-map; }; }; device0 { compatible vendor,device; memory-region dma_buffer0; };这种预留的好处是DMA缓冲区地址完全可控特别适合视频采集、显示这类需要大量连续内存的场景。但要注意no-map属性意味着这块区域不会映射到内核页表驱动必须用dma_alloc_coherent系列接口来承接这块保留内存。如果自己用ioremap直接去操作绕过了DMA API的维护机制很容易导致cache一致性问题。我之前调试一个ISP驱动时就发现内存区域既配置了no-map又在驱动里直接ioremap访问导致采集的图像数据偶尔出现花屏后来统一走dma_alloc_coherent才稳定。3. 核心API使用与实操细节3.1 DMA映射的完整流程拆解对于大多数驱动使用DMA映射有一个标准套路。以一个虚拟的网络设备驱动为例它需要接收DMA缓冲区#define RX_BUF_SIZE 2048 #define RX_NUM_DESC 64 static int rx_dma_init(struct my_dev *dev) { struct device *d dev-pdev-dev; int i; /* 设置DMA掩码告诉内核设备能寻址的范围 */ ret dma_set_mask_and_coherent(d, DMA_BIT_MASK(64)); if (ret) { dev_err(d, Failed to set DMA mask: %d\n, ret); return ret; } /* 为每个接收描述符分配流式映射的DMA缓冲区 */ for (i 0; i RX_NUM_DESC; i) { dev-rx_buf[i].cpu_addr kzalloc(RX_BUF_SIZE, GFP_KERNEL); if (!dev-rx_buf[i].cpu_addr) goto err_free; dev-rx_buf[i].dma_addr dma_map_single(d, dev-rx_buf[i].cpu_addr, RX_BUF_SIZE, DMA_FROM_DEVICE); if (dma_mapping_error(d, dev-rx_buf[i].dma_addr)) { dev_err(d, rx dma_map_single failed at %d\n, i); kfree(dev-rx_buf[i].cpu_addr); goto err_free; } } return 0; err_free: while (i 0) { i--; dma_unmap_single(d, dev-rx_buf[i].dma_addr, RX_BUF_SIZE, DMA_FROM_DEVICE); kfree(dev-rx_buf[i].cpu_addr); } return -ENOMEM; }这段代码里有几个关键点。dma_map_single返回的是设备可用的DMA地址在映射之后、设备发起DMA之前这片内存里的数据对CPU来说暂时不能随便改——因为DMA_FROM_DEVICE方向意味着硬件要从内存读内核可能会在map时先做cache invalidate。拿到DMA地址后填充到设备描述符里再对设备寄存器做通知操作硬件就可以开工了。接收完成中断到来时驱动要调用dma_unmap_single来结束这次映射并执行对应的方向清理。用DMA_FROM_DEVICE方向unmap时内核会把cache作废保证CPU从cpu_addr里读到的数据是硬件写入后的最新值。这里方向非常重要使用错了会导致数据错乱甚至硬件写完之后cache里面还是旧数据。3.2 一致性映射的实际用法和生命周期如果数据缓冲区需要在很多次DMA传输中反复使用比如描述符环形缓冲区、控制命令的输出缓冲区那就应该用dma_alloc_coherentstruct dma_desc *descs; dma_addr_t descs_dma; descs dma_alloc_coherent(pdev-dev, ring_size * sizeof(struct dma_desc), descs_dma, GFP_KERNEL); if (!descs) return -ENOMEM; /* 使用descs_dma填充设备寄存器使用descs操作软件结构体 */ dma_free_coherent(pdev-dev, ring_size * sizeof(struct dma_desc), descs, descs_dma);一致性映射的优势在于descs这块内存的读写在软件侧和硬件侧之间没有不一致风险不需要额外的dma_sync_single_for_cpu等同步操作。它适合那些硬件随时都可能去访问的共享结构。但要注意的是dma_alloc_coherent在Arm平台上使用的内存页是uncached或write-through的从CPU侧读写的开销比普通内存大。如果对这个缓冲区做高频CPU访问性能会受影响。所以在设计驱动时最好把CPU经常访问的控制数据结构与数据缓冲区分开控制结构用dma_alloc_coherent大批量数据的搬运用普通的kmalloc加流式映射。3.3 散列表DMA映射DMA SG很多块设备驱动传输数据时数据在物理内存里不一定是连续的。比如文件系统读写时一个请求可能要访问多个分散的页。Linux内核的dma_map_sg就是为了解决这种分散物理页但要从一个DMA事务发出的需求。API的常规用法是struct scatterlist *sg; int nents, n_mapped, i; nents dma_map_sg(pdev-dev, sgl, n_elements, dir); if (!nents) { dev_err(pdev-dev, dma_map_sg failed\n); return -ENOMEM; } /* 遍历每个sg取出映射后的DMA地址和长度 */ for_each_sg(sgl, sg, nents, i) { dma_addr_t addr sg_dma_address(sg); unsigned int len sg_dma_len(sg); /* 把addr/len填入该设备支持的分段DMA描述符 */ } dma_unmap_sg(pdev-dev, sgl, n_elements, dir);这个接口返回的nents是映射后的DMA段数可能是多个也可能因为某些段合并而少于原始n_elements。遍历时必须使用sg_dma_address和sg_dma_len而不是sg-address和sg-length后者是原始物理段信息前者才行。需要特别小心的是dma_map_sg调用后原始scatterlist里的page_link、offset会被修改吗实际上不一定但协议上不允许在unmap之前再修改sg列表里的任何内容。如果n_elements很大或者设备描述符数量有限建议驱动自己维护一个压缩表把sg_dma_address存下来不要依赖sg原始结构里的数据。3.4 手动同步接口的作用还有一些情况流式映射的缓冲区在map之后、unmap之前CPU和硬件需要交替访问同一块内存。比如一个PCIe设备接收数据到缓冲区CPU要分几次去读取消费部分数据同时又还要硬件继续往剩余区域里写。此时不能在每次CPU访问前都unmap再map那开销太大了。内核提供了dma_sync_single_for_cpu和dma_sync_single_for_device用来在同一个mapping生命周期内做cache同步。常见用法是/* 硬件写完后CPU读之前 */ dma_sync_single_for_cpu(d, dma_addr, len, DMA_FROM_DEVICE); /* CPU处理数据 */ /* CPU修改数据后硬件读之前 */ dma_sync_single_for_device(d, dma_addr, len, DMA_TO_DEVICE);需要严格注意的是同步的范围和数据访问范围要一致方向要匹配。我见过很多驱动在同步时大小写错了导致部分数据没刷出去或者缓存了一部分旧数据等到排查的时候特别难查。好的习惯是每次都从驱动设备私有结构体里取出预存的dma_addr、大小和方向统一封装成一个小函数避免在多个地方散落调用。4. 缓存一致性处理Arm平台的核心难点4.1 CPU Cache与DMA的冲突根源Arm架构的CPU cache默认是write-back型CPU写数据后不会立刻写回内存而是先放到cache里后续由cache一致性协议或显式的clean操作来同步。DMA硬件访问内存时不会经过CPU cache它直接读取物理内存。于是如果CPU刚刚把一些数据写到了cache里但没有回写内存DMA设备去读物理内存时就会读到旧值反过来DMA往内存里写入了新数据而CPU cache里还保留着这片内存的旧拷贝CPU读到的也是过期数据。这就是数据一致性问题解决的办法就是在合适的时机执行cache clean把脏数据刷回内存或cache invalidate丢弃cache里的旧数据。4.2 内核如何自动帮你处理好消息是标准API的dma_map_single和dma_unmap_single内部已经处理了这些cache操作。比如dma_map_single(..., DMA_TO_DEVICE)内核会在map阶段执行cache clean保证硬件后续读内存时拿到的是CPU的最新数据dma_map_single(..., DMA_FROM_DEVICE)会在map阶段执行cache invalidate防止后续CPU读取时拿到旧版本dma_unmap_single(..., DMA_FROM_DEVICE)也会invalidate确保unmap后CPU读的是最新数据。但是如果设备被标记了dma-coherent这些cache操作会被跳过。有些平台上如果你的设备确实可以自动维护一致性就会把这个属性加到设备树性能会明显提升因为省掉了软硬件的屏障开销。不过驱动代码里不应该依赖设备树是不是标了dma-coherent来写逻辑而要完全以API行为为准。设备树和驱动的匹配是平台层面配置的事情驱动只要正确调用API不同平台都会自动适配。4.3 调试缓存一致性问题的实用手段当出现数据偶尔错乱、第一次运行正常重启后数据异常的怪问题时优先怀疑cache一致性问题。调试手段上可以用内核的CONFIG_DMA_API_DEBUG选项来开启DMA API的完整性检查会检测出dma_map和dma_unmap是否成对调用、map时的方向与unmap时方向是否一致、内存区域是否越界等常见错误。这个开关本身会明显降低性能但排查这类问题特别值得开一把。另一个工具是CONFIG_DEBUG_SG专门调试scatterlist使用错误以及CONFIG_DEBUG_VM检测虚拟内存映射异常。我的做法是先在/boot/config-*里确认内核开了哪个选项若没开就需要把内核重新配置并编译。这些工具的输出日志都已经过系统打印机制输出到内核日志里配合dmesg就能看到具体哪个文件、哪一行调了DMA API触发了校验错误定位速度非常快。5. IOMMU与SMMU介入时的DMA映射变化5.1 IOMMU让映射复杂了一个层级如果你的Arm系统设备树里配置了SMMU节点并且设备节点关联了iommus属性那么DMA API的行为会发生一个根本性变化dma_map_single返回的地址不再等于物理地址而是一个由IOMMU管理的I/O虚拟地址IOVA。这个IOVA由内核的IOMMU DMA层负责分配通常是一段连续的虚拟地址区间通过SMMU页表映射到物理页。驱动对它完全不感知但对调试者来说要理解这个地址和物理地址不是一回事不要拿它去和硬件寄存器里固化的一些物理值对比。SMMU映射带来的最大好处是设备无法访问它没有被授权映射的内存这对于系统安全和隔离是重要的保障。例如在某些多媒体场景中某个协处理器被隔离在一个SMMU域中它只能看到自己管理的那段IOVA即使程序出错也无法破坏其他内存。5.2 dma-ranges、dma_mask与SMMU的联合约束SMMU和dma-ranges会共同作用限制设备实际可访问的物理地址范围。设备侧掩码设置是dma_set_mask_and_coherentIOMMU侧还有自身的输入地址宽度比如SMMU支持32位或48位输入内核最终决定DMA map成功与否时会按照交集来判定。我最常遇到的问题是一个设备支持64位DMA mask但SMMU的上游总线窗口只支持32位此时dma_alloc_coherent返回的地址仍然会受SMMU窗口限制。如果驱动想当然地以为64位就一定能映射到64位物理地址就可能出现返回的DMA地址高32位不为零但设备实际寻址不了的情况。这里告诉大家一个我的惯例拿到一个新平台先读设备树里iommus和dma-ranges再看设备的dma mask最后实际打印dma_alloc_coherent返回的地址确认走势是否符合预期。如果发现地址很不自然比如所有DMA地址都在某个固定窗口内往往是SMMU在做约束。5.3 需要自行绕过IOMMU吗有些驱动开发者觉得SMMU麻烦想通过关闭设备树的iommus属性来让设备直接用物理地址。如果在没有地址隔离要求的简单系统里这样做确实可以但在有多个Master设备和安全需求的系统里关闭SMMU会让系统处于一个很不安全的模式。我建议尽量保留SMMU让驱动走标准API。在驱动层面使用IOMMU和不用IOMMU的代码路径其实完全一样只是底层地址翻译方式不同这就体现出了Linux DMA API设计的精妙之处。有一个错误认知是开了SMMU之后dma_alloc_coherent分配的内存在物理上不连续也没关系反正IOVA是连续的设备看到的是连续的虚拟地址。这个理解其实不准确在IOMMU的映射下IOVA可以映射到多个分散的物理页虚拟层面连续确实对设备友好但对部分硬件比如要求物理连续的GPU显存来说就不够。所以实现了SMMU并不能完全豁免物理连续性约束具体还要看设备和驱动的要求。6. 常见问题与排查技巧实录6.1 设备DMA地址总为零现象硬件一直不工作调试打印发现传给设备的DMA寄存器地址始终是0。这个问题的根因通常是dma_alloc_coherent返回了NULL导致dma_addr没有被赋值比如内存不足或者GFP_ATOMIC在原子上下文里分配不了大块内存或者因为cma预留区域用完了。排查时先在驱动里加打印看dma_alloc_coherent返回的CPU地址和DMA地址两个都为0时基本可以断定是核心内存分配失败。还可以查看系统CMA大小cat /proc/meminfo | grep Cma如果CmaFree为0就说明CMA区域耗尽。6.2 第一次传输数据正常重启后异常现象刚上电第一次DMA传输正常系统运行一会儿或者重启驱动之后数据就开始错乱。这个问题几乎百分之百是cache一致性的锅。第一次能正常往往是因为这块内存恰好是冷cache还没有被CPU写过到第二次CPU往cache里写了数据但没回写内存DMA设备读内存就读到了旧数据。解决思路是严格检查map/unmap的方向与时机尤其是DMA_BIDIRECTIONAL的使用驱动要确保在每次传输前重新map或者做sync。6.3 DMA传输完成中断来了数据却是旧的现象硬件已经写完内存中断也到了CPU从内存里读却读到旧数据。这种情况多半是unmap时方向用了DMA_TO_DEVICE导致内核认为硬件只读这块内存在unmap阶段做了clean而不是invalidateCPU缓存里的旧数据就残留了。检查代码里dma_map_single和dma_unmap_single的方向是否一致且是否与实际硬件读写方向一致。如果方向确实没错就要怀疑是不是中断处理里在unmap之前就提前读了数据数据被cache污染了。6.4 使用SG时无法发送多个物理段现象dma_map_sg返回的映射段数比预期的少了很多或者硬件报告接收数据的内容错乱。通常是因为dma_map_sg之后错误地使用了sg原始字段比如sg-address、sg-offset而没有用sg_dma_address(sg)和sg_dma_len(sg)。在遍历SG时也发现过有人提前修改了sg链表导致后续段信息错乱。请务必在unmap之前保持sg列表不被改动并且严格使用sg_dma_address来读取映射后的地址。6.5 高速数据路径下性能很差现象传输速率不达标CPU占用率飙升。不少驱动在每包数据上都使用dma_alloc_coherent分配和释放内存这个开销巨大。推荐的做法是驱动初始化时建立一张预先分配好的DMA缓冲区池pool每次收发时从池子里取一个缓冲区用完归还而不是每次分配。另一个性能瓶颈是方向频繁在DMA_TO_DEVICE和DMA_FROM_DEVICE之间切换CPU执行大量cache操作可以考虑在硬件和驱动设计上尽量减少这种切换比如多块缓冲区之间交替使用、批量同步等。6.6 使用DMA API调试器定位问题最后再提一个效率工具层面的技巧。开启内核的DMA API调试机制后dmesg里会出现类似DMA-API: device driver ... maps DMA buffer ... but ...这类详细信息它会精确告诉你是哪个设备、哪个地址、哪类map参数不对。用这个东西排查映射泄漏比如map了但没有unmap一抓一个准。开启方法很简单内核配置里打开DMA API debug并在内核启动参数里追加dma_debugforce它会强制产生大量检测输出。不过生产环境不要开会拖慢系统。7. 收尾的几点真心话DMA mapping这套东西刚接触会觉得API又多又绕但只要你把CPU地址、设备地址、cache一致性、IOMMU这四件事在脑子里拧成一根绳遇到问题基本就有一套固定排查路径。我当时在做一个USB高速采集驱动时被DMA数据错乱折磨了两个星期最后用DMA API debug定位到是一个SG遍历错误修复之后整个数据通路就顺畅了。那种豁然开朗的感觉就是值得的。我的建议是在你自己的平台上一开始就用dma_alloc_coherent做一个小型测试驱动把设备树配好打印出返回地址手动发起一次DMA传输成功之后再逐步改成流式映射和SG方式。把这个测试驱动当作一个基础模板以后每个DMA相关驱动都可以从它开始搭建。这条路走通一次后面的项目都会顺很多。