ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Linux PCI驱动框架分析:总线、设备与驱动的匹配与资源流转

2026/9/26 1:28:52 拓冰建站 浏览量
Linux PCI驱动框架分析:总线、设备与驱动的匹配与资源流转 把PCI驱动框架摸透这件事我拖了大半年。之前一直觉得写驱动嘛无非就是填一个struct pci_driver实现probe/remove往id_table里塞几个设备ID系统就能跑起来。直到有次把一块老网卡驱动从4.19内核迁到5.15设备死活认不出来lspci能看见硬件加载驱动却毫无反应才逼着自己把Linux PCI子系统从头到尾过了一遍。这篇是Linux PCI驱动框架分析系列的第一篇先把总线、设备、驱动这三个角色在内核里的挂接关系、匹配机制和资源流转路径讲清楚适合刚接触内核驱动开发、以及对着pci_register_driver不知道背后发生了什么的人。1. 先把PCI在设备模型中的位置钉死1.1 设备模型的三层关系Linux内核在2.6之后引入了一套统一的设备驱动模型核心对象就三个总线(bus)、设备(device)、驱动(driver)。PCI子系统本质上只是这套通用模型里一个相对复杂的总线实现。pci_bus_type注册进内核后设备模型并不关心你到底是不是PCI它只关心总线上有没有设备增删有没有驱动注册进来两者能不能匹配我第一次看struct bus_type时有点晕其实重点就是几个回调match负责判断驱动和设备是否门当户对probe负责匹配成功后把设备交给驱动往下还有suspend/resume/remove这些电源管理和生命周期回调。PCI子系统的pci_bus_type定义大致长这样struct bus_type pci_bus_type { .name pci, .match pci_bus_match, .probe pci_device_probe, .remove pci_device_remove, .shutdown pci_device_shutdown, .dev_groups pci_dev_groups, ... };那个明明加载了驱动系统却一点反应都没有的问题根源往往就藏在match和probe这两步。设备模型会先把总线上的设备枚举出来生成一个个struct pci_dev再根据驱动注册时提供的id_table去比对。比对不上驱动就算加载了也只是挂在/sys/bus/pci/drivers下面不会和任何设备产生联系。理解这一层之后再看/sys/bus/pci目录下的布局就会清楚很多devices子目录挂着系统里所有PCI设备drivers子目录挂着所有注册进来的PCI驱动它们之间通过符号链接表示绑定关系。这比直接啃一堆内核代码直观得多。1.2 总线、桥与Root ComplexPCI拓扑上真正起通道作用的其实是host bridge和PCI-PCI bridge。host bridge是CPU与PCI总线的接口x86上通常被抽象成ACPI里的PNP0A03/PNP0A08设备ARM上多半由设备树里的pcie controller节点描述。它不只负责把CPU地址路由到PCI总线还提供配置空间访问通路也就是struct pci_ops。PCI-PCI bridge本身在框架眼里也是一个pci_dev只不过它的header type是PCI_HEADER_TYPE_BRIDGE因此多出一个subordinate字段指向它下游那条子总线。从这个角度说整个PCI域就是一棵树根总线(root bus)是树根桥是内部节点普通端点设备是叶子。内核枚举的时候也是按这个递归逻辑干的。这里想强调一个关键点PCI框架里没有一个叫PCI控制器的统一大对象它被拆散在pci_bus、pci_ops、host bridge这几个概念里。很多刚入门的人看到某个pcie地址段就以为那就是设备其实那段描述的是控制器资源最终会被抽象成一个或多个struct pci_bus。这个认知对后面排查设备枚举问题特别重要。2. 四个核心结构体读懂了框架就通了一半2.1 struct pci_dev设备侧的户口本先看一段简化过的定义struct pci_dev { struct list_head bus_list; struct pci_bus *bus; /* 所在总线 */ struct pci_bus *subordinate; /* 若是桥指向下游总线 */ unsigned int devfn; /* 设备号功能号 */ unsigned short vendor; unsigned short device; unsigned short subsystem_vendor; unsigned short subsystem_device; unsigned int class; u8 revision; u8 hdr_type; struct resource resource[PCI_NUM_RESOURCES]; /* BAR资源 */ unsigned int irq; /* 中断号 */ ... struct device dev; /* 内嵌通用设备对象 */ };struct pci_dev有两个身份。从PCI总线角度看它是总线上的一个节点所以有vendor/device/class/devfn/irq这些PCI术语从Linux设备模型角度看它内嵌一个struct device因此可以被挂进sysfs、参与电源管理、和驱动绑定。对驱动开发者来说probe函数拿到手的第一个参数就是这个结构体你要的BAR地址、中断号、设备ID全在这里面。最容易忽略的是resource数组。它不是空洞的概念而是设备报告给内核的地址资源清单。枚举阶段内核逐条读配置空间里的BAR把每个BAR的长度和地址范围填到resource里驱动随后通过pci_iomap、pci_request_regions去使用。我见过不少新人直接对某个地址做ioremap完全不看pci_request_regions这等于绕开了框架的资源管理很容易造成地址冲突。2.2 struct pci_driver驱动侧的简历struct pci_driver { struct list_head node; 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); ... struct device_driver driver; };id_table是简历里的技能列表。每一项struct pci_device_id声明我能伺候哪些设备驱动注册后内核就是拿它和总线上所有设备比对。probe是我接手这台设备后先干什么remove是干完了怎么交还。注意pci_driver里的driver成员也是一个device_driver注册时会和pci_bus_type关联起来所以通用设备模型的driver_register机制能无缝作用到PCI上不需要自己写一套匹配逻辑。有一个细节常被忽略id_table数组必须以全零的结束项结尾否则内核遍历时会越界。这个错误往往不会在加载时报错但可能在扫描时产生诡异行为。我自己就吃过亏id_table里只写了三条实际条目忘了收尾结果系统里某个不相关的PCI设备被莫名匹配上了。2.3 struct pci_bus与struct pci_ops通道与门卫struct pci_bus { struct list_head node; struct pci_bus *parent; struct list_head children; struct list_head devices; struct pci_ops *ops; struct resource *resource[PCI_BRIDGE_RESOURCE_NUM]; int number, primary, secondary; ... struct device *bridge; }; struct pci_ops { void __iomem *(*map_bus)(struct pci_bus *bus, unsigned int devfn, int where); int (*read)(struct pci_bus *bus, unsigned int devfn, int where, int size, u32 *val); int (*write)(struct pci_bus *bus, unsigned int devfn, int where, int size, u32 val); };pci_bus代表一条物理PCI总线内部维护children和devices链表。number、primary、secondary是总线号和桥两侧的关系这是枚举总线号分配的基础。ops就是这条通道的操作能力提供配置空间的read/write回调。x86平台上经典实现是PCI Configuration Address/Data端口也就是0xCF8/0xCFCARM平台上一般是内存映射的ECAM或vendor-specific地址窗口。对驱动开发来说pci_ops基本不用直接碰那是core层和arch层的事。但理解它有好处当lspci读不到某个设备的配置空间时本质上就是这一层出了问题。我调试过一块ARM板子PCIe控制器驱动加载后配置空间始终读回全FF最后定位到是ECAM窗口的设备树reg描述和总线号分配不一致pci_ops里的map_bus把地址算错了。这种问题完全靠设备驱动层查不出来必须往上走到框架层。2.4 四个结构体的协作关系结构体对应角色一句话职责pci_bus总线组织其上的设备和子总线提供配置空间访问能力(ops)pci_dev设备描述单个PCI端点/桥的资源、ID、中断状态内嵌device可被模型挂载pci_driver驱动声明支持哪些设备(id_table)实现probe/remove等生命周期回调pci_ops配置空间门卫平台相关配置空间读写底层函数框架与硬件解耦的关键实际运转关系可以这么记内核启动时先探索并创建pci_bus和pci_dev把树的骨架搭好驱动注册时带来pci_driverpci_bus_type负责在pci_dev和pci_driver之间牵线。probe之后驱动才真正意义上接管设备开始操作BAR、中断、DMA。资源审批权始终在框架手里驱动只有使用权。3. 设备枚举一颗PCI设备是如何被内核看到的3.1 从pci_scan_bus开始设备枚举的入口通常是pci_scan_bus或pci_scan_root_bus。以PCIe时代最常见的pci_scan_root_bus为例调用链大致是pci_scan_root_bus - pci_create_root_bus - pci_scan_child_bus - pci_scan_slot - pci_scan_single_device - pci_scan_device - pci_bus_read_dev_vendor_id - pci_setup_device - pci_read_bases()每条总线的扫描都会先给总线分配一个总线号然后遍历总线上可能存在的设备号。PCI规范里一条总线最多挂32个设备每个设备最多8个功能所以pci_scan_slot的循环会遍历每个devfn逐个尝试读取Vendor ID。读回0xFFFFFFFF说明这个位置没有设备跳过读回其他有效值说明有设备继续往下初始化。这里有个细节pci_bus_read_dev_vendor_id并不是只读一次它会做先读后校验甚至多次重试因为部分设备上电初始化慢第一次读取会返回全FF。从驱动调试角度看如果某个设备在lspci里时有时无十有八九和电源时序或链路训练不稳定有关而不是枚举代码本身的问题。3.2 pci_setup_device与BAR的读大小技巧读到合法的Vendor ID后内核分配struct pci_dev调pci_setup_device继续填充字段。这个函数会读取Header Type区分普通设备、PCI-PCI桥和CardBus桥因为三类设备的配置空间布局不同BAR个数也不同。普通设备有6个BAR桥只有2个BAR外加subordinate总线号和窗口资源。读BAR的过程值得多说两句。内核不是简单地读一下BAR寄存器完事而是要判断这段资源的长度。做法是先保存BAR原值往BAR寄存器写全1再读回来。写全1后地址字段里能保持为1的那些位就是硬件实际没有接入的地址线按掩码整理就能算出资源大小。比如读回来发现低12位是0、高位全1那资源长度就是4KB。读完后必须立刻把保存的原值写回去否则BAR内容被破坏设备地址窗口就乱了。这段逻辑在内核里对应pci_read_bases()和__pci_read_base()实际还处理了64位BAR和IO空间BAR的掩蔽差异。理解这个技巧对排查为啥驱动里读到的resource长度是0很有帮助——如果某个BAR长度为0先别怪驱动多半是枚举阶段资源就没被正确解析可能设备固件没配置BAR也可能桥窗口被上游资源不足挡住了。3.3 桥的递归扫描与总线号分配如果扫描到PCI-PCI桥内核不会把它当普通叶子节点处理完就结束而是递归扫描它的下游总线。pci_scan_child_bus会在桥的subordinate总线上继续执行同样的扫描逻辑。为了给每条子总线编号内核引入pci_alloc_child_bus桥的secondary bus number写进配置空间subordinate bus number则是下游所有总线的最大号。整套递归机制下来最终形成的就是lspci -t里那棵树。这里有一个资源问题经常让初学者抓狂桥的I/O和MMIO窗口大小有限如果下游设备的BAR总需求超过桥窗口即使系统整体内存足够设备也拿不到资源。对应到代码就是pci_assign_unassigned_resources这类资源分配逻辑会打印out of resources之类的错误。现代PC上这类问题很少见但在一堆PCIe转接卡、开发板上非常常见。3.4 枚举完成不等于设备可用枚举结束后bus和dev都建立起来了lspci能看到设备但设备还处于未被驱动启用状态。真正让设备可以被驱动直接操作要等pci_enable_device。这个函数内部会检查并设置Command寄存器里的IO Enable和Memory Enable位把设备对地址空间的响应打开。枚举阶段相当于把设备登记入册但设备本身可能一直沉默。读到这应该能理解为什么很多驱动probe的第一行就是pci_enable_device。这不只是惯例更是硬性要求。不启用设备后面做ioremap、操作寄存器轻则读到无效值重则在某些平台上直接触发总线错误。4. 匹配、注册与probe一份PCI驱动从加载到工作的完整路径4.1 驱动注册时内核在忙什么驱动模块加载后入口函数通常调用pci_register_driver宏module_pci_driver会替你生成这些样板代码。pci_register_driver内部把pci_driver里的driver.bus赋成pci_bus_type再调用driver_register。关键点来了driver_register不只是把驱动挂进总线的driver链表它还会立刻遍历总线上已经存在的所有设备用bus_type-match对每个设备做一次匹配尝试匹配成功就当场执行probe。也就是说如果你在系统启动后才insmod一个驱动只要总线上有对应设备probe会马上被调用不需要重启也不需要重新热插拔。反过来如果是设备后插入的设备模型会在device_add时去驱动链表里找能匹配的驱动。无论哪边先到最终都由pci_bus_match统一裁决。这套后到者主动寻亲机制是设备模型的核心设计。理解了它就不会再问为什么我insmod之后dmesg没有probe日志这种问题了——先去看id_table对不对。4.2 pci_bus_match与pci_device_id的精髓pci_bus_match最终会调用pci_match_device进入真正的匹配逻辑。匹配顺序大致是如果pci_dev-driver_override被设置过只认override指定的驱动名。否则遍历drv-id_table逐个调用pci_match_one_device。pci_match_one_device依次比较vendor、device、subvendor、subdevice和class。id表项里的任一字段如果设成PCI_ANY_ID就视为通配符。class字段单独有class_mask只比较mask置1的位。struct pci_device_id的几种典型写法static const struct pci_device_id demo_ids[] { { PCI_DEVICE(0x8086, 0x10d3), }, // 精确匹配vendor/device { PCI_VDEVICE(INTEL, 0x1533), }, // 厂商宏快捷写法 { PCI_DEVICE_CLASS(PCI_CLASS_NETWORK_ETHERNET, ~0), }, // 按class匹配 { 0, } };一些特殊功能驱动喜欢用class匹配覆盖面广但容易误伤。比如你写一个通用网卡驱动用PCI_ANY_ID加class网络类匹配系统里所有网卡都会被接管。如果同class里的设备行为差异大很容易出兼容性问题。我的建议是能用vendor/device精确匹配就别偷懒只有虚拟机透传这类确实需要抓全一类设备的场景才用class或通配符。4.3 probe阶段的资源四步走匹配成功后内核调pci_device_probe最终执行你的pci_driver-probe。在probe里资源获取一般按这个顺序来pci_enable_device打开设备地址空间访问。pci_request_regions或pci_request_region向资源中心报备防止其他驱动碰同一段BAR。按需配置DMApci_set_master、pci_set_dma_mask、pci_set_consistent_dma_mask。注意dma_mask设置要在分配DMA buffer之前完成。用pci_iomap把BAR映射到CPU虚拟地址或直接用devm_ioremap_resource这类托管接口。中断方面用pci_alloc_irq_vectors分配MSI/MSI-X向量再做request_irq。顺序真的不能乱。我见过一个驱动先做ioremap后调pci_enable_device在x86上可能碰巧能跑在ARM上经常触发总线错误。因为ioremap只建立页表映射设备本身没被使能总线事务根本发不出去。pci_request_regions的作用是注册占用防止多个驱动重叠访问同一个BAR。如果驱动里没用它一旦系统里另一个驱动也试图申请同一段地址后果很难排查。我建议尽量用devm_*托管资源接口比如devm_request_mem_region、devm_ioremap_resource、devm_request_irq。这样remove和错误路径能少写很多释放代码内核会在设备分离时自动回收避免probe中途失败导致资源泄漏。但要注意devm和pci接口混用时pci_release_regions还是要手动释放否则PCI核心的资源链表里会残留占用记录。4.4 remove的逆序回收remove函数负责把probe里建立的东西全部归还。最常见的命题是中断、映射、region、enable这四样东西释放顺序怎么排。正确姿势一般是先释放中断因为此后不再响应设备事件再释放映射再调pci_release_regions最后pci_disable_device。如果驱动用了devm中断和映射可以交给devm回收pci_release_regions和pci_disable_device仍需显式调用。顺带说一句有些驱动模块卸载时remove可能不执行这个需要结合具体的PCI电源管理和热插拔路径去理解属于进阶话题。第一篇先把主流程搞通比背下所有边界case重要得多。5. 一个最小可用PCI驱动模板跑起来才叫真的懂5.1 代码骨架很多PCI驱动教程给的代码要么太啰嗦要么太抽象加载后根本不知道发生了什么。我建议第一次做实验就用最简版本目标是insmod后dmesg能看到设备资源信息rmmod后干净退出。#include linux/module.h #include linux/pci.h #include linux/errno.h #define PCI_DEMO_VENDOR 0x1234 #define PCI_DEMO_DEVICE 0x5678 static const struct pci_device_id demo_ids[] { { PCI_DEVICE(PCI_DEMO_VENDOR, PCI_DEMO_DEVICE) }, { 0, } }; static int demo_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; ret pci_enable_device(pdev); if (ret) { dev_err(pdev-dev, enable failed\n); return ret; } ret pci_request_regions(pdev, pci-demo); if (ret) { dev_err(pdev-dev, request regions failed\n); pci_disable_device(pdev); return ret; } pci_set_master(pdev); dev_info(pdev-dev, probe ok, BAR0: size%lu irq%u\n, (unsigned long)resource_size(pdev-resource[0]), pdev-irq); return 0; } static void demo_remove(struct pci_dev *pdev) { pci_release_regions(pdev); pci_disable_device(pdev); dev_info(pdev-dev, remove ok\n); } static struct pci_driver demo_driver { .name pci-demo, .id_table demo_ids, .probe demo_probe, .remove demo_remove, }; module_pci_driver(demo_driver); MODULE_LICENSE(GPL); MODULE_DEVICE_TABLE(pci, demo_ids);代码量不大但每一句都有存在理由。pci_enable_device不写BAR操作必翻车pci_request_regions不写资源管理等于裸奔pci_set_master只在需要DMA时有意义示例里加上是为了说明时机。至于MODULE_DEVICE_TABLE它的作用不是运行时匹配而是给modpost生成模块别名用的让modprobe能依据设备的modalias自动把模块挂进来。5.2 编译与验证编译内核模块比编译用户态程序要小心一点这里用一个常规MakefileKERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) obj-m : pci-demo.o all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean编译出pci-demo.ko后加载前先用lspci确认设备存在。如果没有Vendor ID 0x1234/0x5678的设备把id_table改成实际设备的vendor/device即可。也可以加载后立刻看/sys/bus/pci/drivers/pci-demo如果有符号链接指向设备说明绑定成功。验证步骤整理成了一张表步骤命令期望结果查看设备lspci -nn能看到xx:xx.x 1234:5678这样的条目加载驱动sudo insmod pci-demo.ko无报错查看日志dmesg出现probe ok, BAR0...检查绑定ls /sys/bus/pci/drivers/pci-demo/出现0000:xx:xx.x符号链接卸载驱动sudo rmmod pci-demo无报错dmesg有remove ok没有真实PCI设备的人不用灰心QEMU里用e1000这类模拟网卡或者用-device构造一个带指定vendor/device的PCI设备完全能跑通这套流程。我甚至建议初学者先在QEMU里做实验这样id_table怎么改都有把握不会因为真实设备行为诡异而分心。5.3 为什么我跑的时候probe没被调用如果insmod一切正常但dmesg里没有probe日志十有八九是匹配失败。此时先别急着改代码按下面顺序排查确认lspci -nn看到的vendor/device和id_table里写的一致特别注意大小写和0x前缀。确认id_table不是空的且最后有{ 0 }结束项。看一下/sys/bus/pci/devices/0000:xx:xx.x/driver_override如果有残留内容会覆盖正常匹配。用modprobe而不是insmod时确认MODULE_DEVICE_TABLE对应的modalias已被depmod收录否则modprobe可能根本不会加载模块。多半新手的probe不执行都是前两条造成的。我从一个客户那里见过的真实案例是他把网卡的SSID也写进了id_table可他的卡是公版ID 0x0001结果精确匹配永远失败把subdevice改成PCI_ANY_ID就通了。这种细节文档里不写你是真不好猜。6. 框架运转的边界与容易踩进去的坑6.1 别把PCI框架当成万能设备管理器PCI框架只负责总线和设备/驱动的绑定不负责设备的实际操作。probe里可以什么都不做只要id_table匹配成功绑定就算完成。反过来如果probe失败pci_dev和pci_driver之间的关联会被移除设备重新变回无驱动状态。这个失败即解绑的机制让很多人困惑为什么probe里出个错lspci -k显示内核驱动就不是自己的了因为设备模型就是这么设计的probe返回非零就直接解绑。我一般建议在probe早期就做最基础的资源检查比如BAR长度是否为0、irq是否为非法值发现问题直接返回-ENODEV让系统有机会尝试其他驱动。别在probe里打一堆日志然后返回0那等于霸占了一个设备却什么都没干后续维护会很痛苦。6.2 中断选择INTx、MSI和MSI-X的认知PCI传统中断是INTx四个引脚映射到IRQ且经常共享。现代PCIe设备默认使用MSI/MSI-X不再占用固定INTx线。驱动里支持MSI通常用pci_alloc_irq_vectors一次搞定而不是老式pci_enable_msi。几点经验MSI向量数量取决于配置空间里的MSI Capability不一定支持多个向量。MSI-X是PCIe时代推荐的方案支持更多向量。多队列网卡基本绕不开MSI-X。INTx可能是共享中断request_irq时记得检查IRQF_SHARED。我自己踩过最典型的坑在支持MSI的PCIe设备上先调pci_enable_device再用旧接口pci_enable_msiremove里用pci_disable_msi。新内核一切正常换到老内核就偶发中断注册失败。后来统一改成pci_alloc_irq_vectors兼容性一下子好了很多。6.3 资源残留与模块重载模块卸载后再加载如果remove没有正确释放最常见问题就是第二次insmod时probe能进但某些资源申请返回EBUSY。比如pci_request_regions在第一次卸载时没调用pci_release_regionsPCI核心的资源链表里还记着这段地址被占用第二次probe自然失败。用devm接口能在一定程度上缓解但PCI资源链表里的占用记录由pci_request_regions自己维护必须由pci_release_regions清理。这是很多人踩了又踩的坑。我自己的习惯是即使probe里用了devm接口也要在remove里显式pci_release_regions再加一个状态位保证幂等避免热插拔和模块卸载路径重复释放。6.4 跨平台差异与碰巧能用PCI框架在x86、ARM、RISC-V上的主体逻辑一致但底层配置空间访问方式、ACPI/设备树对根的描述、DMA映射方式都有差异。同样一份驱动在x86上probe时序稍有扰动也能跑在ARM上就可能因为pci_enable_device顺序没做好直接总线错误。跨平台调试时第一件事就是把平台相关PCI初始化日志打开比如pcidebug或设备树相关的log level先把枚举和资源分配是否正常确认了再谈驱动逻辑。开始写这个系列时我最大的体会是PCI驱动细看很庞杂但主框架放大了就是枚举-匹配-probe-remove这一条主链路外加地址、中断两个资源仓库。只要主链路每一步都知道核心里对应哪个函数剩下的都是细节。一个很好的练习是用ftrace打开pci_scan_bus、pci_bus_match、pci_device_probe这些函数的调用栈一次insmod就能看到完整链路。这篇先讲到这里下一篇我会挑一个真实的PCIe网卡驱动把probe到数据通路收发全链路拆开讲遇到的中断、DMA和电源管理问题也都展开说。