1. 从一根金手指说起:PCIe设备内存访问到底在解决什么问题
做过PCIe驱动开发的人都有一个共同体会:枚举过程跑通了,BAR空间也读到了,但真正让设备“动起来”的那一刻,往往卡在内存访问这一环。CPU怎么访问PCIe设备内部的寄存器?设备又怎么把采集到的数据搬到主机内存?这两件事听起来简单,但背后牵扯到地址映射、缓存一致性、IOMMU重映射、DMA方向性等一系列机制。任何一个环节搞错,轻则读写到错误数据,重则系统直接挂死。
这篇内容围绕PCIe设备内存访问这条主线,把从ioremap到DMA映射的完整流程拆开讲清楚。核心关键词包括PCIe、ioremap、DMA映射、内存访问、IOMMU。适合有一定Linux驱动基础、正在调试PCIe设备(网卡、采集卡、NVMe、自定义加速卡都算)的工程师阅读。如果你刚开始接触PCIe,建议先把枚举和BAR空间配置搞清楚再来看这篇,否则容易一头雾水。
先说结论性的认知:PCIe设备的内存访问分两个方向——CPU侧访问设备内存和设备侧访问主机内存。前者靠ioremap把BAR物理地址映射到内核虚拟地址空间,后者靠DMA映射建立设备可识别的总线地址。两者看似独立,但在IOMMU开启的情况下会通过同一套地址转换机制产生关联。理解这条主线,后面所有细节都能挂上去。
我在实际项目里踩过最典型的坑是:驱动里用ioremap映射了BAR0,读写寄存器一切正常,但设备DMA一开就报IOMMU fault。排查了半天才发现,DMA缓冲区用的是kmalloc返回的虚拟地址直接塞给了设备寄存器,没有经过dma_map_single做总线地址转换。这个错误在新手里非常常见,后面会详细展开。
2. 核心机制拆解:ioremap与DMA映射的本质区别
2.1 为什么不能直接解引用BAR地址
PCIe设备的配置空间里有六个BAR(Base Address Register),系统固件或内核枚举阶段会给每个BAR分配一段物理地址区间。这段物理地址落在系统内存映射的MMIO区域,CPU可以通过load/store指令访问。但问题是,内核代码运行在虚拟地址空间,不能直接拿一个物理地址去解引用。
裸机环境下可以直接操作物理地址,因为那时没有MMU或者MMU是关闭的。但Linux内核开启MMU之后,所有访问都必须经过虚拟地址。ioremap的作用就是建立一段虚拟地址到MMIO物理地址的映射,并且把这段映射标记为不可缓存(uncached)或写合并(write-combining),避免CPU缓存和设备寄存器之间产生不一致。
这里有个关键点:ioremap返回的虚拟地址不能直接用memcpy或指针解引用访问,必须配合readl/writel这类IO访问函数。原因在于不同架构对MMIO访问的顺序和屏障要求不同,readl内部会插入适当的内存屏障,保证读写顺序符合设备预期。直接解引用在x86上可能碰巧能跑,换到ARM上大概率出问题。
2.2 DMA映射解决的地址视角问题
设备发起DMA时,它看到的是总线地址(bus address),不是CPU的物理地址,也不是虚拟地址。在没有IOMMU的简单系统里,总线地址等于物理地址,所以驱动可以把virt_to_phys的结果直接写给设备。但这种方式有几个致命缺陷:一是要求DMA缓冲区物理连续,大块内存很难保证;二是设备可以访问任意物理内存,安全性差;三是32位设备无法访问4GB以上的物理地址。
DMA映射API就是来解决这些问题的。dma_map_single、dma_map_page、dma_map_sg这些函数会返回一个dma_addr_t类型的总线地址,驱动把这个地址写给设备寄存器。在IOMMU开启时,这个总线地址是IOMMU的IOVA(I/O Virtual Address),IOMMU负责把它翻译成真正的物理地址。设备完全不需要知道物理内存长什么样。
注意:
dma_addr_t的宽度和CPU物理地址宽度不一定相同。32位设备配64位系统时,必须用dma_alloc_coherent并检查返回的地址是否在设备可寻址范围内,否则DMA会失败。
2.3 一致性内存与非一致性内存的选择
DMA映射分两类:一致性映射(coherent mapping)和流式映射(streaming mapping)。一致性映射用dma_alloc_coherent分配,保证CPU和设备看到的内存内容始终一致,驱动不需要手动做cache维护。代价是这块内存通常不可缓存,CPU访问性能较差。
流式映射用dma_map_single这类函数,它不保证一致性,需要驱动在适当时候调用dma_sync_single_for_cpu或dma_sync_single_for_device来做cache同步。好处是CPU可以正常使用cache,性能更好。选择哪种取决于数据访问模式:如果CPU和设备频繁交替访问同一块小内存(比如描述符环),用一致性映射;如果是一次性大块数据传输(比如网络包),用流式映射。
| 映射类型 | 分配函数 | 一致性 | CPU性能 | 典型场景 |
|---|---|---|---|---|
| 一致性映射 | dma_alloc_coherent | 硬件保证 | 较低 | 描述符环、控制结构 |
| 流式映射 | dma_map_single | 需手动同步 | 较高 | 网络包、大块数据 |
| 散射聚集 | dma_map_sg | 需手动同步 | 较高 | 多段缓冲区 |
3. 实操全流程:从BAR映射到DMA传输的完整代码路径
3.1 第一步:读取BAR并完成ioremap
假设设备已经枚举完成,驱动在probe函数里拿到了struct pci_dev *pdev。第一步是用pci_resource_start读取BAR的物理起始地址,用pci_resource_len读取长度,然后请求这段资源并映射。
/* 请求BAR0资源 */ ret = pci_request_region(pdev, 0, "my_pcie_driver"); if (ret) { dev_err(&pdev->dev, "Failed to request BAR0\n"); return ret; } /* 获取BAR0物理地址和长度 */ phys_addr_t bar0_phys = pci_resource_start(pdev, 0); resource_size_t bar0_len = pci_resource_len(pdev, 0); /* 映射到内核虚拟地址空间 */ void __iomem *bar0_virt = ioremap(bar0_phys, bar0_len); if (!bar0_virt) { dev_err(&pdev->dev, "ioremap failed\n"); pci_release_region(pdev, 0); return -ENOMEM; }这段代码里有个细节:pci_request_region必须在ioremap之前调用,它的作用是向内核声明这段BAR资源已被占用,防止其他驱动误操作。如果跳过这一步直接ioremap,功能上可能没问题,但资源管理上不规范,多个驱动抢同一段BAR时会出现难以排查的问题。
映射完成后,读写寄存器用readl/writel:
/* 读取设备ID寄存器(偏移0x00) */ u32 dev_id = readl(bar0_virt + 0x00); dev_info(&pdev->dev, "Device ID: 0x%08x\n", dev_id); /* 写控制寄存器(偏移0x04),启动设备 */ writel(0x01, bar0_virt + 0x04);实操心得:
ioremap返回的指针一定要用__iomem标注,编译时开启sparse检查能帮你发现直接解引用的错误。我见过太多代码把__iomem去掉后直接用*ptr访问,在x86上跑得好好的,移植到ARM64就崩。
3.2 第二步:分配DMA缓冲区并建立映射
设备要往主机内存写数据,驱动需要先准备好缓冲区。以流式映射为例:
/* 分配DMA缓冲区 */ size_t buf_size = 4096; dma_addr_t dma_handle; void *cpu_addr; /* 方式一:一致性映射,适合描述符环 */ cpu_addr = dma_alloc_coherent(&pdev->dev, buf_size, &dma_handle, GFP_KERNEL); if (!cpu_addr) { dev_err(&pdev->dev, "dma_alloc_coherent failed\n"); return -ENOMEM; } /* 把总线地址写给设备寄存器 */ writel(lower_32_bits(dma_handle), bar0_virt + 0x10); writel(upper_32_bits(dma_handle), bar0_virt + 0x14); writel(buf_size, bar0_virt + 0x18); writel(0x01, bar0_virt + 0x1C); /* 启动DMA */如果是流式映射,流程稍有不同:
/* 分配普通内核内存 */ cpu_addr = kmalloc(buf_size, GFP_KERNEL); if (!cpu_addr) return -ENOMEM; /* 建立流式映射,方向是设备读取(DEVICE_TO_CPU) */ dma_handle = dma_map_single(&pdev->dev, cpu_addr, buf_size, DMA_FROM_DEVICE); if (dma_mapping_error(&pdev->dev, dma_handle)) { dev_err(&pdev->dev, "dma_map_single failed\n"); kfree(cpu_addr); return -ENOMEM; } /* 把dma_handle写给设备,启动DMA */ writel(lower_32_bits(dma_handle), bar0_virt + 0x10); writel(upper_32_bits(dma_handle), bar0_virt + 0x14); writel(buf_size, bar0_virt + 0x18); writel(0x01, bar0_virt + 0x1C);DMA方向参数很关键。DMA_FROM_DEVICE表示数据从设备流向内存,DMA_TO_DEVICE表示数据从内存流向设备,DMA_BIDIRECTIONAL表示双向。方向写错会导致cache维护操作反向执行,数据出现随机错误。这个参数在x86上因为硬件cache一致性做得好,写错可能看不出来,但在ARM等弱一致性架构上必然出问题。
3.3 第三步:等待DMA完成并同步缓存
设备DMA完成后通常会触发中断。在中断处理函数里,驱动需要先做cache同步,再读取数据:
/* 中断处理函数 */ static irqreturn_t my_dma_isr(int irq, void *dev_id) { struct my_dev *dev = dev_id; u32 status = readl(dev->bar0_virt + 0x20); if (status & DMA_DONE_BIT) { /* 流式映射需要同步cache */ dma_sync_single_for_cpu(&dev->pdev->dev, dev->dma_handle, dev->buf_size, DMA_FROM_DEVICE); /* 现在CPU可以安全读取数据了 */ process_data(dev->cpu_addr, dev->buf_size); /* 如果还要继续接收,重新同步给设备 */ dma_sync_single_for_device(&dev->pdev->dev, dev->dma_handle, dev->buf_size, DMA_FROM_DEVICE); } return IRQ_HANDLED; }一致性映射就不需要这些同步操作,dma_alloc_coherent分配的内存在硬件层面保证了CPU和设备的视图一致。但要注意,一致性映射的内存不要用memset之外的方式频繁读写,因为不可缓存意味着每次访问都走内存总线,性能损失明显。
3.4 第四步:清理与释放
驱动卸载或出错时,释放顺序和申请顺序相反:
/* 停止DMA */ writel(0x00, bar0_virt + 0x1C); /* 释放流式映射 */ dma_unmap_single(&pdev->dev, dma_handle, buf_size, DMA_FROM_DEVICE); kfree(cpu_addr); /* 释放一致性映射 */ dma_free_coherent(&pdev->dev, buf_size, cpu_addr, dma_handle); /* 解除ioremap */ iounmap(bar0_virt); /* 释放BAR资源 */ pci_release_region(pdev, 0);常见错误:忘记
dma_unmap_single直接kfree。这会导致IOMMU里的映射表项泄漏,跑一段时间后IOVA空间耗尽,新的DMA映射全部失败。这个bug在长时间压力测试下才会暴露,调试起来很痛苦。
4. IOMMU介入后的地址转换链路与排查技巧
4.1 IOMMU开启后地址是怎么走的
没有IOMMU时,设备发出的总线地址直接就是物理地址,路径是:设备 -> 总线地址 -> 物理内存。开启IOMMU后,路径变成:设备 -> 总线地址(IOVA)-> IOMMU翻译 -> 物理地址 -> 物理内存。
dma_map_single返回的dma_handle在IOMMU开启时是IOVA,不是物理地址。驱动不需要关心这个区别,因为DMA API已经屏蔽了底层差异。但调试时会遇到一个现象:打印dma_handle的值和virt_to_phys(cpu_addr)的结果不一样,这是正常的,不要试图用virt_to_phys去验证DMA地址。
IOMMU的页表粒度通常是4KB,和CPU页表类似。如果DMA缓冲区跨越了不连续的物理页,IOMMU会建立多个页表项把它们映射到连续的IOVA空间。这就是为什么dma_map_single可以接受非物理连续的内存,而设备看到的总线地址仍然是连续的。
4.2 IOMMU fault的典型原因与排查
IOMMU fault是PCIe驱动调试中最常见的问题之一。内核日志里会出现类似DMAR: DRHD: handling fault status reg或arm-smmu: Unhandled context fault的报错。常见原因有这几类:
第一类是映射遗漏。驱动把kmalloc返回的虚拟地址直接写给设备,没有经过dma_map_single。设备拿着一个无效的IOVA去访问,IOMMU查不到映射就报fault。排查方法是检查所有写给设备寄存器的地址是否都来自DMA API。
第二类是映射已释放但设备仍在访问。驱动调用了dma_unmap_single,但设备DMA还没完成。这种情况通常发生在驱动没有正确等待DMA完成就释放资源。解决方法是在释放前先停止设备DMA,并等待状态寄存器确认DMA已停止。
第三类是方向参数错误导致cache同步反向。虽然这通常不直接触发IOMMU fault,但会导致数据错误,间接引发设备异常行为。
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
| IOMMU fault,地址为0 | 未初始化DMA地址 | 检查设备寄存器是否在映射前被写入 |
| IOMMU fault,地址非0但无效 | 映射遗漏或已释放 | 对比dma_handle和寄存器值 |
| 数据随机错误 | 方向参数错误 | 检查DMA_FROM_DEVICE/TO_DEVICE |
| DMA超时 | 缓冲区物理不连续 | 改用dma_alloc_coherent |
4.3 用debugfs查看IOMMU映射状态
内核提供了debugfs接口查看IOMMU的映射情况。挂载debugfs后,在/sys/kernel/debug/iommu/目录下可以看到各个IOMMU设备的映射表。以Intel IOMMU为例:
# 挂载debugfs mount -t debugfs none /sys/kernel/debug # 查看IOMMU设备列表 ls /sys/kernel/debug/iommu/ # 查看某个设备的映射表 cat /sys/kernel/debug/iommu/dmar0/domains/*/pages这个接口在排查“映射是否存在”“IOVA范围是否正确”时非常有用。我一般会在dma_map_single之后立刻查看映射表,确认IOVA已经建立,再去启动设备DMA。
实操心得:调试阶段可以在
dma_map_single和dma_unmap_single里加打印,记录每次映射的IOVA、大小和方向。配合IOMMU fault日志里的地址,能快速定位是哪个映射出了问题。这个方法比盲目猜原因高效得多。
5. 几个容易翻车的细节与个人经验
5.1 32位设备的地址限制
有些PCIe设备只支持32位DMA地址,但系统物理内存超过4GB。这时候dma_alloc_coherent可能返回一个高于4GB的物理地址,设备无法访问。解决方法是在pci_set_dma_mask里设置设备支持的地址宽度:
/* 设置设备DMA掩码为32位 */ ret = pci_set_dma_mask(pdev, DMA_BIT_MASK(32)); if (ret) { dev_err(&pdev->dev, "No suitable DMA available\n"); return ret; } ret = pci_set_consistent_dma_mask(pdev, DMA_BIT_MASK(32)); if (ret) { dev_err(&pdev->dev, "No suitable consistent DMA available\n"); return ret; }设置之后,DMA API会保证返回的地址在设备可寻址范围内。如果系统无法满足,dma_alloc_coherent会返回NULL,驱动应该优雅地报错而不是继续执行。
5.2 内存屏障与读写顺序
PCIe是乱序执行的,CPU也是乱序执行的。驱动写设备寄存器时,如果顺序很重要(比如先写地址再写控制),必须插入内存屏障。writel本身在大多数架构上带有屏障语义,但连续多个writel之间不保证顺序。需要严格顺序时用wmb():
writel(lower_32_bits(dma_handle), bar0_virt + 0x10); writel(upper_32_bits(dma_handle), bar0_virt + 0x14); wmb(); /* 确保地址写入完成后再写控制寄存器 */ writel(0x01, bar0_virt + 0x1C);读方向同理,rmb()用于确保读顺序。这些屏障在x86上通常是空操作(因为x86的MMIO访问本身有序),但在ARM和PowerPC上必不可少。
5.3 中断与DMA的竞态处理
设备DMA完成触发中断,但中断处理可能被延迟。如果驱动在中断到来之前就读取了缓冲区,会读到旧数据。正确的做法是在中断处理函数里确认状态寄存器后再读数据,并且用dma_sync_single_for_cpu同步cache。
另一个竞态是驱动释放DMA缓冲区时设备还在写。这种情况通常发生在设备异常或驱动卸载流程中。稳妥的做法是先禁用设备DMA,等待状态寄存器确认DMA已停止,再释放缓冲区。有些设备提供DMA停止确认位,没有的话需要加延时等待。
5.4 一致性映射的内存不要滥用
dma_alloc_coherent分配的内存不可缓存,CPU访问速度慢。我见过有驱动把整个接收缓冲区都用一致性映射,结果CPU处理数据时性能只有流式映射的三分之一。正确的做法是描述符环用一致性映射,数据缓冲区用流式映射,兼顾正确性和性能。
最后分享一个调试技巧:如果怀疑DMA数据不对,可以先用一致性映射做对照实验。把流式映射改成一致性映射,如果数据正确了,说明问题出在cache同步上;如果还是不对,说明问题在地址映射或设备配置上。这个二分法能快速缩小排查范围。