Linux PCI驱动框架剖析:从核心数据结构到probe触发机制
发布时间:2026/9/26 0:51:55 作者:尧图编辑部 阅读量:1,286

1. 项目概述为什么人人都该懂点PCI驱动框架如果你刚接触 Linux 驱动多半有这种体会打开内核源码看到/drivers/pci/下面堆着一大堆文件PCI 总线模型、sysfs 接口、配置空间、BAR 映射、MSI 中断……每个词都认识连起来就懵。我刚开始也是这样后来才发现这东西并没有想象中那么玄乎核心就三件事搞清楚数据结构、摸清匹配机制、学会访问资源。这个系列要聊的就是 Linux PCI 驱动框架。我们知道不管是服务器上的万兆网卡、显卡还是嵌入式板卡上的 PCIe 采集卡、FPGA 加速卡绝大多数高性能外设都挂在 PCI/PCIe 总线上。你写一个 PCI 设备的驱动本质上不是“从零操作硬件”而是站在内核 PCI 核心的肩膀上把自己这块设备“登记”进系统然后等内核把设备匹配给你再由你去操作寄存器、搬运数据。框架把枚举、配置、资源分配这些脏活累活全包了。所以这篇“一”先把整棵树的树干讲清楚核心数据结构、驱动注册与匹配过程、BAR 资源访问方法再加上我实际调驱动时踩过的一堆坑。把这些掌握之后你再去看 V4L2 驱动框架、字符设备驱动框架甚至块设备驱动会发现全是同一套套路驱动对象挂到总线上匹配到设备之后触发 probe然后在 probe 里完成资源申请和硬件初始化。适合人群很明确刚转内核开发的嵌入式工程师、正在被 PCIe 设备搞到头秃的调试人员以及想系统补一补 Linux 驱动基础的同学。1.1 一套框架帮你挡掉的脏活先说说 PCI 核心到底替你干了什么。裸机时代写驱动你得自己扫描总线、读 vendor ID、配置 Base Address Register还得计算地址映射、处理中断路由一套流程下来能写几百行汇编。但在 Linux 里内核启动阶段已经把 PCI 总线枚举完了每个设备对应一个struct pci_dev节点挂在以struct pci_bus为骨架的树形结构上。你的驱动只需要声明“我支持哪些设备”然后等着 probe 被调用即可。这里有一个很多人忽略的点PCIe 热插拔场景下设备可能在你运行到一半时被拔掉或插入。这时候 PCI 核心负责处理热插拔事件动态创建或销毁设备节点、触发总线重扫描。比如你写一个pciehp驱动的 slot 管理逻辑或者在 QEMU 里动态 hotplug 一块网卡测试背后都是这套机制在跑。如果亲手在/sys/bus/pci/rescan文件上执行一次echo 1 rescan设备就被重新枚举出来的过程没有走你的驱动代码你会更直观地感受到框架的边界设备管理归核心设备使用归驱动。1.2 本篇定位与阅读路线整个系列我打算按这个顺序写第一篇讲框架主干和核心结构体第二篇展开 BAR 映射、DMA 方向和中断处理第三篇再聊具体一类设备驱动怎么写比如网卡或 V4L2 采集设备。这一篇虽然内容偏“数据结构”和“流程”但我会尽量用实际可以跑通的代码去串避免给你一堆记不住的结构体参数。你需要的基础其实不多知道怎么编译内核模块会看dmesg用过lspci。如果再懂一点设备模型的概念比如 bus、device、driver 三者的关系那读起来会更轻松。但就算这些都不熟也没关系我会在关键位置停下来把背景补上你完全可以一边读一边打开内核源码对照着看。2. 核心数据结构驱动框架的四根柱子Linux 驱动框架和“面向对象”很像虽然没有 C 的继承但通过结构体嵌套和容器宏container_of实现了类似的效果。PCI 子系统里有四个核心结构体你必须记住pci_driver、pci_device_id、pci_dev、pci_bus。这四个结构体之间的关系我习惯用“物业管理”来类比总线是小区设备是住户驱动是管家ID 表是住户名册。下面一个个拆开看。2.1 pci_driver你交给内核的“入住登记表”struct pci_driver定义在include/linux/pci.h里是驱动与 PCI 核心打交道的唯一入口。它的关键成员就几个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); void (*shutdown)(struct pci_dev *dev); };name是驱动名字会出现在/sys/bus/pci/drivers/目录下一般用模块名就行。id_table是本驱动支持的设备 ID 列表它是匹配的“通行证”后面细讲。probe和remove是生命周期回调设备与驱动匹配成功时调用 probe设备拔出或驱动卸载时调用 remove。另外还有 suspend/resume 用于电源管理shutdown 用于系统关机或重启时做清理。我看到不少新手写驱动时probe 函数写成“初始化硬件注册字符设备”一气呵成这没问题但工程上建议把打断点放在 probe 里用dev_info打印设备名、vendor、device、irq、资源起始地址方便后面排查。还有一点probe 返回 0 表示成功返回错误码比如-ENODEV、-ENOMEM内核就会认为绑定失败sysfs 里的 driver 链接不会建立。2.2 pci_device_id设备清单与匹配口诀struct pci_device_id是四元素结构vendor、device、subvendor、subdevice再加上 class 和 class_mask。实际匹配时内核会把设备的这几个字段和你表中每一项做对比规则很简单vendor 和 device 必须精确匹配除非你用PCI_ANY_ID表示通配。subvendor、subdevice 如果设成PCI_ANY_ID一般驱动都会这么写表示不限制子系统厂商和设备号。class 字段配合 class_mask 使用掩码为 0 则忽略类别匹配只看 vendor/device。举个例子。我调过一块 FPGA 板卡lspci 显示 vendor 是 0x10EEXilinxdevice 是 0x9038。驱动里这样写static const struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x10EE, 0x9038) }, { 0 } }; MODULE_DEVICE_TABLE(pci, my_pci_ids);PCI_DEVICE(vendor, device)是一个便捷宏它展开后把 subvendor 和 subdevice 都设为PCI_ANY_ID。还有一个宏PCI_DEVICE_CLASS(class, class_mask)可以用来匹配某一类设备比如匹配所有网络控制器、所有显示控制器在写通用框架驱动时会用到。注意事项MODULE_DEVICE_TABLE这行千万不能省略。它不仅是给内核匹配用的更重要的是 modpost 阶段会根据它生成modules.alias文件。这样modprobe在插入设备时才能自动加载对应驱动模块不然你得每次手动insmod。2.3 pci_dev 与 pci_bus设备在树上怎么挂struct pci_dev描述一个实际存在的 PCI/PCIe 设备。它最重要的字段包括dev内嵌的通用 device 对象、vendor、device、bus所属总线、devfn设备号和功能号、irq中断号、resource[]BAR 资源数组、driver当前绑定的驱动指针。resource[]数组的索引对应 0~5 号 BAR。BAR 是 PCI 配置空间里的基地址寄存器设备的寄存器窗口就映射在这里。比如pci_resource_start(pdev, 0)返回 BAR0 的物理起始地址pci_resource_len(pdev, 0)返回大小。下面的代码在 probe 里打印这些信息for (i 0; i PCI_STD_NUM_BARS; i) { if (pci_resource_len(pdev, i) 0) continue; dev_info(pdev-dev, BAR%d: start0x%llx len0x%llx flags0x%lx\n, i, pci_resource_start(pdev, i), pci_resource_len(pdev, i), pci_resource_flags(pdev, i)); }struct pci_bus则表示一条 PCI 总线里面有parent指向上游总线children链表指向下游总线devices链表挂了这条总线上的所有设备。从 lspci 输出的树形结构就能看到这种层级root port 是根节点往下分支出各种设备。搞驱动不一定天天碰pci_bus但当你需要遍历总线、统计设备、处理桥接配置时就知道该往哪个方向查了。3. 注册、匹配与probe触发驱动的“破门”流程有了数据结构下面要解决一个关键问题你的驱动模块加载进内核之后到底是怎么“找到”设备的这个过程涉及驱动注册、总线匹配、设备绑定三个环节我一步步拆开讲。3.1 从module_init到module_pci_driver传统写法在模块入口调用pci_register_driver()出口调用pci_unregister_driver()static int __init my_pci_init(void) { return pci_register_driver(my_pci_driver); } static void __exit my_pci_exit(void) { pci_unregister_driver(my_pci_driver); } module_init(my_pci_init); module_exit(my_pci_exit);但现在的驱动基本都用快捷宏module_pci_drivermodule_pci_driver(my_pci_driver);这个宏展开后会自动生成 init/exit 函数并调用注册接口省去样板代码。使用它唯一的硬性要求是驱动结构体变量名要作为参数传入且模块必须声明许可证MODULE_LICENSE(GPL)。pci_register_driver()具体做什么它会把你的pci_driver挂到 PCI 总线的驱动链表上然后立刻触发一次“设备-驱动匹配扫描”。如果此时系统里已经有同 ID 的设备probe 马上被调用。如果设备是后来插入的PCI 核心在设备枚举阶段会反过来扫描驱动链表找到匹配项后再调用 probe。两条路径殊途同归。3.2 匹配机制是怎么跑起来的匹配的核心函数是pci_bus_match()它接收一个pci_dev和一个pci_driver本质上就是拿驱动的id_table逐项和设备信息做比对。伪代码逻辑for (id drv-id_table; id-vendor || id-subvendor || id-class_mask; id) { if ((id-vendor dev-vendor || id-vendor PCI_ANY_ID) (id-device dev-device || id-device PCI_ANY_ID) (id-subvendor dev-subsystem_vendor || id-subvendor PCI_ANY_ID) (id-subdevice dev-subsystem_device || id-subdevice PCI_ANY_ID) !((id-class ^ dev-class) id-class_mask)) { return 1; } }这里有个容易忽略的细节id_table的最后一项通常用全 0 结构体作为哨兵所以循环条件才会去判断 vendor/subvendor/class_mask 这些字段是否全为零。如果漏了哨兵驱动可能会越界匹配到未定义数据导致意想不到的 bug。匹配成功后内核会在 sysfs 里建立drivers和devices之间的符号链接设备节点pci_dev-driver指向你的驱动对象然后调用 probe。probe 运行在什么上下文如果是设备枚举阶段或热插拔事件是在内核线程中如果你自己写平台代码在 probe 前做了什么异步操作那就要小心并发问题。probe 里尽量不要做长时间睡眠等待如果真有耗时操作比如要访问一个可能要超时的 FPGA建议丢到 workqueue 里去。3.3 一个能跑的最小PCI驱动空谈无益。下面这个驱动是我实际用来验证 PF 功能的简化版适用于 QEMU 模拟的 virtio 之类设备或者你自己手里的板卡只需要把 ID 替换成实际值。#include linux/module.h #include linux/pci.h static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { 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_info(pdev-dev, probe ok, irq%d, bar00x%llx\n, pdev-irq, (unsigned long long)pci_resource_start(pdev, 0)); return 0; } static void my_pci_remove(struct pci_dev *pdev) { pci_release_regions(pdev); pci_disable_device(pdev); dev_info(pdev-dev, removed\n); } static const struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x1AF4, 0x1000) }, /* virtio net或者填你的设备 */ { } }; MODULE_DEVICE_TABLE(pci, my_pci_ids); static struct pci_driver my_pci_driver { .name my_pci_driver, .id_table my_pci_ids, .probe my_pci_probe, .remove my_pci_remove, }; module_pci_driver(my_pci_driver); MODULE_LICENSE(GPL);Makefile 也是老套路obj-m : my_pci_driver.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译后insmod my_pci_driver.ko再dmesg | tail如果一切正常你会看到probe ok ...的输出/sys/bus/pci/drivers/my_pci_driver/下也多了一个指向设备的链接。如果在别的发行版上modprobe不能自动加载先检查modules.alias里有没有对应的 alias 行没有多半是MODULE_DEVICE_TABLE写漏了或者内核版本过旧。4. 资源访问BAR空间、MMIO与pci_enable_device结构体和匹配机制搞懂了probe 也触发起来了接下来就是驱动最主要的工作访问设备寄存器。这里有两个问题必须先搞清楚设备地址窗口在哪里通过什么方式读写4.1 配置空间与BAR设备的“地址窗口”PCI 设备有两类地址空间配置空间和 BAR 空间。配置空间负责“识别设备和管理参数”比如 vendor ID、device ID、状态寄存器、BAR 寄存器本身都在配置空间里。BAR 空间则是设备暴露给软件读写的寄存器/内存区域。你可以把配置空间想象成小区物业的业主档案BAR 空间则是住户家里的房间。用lspci -vvv看任意设备输出中会有Region 0: Memory at 0x... [size...]这样的行这就是 BAR0。注意后面还有[size16K]、[disabled]之类的标注[disabled]表示这块资源还没被驱动申请或者当前还处于未使能状态。lspci -x可以看配置空间原始字节setpci可以改配置空间寄存器不过那是高级操作改错容易让设备立刻消失。在驱动代码里访问 BAR 信息有三个标准 APIpci_resource_start(pdev, bar); pci_resource_len(pdev, bar); pci_resource_flags(pdev, bar);flags 会告诉你是 I/O 空间IORESOURCE_IO还是内存空间IORESOURCE_MEM。现在新的 PCIe 设备基本都用 MMIO老设备才用 I/O 端口。如果你的 flags 是IORESOURCE_IO访问方式会改成ioremap不适合要用ioport_map或直接inb/outb系列指令这块后面文章再展开。4.2 ioremap、readl/writel读写BAR空间的正确姿势拿到物理地址之后不能直接解引用操作原因有两个一是这段地址可能没有经过 MMU 映射直接访问必然触发 page fault二是 PCIe 设备寄存器的访问有 width 要求和顺序性要求普通 C 指针访问经编译器优化后可能出现乱序、合并访问轻则读回脏数据重则让硬件状态机错乱。正确姿势是先做地址映射void __iomem *bar0; resource_size_t start; resource_size_t len; start pci_resource_start(pdev, 0); len pci_resource_len(pdev, 0); bar0 ioremap(start, len); if (!bar0) { dev_err(pdev-dev, ioremap failed\n); return -ENOMEM; }然后通过readl、writel系列的接口访问u32 val readl(bar0 0x00); writel(0x1, bar0 0x04);readl/writel按 32 位宽度访问还有readb、readw和对应的writeb、writew。如果寄存器只支持 32 位访问而你用了readw硬件可能会直接忽略读操作或者返回全 F这类问题最难排查。所以动手前一定仔细看设备手册对寄存器访问宽度的要求。为什么要用 ioremap 而不是kmalloc之后再映射因为 ioremap 在页表中建立了对设备物理地址的映射并且把这些页标记为 device 内存属性禁止 CPU 缓存、禁止 speculative 预读。如果你用普通内存映射去访问设备寄存器写操作可能被缓存在 CPU 的 cache 里设备永远看不到数据。这属于比“崩溃”更隐蔽的错误寄存器读写都“成功”了但设备就是没反应。顺带一提memremap是新一些的替代品它比 ioremap 更严格地区分内存和设备映射但在 PCI 场景下老代码和内核文档里推荐的一直是 ioremap所以我建议照着大量现成驱动的习惯来就地用 ioremap / iounmap。4.3 pci_enable_device很多崩溃都源于少这一步这是新手最容易翻车的点。如果你在 probe 里没调用pci_enable_device(),就直接ioremap然后访问 BAR大概率会遇到两种情况一是访问直接触发总线错误内核报Unhandled fault板子直接挂掉二是设备寄存器读出来全是 0xFF看起来像硬件坏了。其实问题出在电源管理和总线事务上PCI 设备在上电后可能处于 D0 未完全使能状态不主动开启 IO 和内存访问权限系统根本不允许设备参与地址空间访问。pci_enable_device()内部做了这些事处理电源状态转换确保设备从 D1/D2/D3 回到 D0设置 command 寄存器里的 IO Enable 和 Memory Enable 位分配中断资源。它的返回值必须检查如果你在驱动卸载前调用了pci_disable_device()之后还想再次访问设备必须重新 enable。配套的还有两个资源管理函数pci_request_regions(pdev, name)为所有 BAR 区域申请资源防止其他驱动或子系统重复占用失败返回-EBUSY。pci_release_regions(pdev)释放这些资源。如果 probe 中途出错返回一定要做好“回滚”先释放 regions再 disable device。很多驱动的崩溃都是因为 probe 走到一半失败后设备状态没清理干净下一次热插拔、重绑定就出问题。测试时养成习惯反复执行echo 0000:03:00.0 /sys/bus/pci/drivers/my_pci_driver/unbind再 bind如果流程设计没 bug驱动应该能稳定地加载卸载千次。关于 DMApci_set_master()用来使能设备发起 DMA 写操作的能力配合dma_alloc_coherent分配一致内存。这是第二篇的重点先知道有这件事别急着在 BAR 空间里硬塞数据当共享内存用。5. 常见问题与排查手册实践中的坑我帮你踩过了这部分是我最想写的因为太多问题不是原理不懂而是被一些不起眼的细节卡住好几天。下面按我实际调试的经历整理。5.1 probe没触发先别急着怀疑人生现象模块加载成功lsmod能看到但dmesg里没有 probe 输出。这时按这个顺序排查。第一步确认设备真的存在。lspci -nn | grep 10ee看输出的 vendor/device 是否和你id_table里写的一致。很多板卡厂商会提供多个子型号Vivado 生成的 bitstream 不同 device ID 也可能不同比如 Xilinx 的 DMA/Bridge 在不同版本 IP 下设备号会变。第二步确认 id_table 有没有哨兵项。如果表末尾没有全 0 结构体匹配可能直接越界但更多情况下是匹配不到。把表里每一项都打印出来对比。第三步看 sysfs 里设备已经绑定了谁。ls -l /sys/bus/pci/devices/0000:05:00.0/driver如果链接指向别家驱动说明内核里那个驱动先占了设备。最常见的是设备自带的内核驱动已经支持你的硬件而你写的驱动 ID 表正好和它冲突。解决办法把内核模块里对应驱动 blacklist 掉或者用driver_override强制设备绑定到你的驱动上。第四步确认没有触发 deferred probe。dmesg | grep -i defer能看到相关信息。deferred probe 是内核在设备依赖的某个资源如时钟、IOMMU、中断控制器还没就绪时把 probe 推迟到后面重新调用的机制。如果你的驱动依赖另一个还没 probe 的模块就会延后。5.2 资源与映射问题速查insmod时返回-EBUSY基本逃不出两种情况设备已经被别的驱动绑定或者pci_request_regions与现有资源冲突。检查/proc/iomem里 BAR 地址段是否已经被某驱动占用再用lspci -vvv看 device 的Kernel driver in use字段。pci_enable_device返回失败也常见尤其是热插拔场景下 BIOS/固件分配的资源不够。内核偶尔会报error: insufficient pci resources detected!!!这是固件把 PCI 总线的地址窗口分配得太窄了挂上多个大 BAR 设备后空间耗尽。遇到这种情况可以先检查/proc/iomem看PCI Bus段的覆盖范围然后尝试在内核启动参数里加pcirealloc让内核重新调整 BAR 分配但这和主板型号强相关不是所有平台都有效有些需要在 BIOS 里把Above 4G Decoding打开。ioremap返回 NULL先看资源长度是不是 0。有些设备如果固件没给 BAR 分配地址比如 MMIO 被 disabledpci_resource_start()返回 0ioremap(0, len)必然失败。这种情况检查 BIOS 的 PCIe 设置或者用setpci手动配 BAR 前先把 command 寄存器的 memory enable 位开了再写地址——但手动配置 BAR 是最后的手段正常情况下固件都会做好。5.3 排查命令速查表下面这张表是我日常调试常驻终端里的命令内容不多但关键时刻能救急。命令作用常用参数说明lspci -nnvvv查看设备 ID、资源、状态-n显示数字厂商/设备号-v调详细程度setpci -s 03:00.0 COMMAND0x7直接修改配置空间改前先备份写错可能丢设备dmesg -w实时跟踪内核日志配合 probe 里的 dev_info 使用cat /proc/iomem查物理地址分配找PCI Bus段和你的 BAR 地址ls -l /sys/bus/pci/devices/0000:03:00.0/看设备当前绑定状态driver链接指到哪个模块就是谁绑的echo 1 /sys/bus/pci/rescan触发总线重扫描设备插入后系统没识别时用调试时我还有一个个人习惯在 probe 函数第一行就加dev_info(pdev-dev, enter probe\n)如果这个都没有打印说明根本没匹配上如果打印了但后面卡住再逐行定位。烂驱动的特点就是只有最后成功了才打日志失败路径全是黑的出了问题根本不知道死在哪个环节。多打日志、打有意义的日志不会让你显得外行反而说明你懂怎么排查现场。最后再分享一个我自己的固执习惯凡是经过pci_request_regions或者pci_enable_device这类可能失败的调用之后每一条资源操作我都会先保存返回值fail 的时候走到统一的err_release_region/err_disable_device标签按逆序清理。听起来有点小题大做但我在做热插拔测试时因为 probe 中途失败后资源没有释放导致同一块板卡反复插拔后系统彻底找不到设备那次排错花了一整天才定位到是资源泄漏。从第一篇开始就养成这个习惯后面写网卡、写 GPU 驱动、写 NVMe 驱动能省下大量无意义的 debug 时间。