Linux PCI驱动框架核心解析:设备模型、匹配与probe机制
发布时间:2026/9/26 14:16:43来源:尧图网络
做Linux驱动开发的朋友很难绕过PCI。无论是服务器上的NVMe控制器、万兆网卡还是某块带PCIe接口的FPGA加速卡插槽里能稳定枚举出来靠的是内核里那套打磨了二十多年的PCI驱动框架。这篇文章我打算把它从里到外拆一遍先讲透PCI设备模型和驱动匹配机制再带你过一遍最小PCI驱动的加载流程最后把“设备不识别”“probe不跑”“中断不来”这些高频问题集中复盘一下。如果你刚接触Linux驱动或者总感觉PCI这套东西像个黑盒那你看这篇就对了。注意这是“框架分析”系列的第一部分侧重点是整个PCI子系统的骨架数据结构、注册匹配流程、资源管理。第二会再深入PCIe错误处理、SR-IOV、MSI-X中断这些更进阶的内容。先打好底子后面才有得聊。1. 为什么分析PCI驱动框架1.1 PCI在Linux设备模型中的位置很多没做过总线类驱动的人对PCI的第一印象是“lspci能看到设备但我不知道怎么让自己的驱动跟它扯上关系”。其实PCI在Linux设备模型里的角色非常清晰它是一条总线总线的作用就是把物理设备组织成可枚举、可匹配、可挂接的结构。你可以把这套模型理解成城市管理。整个系统是一座城市PCI总线是其中一条主干道每个PCI设备是沿街的铺面。铺面有门牌号比如供应商ID、设备ID、子供应商ID、子设备ID、类别码。驱动就是招商手册上面写了“我愿意接收哪些门牌号的铺面”。总线负责牵线搭桥有符合条件的铺面出现就把对应的招商手册引过去执行probe。这就是Linux设备模型里device、driver、bus三者的关系。对驱动开发者来说PCI框架最大的价值在于你不用自己写扫描枚举的逻辑也不用手工维护设备链表。内核PCI核心已经把设备树建好了。你只需要提供一个pci_driver结构体写明自己支持哪些设备ID然后把probe函数写得够结实剩下的匹配和绑定工作全部由框架代劳。这就是为什么我们总说“写PCI驱动大多数时候是在写probe”。1.2 学习PCI框架前必须补齐的内核基础在往下看代码之前有四个概念是绕不开的建议你脑子里先有个底Linux设备模型基本三角struct device、struct device_driver、struct bus_type。PCI驱动框架只是这一套模型的PCI特化版。模块加载与符号导出module_init、module_exit、EXPORT_SYMBOL至少要知道驱动是怎么变成.ko的。中断基本认知IRQ号、中断handler、共享中断后面配MSI-X时还要知道capability机制。DMA基础明白dma_alloc_coherent和dma_set_mask是怎么回事因为PCI设备的数据通路基本都走DMA。我不是要劝你先去通读《Linux Device Drivers》但上面几个点如果完全空白后面读起来会很吃力。反过来只要这几块你动手写过字符设备驱动那PCI驱动框架对你来说就只是“换一套API的事”。2. 驱动框架的关键数据结构2.1 接口入口pci_driver结构体每个PCI驱动都要构造一个struct pci_driver这是整个框架的门面。内核文档里把这个结构体叫“PCI driver structure”它向外说明了三件事我是谁、我支持谁、我能在什么时机做什么事。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); int (*suspend) (struct pci_dev *dev, pm_message_t state); int (*resume) (struct pci_dev *dev); const struct pci_error_handlers *err_handler; struct device_driver driver; };实际工程中我最常打交道的四个字段是name、id_table、probe、remove。name会出现在/sys/bus/pci/drivers/下面是驱动目录名。id_table是驱动支持的设备ID列表。probe是设备匹配成功后的入口。remove是设备拔出或者驱动卸载时做清理的地方。很多人会忽略err_handler但对于要做生产级稳定性的设备比如存储卡、网卡这个字段非常关键。它挂着一组PCI错误处理回调错误检测、错误报告、恢复入口。PCIe的AER机制会通过这个handler通知驱动让驱动决定是重试还是重置链路。这些内容第一部分先点到为止第二部分会专门展开。2.2 设备端pci_dev、pci_bus和pci_device_id驱动是招商手册设备就是铺面本身。struct pci_dev描述一个具体的PCI设备实例内核里每个PCI设备对应一个。struct pci_dev { struct list_head bus_list; struct pci_bus *bus; struct pci_bus *subordinate; void *sysdata; struct proc_dir_entry *procent; unsigned int devfn; unsigned short vendor; unsigned short device; unsigned short subsystem_vendor; unsigned short subsystem_device; unsigned int class; struct pci_driver *driver; struct resource resource[DEVICE_COUNT_RESOURCE]; ... };这里我特别提醒两个字段devfn和resource。devfn是设备功能号低三位是功能号高五位是设备号在PCI枚举时用来唯一标识设备在总线上的位置。resource数组则是设备的资源窗口BAR映射回来以后就存在这里最多6个。struct pci_bus描述一条PCI总线。总线之间有层级关系主桥root port下面可能挂着下级总线PCIe的链路就是这样一层层扩展出来的。总线结构体里有个ops指针指向具体平台怎么访问配置空间这是x86和ARM差异最大的地方。x86用IO端口访问配置空间ARM一般通过内存映射方式但到了驱动这一层你根本不需要关心这些差异因为内核已经把配置空间的读写封装成了统一接口。struct pci_device_id是匹配表的条目struct pci_device_id { __u32 vendor, device; __u32 subvendor, subdevice; __u32 class, class_mask; unsigned long driver_data; };vendor/device是必须匹配的subvendor和subdevice如果不匹配可以填PCI_ANY_ID。类匹配也是同样的道理。里面driver_data太实用了一个驱动如果同时支持多个型号可以通过driver_data区分具体版本probe时直接把它拿出来转换成私有数据省得再查寄存器。2.3 匹配表与资源映射的关系驱动支持的设备通过MODULE_DEVICE_TABLE(pci, id_table)导出。这个宏不只是声明符号更重要的是它把匹配表段放入了内核模块的特殊section里。static const struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x1234, 0x5678), .driver_data MY_DEV_TYPE_A }, { PCI_DEVICE(0x1234, 0x5679), .driver_data MY_DEV_TYPE_B }, { /* 结束标记 */ } }; MODULE_DEVICE_TABLE(pci, my_pci_ids);驱动加载后内核并不是在所有设备里暴搜而是PCI核心遍历自己管理的设备链表逐个对照驱动ID表。匹配成功后会把设备从“无驱动”状态切到“已绑定”状态然后调用probe。资源映射方面pci_dev里的resource数组在探测阶段就准备好了。枚举时PCI核心会读取设备配置空间的BAR寄存器把分配到的地址段翻译成struct resource。驱动只需要通过pci_resource_start()、pci_resource_end()取出地址再配合ioremap()或者pci_iomap()映射到内核虚拟地址空间。这一段逻辑是整个PCI驱动最常踩坑的地方后面我会单独用一节来讲。3. 从注册到探测驱动生命周期全解3.1 pci_register_driver的幕后动作每个PCI驱动的入口都在做类似的事static int __init my_pci_init(void) { return pci_register_driver(my_pci_driver); } module_init(my_pci_init); static void __exit my_pci_exit(void) { pci_unregister_driver(my_pci_driver); } module_exit(my_pci_exit);你可能会觉得这太简单了不就调用一个注册函数嘛。但pci_register_driver实际做的事比你想象的多。它先调用driver_register()把通用驱动结构体挂到Linux设备模型里然后调用pci_bus_type上的遍历接口去扫描当前已经存在的所有PCI设备执行匹配和绑定。这也就是为什么驱动可以在系统启动后动态加载设备早就枚举好了驱动一注册框架自动“补婚”。反过来pci_unregister_driver时会先扫描已经和这个驱动绑定的所有设备逐个调用驱动的remove回调再从PCI总线中解绑。所以remove函数里通常要释放中断、取消映射、释放DMA缓冲顺序和probe正好相反这也算是一种另类的“对称美学”。3.2 probe回调里我们最常干的三件事probe是设备匹配成功后的初始化入口。我见过很多新手在probe里堆了一大堆业务代码这种做法在PCI驱动里是大忌。probe应当只做三件事使能设备pci_enable_device()设置PCI命令寄存器打开内存和IO访问能力。申请资源pci_request_regions()向内核资源管理器申请设备的BAR区间避免和其他驱动冲突。保存私有数据用pci_set_drvdata()把设备私有数据指针存起来后续remove和中断处理都要用到。示例代码static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_dev *dev; int ret; ret pci_enable_device(pdev); if (ret) return ret; ret pci_request_regions(pdev, my_pci_driver); if (ret) { pci_disable_device(pdev); return ret; } dev kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) { pci_release_regions(pdev); pci_disable_device(pdev); return -ENOMEM; } dev-pdev pdev; dev-bar_addr pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!dev-bar_addr) { kfree(dev); pci_release_regions(pdev); pci_disable_device(pdev); return -EIO; } pci_set_drvdata(pdev, dev); return 0; }顺便说一句pci_enable_device顺手还会配置DMA使用PCI总线主控能力。如果你要操作DMA还要记得调用dma_set_mask_and_coherent()设置好设备能处理的地址宽度否则在64位平台上32位DMA掩码会导致内存分配失败这是个非常隐蔽的问题。4. 配置空间与资源管理4.1 访问配置空间的正确姿势PCI配置空间是设备与系统对话的“门厅”每个函数都有最多4096字节。x86平台传统上通过CF8/CFC IO端口访问PCIe还支持ECAM内存映射方式。对驱动开发者来说这些底层差异全部被封装在pci_read_config_*系列函数里。u16 vendor_id, device_id; pci_read_config_word(pdev, PCI_VENDOR_ID, vendor_id); pci_read_config_word(pdev, PCI_DEVICE_ID, device_id); u32 bar0; pci_read_config_dword(pdev, PCI_BASE_ADDRESS_0, bar0);我见过不少人觉得配置空间读起来太麻烦干脆直接访问BAR里的MMIO寄存器去判断设备型号。这种思路在硬件验证阶段能凑合用但到了驱动开发阶段绝对不行。设备能力枚举、BAR大小探测、IRQ分配、PCIe链路状态检查全都依赖配置空间。如果配置空间都读不了那说明设备枚举根本没成功。4.2 BAR、MMIO和DMA资源的处理BAR寄存器是PCI设备资源分配的命脉。CPU要通过内存映射访问设备寄存器就必须操作BAR。BAR指示的是设备寄存器在PCI地址域中的窗口位置枚举时BIOS或者内核会为它分配物理地址。从驱动视角看资源相关的API非常固定pci_resource_start(pdev, bar_index)取BAR对应区间的起始物理地址。pci_resource_end(pdev, bar_index)取结束地址。pci_resource_len(pdev, bar_index)取长度。pci_resource_flags(pdev, bar_index)取资源类型可判断是IO资源还是MEM资源。得到起始地址后通常用pci_iomap()映射。这个函数内部会根据资源类型自动选择ioremap()或者ioport_map()避免你手工判断。映射完成后设备寄存器就能像指针一样读写。DMA资源这块是PCI驱动最容易出性能问题的开关。很多设备支持64位寻址但驱动忘了设置掩码导致dma_alloc_coherent只能分到低32位内存。高负载下频繁分配失败网络吞吐直接掉一半。正确的做法是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)); if (ret) return ret; }4.3 MSI/MSI-X中断配置要点PCI中断曾经是INTx引脚共享中断一根线上挂好几个设备处理时还得逐个判断。现代PCIe设备基本都支持MSI或MSI-X。MSI-X的优势是支持几十个独立中断向量每个向量指定到不同CPU核非常适合高性能网卡和多队列设备。分配中断向量现在推荐使用pci_alloc_irq_vectorsint nr_irqs pci_alloc_irq_vectors(pdev, 1, 16, PCI_IRQ_MSIX | PCI_IRQ_MSI); if (nr_irqs 0) return nr_irqs;拿到向量后用pci_irq_vector()取指定index的中断号irq pci_irq_vector(pdev, 0); ret devm_request_irq(pdev-dev, irq, my_isr, 0, my_pci_dev, dev);这里要记住两件事MSI中断是边沿触发的不能共享也不需要调理IRQ触发方式中断handler在probe里晚一点注册没关系但一定要在DMA真正启动之前注册好否则设备来第一个中断时handler还没挂上轻则丢事件重则触发IRQ storm。5. 手写一个最小PCI驱动5.1 驱动代码与Makefile下面这个驱动不做什么实际业务只演示最小框架。它匹配一个厂商ID为0x1234、设备ID为0x5678的设备probe时打印BAR信息并映射remove时做反向清理。#include linux/module.h #include linux/pci.h #define MY_DRIVER_NAME my_pci_mini static const struct pci_device_id my_pci_id_table[] { { PCI_DEVICE(0x1234, 0x5678) }, { 0 } }; MODULE_DEVICE_TABLE(pci, my_pci_id_table); struct my_bar_info { unsigned long start; unsigned long end; unsigned long flags; }; static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_bar_info *info; int i; dev_err(pdev-dev, %s: probe entered\n, MY_DRIVER_NAME); info kzalloc(sizeof(*info), GFP_KERNEL); if (!info) return -ENOMEM; for (i 0; i 6; i) { if (!pci_resource_len(pdev, i)) continue; info-start pci_resource_start(pdev, i); info-end pci_resource_end(pdev, i); info-flags pci_resource_flags(pdev, i); dev_err(pdev-dev, BAR%d: [%#lx-%#lx] flags %#lx\n, i, info-start, info-end, info-flags); } pci_set_drvdata(pdev, info); return 0; } static void my_pci_remove(struct pci_dev *pdev) { struct my_bar_info *info; info pci_get_drvdata(pdev); if (info) { kfree(info); pci_set_drvdata(pdev, NULL); } dev_err(pdev-dev, %s: remove complete\n, MY_DRIVER_NAME); } static struct pci_driver my_pci_driver { .name MY_DRIVER_NAME, .id_table my_pci_id_table, .probe my_pci_probe, .remove my_pci_remove, }; static int __init my_pci_init(void) { int ret; ret pci_register_driver(my_pci_driver); if (ret) pr_err(%s: failed to register driver\n, MY_DRIVER_NAME); return ret; } static void __exit my_pci_exit(void) { pci_unregister_driver(my_pci_driver); } module_init(my_pci_init); module_exit(my_pci_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name);对应的Makefileobj-m : my_pci_mini.o KDIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean写这个驱动时有个细节不能省pci_request_regions()。上面的最小示例里我没调它但真实工程里几乎必须加。因为这个函数不仅检查区域冲突还会在系统资源树里登记设备占用的地址区间。如果没登记万一你设备BAR和另一张卡的MMIO区间重叠两边同时访问会导致不可预知的总线错误。5.2 加载验证与sysfs观测编译好后加载sudo insmod my_pci_mini.ko如果设备确实存在且ID匹配dmesg里马上能看到probe日志。同时检查驱动是否绑定成功ls /sys/bus/pci/drivers/my_pci_mini/ ls -l /sys/bus/pci/devices/0000:03:00.0/driver如果绑定成功driver符号链接会指向my_pci_mini。此时卸载模块sudo rmmod my_pci_miniremove回调会被调用设备回到无驱动状态。这个过程完整跑一遍你就等于把Linux PCI的设备匹配、绑定、解绑全流程体验了一次。6. 常见问题与排查技巧实录6.1 驱动加载了但probe就是不执行这是PCI驱动开发排障第一名。常见原因有三个id_table中的vendor/device和设备实际配置空间里的值不一致。先运行lspci -nn确认真实ID再核对代码。我见过有人把subsystem vendor当vendor填结果总是匹配不上。设备不是标准PCI设备而是挂在平台总线下的PCIe控制器端口。这类设备要确认用的是pci_register_driver还是平台驱动注册接口。设备被别的驱动先绑定了。查看/sys/bus/pci/devices/0000:xx:00.0/driver如果已经指向其它驱动你的驱动自然不会被probe。可以在内核启动参数里用pci-stub或者直接禁用原驱动腾出来。综上所述最快的定位命令是lspci -nnk。它会列出设备以及当前绑定的驱动一排一个准。6.2 系统提示PCI资源不足BAR分配异常报错关键词是“insufficient PCI resources detected”或者“no space for BAR”。这个问题在带多个PCIe switch的服务器上尤其常见。本质是PCI桥的bus range或者MMIO窗口预留不够导致下游设备的BAR无法分配。排查步骤我建议这样走lspci -vvv | grep -E Region|Bus cat /sys/bus/pci/devices/0000:xx:xx.x/resource看设备resource文件是否存在且非空。如果全零说明枚举阶段BAR就没拿到地址。更麻烦的是上游PCI桥自己也没有足够的MMIO窗口这种时候优先从固件层面考虑更新BIOS或者调整PCI bus域范围。如果只是实验环境可以试试内核参数pcirealloc它会让内核在启动时重排PCI资源。遇到pcirealloc后设备能正常识别但实际生产环境不建议依赖这个参数治标不治本。6.3 中断注册成功但一直不触发中断不触发先区分是设备没发中断、还是中断被路由错了。检查设备配置空间的Command寄存器是否允许总线主控和中断使能。lspci -vvv输出里能看到DevCtl和LinkCtl。检查MSI/MSI-X是否真的使能。如果申请到了MSI向量但设备用的是传统INTx引脚驱动又没做回退很容易出现“静默”。建议用pci_alloc_irq_vectors并且同时传入PCI_IRQ_INTX做兜底。检查中断是否被共享了。旧的INTx共享中断必须free_irq和request_irq配对且在handler里先读设备中断状态寄存器确认“是不是我的中断”否则会无意间清掉别的设备的中断状态。如果怀疑是设备侧固件的问题用逻辑分析仪或者PCIe分析仪最直接。没有硬件工具时可以在probe后主动写设备寄存器触发一次测试中断验证整条中断链路是否畅通。6.4 快速问题定位速查表现象优先检查项经典原因probe没被调用lspci -nnk、id_table、driver占用ID不匹配、已绑定其他驱动读写BAR导致死机resource区间、iomap返回NULLBAR未分配、地址越界访问DMA分配失败dma_set_mask_and_coherent设备掩码设置过小中断不断触发风暴中断状态寄存器不清intx共享误判、MSI写触发rmmod时卡死remove回调里锁的顺序持有自旋锁访问设备这张表是我实际排障时最常用的起点。PCI驱动的问题七成出在“匹配”和“资源”这两个环节先把这两块理清楚剩下的是体力活。7. 写在系列第一篇之后第一篇把PCI驱动的骨架过了一遍你可能已经感觉到了这套框架最聪明的地方是把硬件细节全部藏在了总线抽象后面。驱动开发者不需要关心枚举算法不需要关心配置空间怎么访问只要把设备能力在probe里初始化好然后专注业务逻辑。但现实工程没那么客气PCIe链路故障、AER错误、SRIOV虚拟功能、DMA重映射这些才是一线驱动真正难啃的骨头。我个人在给客户调FPGA加速卡驱动时有个体会PCI驱动调试时间多数花在硬件时序和理解能力边界上而不是内核API。内核提供的接口很稳定真正的坑永远在设备寄存器细节和平台差异里。所以如果你刚开始学拿着一个真实设备对照lspci -vvv输出把BAR、中断、DMA一条条验下来比翻十篇文档都管用。读到这里的应该都在动手写或者准备写自己的PCI驱动。下一篇我会把PCIe错误处理、SR-IOV和中断优化单独拆开讲那些是高性能网卡和存储控制器绕不开的硬话题。先消化第一篇遇到具体问题随时在评论区互相验证我看到了都会回。
网站建设高端定制企业官网