Linux内核DMA映射核心:dma_map_ops三种实现方式解析

发布时间:2026/9/17 10:08:42
Linux内核DMA映射核心:dma_map_ops三种实现方式解析
做Linux驱动开发的朋友应该都对dma_map_ops不陌生。这个结构体平时挂在struct device下面看起来只是一组函数指针但只要你碰过网卡驱动、块设备驱动、GPU或者视频编解码器这类高性能外设就会明白它才是DMA映射真正的“大脑”。很多刚入门内核的人一看到驱动里调用dma_map_single()、dma_alloc_coherent()就完事了从不关心底层谁在帮你干活。但如果哪一天你需要在板子上加一块特殊DMA控制器或者想给某个设备挂上自定义的地址翻译逻辑你就绕不开一个问题dma_map_ops到底有哪几种实现方式每种方式应该什么时候用、怎么用。这篇文章不聊教科书式的定义直接讲我实际接触过的三种dma_map_ops实现路线第一种是复用内核通用实现第二种是自定义一套ops并注册到设备上第三种是驱动运行时动态替换设备的DMA操作集。我会把每种方式的原理、代码骨架、适用场景和踩过的坑都摊开讲适合已经会写基本字符设备驱动、想深入DMA子系统的内核开发者。1. dma_map_ops到底是什么先搞清楚结构语义再谈实现1.1 看透这个核心结构体回调函数如何定义DMA能力dma_map_ops定义在include/linux/dma-map-ops.h本质上就是一组描述“怎么把一个内存区间变成设备能访问的DMA地址”的函数指针。设备驱动平时调用的dma_map_single()、dma_unmap_single()、dma_map_sg()、dma_alloc_coherent()这些通用API最终都会走到struct device上挂的这个ops里去执行。struct dma_map_ops { void *(*alloc)(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t gfp, unsigned long attrs); void (*free)(struct device *dev, size_t size, void *vaddr, dma_addr_t dma_handle, unsigned long attrs); dma_addr_t (*map_page)(struct device *dev, struct page *page, unsigned long offset, size_t size, enum dma_data_direction dir, unsigned long attrs); void (*unmap_page)(struct device *dev, dma_addr_t dma_handle, size_t size, enum dma_data_direction dir, unsigned long attrs); int (*map_sg)(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction dir, unsigned long attrs); void (*unmap_sg)(struct device *dev, struct scatterlist *sg, int nents, enum dma_data_direction dir, unsigned long attrs); void *(*alloc_pages)(struct device *dev, size_t size, dma_addr_t *dma_handle, enum dma_data_direction dir, gfp_t gfp); void (*free_pages)(struct device *dev, size_t size, void *vaddr, dma_addr_t dma_handle, enum dma_data_direction dir); int (*mmap)(struct device *dev, struct vm_area_struct *vma, void *cpu_addr, dma_addr_t dma_addr, size_t size, unsigned long attrs); int (*get_sgtable)(struct device *dev, struct sg_table *sgt, void *cpu_addr, dma_addr_t dma_addr, size_t size, unsigned long attrs); int (*dma_supported)(struct device *dev, u64 mask); u64 (*get_required_mask)(struct device *dev); ... };这个结构体里的每一个回调都对应一类DMA操作。alloc和free管理一致性DMA缓冲区返回CPU虚拟地址和DMA地址map_page和unmap_page处理流式DMA映射map_sg和unmap_sg是批量映射散布的物理页mmap负责把设备DMA内存映射到用户态dma_supported则是告诉DMA API层“我这设备支持多大的地址掩码”。这套设计很像面向对象里的接口通用DMA子系统只定义规则具体每个平台、每个总线、每个IOMMU怎么实现由不同的ops实例接管。你在驱动里写dma_alloc_coherent()时根本不需要知道底层是走页表直映射、走swiotlb反弹缓冲、还是走IOMMU的IOVA分配器ops已经在中间把差异全部消化掉了。1.2 三种实现方式的本质区别与选型逻辑根据我多年的实践经验内核里实现和装配dma_map_ops的方式本质上是三种路径它们的区别在于“ops实例从哪来”和“在什么时机挂到设备上”。第一种是复用内核通用ops。这是绝大多数驱动不需要写任何DMA底层逻辑的原因。无IOMMU时系统使用dma_direct_ops走的是物理地址直接映射有swiotlb时系统使用swiotlb_dma_ops走的是反弹缓冲有IOMMU时系统使用intel_dma_ops、amd_iommu_dma_ops或arm_smmu_dma_ops等。你什么都不用做设备探测的时候内核已经帮你把对应的ops挂好了。这个方案的好处是零代码成本、稳定可靠坏处是行为完全由内核决定如果你的硬件有特殊映射需求它就满足不了。第二种是自定义ops并注册到设备。常见于平台设备你需要自己写一套struct dma_map_ops然后在arch_setup_dma_ops()这个架构回调里把ops挂到设备上。这类需求通常来自特殊总线或特殊DMA控制器例如设备有独立的DMA地址窗口、需要额外TLB刷洗、或者你不想走IOMMU的默认分配器。这个方案灵活度高但你要对每个回调的语义理解得非常透彻。第三种是驱动运行时动态替换ops。在probe阶段根据硬件版本、设备树属性或运行状态手动把设备的dma_ops指针替换成另一套。内核里典型的例子包括某些GPU驱动、FPGA加速卡驱动它们会在运行时根据不同的固件加载情况在通用ops和自定义ops之间切换。这个方案最灵活也最危险因为DMA API外层已经基于旧ops做了一些缓存和假设切换时容易埋雷。这三条路不是互斥的。我自己在项目里常干的事情是先用方式一让设备跑起来验证DMA通路没有硬件问题再针对瓶颈用方式二或方式三替换成定制实现。先能跑再调优永远是内核开发里的铁律。2. 方式一复用内核通用ops大多数驱动最省事的路线2.1 dma-direct、swiotlb与iommu ops怎么选先说一下方式一底层的三个主角它们虽然都被称作“通用实现”但内部逻辑差异很大弄清楚它们的边界对后续理解自定义实现非常重要。dma_direct_ops是DMA子系统最基础的实现。它假设设备的DMA地址空间能覆盖物理内存DMA地址等于物理地址经过某种偏移或掩码换算。它做的事情其实就两件一是做地址掩码校验确保设备真的能访问这块内存二是做cache一致性维护。在大多数ARM64平台如果设备树里没有配置IOMMUdma_ops实际指向的就是这组操作。swiotlb_dma_ops解决的是设备访问不了高地址内存的问题。比如32位设备只能访问低4GB但系统内存挂在64位地址上或者IOMMU限制了设备的访问范围。此时swiotlb会在低端内存里预留一块反弹缓冲把高地址的数据先拷贝到低地址缓冲区再把低地址缓冲区地址交给设备去DMA。代价是每次DMA多一次内存拷贝吞吐量会有明显损耗。IOMMU场景下的ops就复杂了。intel_dma_ops、amd_iommu_dma_ops、arm_smmu_dma_ops这些实现本质上是把DMA地址翻译和IOMMU页表绑定在一起。dma_map_single()到了这一层不只是做cache操作还要从IOVA分配器里申请一块虚拟地址建立IOMMU页表映射返回给设备的地址是IOVA而不是物理地址。好处是设备看到的是一个连续地址空间可以拿到物理上不连续的内存坏处是每个map/unmap都要操作页表和TLB频繁映射小块内存开销很大。这三种ops怎么选规则是内核在设备初始化阶段自动决定的。设备树里配了iommus属性就走IOMMU没配就优先dma-direct地址范围不够就退到swiotlb。驱动开发者通常不需要干预这个决策但有时候你会遇到一个情况IOMMU明明存在可某个设备就是不应该走IOMMU——这时候可以在设备树里去掉iommus属性或者通过iommu_group设备属性屏蔽让设备落到dma-direct路径。2.2 只做配置不写回调把设备挂到正确路径上既然不用写回调那“实现”体现在哪里体现在设备模型的配置上。我拿平台设备举一个最简单的例子设备树片段如下soc { dma_test_device: dma-test10000000 { compatible demo,dma-test; reg 0x0 0x10000000 0x0 0x1000; interrupts 0 42 4; dma-coherent; /* 如果不配 iommus, 则默认走 dma-direct/swiotlb */ }; };驱动probe时代码可以这样检查设备当前挂在哪个ops上static int demo_probe(struct platform_device *pdev) { struct device *dev pdev-dev; const struct dma_map_ops *ops get_dma_ops(dev); dev_info(dev, current dma ops: %pS\n, ops); if (ops dma_direct_ops) dev_info(dev, using dma-direct path\n); else if (ops) dev_info(dev, using custom or iommu ops\n); /* 在这里正常调用 DMA API 即可 */ dma_addr_t dma_handle; void *cpu_addr dma_alloc_coherent(dev, SZ_4K, dma_handle, GFP_KERNEL); ... }get_dma_ops()是读取dev-dma_ops的推荐方式它内部会处理架构差异。如果用CONFIG_DMA_OPS未开启的配置get_dma_ops()返回的是NULLDMA API会走默认的直接映射逻辑此时打印出来的ops就是空的——这不是异常而是表示当前平台把dma-direct编译成了内建路径。这个阶段最常见的坑是设备树里漏了dma-ranges属性。dma-ranges描述了父总线到子总线的地址转换关系如果总线控制器后面的设备地址窗口和CPU地址窗口不一致却没有配置dma-rangesDMA API换算出来的地址就会错乱典型症状是你的缓冲区CPU地址和DMA地址对不上DMA操作后数据写飞。所以方式一的“实现”本质是把设备的DMA拓扑描述对。设备树描述清楚了内核的通用层就会自动选好ops并帮你完成全部映射。对绝大多数驱动来说这就是你需要的全部。3. 方式二自定义ops并注册到设备平台级DMA需求的实现细节3.1 从零写一套dma_map_ops的代码骨架当你发现通用ops不够用必须自定义一套时第一步是写代码骨架。以我自己做过的一个PCIe加速卡驱动为例硬件要求DMA地址必须映射到一块固定的PCIe BAR空间内核的IOMMU默认分配器做不到这个约束所以必须自己实现。下面是精简后的ops定义static void *foo_dma_alloc(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t gfp, unsigned long attrs) { struct foo_priv *priv dev_get_drvdata(dev); void *vaddr; dma_addr_t dma_addr; unsigned long phys; /* * 在私有内存池里分配物理连续内存。 * 这里强调一下自定义 alloc 必须要保证物理连续 * 否则你自己得在 map_page 里做分散表处理。 */ vaddr alloc_pages_exact(size, gfp); if (!vaddr) return NULL; phys virt_to_phys(vaddr); dma_addr priv-bar_phys_base phys; *dma_handle dma_addr; return vaddr; } static void foo_dma_free(struct device *dev, size_t size, void *vaddr, dma_addr_t dma_handle, unsigned long attrs) { free_pages_exact(vaddr, size); } static dma_addr_t foo_dma_map_page(struct device *dev, struct page *page, unsigned long offset, size_t size, enum dma_data_direction dir, unsigned long attrs) { struct foo_priv *priv dev_get_drvdata(dev); unsigned long phys page_to_phys(page) offset; /* 硬件要求必须先写bar寄存器完成地址映射再返回转换后地址 */ foo_bar_map(priv, phys, size); return priv-bar_phys_base phys; } static void foo_dma_unmap_page(struct device *dev, dma_addr_t dma_handle, size_t size, enum dma_data_direction dir, unsigned long attrs) { struct foo_priv *priv dev_get_drvdata(dev); foo_bar_unmap(priv, dma_handle); }看到这里你会发现自定义ops的核心工作量其实是在“把物理地址变成设备能用的DMA地址”这个步骤里加入自己的映射规则。无论你是要经过BAR窗口、要经过某种私有地址翻译器、还是要在map之前刷一次硬件TLB逻辑都要落在这几个回调里。3.2 通过arch_setup_dma_ops把ops挂到设备上的方法有了ops实例第二步是怎么把它挂到设备上。内核设备模型里有一个关键钩子叫arch_setup_dma_ops()在设备注册过程中device_add()-device_initialize()路径上会调用它让架构代码有机会设置设备的DMA参数和ops。以ARM64为例arch_setup_dma_ops()的实现位于arch/arm64/mm/dma-mapping.cvoid arch_setup_dma_ops(struct device *dev, u64 dma_base, u64 size, const struct iommu_ops *iommu, bool coherent) { dev-dma_coherent coherent; if (iommu) iommu_setup_dma_ops(dev, dma_base, size); else x86_dma_setup_ops_impl(...); /* 不同架构有差异, 这里示意 */ }要在这个阶段“插入”你自己的ops通常的做法是在平台设备的probe函数里配合一个已经创建好的dummy设备手动调用arch_setup_dma_ops()并传入你的ops指针。但这里有个现实约束dev-dma_ops在设备模型里是const的直接赋值在一些配置下会被编译器警告。所以更规范的路径是使用内核提供的dma_set_ops()或set_dma_ops()辅助函数。static int foo_probe(struct platform_device *pdev) { struct device *dev pdev-dev; /* * 通知DMA子系统设备使用自定义ops。 * set_dma_ops 是少数允许修改 ops 的路径 * 它内部会处理架构相关的对齐和兼容性问题。 */ set_dma_ops(dev, foo_dma_ops); dev-dma_coherent true; return 0; }set_dma_ops()其实是宏或者内联函数不同架构定义有差异。在你自己的代码里使用之前先grep一下内核源码里有没有现成的调用范例。比较常见的是在arm64的arch_setup_dma_ops里根据dev-dma_ops NULL才设置默认的ops如果你在设备树上给平台设备绑了一个自定义的dma-ops属性那就需要在这条路径上做判断。3.3 自定义ops的边界哪些回调必须实现哪些可以交给通用层很多初学者一上来就把struct dma_map_ops里所有回调都填了一遍这种做法我强烈反对。内核的DMA API在设计上允许某些回调为空比如alloc和free为空时dma_alloc_coherent()会退回到dma_direct_alloc()然后在通用层做一致性内存的默认分配map_sg为空时DMA API有一个公共实现dma_common_map_sg()它会逐条调用map_page。我总结的自定义实现优先级如下必须实现map_page和unmap_page只要你想接管流式DMA映射这两个是基本功强烈建议实现alloc和free否则dma_alloc_coherent()的行为你控制不了视情况实现map_sg和unmap_sg如果硬件支持批量映射直接实现可以避免逐页调map_page的性能损耗可选实现mmap、get_sgtable、dma_supported、get_required_mask用不到就让DMA子系统的通用逻辑接管。一个比较聪明的做法是只重写你有特殊需求的回调其余回调直接借用内核通用实现。比如你只想在物理地址转换前加一个硬件窗口映射那可以这样写static const struct dma_map_ops foo_hybrid_dma_ops { .alloc dma_direct_alloc, /* 复用通用分配器 */ .free dma_direct_free, .map_page foo_map_page_hybrid, /* 只覆盖map_page */ .unmap_page foo_unmap_page_hybrid, .map_sg dma_direct_map_sg, .unmap_sg dma_direct_unmap_sg, .dma_supported dma_direct_supported, .get_required_mask dma_direct_get_required_mask, };这种混合式ops在工程里最实用既拿到了自定义控制能力又尽量复用了内核已经稳定的分配逻辑。注意一点dma_direct_alloc这类通用函数是否可以直接赋值给ops取决于你的内核版本和架构是否将这些函数导出。一般使用过程中如果出现“static declaration follows non-static”的编译错误就说明该函数在目标架构里不是全局可见的你需要换一种实现策略。4. 方式三驱动运行时动态替换ops特殊硬件的灵活手段4.1 什么时候必须动态替换dma_map_ops有一类硬件它的DMA能力不是固定的。典型例子是FPGA加载不同镜像后有时变成普通PCIe EP设备需要走标准dma_direct_ops有时变成带内部DMA引擎的加速器需要走自定义地址翻译。这种设备在系统里是同一个PCIe function、同一个struct device但DMA行为却会变。此时你只能在运行时判断固件状态动态替换dma_ops。另一个场景是模拟器或热插拔测试模块。我自己做过一个DMA故障注入测试模块通过debugfs接口让用户手动切换ops用来验证DMA API调用方的错误处理路径。没有这种动态能力每次切换ops都要重新插拔设备开发效率非常低。还有一个典型场景是多虚拟功能设备VF。有些网卡的VF在SR-IOV启用之前走IOMMU ops启用之后硬件开始做自己的地址翻译设备树属性又不能改只能在ndo_open()回调里根据VF状态切换ops。这种运行时的决策逻辑只有方式三能兜住。4.2 运行时切换ops的实操模板动态切换的核心就是一个指针替换但难点在于切换时机和并发控制。切换时绝对不能有正在进行的DMA映射操作否则一个老的map调用可能拿到的是旧ops的语义而unmap却走到新ops上IOVA和页表直接乱掉。我实践下来的安全切换模板分三步。第一步在驱动里定义两种ops实例第二步提供切换接口通过debugfs或属性文件触发第三步在切换前用驱动自己的锁把DMA映射路径全部串行化。static const struct dma_map_ops vf_dma_ops_direct { .alloc dma_direct_alloc, .free dma_direct_free, .map_page dma_direct_map_page, .unmap_page dma_direct_unmap_page, .map_sg dma_direct_map_sg, .unmap_sg dma_direct_unmap_sg, }; static const struct dma_map_ops vf_dma_ops_hw { .alloc vf_hw_alloc, .free vf_hw_free, .map_page vf_hw_map_page, .unmap_page vf_hw_unmap_page, .map_sg vf_hw_map_sg, .unmap_sg vf_hw_unmap_sg, }; static int vf_switch_dma_ops(struct device *dev, bool use_hw) { /* 这里需要持有驱动自己的dma_switch_lock */ guard(mutex)(vf_priv-dma_switch_lock); /* 在切换前等待所有正在执行的DMA映射操作完成 */ wait_event(vf_priv-dma_idle_wq, atomic_read(vf_priv-dma_active_count) 0); /* * 直接修改 dev-dma_ops。 * 之所以可以直接改是因为我们已经保证了修改不会发生在 * DMA映射的临界区内而DMA API层在每次调用时会重新读取ops。 */ dev-dma_ops use_hw ? vf_dma_ops_hw : vf_dma_ops_direct; /* 如果硬件还有IOMMU页表缓存需要在这里清掉 */ return 0; }代码里的dev-dma_ops直接赋值有些内核配置下会有编译警告因为你覆盖的是const指针。解决办法要么是在自定义内核补丁里放开这个限制要么用set_dma_ops()等架构钩子。不少生产驱动是直接绕过const修饰的因为驱动作者清楚切换时机受自己锁保护但这个操作如果没做规范很容易被checkpatch和内核社区拒绝合并。动态切换还有一个容易被忽略的问题dma_set_mask_and_coherent()和dma_supported()这类接口会调用当前ops里的回调如果你的新旧ops对地址掩码的支持范围不同切换后必须重新设置DMA掩码。我遇到过切换后设备因为mask仍停留在旧值导致dma_map_single()在dma_direct_map_page()里检测到地址越界而返回错误。解决方法是切换后主动调用一次dma_set_mask_and_coherent(dev, DMA_BIT_MASK(64));把这个调用放在切换路径的最后确保新ops的dma_supported被正确执行。5. 常见问题与排查技巧实录5.1 我踩过的坑cache一致性、far地址映射和sg表异常先说cache一致性。自定义ops最容易翻车的场景是你在map_page里只转换了地址却忘了处理cache。很多新架构设备是cache-coherent的但老式总线桥和某些私有DMA引擎不是。如果你的设备不是dma-coherent那么在map_page中必须调用dma_direct_sync_single_for_device()这类缓存同步函数或者直接调用架构提供的arch_sync_dma_for_device()。漏掉一次cache flush结果就是CPU写入的数据设备读不到或者设备写完的数据CPU读到的是缓存里的老值。我遇到过一次特别隐蔽的问题map_page里调用了dma_map_page_attrs()内部通用的cache操作但由于第二个参数传的attrs带了DMA_ATTR_SKIP_CPU_SYNC导致这个map操作被省略了cache同步而调用方又不负责后续sync。排查了很久最后发现是attrs传递链上一个标志位错误。所以自定义ops的字段里凡是涉及attrs的代码要仔细追踪它是否被上层传递的flag影响。再说map_sg。自定义实现里如果map_sg为空内核会用dma_common_map_sg()去遍历sg表并逐条调用map_page。这个回退逻辑本身没问题但性能极差因为每个sg条目都独立分配IOVA和建立映射。如果你自定义了map_page我建议顺手实现map_sg把多个连续的sg条目合并成一次映射。实践中我常遇到的问题是驱动里构建的scatterlist并没有按物理地址排序导致IOMMU模式下页表碎片化严重。排查时可以通过/sys/kernel/debug/dma-api/下的分配统计看到IOVA分配次数异常放大。最后是“far地址”问题。简单说就是设备的DMA地址范围很窄比如只有36位但系统内存超过64GB后部分物理页地址超出了设备可访问范围。通用swiotlb能兜住这个场景但自定义ops里如果自己管理IOVA分配器就必须在dma_supported和get_required_mask里给出正确的能力描述。我见过一个自定义ops忘记实现get_required_mask结果内核按默认64位地址能力给设备分配了超出其访问范围的物理页DMA直接触发总线错误。5.2 dma_map_ops相关的调试手段与自查清单调试自定义dma_map_ops我平时最依赖下面这几样工具和手段。第一内核的CONFIG_DMA_API_DEBUG要打开。它会对dma_map_single、dma_unmap_single、dma_map_sg做一致性检测能发现“重复unmap”“unmap一个没有map过的地址”“DMA方向错误”等典型问题。这个开关虽然会影响一部分性能但调试阶段开着收益远远大于代价。第二CONFIG_IOMMU_DEBUG和IOMMU的debugfs接口。/sys/kernel/debug/iommu/下可以看到IOMMU域的映射统计/sys/kernel/debug/dma-api/下能看到DMA API层的分配和释放记录。当怀疑IOVA泄漏时比较dma_map和dma_unmap的调用次数基本就能定位泄漏方向。第三使用tracepoint。内核在dma_map_page、dma_unmap_page路径上埋了dma:map_page等trace事件可以通过trace-cmd记录每次map的地址、大小和返回值。这个工具在定位地址转换错误时非常有用比如你可以看到返回给设备的DMA地址是否落在BAR窗口之外。第四构造最小复现驱动。如果你怀疑问题出在自定义ops不要在一整个复杂驱动里调试。我通常的做法是写一个极小的测试模块直接调dma_map_single()和dma_unmap_single()配合一个临时的dummy设备把映射次数、地址范围、cache操作全部打印出来。几十行代码就能复现90%的DMA映射问题。下面是一份自查清单我每次提交dma_map_ops相关代码前都会过一遍检查项操作地址范围ops返回的DMA地址是否满足设备实际可访问范围是否超过dma_maskcache一致性非coherent设备在map/unmap时是否做了正确的cache同步attrs传播DMA_ATTR_SKIP_CPU_SYNC等flag是否被意外吞掉或错误触发sg处理自定义map_sg是否处理了sg链表上可能出现的空条目和page偏移错误路径map失败时是否正确返回DMA_MAPPING_ERROR调用方是否识别并发安全map/unmap是否有锁保护动态切换ops时能否保证无在途映射资源释放free和unmap是否成对出现是否存在IOVA/页表泄漏mask设置切换ops后是否重新调用dma_set_mask_and_coherent这些检查项不是凭空想出来的每一项背后都是我实际踩过的坑。比如DMA_MAPPING_ERROR这一点早期有些驱动在map失败时返回0导致内核DMA API在后续逻辑里把0当成了合法地址直到CONFIG_DMA_API_DEBUG报出“device driver tried to dma_map a region that is too small”才知道问题。最后再分享一个小经验。无论你采用三种方式里的哪一种都要先在板子上验证最简单的dma_alloc_coherent()通路。这一步通了再去做map_page和map_sg的流式映射测试。DMA映射出问题不一定是ops本身的逻辑错误也可能是硬件总线地址写错了、内存控制器配置不对、甚至可能是驱动里DMA完成中断没等到。从小处入手逐步放大才能真正把dma_map_ops吃透。我自己的体会是不要急着写一大堆自定义回调先把通用路径跑熟再针对硬件特性逐个覆盖这样定位问题会快很多。