十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux内核dma_map_ops三种实现:直接映射、IOMMU与自定义详解

Linux内核dma_map_ops三种实现:直接映射、IOMMU与自定义详解 1. dma_map_ops 到底是什么理解三种实现前必须先搞懂的事如果你写过 Linux 下的设备驱动多少会在include/linux/dma-mapping.h和kernel/dma/目录里看到dma_map_ops这个结构体。但真正去抠它实现的场合并不多因为大多数驱动只调用dma_map_single()、dma_alloc_coherent()这类 API底层发生了什么往往被内核封装得严严实实。直到你遇到这三种情况才会被逼着去翻它的实现设备性能上不去、IOMMU 开关后行为不一致、或者要对接一个不走寻常路的 DMA 控制器。这篇文章我会完整拆解dma_map_ops的三种典型实现方式默认的直接映射、基于 IOMMU/SMMU 的映射、以及驱动自定义 ops。内容偏向 5.x / 6.x 内核的现状适合驱动开发新手扫盲也适合有一定经验的人查漏补缺。1.1 从 dma_map_single 到 dma_map_ops 的调用链先梳理调用关系。驱动里写dma_map_single(dev, cpu_addr, size, dir)之后内核不直接调用某个固定的函数而是走一层间接调用。dma_map_single()最终会调到dma_map_page()或者更底层的dma_direct_map_page()/iommu_dma_map_page()。关键点在于每个设备更准确说是设备的dev-dma_ops可能挂了一套完全不同的映射函数指针。这套函数指针就是struct dma_map_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); ... };日常驱动不直接操作这个结构体内核提供了一整套 wrapperdma_map_single()内部根据dev-dma_ops是否为空决定走哪个实现。如果dev-dma_ops NULL默认走dma_direct_*这一套直接映射逻辑或者因为地址限制落到 swiotlb。如果设备挂在 IOMMU/SMMU 下驱动模型会把这个指针替换成iommu_dma_ops。如果某个特殊设备想自己接管可以在驱动初始化时调用arch_setup_dma_ops()或者直接给dev-dma_ops赋值。这个间接层设计的核心原因是 DMA 地址和 CPU 物理地址的关系在不同硬件平台上是不同的。有的平台设备能直接访问全部物理内存有的平台设备只能看到一小段地址窗口有的平台干脆不认识物理地址只认 IOMMU 给它的映射地址。内核用一套 API 掩盖这些差异让驱动作者写出跨平台代码。1.2 为什么会有多种实现硬件差异和内核抽象dma_map_ops之所以需要多种实现方式跟 CPU 架构和外设总线的耦合程度强相关。x86 平台传统上给 PCIe 设备的是 物理地址直通设备可以随意访问所有 RAM几乎没有 DMA 地址限制ARM64 平台因为有 SMMU很多 SoC 设计成 所有 DMA 必须经过 SMMU 翻译于是地址空间被抽象成一层。除了平台差异内存的连续性也是关键变量。一个使用 4K 页的系统里DMA 缓冲区往往是散落的页面设备却可能需要连续的 DMA 地址。这跟 CPU 的虚拟地址映射是一个思路物理上不连续通过页表映射成虚拟上连续。DMA 地址也一样IOMMU 能把分散的物理页映射成一段连续的 DMA 地址给设备用。这些差异总结下来就是三个问题设备能访问哪些物理内存DMA 地址和物理地址需不需要翻译映射关系由谁维护软件还是硬件页表三种实现方式正好是这些问题的三种解法。直接映射设备能访问全部内存不做翻译最省事IOMMU 映射必须翻译由硬件页表维护自定义 ops默认逻辑不满足需求驱动自己重写映射行为。2. 方式一默认的直接映射swiotlb / dma-direct第一种实现是内核默认的、也是几乎每个 Linux 系统都会走一遍的路径。没有 IOMMU、没有 SMMU、设备又没有特殊 DMA 限制时内核走dma_direct_*逻辑。简单说它就是“物理地址直接当 DMA 地址用”。2.1 直接映射的适用场景和判断标准先看一个典型 x86 桌面环境主板没有开 VT-dPCIe 设备直接访问物理地址。此时dev-dma_ops是 NULLdma_map_single()调用链落到dma_direct_map_page()。判断设备是否走直接映射在内核日志里其实有迹可循。开机过程中看到类似pci 0000:00:1f.6: DMA: using IOMMU for device或者没有 IOMMU 相关的行通常说明走的是 direct mapping。更直接的方式是看/sys/kernel/debug/dma-api/下的信息或者用bpftrace挂dma_direct_map_page看调用栈。直接映射为什么快因为它省去了页表维护、TLB 刷新、IOMMU 缓存失效这些步骤。dma_direct_map_page()做的事极其简单核对设备和物理地址范围如果需要 swiotlb 就跳转否则直接返回物理地址作为 DMA 地址。核心代码如下简化自 kernel/dma/direct.cdma_addr_t dma_direct_map_page(struct device *dev, struct page *page, unsigned long offset, size_t size, enum dma_data_direction dir, unsigned long attrs) { phys_addr_t phys page_to_phys(page) offset; if (unlikely(!dma_direct_possible(dev, phys, size))) { if (is_swiotlb_active(dev)) return swiotlb_map(dev, phys, size, dir, attrs); if (dma_direct_need_sync(dev)) return direct_remap(dev, phys, size, dir, attrs); return DMA_MAPPING_ERROR; } return phys; }注意dma_direct_possible()这个判断它会检查设备声明的 DMA 掩码比如dev-coherent_dma_mask是否覆盖这段物理地址。如果设备只能访问 32 位地址空间而phys落在 4G 以上直接映射会失败然后尝试 swiotlb 或 direct_remap。这个逻辑是直接映射能稳住的前提。2.2 swiotlb 在直接映射里扮演的角色很多人以为 swiotlb 是独立的 DMA 实现其实它只是直接映射的一个 fallback 机制。当设备有小地址窗口限制而内存又分配到高位时swiotlb 从低端内存里预留一块 bounce buffer把数据先拷贝进去再把这块缓冲区的 DMA 地址给设备。代价是两次内存拷贝性能差但保底能用。内核启动参数swiotlb可以控制预留大小默认是 64MB 的 1/4这里容易搞混。实际默认是 64MBswiotlbforce可以强制启用。在某些虚拟化场景特别是 guest 内部没有 IOMMU 时不开 swiotlb 可能导致 DMA 失败。日志里出现DMA: Out of SW-IOMMU space就是 buffer 耗尽。实操经验如果你发现一个设备在 direct mapping 模式下性能奇差比如网络吞吐只有预期一半先查是不是大量数据落到 swiotlb bounce buffer。观察方法可以看/sys/kernel/debug/swiotlb/io_tlb_used这个文件记录了当前已用的 swiotlb slot。如果长期高位运行要么调大预分配要么考虑把设备切到 IOMMU 模式从根本上避免 bounce。也要注意 direct mapping 在 cache 一致性上的处理。x86 平台因为硬件保证了 DMA 一致性或者说平台能正确处理CPU 和设备的 cache 视图一致。ARM32 或某些老 ARM 平台需要内核做 cache 维护arch_sync_dma_for_device()和arch_sync_dma_for_cpu()会被调用这也是 ARM 平台上 direct mapping 不一定比 IOMMU 快的原因——cache 操作的开销可能在部分场景超过 IOMMU 的翻译开销。3. 方式二基于 IOMMU/SMMU 的映射第二种实现是大多数现代服务器和 ARM SoC 平台的标配。设备不能直接访问物理内存必须经过 IOMMU/SMMU 的翻译。在 x86 上叫 IOMMUVT-d在 ARM 上叫 SMMU在内核代码里统一走iommu_dma_ops。3.1 IOMMU 映射的核心流程和关键结构当系统初始化时如果 IOMMU 驱动探测到设备并在设备模型里完成了 DMA 域绑定dev-dma_ops会被替换为iommu_dma_ops。这发生在of_dma_configure()或者iommu_probe_device()流程中。之后dma_map_single()就会走到static dma_addr_t iommu_dma_map_page(struct device *dev, struct page *page, unsigned long offset, size_t size, enum dma_data_direction dir, unsigned long attrs) { phys_addr_t phys page_to_phys(page) offset; dma_addr_t dma_addr; dma_addr __iommu_dma_map(dev, phys, size, dma_get_mask(dev)); if (dma_addr DMA_MAPPING_ERROR) return DMA_MAPPING_ERROR; return dma_addr; }__iommu_dma_map()最终调用iommu_map()通过 IOMMU 驱动创建一段 IO 虚拟地址IOVA到物理地址的映射。这段 IOVA 是一段连续的、与物理地址无关的 DMA 地址。设备看到的是 IOVACPU 看到的是物理地址两边通过 IOMMU 页表转换。关键点在于 IOVA 的分配方式。内核维护一个 per-device或者 per-domain的 IOVA 分配器基于红黑树管理。分配时优先找 hole尽量避免碎片化。内核文档Documentation/core-api/dma-api.rst里没细说但调试的时候用/sys/kernel/debug/iommu/下的文件可以看到当前 domain 的映射统计。IOMMU 映射的意义不仅在做地址翻译还在于它可以实现真正的 DMA 隔离。每个设备只能访问自己映射过的物理页恶意或错误的 DMA 操作不会破坏其他设备或内核的数据。这也是为什么虚拟机直通场景VFIO强制依赖 IOMMU——它构建了设备级的内存保护。3.2 IOMMU 开启后的性能开销和针对性调优很多开发者的第一反应是 IOMMU 会拖慢速度。实测下来大块连续 DMA 缓冲的场景IOMMU 翻译开销几乎可以忽略因为现代 IOMMU 有 IOTLB 缓存类似 CPU TLB同一段 IOVA 反复访问时直接命中缓存。但小包高频映射的场景比如网络收包每包 dma_map dma_unmap性能下降明显。原因是每次 unmap 后 IOVA 归还TLB 缓存可能失效。针对这个场景内核提供了两个优化方向。一是iommu_dma_alloc时尽量复用 IOVA二是使用 DMA API 的attrs参数比如DMA_ATTR_SKIP_CPU_SYNC和DMA_ATTR_WEAK_ORDERING。特别是DMA_ATTR_SKIP_CPU_SYNC如果驱动明确知道 CPU 已经做了 cache 刷新可以省去内核的 sync 操作。还有动态 IOMMU 域切换比如 Intel 平台的intel-iommuon可以搭配iommupt参数。pt表示 passthrough 模式IOMMU 不翻译设备直接访问物理地址。这在性能敏感但不要求隔离的场景很好用。要注意iommupt是全局配置客制化场景下可以针对单个设备修改sysfs里的iommu_group属性但操作比较复杂生产环境不多见。用dmesg确认 IOMMU 是否生效dmesg | grep -i DMAR\|IOMMU\|SMMU如果看到DMAR: IOMMU enabled说明 Intel IOMMU 已启用。此时查看设备的 DMA ops 类型可以通过 ftrace 打开iommu_dma_map_page的调用统计echo iommu_dma_map_page /sys/kernel/debug/tracing/set_ftrace_filter echo function /sys/kernel/debug/tracing/current_tracer echo 1 /sys/kernel/debug/tracing/tracing_on实际项目里一次网络吞吐下降的排查经历让我记住了 IOMMU 缓存的重要性某个平台开启 VT-d 后40G 网卡的吞吐从 35Gbps 掉到 22Gbps。最后定位到是驱动没有使用DMA_ATTR_SKIP_CPU_SYNC每次 map/unmap 都触发 IOTLB 失效和 cache sync。加上属性后恢复到了 33Gbps剩下 2Gbps 的差距来自 IOVA 分配本身的开销可以接受。4. 方式三驱动/平台自定义 dma_map_ops第三种方式不是每块硬件都用得上但它确实是解决“默认映射逻辑不满足需求”的最终手段。所谓自定义就是驱动为某个设备或某类设备定义一套自己的dma_map_ops替换内核默认实现。4.1 什么场景下需要自定义 ops最常见的是老式非 PCI 总线设备比如某些 SoC 内部集成的 DMA 控制器它们有自己的 DMA 地址翻译逻辑甚至需要驱动主动配置 DMA 引擎的寄存器才能完成地址映射。这种设备挂平台总线内核默认的 direct mapping 和 IOMMU 映射都不适用。另一类是需要直接管理 IOMMU 页表的场景。比如 VFIO 的 mdev 设备mdev 驱动为了让用户态能直接操作 DMA 映射会自定义dma_map_ops把map_page实现成“创建 IOMMU domain 映射用户地址”两个步骤。这种情况下默认的iommu_dma_ops无法表达“映射的发起者是用户态而不是内核驱动”这一语义。自定义 ops 也能用来做调试和性能统计。我在一个项目里见过有人写了一套包装 ops把每次 map 的 size、方向、耗时记录到 tracepoint 里用来分析 DMA 映射热点。这种用法不算生产方案但排查问题时非常有价值。还有很实际的场景设备 DMA 地址宽度小于系统总线宽度但 swiotlb 不适合。比如某些老 FPGA 加速卡只能处理 30 位地址直接映射会频繁触发 bounce buffer自定义 ops 可以预先分配一块适合的 DMA 内存池并维护从“设备可见地址”到“物理地址”的映射表避免每次拷贝。4.2 最小自定义实现参考下面这个例子模拟一个假设的 DMA 控制器它要求 DMA 地址必须是 256 字节对齐且控制器只能访问 16MB 以内的地址。实现自定义 ops 的关键是填好struct dma_map_ops然后把它挂到设备上。#include linux/dma-map-ops.h static dma_addr_t mydev_map_page(struct device *dev, struct page *page, unsigned long offset, size_t size, enum dma_data_direction dir, unsigned long attrs) { dma_addr_t dma_addr; if (size MYDEV_DMA_ALIGN) return DMA_MAPPING_ERROR; dma_addr page_to_phys(page) offset; /* 控制器要求 256 字节对齐 */ if (dma_addr (MYDEV_DMA_ALIGN - 1)) return DMA_MAPPING_ERROR; /* 超出控制器地址窗口 */ if (dma_addr size MYDEV_DMA_WINDOW) return DMA_MAPPING_ERROR; /* 有些控制器还要在这里配置 DMA 映射寄存器 */ return dma_addr; } static void mydev_unmap_page(struct device *dev, dma_addr_t dma_addr, size_t size, enum dma_data_direction dir, unsigned long attrs) { /* 如果 map 时配置了寄存器这里要复位 */ } static const struct dma_map_ops mydev_dma_ops { .map_page mydev_map_page, .unmap_page mydev_unmap_page, };挂载到设备的方式取决于初始化时机。如果是平台驱动可以在probe()里做static int mydev_probe(struct platform_device *pdev) { struct device *dev pdev-dev; /* 先禁用默认 DMA 设置再挂自定义 ops */ arch_teardown_dma_ops(dev); set_dma_ops(dev, mydev_dma_ops); return 0; }注意arch_teardown_dma_ops()的作用不先清掉默认 ops 的话set_dma_ops()可能因为 IOMMU 域已经建立而失败或者留下一个 iommu 相关的 ops 覆盖掉自定义值。这里有几个容易踩的坑后面单独讲。5. 三种方式的对比、选型建议与排查技巧了解了三种实现之后最关键的是在实际项目中做出合适的选择。没有绝对的好与坏只有当前硬件和性能需求下更合适的选择。5.1 三种实现方式的横向对比维度直接映射IOMMU/SMMU 映射自定义 ops地址翻译无有IOVA 到物理地址取决于实现DMA 隔离弱设备可访问全部内存强只能访问映射过的页取决于实现性能最高无翻译开销中有 IOTLB 缓存时接近不确定可能很高也可能很慢复杂度低内核自动处理中涉及 IOMMU 域和 IOVA 管理高所有逻辑自己维护适用平台x86 未开 VT-d、ARM 无 SMMU 或 passthrough现代服务器、ARM SoC、虚拟化直通场景特殊总线设备、FPGA 卡、自定义控制器常见坑swiotlb 泄漏、高地址分配失败IOVA 碎片化、IOTLB 失效dma_ops 覆盖失败、地址窗口校验遗漏直接映射适合追求极致性能和硬件本身具备良好隔离能力的场景。IOMMU 映射适合需要隔离、动态映射、虚拟化直通的场景。自定义 ops 只建议在确认默认实现无法表达硬件语义时使用否则维护成本极高。5.2 调试排查技巧与典型错误定位排查 DMA 映射问题最有用的工具是内核自带的 DMA API debug开启CONFIG_DMA_API_DEBUG后内核会检测 unmap 的地址和 size 是否匹配、map 的 page 是否经过正确 sync 等。缺点是开销较大生产环境别开。常见问题里排第一的是DMA mapping error - address is not aligned。这类日志说明设备请求的 DMA 地址不满足硬件的对齐要求。对策是调整缓冲区分配方式使用dma_alloc_coherent()时内核会返回满足设备dma_mask和alignment的地址如果是从回收的 skb 里拿的 page则要检查 offset 是否满足要求。第二个常见问题swiotlb buffer is full。原因是大量同时进行的中断触发 DMA 映射swiotlb 的 slot 耗尽。调大swiotlb参数只是治标更根本的方案是改用 IOMMU 模式或者减少小包映射的次数。内核 6.2 之后引入了dma_alloc_noncontiguous等 API某些场景可以绕过 bounce buffer但适配成本高。第三个问题是自定义 ops 时遇到 DMA 地址错乱。我之前排查过一个 PCIe 转并口设备数据写到 DMA 目的地址总有一小段错位。最后发现是map_page返回的地址没有做总线地址转换在非 x86 平台上page_to_phys和总线地址不是一回事。正确的做法是调用dma_direct_to_bus()或查询平台提供的dma_pfn_offset。这类问题在 x86 上不会暴露一换平台就翻车。还有一类启动早期的问题设备 probe 在 IOMMU 初始化完成之前导致arch_setup_dma_ops()没有执行成功。此时dev-dma_ops还是空驱动如果强制走到自定义映射要判断dev-dma_ops是否已经被正确设置。稳妥的方式是在 probe 里检查get_dma_ops(dev)是否等于预期值否则打印告警并返回-ENODEV。6. 我的一些落地经验和建议写了这么多年驱动个人感受是dma_map_ops这个结构体就像一个翻译层它隔离了驱动作者和硬件平台的差异。但翻译层本身也是一层复杂性搞懂它需要在“性能”“隔离”“可维护性”之间反复权衡。如果只是写普通驱动建议不要碰自定义 ops优先依赖默认实现。只有在真正测量到性能瓶颈或者硬件手册明确说明“需要一个私有映射流程”时才考虑第三种方式。选型时先用 5.1 的表对照一遍确认硬件特性属于哪一类再动手写。调试时除了CONFIG_DMA_API_DEBUG我强烈建议在代码里加临时的dev_err()打印把 map 的物理地址、DMA 地址、大小和对齐值都打出来。DMA 问题很多是运行时才暴露的光靠静态代码不容易看出来。我见过很多“看起来没错但就是数据传输不对”的问题最后都靠打印定位到是 offset 没传对或者 size 超过了实际缓冲。最后提一个小技巧不要把dma_map_single和dma_unmap_single散在驱动的各个角落最好封装成一个专门的 buffer 管理模块统一管理映射生命周期。这样不仅好排查问题后面想切换映射方式比如从直接映射换到 IOMMU 模式也只需要改一个文件。我自己实践下来这种封装对代码可维护性的提升远超预期。
返回列表