恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
PCIe驱动开发:ioremap与DMA映射原理及实战
首页
资讯中心
/
PCIe驱动开发:ioremap与DMA映射原理及实战
PCIe驱动开发:ioremap与DMA映射原理及实战
发布时间:2026/9/19 13:58:45
1. 从一次网卡驱动调试说起PCIe设备内存访问到底难在哪很多人第一次接触PCIe设备驱动开发都是从一块网卡或者一块采集卡开始的。硬件插上去系统能枚举到设备lspci能看到厂商ID和设备ID但接下来要读写设备寄存器、搬运数据就卡住了。CPU直接访问物理地址不行MMU拦着。用/dev/mem权限和安全都是问题。这时候就绕不开两个核心机制ioremap和DMA映射。我拿一块常见的PCIe网卡举例。设备上电后BAR空间里映射着控制寄存器和状态寄存器CPU想配置它必须先把这段物理地址映射到内核虚拟地址空间这就是ioremap干的活。而网卡要往内存里写数据包它作为总线主设备直接访问系统内存这个地址转换和一致性维护就是DMA映射要解决的问题。两者一个管CPU访问设备一个管设备访问内存方向相反但经常在同一个驱动里配合出现。这篇文章适合谁看如果你正在写PCIe设备驱动或者在做嵌入式Linux下的外设开发又或者你只是好奇“为什么驱动代码里到处是ioremap和dma_alloc_coherent”那这篇内容能帮你把这条链路彻底串起来。我会从地址空间的基本概念讲起把ioremap的映射原理、DMA映射的两种模式、IOMMU在中间扮演的角色以及实际编码中容易踩的坑全部拆开揉碎讲清楚。涉及到的参数计算和代码片段你都可以直接拿去改改用。注意本文讨论的是Linux内核态驱动开发场景用户态通过VFIO或UIO访问设备的路径不在本次范围内那是另一套机制。2. 地址空间的三层结构CPU、总线与设备各看各的2.1 物理地址、总线地址与虚拟地址的区别理解PCIe内存访问第一步是把“地址”这个词拆开。在x86或ARM系统里至少存在三种地址视角CPU虚拟地址内核代码里指针指向的地址经过MMU页表转换后才能落到物理内存。CPU物理地址内存控制器看到的地址DRAM颗粒上的实际位置。总线地址PCIe设备在总线上发起访问时使用的地址也叫DMA地址。在大多数简单的ARM嵌入式系统里总线地址和CPU物理地址是1:1的所以很多人感觉不到区别。但在x86服务器或者带IOMMU的ARM64系统上总线地址和物理地址之间隔着一层地址转换。设备发出的总线地址先经过IOMMU翻译成物理地址才能访问到真正的内存。这就解释了为什么驱动代码里不能直接把virt_to_phys的结果塞给设备当DMA地址用。你必须用dma_map_single或者dma_alloc_coherent让内核DMA层帮你处理这层转换。2.2 BAR空间设备寄存器的窗口PCIe设备的配置空间里有六个BARBase Address Register每个BAR描述一段设备内部的地址范围。固件或者内核在枚举阶段会给BAR分配一段总线地址设备收到落在BAR范围内的读写请求就会路由到自己的内部寄存器或内存。CPU要访问这些寄存器不能直接用BAR里的总线地址因为MMU不认。必须先用ioremap把这段总线地址映射成内核虚拟地址。映射完成后你拿到一个void __iomem *指针用readl/writel去读写。这里有个细节BAR空间可能是Memory空间也可能是IO空间。PCIe时代基本全是Memory BARIO BAR只在一些老设备上残留。Memory BAR又分可预取和不可预取寄存器通常是不可预取的因为读操作可能有副作用。2.3 为什么不能直接解引用物理地址有人会问内核不是运行在特权级吗为什么不能直接访问物理地址原因有两个。第一MMU开启后CPU发出的任何地址都要经过页表转换你没有建立映射的物理地址访问就是缺页异常。第二即使你建立了恒等映射直接访问设备寄存器也可能因为乱序执行和缓存而出现意外行为。ioremap返回的指针带有__iomem标记配合readl/writel能保证访问顺序和宽度符合设备要求。3. ioremap实战把设备寄存器映射到内核空间3.1 ioremap的函数族与选择依据Linux内核提供了一组ioremap函数常用的有这几个函数用途特点ioremap基本映射可缓存属性由架构决定ioremap_np非发布写映射写操作不经过写缓冲适合寄存器devm_ioremap带设备管理的映射驱动卸载时自动释放devm_ioremap_resource从resource映射自动申请region推荐使用ioremap_wc写合并映射适合帧缓冲等大块内存写驱动时我优先用devm_ioremap_resource因为它会帮你检查resource是否已经被占用避免多个驱动抢同一段BAR。函数签名是void __iomem *devm_ioremap_resource(struct device *dev, struct resource *res);传入从pci_resource_start拿到的resource返回映射后的虚拟地址。如果失败返回ERR_PTR用IS_ERR判断。3.2 映射长度与BAR大小的计算映射长度不能随便写。你需要先读BAR的配置确定设备实际请求了多大空间。内核在枚举时已经把BAR大小算好了可以通过pci_resource_len获取resource_size_t bar_len pci_resource_len(pdev, bar); void __iomem *base devm_ioremap_resource(pdev-dev, pdev-resource[bar]);如果设备支持64位BARpci_resource_start返回的是64位地址映射时不用特殊处理ioremap内部会处理。但要注意有些设备的BAR大小是2的幂次实际使用的寄存器可能只占前面一小段映射整个BAR虽然浪费一些虚拟地址空间但省事且安全。3.3 读写寄存器的正确姿势映射完成后读写必须用内核提供的IO访问函数u32 val readl(base REG_CTRL); writel(val | CTRL_ENABLE, base REG_CTRL);不要用*(volatile u32 *)base这种写法。原因有三第一readl/writel内部有内存屏障保证访问顺序第二它们处理了大小端问题第三在有些架构上设备寄存器需要特殊的访问指令。对于64位寄存器用readq/writeq但要注意不是所有架构都支持原子64位访问。如果不支持需要拆成两次32位访问并考虑加锁。实操心得调试阶段可以在readl/writel外面包一层打印记录寄存器的读写值和地址偏移。但正式代码里一定要去掉否则高频寄存器访问会被打印拖垮。4. DMA映射让设备直接访问系统内存4.1 一致性DMA与流式DMA的取舍DMA映射分两大类一致性DMACoherent DMA用dma_alloc_coherent分配CPU和设备看到的内存内容始终一致不需要手动同步。适合长期存在的描述符环、控制结构。流式DMAStreaming DMA用dma_map_single或dma_map_sg映射已经分配好的内存在设备访问前后需要dma_sync_single_for_device和dma_sync_single_for_cpu来同步。适合网络包、磁盘数据这类一次性传输。选择依据很简单如果这块内存会被CPU和设备反复读写且没有明确的“交接”时刻用一致性DMA。如果是一次性传输用完就完用流式DMA性能更好因为可以配合缓存刷新做优化。4.2 dma_alloc_coherent的分配过程与参数void *cpu_addr; dma_addr_t dma_handle; size_t size 4096; cpu_addr dma_alloc_coherent(pdev-dev, size, dma_handle, GFP_KERNEL);返回两个地址cpu_addr是CPU侧的虚拟地址dma_handle是设备侧的总线地址。驱动把dma_handle写进设备的描述符寄存器设备就能直接访问这块内存。size建议按页对齐虽然内核会帮你对齐但显式对齐更清晰。GFP_KERNEL表示可以睡眠如果在原子上下文里要用GFP_ATOMIC但原子分配大块内存容易失败尽量在probe里提前分配好。释放用dma_free_coherent(pdev-dev, size, cpu_addr, dma_handle)四个参数一个都不能少。4.3 流式DMA映射的完整流程以网卡发送一个数据包为例dma_addr_t dma_addr; dma_addr_t dma_addr dma_map_single(pdev-dev, skb-data, skb-len, DMA_TO_DEVICE); if (dma_mapping_error(pdev-dev, dma_addr)) { /* 处理错误 */ } /* 把dma_addr和长度写进发送描述符 */ /* 通知设备开始发送 */ /* 发送完成后 */ dma_unmap_single(pdev-dev, dma_addr, skb-len, DMA_TO_DEVICE);方向参数很重要DMA_TO_DEVICE表示数据从内存到设备DMA_FROM_DEVICE相反DMA_BIDIRECTIONAL双向。方向用错会导致缓存刷新范围不对出现数据不一致。对于分散聚集列表用dma_map_sg一次映射多个片段返回实际映射的片段数可能小于传入的数量因为相邻的物理页可能被合并。4.4 DMA地址与CPU物理地址的转换关系在没有IOMMU的系统上dma_handle通常等于CPU物理地址。在有IOMMU的系统上dma_handle是IOVAI/O虚拟地址IOMMU负责把它翻译成物理地址。驱动不需要关心这个区别DMA API已经屏蔽了。但有一个例外如果你需要把DMA地址反向转换成CPU虚拟地址不能用phys_to_virt而应该用dma_to_virt或者保存好分配时的对应关系。很多驱动会维护一个描述符数组每个描述符里存DMA地址和对应的CPU指针这样最稳妥。5. IOMMU的介入地址转换与隔离保护5.1 IOMMU在DMA路径中的位置IOMMU位于设备和内存控制器之间。设备发出的DMA请求带着总线地址先到IOMMUIOMMU查页表把总线地址翻译成物理地址再发给内存控制器。如果查不到映射或者权限不对IOMMU会拦截这个请求产生一个 fault。这个机制带来两个好处第一设备只能访问驱动明确映射过的内存不能乱写第二驱动可以用不连续的物理页拼出一个连续的IOVA空间解决设备对物理地址连续性的要求。5.2 DMA映射在IOMMU下的行为变化有IOMMU时dma_map_single会做两件事建立IOVA到物理地址的映射以及刷新CPU缓存。返回的dma_handle是IOVA不是物理地址。dma_unmap_single会拆除映射。如果驱动错误地把dma_handle当成物理地址用比如传给virt_to_page在有IOMMU的系统上就会出错。这种bug在没有IOMMU的开发板上不会暴露一到服务器上就炸排查起来很痛苦。5.3 排查IOMMU相关故障的思路遇到DMA相关的问题先看内核日志里有没有IOMMU fault。常见的fault原因有驱动用了错误的DMA地址映射方向不对导致缓存不一致映射还没建立就启动了DMA映射已经拆除但设备还在访问排查时可以在内核启动参数里加iommuptpassthrough模式做对比测试。如果passthrough模式下正常开启翻译就出错基本可以确定是IOVA使用有问题。注意生产环境不要长期用passthrough那等于关掉了IOMMU的保护作用。6. 完整驱动流程从probe到数据传输6.1 probe阶段的资源申请顺序一个典型的PCIe驱动probe函数按这个顺序走pci_enable_device使能设备分配中断等资源。pci_request_regions申请BAR资源防止冲突。pci_set_master使能总线主设备模式允许设备发起DMA。devm_ioremap_resource映射BAR空间。dma_set_mask_and_coherent设置DMA地址掩码告诉内核设备支持多少位地址。dma_alloc_coherent分配描述符环等一致性内存。注册中断处理函数。初始化设备寄存器启动设备。顺序不能乱。比如pci_set_master必须在任何DMA操作之前调用否则设备发不出DMA请求。6.2 DMA掩码的设置与含义int ret dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(64)); if (ret) { ret dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(32)); }这行代码告诉内核我的设备支持64位DMA地址。如果设置失败回退到32位。内核会根据这个掩码决定从哪里分配DMA内存。如果设备只支持32位但系统内存有超过4GB的部分内核会从低4GB区域分配或者用IOMMU做 bounce buffer。设置掩码失败通常意味着设备硬件不支持这时候驱动要么报错退出要么用dma_set_mask配合dma_set_coherent_mask分别设置。6.3 数据传输的中断处理与同步以接收方向为例设备收到数据包通过DMA写入内存然后触发中断。中断处理函数里/* 读取状态寄存器确认是接收中断 */ u32 status readl(base REG_ISR); if (status ISR_RX) { /* 处理接收描述符 */ struct rx_desc *desc rx_ring[rx_tail]; dma_sync_single_for_cpu(pdev-dev, desc-dma_addr, desc-len, DMA_FROM_DEVICE); /* 此时CPU可以安全读取数据 */ process_packet(desc-cpu_addr, desc-len); /* 重新映射给设备 */ dma_sync_single_for_device(pdev-dev, desc-dma_addr, desc-len, DMA_FROM_DEVICE); /* 更新尾指针通知设备 */ writel(rx_tail, base REG_RX_TAIL); }dma_sync_single_for_cpu的作用是确保CPU看到设备写入的最新数据在有些架构上需要 invalidate 缓存。dma_sync_single_for_device则相反确保设备看到CPU的修改。7. 常见问题与排查技巧实录7.1 寄存器读写返回全F或全0这是调试PCIe驱动最常见的问题。可能原因现象可能原因排查方法读回全FBAR未映射或映射错误检查ioremap返回值确认BAR索引读回全0设备未使能或时钟未开检查pci_enable_device返回值读回值不稳定访问宽度不对确认寄存器是32位还是64位写不进去寄存器只读或需要解锁查数据手册的寄存器属性我遇到过一次读回全F查了半天发现是pci_request_regions失败但没检查返回值BAR被另一个驱动占用了。所以每一步的返回值都要检查不要偷懒。7.2 DMA传输数据错乱或丢失DMA问题排查起来比寄存器访问更麻烦因为涉及缓存和地址转换。常见原因缓存不一致流式DMA忘记调用dma_sync_single_for_cpuCPU读到的是缓存里的旧数据。方向错误DMA_TO_DEVICE和DMA_FROM_DEVICE搞反缓存刷新方向不对。地址错误把CPU虚拟地址当DMA地址用或者把物理地址当DMA地址用。映射时机错误设备还在访问就dma_unmap了。排查时可以先在dma_map_single和dma_unmap_single里打印地址和长度对比设备描述符里的值。如果地址对不上就是映射环节的问题。7.3 IOMMU fault的定位方法IOMMU fault日志里会包含设备BDF和出错的IOVA。根据BDF找到对应的设备然后检查驱动里这个设备的DMA映射代码。常见的是映射了A地址设备却访问了B地址或者映射已经拆除但设备还在用。有个技巧在dma_map_single里把返回的dma_handle和传入的CPU地址都打印出来然后在IOMMU fault日志里找对应的IOVA。如果IOVA不在任何一次映射的范围内说明设备用了野地址。7.4 驱动卸载时的资源释放顺序卸载顺序和probe顺序相反停止设备写寄存器关闭DMA和中断。释放中断。dma_free_coherent释放一致性内存。iounmap释放映射用devm的会自动释放。pci_release_regions。pci_disable_device。顺序错了会导致use-after-free或者设备还在DMA但内存已经释放。我见过一个驱动先释放了DMA内存再关设备结果设备DMA写到已释放的内存触发IOMMU fault。8. 性能优化与进阶话题8.1 写合并映射在帧缓冲中的应用对于显存、帧缓冲这类不需要CPU频繁读、只需要大块写的内存用ioremap_wc做写合并映射能显著提升吞吐。写合并会把多次小写合并成一次大写减少总线事务。但注意写合并内存不能保证写顺序也不适合寄存器。8.2 分散聚集DMA与零拷贝网络和存储驱动里大量使用dma_map_sg。它把多个物理不连续的缓冲区映射成设备可见的连续IOVA有IOMMU时或者多个总线地址段无IOMMU时。配合skb的碎片结构可以实现零拷贝发送避免数据在内存里搬来搬去。使用dma_map_sg时要注意返回的片段数可能小于传入数遍历时以返回值为准。每个片段的长度和地址都要正确填进设备描述符。8.3 64位DMA掩码与bounce buffer如果设备只支持32位DMA但分配的缓冲区物理地址在4GB以上内核会启用bounce buffer把数据先拷贝到低4GB的缓冲区再让设备DMA。这会严重拖累性能。解决办法是在dma_alloc_coherent时加GFP_DMA32标志强制从低4GB分配。但GFP_DMA32不是所有架构都支持用之前要确认。我在一块ARM64板子上调网卡时发现吞吐只有预期的一半后来查出来是设备只支持32位DMA而系统内存大部分在4GB以上每次传输都在走bounce buffer。改成GFP_DMA32分配描述符环后吞吐直接翻倍。8.4 中断合并与NAPI对DMA的影响高速网卡如果每个包都中断CPU会被中断淹没。中断合并让设备攒一批包再中断NAPI在软中断里轮询处理。这对DMA映射的影响是一次中断要处理多个描述符每个描述符的dma_sync要逐个做不能漏。漏掉一个就会读到旧数据。轮询模式下dma_sync_single_for_cpu的调用频率降低但每次处理的描述符数量增加总体开销反而下降。配置中断合并参数时要在延迟和吞吐之间找平衡通常用ethtool -C调整。9. 我个人在实际操作中的几点体会调PCIe驱动这些年最大的感受是地址问题占bug来源的八成。ioremap映射错BAR、DMA地址用错类型、IOMMU映射没建立就启动传输这些问题在没有IOMMU的简单平台上往往不暴露一到复杂系统就集中爆发。所以我的习惯是probe阶段每一步都打印关键地址BAR的物理地址和映射后的虚拟地址、DMA分配返回的CPU地址和DMA地址、DMA掩码设置结果。这些打印在调试完成后可以关掉但调试期能省下大量猜测时间。另一个体会是不要迷信“参考驱动”。很多厂商提供的驱动是在老内核、无IOMMU环境下写的直接移植到新内核加IOMMU的系统上DMA部分几乎一定要改。重点检查dma_alloc_coherent的返回值有没有判断、dma_map_single的方向参数对不对、dma_unmap的时机是否在设备停止之后。把这几个点过一遍大部分DMA问题都能提前避免。最后分享一个小技巧如果怀疑是缓存一致性问题可以在dma_map_single之后手动调用dma_sync_single_for_device在dma_unmap_single之前手动调用dma_sync_single_for_cpu虽然API本身可能已经做了但显式调用能帮你确认同步点在哪里。确认没问题后再去掉冗余调用。这个办法在排查偶发的数据错乱时特别管用。