Linux PCI驱动框架精讲:从probe到DMA与中断的关键路径

发布时间:2026/9/26 1:47:00
Linux PCI驱动框架精讲:从probe到DMA与中断的关键路径
上篇把 PCI 总线的基础概念和驱动模型的整体轮廓过了一遍这篇就直接钻到框架内部把pci_driver从注册到probe再到 DMA、中断、资源管理这一整条链路上最关键的节点逐个拆开看。涉及的核心关键词还是那几个Linux、PCI、驱动、框架但这次不只是扫一眼数据结构而是把每个函数、每个字段背后为什么这样设计讲清楚。无论你是准备写网卡驱动、SSD 驱动还是调试 FPGA 板卡上的 PCIe 设备读完这篇应该都能少走不少弯路。1. 框架骨架pci_driver 与设备模型是怎么接上的1.1 先认识 pci_driver 这个核心入口在 Linux 内核里一个标准 PCI 驱动几乎都是从一个struct pci_driver开始的。这个结构体就是驱动和设备模型之间的“注册证”它长这样struct pci_driver { const char *name; const struct pci_device_id *id_table; int (*probe)(struct pci_dev *dev, const struct pci_device_id *id); void (*remove)(struct pci_dev *dev); void (*shutdown)(struct pci_dev *dev); int (*suspend)(struct pci_dev *dev, pm_message_t state); int (*resume)(struct pci_dev *dev); const struct pci_error_handlers *err_handler; ... };字段很多但真正决定一个驱动“存在意义”的是两个回调probe和remove。设备被系统发现后内核会调用probe来初始化设备设备被移除或驱动被卸载时调用remove来释放资源。其余像suspend/resume是电源管理用的shutdown是关机流程用的err_handler则用于 PCIe AER 错误恢复。很多人刚接触这块时容易犯一个理解偏差以为pci_driver注册之后驱动就会立刻绑定设备。实际上一个pci_driver注册进内核后它只是“候选者”。真正决定它能不能和设备绑定靠的是下面的id_table匹配机制。我在实际写数据采集卡驱动时第一步就是定义pci_driver和id_table然后调用pci_register_driver注册。这个流程几乎对所有 PCI 设备都是通用的所以把pci_driver理解成“框架入口”是没问题的。1.2 id_table驱动和设备是怎么“对上眼”的struct pci_device_id是 PCI 驱动匹配设备的核心依据。它的主要字段包括vendor、device、subvendor、subdevice、class、class_mask。内核通过pci_match_one_device来比较这些字段如果id_table里有一条记录和设备一致就说明“对上眼了”。实际中常用的宏有两个PCI_DEVICE(vendor, device)只匹配厂商 ID 和设备 IDPCI_DEVICE_CLASS(device_class, class_mask)按类别匹配适合某一类通用驱动。比如一个 FPGA 板卡常见写法static const struct pci_device_id demo_pci_ids[] { { PCI_DEVICE(0x10ee, 0x9038) }, { PCI_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(pci, demo_pci_ids);MODULE_DEVICE_TABLE会把这个表导出到模块的.modinfo段供 hotplug 工具生成 modprobe 别名所以别漏掉。内核匹配时是按数组顺序逐条尝试建议最具体的设备放前面最泛的类别匹配放最后。还有一种情况是“同一驱动支持多个设备”比如一个网卡芯片有多个 VID/DID直接在表里多写几行即可。驱动里不区分硬件型号时可以在probe里再通过id-device做精细分支。1.3 pci_dev 到 device总线层到底干了什么每个 PCI 设备在内核里对应一个struct pci_dev它里面嵌了一个struct device dev这个嵌套就是 Linux 设备模型的关键PCI 总线通过pci_bus_type把自己的设备和驱动接到通用驱动模型上。当 PCI 枚举逻辑创建pci_dev时会把dev-bus指向pci_bus_type把dev-type指向pci_device_type。于是设备注册后总线层的pci_bus_match就会接管匹配工作去调用pci_match_device最终找到合适的pci_driver并触发probe。这个设计的巧妙之处在于PCI 层只需要实现“怎么找驱动”和“怎么枚举设备”具体驱动只需要实现“拿到设备后怎么办”两者通过标准接口解耦。我在修改某些平台驱动时经常只需要替换id_table和probe里的初始化代码其余部分完全复用框架的能力。另外pci_dev还提供了一堆直接访问硬件的辅助函数比如pci_read_config_byte/word/dword读写配置空间访问 BAR 资源用pci_resource_start/len/flags。这些后面展开。2. probe 流程逐段拆解设备真正“活过来”的路径2.1 probe 是怎么被触发的设备被系统枚举出来之后本身已经出现在lspci里了但此时还没有“驱动”真正让驱动接管设备的动作发生在probe被调用时。有哪些触发时机最常见的有三个驱动模块加载pci_register_driver执行时总线层会给所有当前未绑定的设备做一次匹配设备热插拔pci_bus_add_device流程会重新扫描总线触发新设备匹配手动绑定比如向/sys/bus/pci/drivers/xxx/bind写入设备的 BDF 地址。probe函数里通常做这几件事使能设备、申请资源、映射寄存器、注册中断、初始化 DMA、创建子系统接口。顺序很有讲究因为有些操作失败后需要立刻回滚做好错误路径比正常路径更需要经验。我见过不少新手驱动跑起来“有时能加载有时挂”核心原因就是probe里前半段成功、后半段失败但只做了部分释放。这里强烈建议用devm系列托管资源函数让资源随设备自动释放后面详细说。2.2 BAR 空间与资源申请的顺序很关键每个 PCI 设备最多有 6 个 BARPCIe 函数在 32 位配置空间里是 6 个64 位 BAR 会占两个槽位BAR 描述的是设备在 IO 空间或内存空间里的地址窗口。驱动第一步就是确认每个 BAR 是否存在、类型是什么、长度多大resource_size_t len pci_resource_len(pdev, 0); unsigned long flags pci_resource_flags(pdev, 0); if (flags IORESOURCE_MEM) { // 内存映射型 BAR }注意寄存器通常放在 BAR0如果 BAR0 长度是 0说明这个 BAR 未被启用。别急着访问先调pci_enable_device。这个函数会“点亮”设备允许其对内存和 IO 请求做出响应。还有配套的pci_enable_device_mem只开启内存访问。接下来是“资源申请”。pci_request_regions会检查当前资源是否和别的驱动冲突并在/proc/iomem里做标记。这一步不是可选的因为如果不申请别的驱动可能会错误接管同一段地址。推荐使用pcim_iomap_regions(pdev, BIT(0), driver_name)它一次性完成“request ioremap”返回后可以通过pcim_iomap_table(pdev)[0]拿到映射后的虚拟地址。更推荐的方式是结合pcim_enable_device使用。pcim_前缀表示这些函数会用devres机制自动释放即使probe中途失败设备移除时资源也会自动返还大幅降低内存泄漏风险。2.3 寄存器映射与电源状态映射完 BAR 之后驱动就能通过读写寄存器来控制设备。映射得到的地址是void __iomem *读写要用ioread32/iowrite32不要直接解引用。有些新手图省事把__iomem强转成普通指针短期可能跑得通但遇到 IO 屏障或非对齐访问就会出问题架构升级后更是一踩一个坑。电源管理这一环容易被忽略。PCI 设备上电默认可能处于 D0 状态但不一定所有平台都保证。稳妥做法是在probe里显式设置pci_set_power_state(pdev, PCI_D0);如果设备支持 PM 能力这会请求固件或设备进入 D0。之后调用pci_enable_device确保总线主控能力开启。需要 DMA 的设备还要调pci_set_master(pdev)否则设备无法主动发起 DMA 写。这个函数本质上是设置配置空间里 Command 寄存器的 Bus Master 位。这里有一个很多人踩过的坑pci_set_master必须在 DMA 操作之前调用否则设备发 DMA 请求会被总线拒绝表现为设备超时。尤其是 FPGA 类板卡经常由于驱动漏调这个函数出现“发中断了但不产生数据”的诡异现象。2.4 错误路径和 remove 的对称性一个健壮的probe应该做到“失败时能完全回滚”一个规范的remove则应该和probe保持对称申请了什么就释放什么开了什么就关什么。推荐的释放顺序是remove里先关中断、再停 DMA、再取消映射、再 disable 设备和probe相反。static void demo_remove(struct pci_dev *pdev) { // 1. 关中断/注销中断处理 // 2. 停止硬件工作 // 3. 释放 DMA 缓冲区 // 4. 取消 IO 映射 // 5. 释放资源 pcim_iounmap_regions(pdev, BIT(0)); pci_disable_device(pdev); }如果用了pcim_enable_devicepci_disable_device可以在设备移除时自动执行但remove里手动释放映射依然很重要。错误路径和正常路径共用一份“清理代码”是常见的工程实践建议写一个独立的demo_cleanup函数既给probe的错误分支用也给remove用。3. DMA 与中断决定 PCI 驱动性能上限的两个关键3.1 中断方案INTx、MSI、MSI-X 怎么选PCI 设备中断有几种类型。最传统的 INTx 是共享中断线多个设备可能共用一条 IRQ中断处理时需要读中断状态寄存器判断是否属于自己的中断。MSI 通过写一个特定内存地址来发送中断绕过共享线MSI-X 进一步支持大量独立中断向量每个队列可以绑定一个中断。现代驱动应该优先使用 MSI/MSI-X回退到 INTx。内核提供的接口是int nr_vecs pci_alloc_irq_vectors(pdev, 1, max_queues, PCI_IRQ_MSI_X | PCI_IRQ_MSI | PCI_IRQ_INTX);返回值是实际分配到的向量数。之后逐个取得中断号并注册 handlerfor (i 0; i nr_vecs; i) { int irq pci_irq_vector(pdev, i); request_irq(irq, demo_irq_handler, 0, demo, queue_info[i]); }这里强调一个容易忽略的点如果你申请了多个 MSI-X 向量那么在remove时必须一个一个free_irq然后调用pci_free_irq_vectors。顺序反了会引发“use-after-free”的调试地狱。实际项目中多队列网卡和 NVMe 驱动都把 MSI-X 用到极致每个 CPU 核心对应一个队列中断配合irq_set_affinity_hint把中断绑定到本地 CPU可以显著降低跨 NUMA 访存的延迟。反之一个只有单中断的采集卡中断太频繁时 CPU 占用会直接拉满这时候就该考虑改用 MSI-X 多队列设计。3.2 DMA 映射方向错了性能全废DMA 操作是 PCI 驱动最核心的瓶颈之一。设备访问的内存地址是“总线地址”和 CPU 看到的物理地址不一定一致。驱动需要借助 DMA API 完成地址转换和缓存一致性维护。流式 DMA 映射用于零散的一次性传输dma_addr_t addr dma_map_single(pdev-dev, cpu_buf, len, DMA_FROM_DEVICE); // 发起硬件读写 dma_unmap_single(pdev-dev, addr, len, DMA_FROM_DEVICE);方向参数很关键DMA_TO_DEVICE表示 CPU 写、设备读DMA_FROM_DEVICE表示设备写、CPU 读DMA_BIDIRECTIONAL两边都要用。方向参错会破坏缓存一致性典型现象是设备看到的 DMA 数据和 CPU 写入的数据不一致。一致性 DMA 映射用于长期使用的 DMA 缓冲区dma_addr_t dma_handle; void *cpu_addr dma_alloc_coherent(pdev-dev, size, dma_handle, GFP_KERNEL); // 硬件使用 dma_handle驱动使用 cpu_addr dma_free_coherent(pdev-dev, size, cpu_addr, dma_handle);这里的cpu_addr和dma_handle是同一块内存的两个视角CPU 通过虚拟地址访问设备通过总线地址访问。dma_alloc_coherent会保证两个视角下数据一致。我在做视频采集卡驱动时踩过一次用kmalloc分配的缓冲区直接丢给硬件做 DMA结果数据偶尔错乱。后来改成从dma_alloc_coherent申请问题消失。原因就是普通kmalloc内存可能有 cache line 回写顺序问题而一致性映射在架构层面保证了正确性。3.3 一致性 DMA 与 GPU 驱动场景的联系GPU 驱动是大块 DMA 内存的典型消费者。显存动辄几 GB单靠dma_alloc_coherent并不现实GPU 驱动通常使用更复杂的内存管理器把 VRAM 或系统内存映射成用户态的 buffer。但从框架角度看基础仍然是 DMA 映射层。如果你要做 GPU 驱动开发最需要关注的是这几件事大页和连续物理内存dma_alloc_coherent在内存碎片严重时可能失败可以考虑dma_set_mask_and_coherent确保支持 64 位地址后再分配用户态 buffer 映射涉及dma_buf框架和 PCI 驱动的probe/remove没有直接关系但底层 DMA 映射逻辑是相通的Resizable BAR如果 PCIe 设备支持固件和驱动配合可以把 BAR 扩大GPU 驱动里可以直接访问更大的显存窗口。顺带提一下FPGA 开发板上常见的“AXI Memory Mapped to PCI Express”桥接 IP在 Linux 下通常显示为一个普通 PCIe 端点设备。驱动打开它之后AXI 地址空间会被映射到 BAR 上通过ioread32/iowrite32访问。设计时需要注意 AXI 侧的 burst 能力和 DMA 方向的匹配否则实际带宽和理论带宽差一个数量级。3.4 IOMMU 与 SWIOTLB 会改变你的 DMA 行为现代 x86 平台默认启用 IOMMU设备看到的 DMA 地址是 IOVAI/O Virtual Address而不是真实物理地址。这对驱动基本透明但会影响调试方式你在/proc/iomem里看到的物理地址并不等于设备读写用的地址。当设备 DMA 能力受限或 IOMMU 不直接支持大块连续内存时内核可能回退到 SWIOTLB即用 bounce buffer 中转。表现为dma_alloc_coherent返回的地址可能不是设备可以直接访问的底层要通过拷贝完成传输。此时性能会明显下降典型症状是大批量小尺寸 DMA 场景的速率异常。我在服务器上调试一个 PCIe SSD 性能时发现开启 IOMMU 后随机写吞吐掉了 30%。排查方法很简单看dmesg里有没有 SWIOTLB 相关提示再用iommuoff对比测试。最终方案不是关 IOMMU而是调整驱动让每次 DMA 的粒度更大减少 bounce 次数。这就是 DMA 层对你的驱动行为的实际约束。4. 调试实战PCI 资源不足和典型故障排查4.1 “insufficient pci resources”是怎么冒出来的很多人在多插了几张 PCIe 卡的高端机器上会看到类似 “Error: Insufficient PCI Resources Detected!!!” 的提示。这个问题的根源不在 Linux 驱动本身而在 PCI 枚举阶段每个 PCIe Root Port / Bridge 需要预留 MMIO 窗口、IO 窗口和总线号范围给下游设备如果固件分配的窗口不够下游设备就会拿不到资源。最常见的触发场景有三个插了多张占用大 BAR 的显卡尤其是 Resizable BAR 没开时设备请求 8GB 窗口但 Root Port 只预留了 512MB主板 BIOS 没有开启 “Above 4G Decoding”32 位地址窗口耗尽大量 PCIe Switch 级联总线号不够用。排查方向很明确先用lspci -vvv看每个 PCIe Bridge 的Bus范围和Memory窗口再用dmesg搜 “out of resources” 定位是窗口不够还是总线号不够。临时验证可以用内核参数pcirealloc让内核重新分配资源但这不是长期方案。根治办法是进 BIOS 打开 Above 4G Decoding、扩大 MMIO 窗口或者升级固件。在嵌入式平台上如果板级设备树里的 PCIe 节点ranges属性没有给足够的空间也会出现同样的资源不足问题。这时候要改的是设备树不是驱动。4.2 一套顺手的基础排查工具链调试 PCI 驱动我日常依赖的工具有这么几个lspci -nnv查看设备列表、厂商 ID、BAR 信息、驱动绑定情况setpci直接读写配置空间比如关掉某个设备的 Bus Master/sys/bus/pci/devices/0000:xx:xx.x/查看资源大小、driver、enable 状态/proc/iomem确认内存资源占用情况trace系统用perf或tracepoint观测中断触发频率和 DMA 映射耗时。有一次调试一个网卡丢中断的问题通过/proc/interrupts看到中断总次数不再增长但设备状态寄存器显示已经产生中断。用setpci确认 MSI 配置无误后我又查了 MSI-X 表地址发现驱动把表的地址写成了 BAR0 的物理地址而不是映射后的总线地址。这类问题不通过配置空间排查根本定位不到。设备驱动状态还可以通过/sys/bus/pci/devices/.../driver_override强制指定这在测试自定义驱动时非常有用。驱动加载后如果不想等热插拔事件可以echo 0000:01:00.0 /sys/bus/pci/drivers/demo/bind4.3 AER 错误处理与恢复PCIe 的 AER 机制用于报告和处理错误包括 Corrected、Uncorrected Non-Fatal、Uncorrected Fatal 三类。驱动层面可以注册pci_error_handlers来处理恢复流程static const struct pci_error_handlers demo_err_handler { .error_detected demo_error_detected, .mmio_enabled demo_mmio_enabled, .resume demo_resume, };通常调试阶段更常见的是被动观察dmesg里出现 “AER: Corrected error received” 时先判断是不是板卡信号完整性或链路问题而不是立刻怀疑驱动逻辑。如果错误源指向你的设备可以考虑用aer_inject工具注入错误来测试恢复回调这在验证驱动健壮性时很实用。需要记住的是AER 回调里的error_detected执行时机可能在驱动正在跑业务的中途此时不能调用可能睡眠的函数很多操作是受限的。框架设计上尽量把恢复逻辑做得短小精悍把复杂操作放到resume阶段。4.4 现场调试经验速查表下面整理一份我在实际项目里反复用到的排查速查表也是文章最核心的避坑参考现象可能原因优先排查手段probe 未调用id_table 不匹配 / BDF 被其他驱动占用lspci -nn对比 VID/DIDcat /sys/bus/pci/devices/*/driver访问 BAR 时系统挂死BAR 未映射 / 设备未 enable检查pci_resource_flags确认先调pcim_iomap_regions中断一直在但没业务数据设备没开 Bus Mastersetpci查看 Command 寄存器补pci_set_masterDMA 数据内容错乱DMA 方向传错 / 普通内存缓冲改dma_map_single方向换dma_alloc_coherent安装多个设备后资源不足BIOS 窗口不够 / 总线号不足lspci -vvv看 Bridge 窗口尝试pcirealloc中断风暴中断没有正确的状态位判断检查共享 IRQ 时的设备挂起处理考虑 MSI设备热拔插后驱动异常remove 清理不彻底检查 remove 是否释放全部资源和中断表格里的每一项背后都是真实调试过的场景。比如“中断风暴”那一行我遇到的是一个共享 INTx 的设备中断处理函数没有检查自己的中断 pending 位导致别的设备发出中断时它也进来跑一圈。改成先读中断状态寄存器、确认是自己的中断再处理之后CPU 占用立刻降了下来。设备热拔插的问题在服务器宕机维护时特别明显。remove函数里漏了一个iounmap第二次插回同一块卡时资源冲突驱动加载直接报 EBUSY。后续我把所有资源获取全部改成pcim_/devm_系列的托管接口问题从根上消失了。这也是这篇文章反复强调“托管资源”的原因它天然规避了错误路径和热拔插场景下的释放遗漏。写在最后根据我个人经验PCI 驱动调试里最耗时间的从来不是“不会调 API”而是资源生命周期管理。map 了没 unmap、中断 handler 注册了没释放、DMA 缓冲还在被硬件访问时就提前回收这些坑在普通测试里可能隐藏很久一旦遇到热插拔、电源管理或高并发场景就集中爆发。后面如果再写三我会重点聊 PCIe 的枚举流程和 Resizable BAR、SR-IOV 这类偏平台侧的话题那些内容更贴近系统级开发也和资源分配紧密相关。先把这篇里的probe流程、DMA 方向、MSI/MSI-X 选择这些东西吃透对大多数人来说已经能应付大部分日常 PCI 驱动开发任务了。