Linux PCI驱动框架拆解:从总线模型到probe/remove链路
做了这么多年Linux下的驱动开发我越来越觉得一个怪现象很多人能熟练写出字符设备驱动一碰到PCI设备就发怵。字符设备那套“register_chrdev file_operations”确实简单但PCI驱动框架完全是另一种玩法——它不是“设备类型框架”像V4L2、ALSA、Input那样而是“总线框架”解决的是硬件如何被发现、资源如何被分配、驱动如何被绑定这一整条链路。这篇是“Linux PCI驱动框架分析”系列的第一篇我会从内核设备模型的大背景切入把PCI驱动框架的核心设计思路、关键数据结构和端到端流程拆开讲清楚适合刚接触PCI/PCIe驱动、被probe和id_table搞得一头雾水的内核开发者。把这一篇吃透后续再讲BAR映射、MSI中断、DMA和性能优化你才有共同语境。1. 先把“总线框架”和“设备类框架”这两张皮分开1.1 驱动工程师最容易混的一个概念我在社区里见过不少提问帖标题都是“Linux PCI驱动怎么写”结果点进去一看问的是“我需不需要像V4L2那样注册一个video_device”。这就是把两套框架搞混了。Linux驱动框架其实分两层。一层是设备类型框架比如V4L2管视频采集、ALSA管音频、Input管输入设备、MTD管Flash它们解决的是“这个设备是干什么的、用户空间怎么访问它”。另一层是总线框架比如PCI、USB、I2C、SPI、Platform它们解决的是“这个设备在系统里怎么被发现、怎么和驱动配对、电源和中断资源怎么管理”。PCI驱动框架属于后者。它不关心你的PCIe网卡收到的是不是UDP包也不关心你的GPU渲染的是不是三角形它只负责把你硬件上的那个PCIe功能Function在Linux里暴露成一个struct pci_dev然后把对应的struct pci_driver绑上去叫你的probe函数一声“设备来了资源已经给你准备好了开工吧。”所以你写一个PCIe转串口芯片的驱动核心工作是提供一套struct pci_driver的回调然后在probe里调用serial8250_register_8250_port这类设备类API。PCI框架和字符设备框架不是二选一而是你先过PCI这关拿到硬件资源再去实现具体的设备类型逻辑。1.2 PCI子系统在Linux设备模型里的位置Linux的设备模型核心是三条链子bus、device、driver。总线是纽带一边挂着设备一边挂着驱动只要设备和驱动的匹配条件满足总线就去撮合一次“绑定”。PCI在设备模型里对应一个叫pci_bus_type的总线对象。你在内核里经常看到的bus_register(pci_bus_type)就是把PCI这条总线注册进内核的设备模型里pci_bus_type的match函数决定了某个PCI设备和某个PCI驱动能不能走到一起。这套设计的妙处在于设备型号千千万驱动写法各不同但只要大家都遵循“总线负责配对、驱动负责具体功能”的约定内核就不需要写死任何设备与驱动的关系。你的驱动编译进内核也好、做成模块也罢只要硬件插在PCIe槽位上总线就能把它俩牵上线。这就是为什么你换一张PCIe网卡不需要重新编译内核——硬件枚举是动态的驱动匹配也是动态的。2. 一台机器从上电到你看到设备节点PCI框架做了四件事2.1 第一步是枚举配置空间就是硬件的身份证PCI设备不像I2C设备那样靠软件“猜”地址它天生有一套标准的自我描述机制叫做配置空间Configuration Space。传统PCI的配置空间是256字节PCIe扩展到了4KB0x000~0xFFF但前256字节被叫做“配置空间头部”兼容老PCI的布局。配置空间头部里有几个关键字段偏移字段作用0x00Vendor ID厂商号比如Intel是0x8086Realtek是0x10EC0x02Device ID设备型号比如某网卡的Device ID是0x81680x04Command / Status控制I/O、MMIO、总线主控、中断状态0x08Revision ID / Class Code设备类别network controller、mass storage等0x10~0x24BAR0~BAR5设备在内存或I/O空间里的地址窗口0x3CInterrupt Line / Pin传统INTx中断号和引脚0x34Capabilities Pointer指向能力链表的头MSI、PM等能力都在这里上电时PCI子系统通过pci_scan_bus系列函数遍历总线上的设备读取每个设备的Vendor ID、Device ID、Class Code然后为每个设备分配一个struct pci_dev把它挂到总线上。这个步骤叫做枚举Enumeration你每次在dmesg里看到的“pci 0000:01:00.0”就是枚举结果。BDFBus:Device.Function是硬件层面的地址形如0000:01:00.0前面是域名domain后面依次是总线号、设备号、功能号。PCIe的多功能设备一个物理卡上可能挂着好几个Function每个Function有独立的配置空间驱动模型里对每个Function都会生成独立的pci_dev。这一点在写驱动时特别容易忽略你的probe回调里拿到的struct pci_dev *dev精确对应一个Function不是一张卡。2.2 剩下三步资源分配、驱动绑定、IO就绪枚举完成只是第一步。PCI框架随后要做三件事资源分配。BARBase Address Register是设备声明“我需要多少内存/IO窗口”的地方但具体把窗口放在哪个物理地址是固件BIOS/UEFI在POST阶段根据系统拓扑分配的或者由内核pci子系统在无固件环境下自行分配。分配完成后BAR里存的就是最终的基地址驱动通过读BAR就能知道设备的寄存器映射到哪里。这一机制对驱动开发者透明你不需要自己管分配但你要知道驱动里看到的物理地址是固件或内核算出来的结果不是设备与生俱来的固定值。驱动绑定。总线枚举完成后driver_attach阶段会遍历所有挂在该总线上的设备对每个设备调用pci_bus_type.match如果驱动注册时声明的id_table能匹配上某个设备就调用该驱动的probe回调。这个阶段其实是“配对”阶段后面第4部分我详细拆。IO就绪。设备在枚举之后并不是立刻就能访问的。你得在probe里显式调用pci_enable_device让内核把Command寄存器里的Memory Space Enable和I/O Space Enable位打开设备才真正对外“松手”允许你访问它的BAR和I/O空间。很多新手第一次写PCI驱动忘记调这个函数结果读写寄存器读回来全0xFF或者直接总线异常——不是硬件坏了是设备还处于“禁用”状态。3. struct pci_driver驱动开发者的全部“钩子”3.1 核心成员逐个讲struct pci_driver的定义在内核头文件include/linux/pci.h里驱动的骨架就是围绕这个结构体展开的。我挑几个核心成员来讲后台的static 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); 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驱动的名字在/sys/bus/pci/drivers/下面会以这个名字出现。注意它不是设备名是驱动名。id_table指向一个以空结尾的struct pci_device_id数组这是驱动“认领”设备的依据。数组最后必须有一个{0}全零项表示结束。probe设备匹配成功后被调用的入口。可以理解为驱动的“构造函数”在这里申请资源、映射BAR、注册中断、创建设备节点一切初始化动作都从它开始。remove设备被移除、驱动卸载或系统关机时调用。必须把所有probe里做过的动作逆序撤销否则会留下悬空的资源句柄严重时系统panic。suspend/resume电源管理回调。现代驱动通常用dev_pm_ops替代这两个回调但老代码里还是经常见到。err_handler高级话题用于PCIe AER错误恢复比如链路错误后驱动如何配合系统做恢复。一般驱动可以不填。我见过很多人只写probe和remove这没错但id_table如果写错驱动注册一百次也匹配不上任何设备。id_table里每一项就是一个匹配条件我给个例子static const struct pci_device_id my_pci_ids[] { { PCI_VDEVICE(XXXX, 0x1234), 0 }, { PCI_DEVICE(0x10EE, 0x8112) }, { 0, 0, 0, 0, 0, 0 } };PCI_VDEVICE宏接收厂商名定义在pci_ids.h里和Device ID自动补全厂商号和掩码。PCI_DEVICE则直接写两个裸数字。最后那行全0是数组终结符。内核在pci_match_id里会遍历到这一项为止如果你漏了它遍历会越界轻则匹配失败重则内核跑飞。3.2 为什么probe/remove是成对生命线“驱动在probe在驱动走remove走”——这是PCI驱动的铁律。probe成功返回0意味着驱动正式接管这个设备remove被调用意味着驱动必须立刻交还所有资源。一个重要心得probe里每一步都可能失败失败必须逆序释放前面已经申请到的东西。比如先pci_enable_device再pci_request_regions再pci_iomap最后request_irq如果request_irq失败前面已经打开的设备还要pci_disable_device掉。很多人图省事probe出错直接返回-ENOMEM不对资源已经申请了一返回泄漏就产生了。驱动加载时内核会树一个警告系统跑一段时间后资源就被占满你再去加载别的驱动就会遇到“resource busy”。remove的逆序同样重要free_irq-pci_iounmap-pci_release_regions-pci_disable_device。这套顺序我建议死记硬背我们团队code review时凡是乱序的改动都会被打回去重写。原因是中断处理程序可能正在访问你已经unmap掉的寄存器先释放irq再清理映射才能保证运行到一半的中断处理不会踩空。4. 配对机制id_table决定你的驱动能“管”谁4.1 从精确匹配到通配符的匹配优先级pci_match_id的核心逻辑其实不复杂遍历驱动的id_table与设备的vendor/device/subvendor/subdevice/class逐一比较。比较的完整顺序是vendor和device必须相等这是最硬的条件。PCI_ANY_ID值为~0表示该字段不限也就是通配。subvendor和subdevice如果指定了必须匹配如果写成PCI_ANY_ID就不限子厂商和子设备。class和class_mask如果指定了按掩码匹配。class_mask表示哪些位必须比较哪些位可以忽略。内核还有一种“优先匹配”的策略先精确匹配所有字段的设备再放松条件。你可以用PCI_DEVICE_CLASS这种宏来匹配一整类设备比如匹配所有“Network Controller”。这个优先级设计是有讲究的。比如某芯片厂商出了一个通用的PCIe控制器但它允许客户通过子设备号定义不同的产品型号。你如果只按vendordevice匹配一个驱动会被绑到所有使用该控制器的板卡上哪怕功能和配置差异很大。但如果你的id_table第一项精确匹配子设备号第二项再写成通配那么精确项优先级更高你会先绑到完全匹配的板卡上其余板卡才落到通配项。这个“靠顺序实现优先级”的trick是内核驱动代码里的常见手法。4.2 “insufficient PCI resources”到底是谁的锅我特意要把这个问题单独拿出来讲因为它在各种论坛里出现频率极高热搜词也挂着“error: insufficient pci resources detected!!! pci out of resources condition”。资源不足的本质是PCIe桥Bridge给下游设备分配的地址窗口不够大或者总线号不够用。PCIe拓扑是树状的Root Complex下面挂Root PortRoot Port下面可能再有SwitchSwitch下面再挂Endpoint。每一个桥设备PCI-to-PCI Bridge都有一组Memory Window/I/O Window/Bus Number Window用来描述“我下游的设备占哪些地址区间”。当系统启动时固件或内核要把所有Endpoint的BAR需求往上游汇总每级桥都得给下游预留足够的窗口。一旦某个桥的窗口已经分配完再往下面塞设备就塞不下了于是报出“insufficient PCI resources”。这个报错基本跟你驱动代码没关系是硬件拓扑、BIOS配置和内核资源分配策略三者之间的冲突。遇到这个问题的常规排查思路是这样先看是不是在虚拟机里。VM里PCI资源本来就受限Q35机型比i440fx机型对PCIe的支持好很多优先换Q35。在BIOS里检查MMAMemory Mapped I/O above 4G或Above 4G Decoding选项很多主板默认关闭而PCIe设备BAR超过4G需求时就会资源不足。用lspci -vvv查看每级桥的Window范围和下游设备的BAR需求大概率能看到桥窗口被占满。卸载无关的PCI设备或者调小某些设备的BAR大小看是否可以缓解。这是从“框架”角度写明白的第一课PCI资源不是无限大的它是沿着总线树逐级分配的围栏。5. 一个能跑起来的最小PCI驱动骨架5.1 BAR映射与中断申请的正确姿势当probe被调用时设备已经被枚举出来了资源也分配好了但驱动得主动去“打开”这些资源。标准动作按顺序来第一步pci_enable_device。打开设备的内存和I/O使能。不调用它后面所有访问都是空指针笑谈。第二步pci_request_regions。向内核声明“这块BAR区域归我这个驱动管”。内核通过request_resource机制防止两个驱动同时操纵同一段地址空间没做过request_regions的驱动代码review时基本会被否决。第三步pci_iomap(pdev, bar, len)。这是把BAR物理地址映射到内核虚拟地址空间的快捷方式。它内部会判断BAR是MMIO还是I/O端口自动选择ioremap还是ioport_map返回的指针可以直接用ioread32/iowrite32读写。如果返回NULL通常表示映射失败或BAR未分配。第四步pci_alloc_irq_vectors。现代PCIe设备强烈推荐使用MSI/MSI-X中断不要在传统INTx上死磕。MSI有独立的向量号不占用全局中断线性能更好多队列场景只能靠它。老式设备只能INTx的话就用pci_intx(pdev, 1)加上request_irq(pdev-irq, ...)注意INTx是共享中断线你的handler里面要能判断中断是否属于本设备。BAR和中断是probe里的两大主线。一个PCI驱动的90%工作量就是围绕“把BAR里的寄存器读写通畅 把中断处理写正确”展开的。5.2 完整代码从probe到remove的模板下面这个驱动骨架不针对某个具体硬件我假设它是一颗PCIe FPGA加速卡上的Function 0BAR0里有一块控制寄存器区域。你可以把它当成模板换掉Vendor/Device ID再把寄存器逻辑换成你自己的。#include linux/module.h #include linux/pci.h #include linux/interrupt.h #define DRV_NAME fpga_accel #define FPGA_VENDOR_ID 0x1234 #define FPGA_DEVICE_ID 0x5678 #define BAR0_LEN 0x1000 struct fpga_dev { void __iomem *bar0; struct pci_dev *pdev; }; static irqreturn_t fpga_irq_handler(int irq, void *data) { struct fpga_dev *fdev data; /* 读中断状态寄存器判断是否为本设备中断 */ u32 status ioread32(fdev-bar0 0x10); if (!(status 0x1)) return IRQ_NONE; /* 实际的中断处理 */ iowrite32(status, fdev-bar0 0x10); /* 清中断 */ return IRQ_HANDLED; } static int fpga_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct fpga_dev *fdev; int err; fdev kzalloc(sizeof(*fdev), GFP_KERNEL); if (!fdev) return -ENOMEM; err pci_enable_device(pdev); if (err) goto err_free; err pci_request_regions(pdev, DRV_NAME); if (err) goto err_disable; fdev-bar0 pci_iomap(pdev, 0, BAR0_LEN); if (!fdev-bar0) { err -EIO; goto err_release; } pci_set_master(pdev); pci_set_drvdata(pdev, fdev); fdev-pdev pdev; err pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_ALL_TYPES); if (err 0) goto err_unmap; err request_irq(pci_irq_vector(pdev, 0), fpga_irq_handler, IRQF_SHARED, DRV_NAME, fdev); if (err) goto err_free_vectors; dev_info(pdev-dev, fpga_accel probe ok, bar0%p\n, fdev-bar0); return 0; err_free_vectors: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, fdev-bar0); err_release: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); err_free: kfree(fdev); return err; } static void fpga_remove(struct pci_dev *pdev) { struct fpga_dev *fdev pci_get_drvdata(pdev); free_irq(pci_irq_vector(pdev, 0), fdev); pci_free_irq_vectors(pdev); pci_iounmap(pdev, fdev-bar0); pci_release_regions(pdev); pci_disable_device(pdev); kfree(fdev); } static const struct pci_device_id fpga_ids[] { { PCI_DEVICE(FPGA_VENDOR_ID, FPGA_DEVICE_ID) }, { 0 } }; MODULE_DEVICE_TABLE(pci, fpga_ids); static struct pci_driver fpga_driver { .name DRV_NAME, .id_table fpga_ids, .probe fpga_probe, .remove fpga_remove, }; module_pci_driver(fpga_driver); MODULE_LICENSE(GPL);代码里的错误处理路径我写得比较全这其实是最值得学的部分。每个goto跳的都是“我申请了什么现在就还掉什么”跟probe步骤严格逆序。module_pci_driver这个宏自动生成了module_init和module_exit省去手写pci_register_driver和pci_unregister_driver的模板代码。加载它之后你在/sys/bus/pci/drivers/fpga_accel/下能看到驱动目录在/sys/bus/pci/drivers/fpga_accel/0000:xx:xx.x下能看到被绑定的设备符号链接。probe返回0设备才出现在这里返回非0设备会立刻被解除绑定dmesg里能看到失败原因。6. 从框架走向实战前还有几个常见的“想当然”6.1 BAR大小不要想成固定的有人习惯在驱动里写#define BAR0_SIZE 0x1000然后用这个值做pci_iomap和后续的资源管理。这在固定硬件上当然没问题但一旦这个驱动要适配多款板卡硬件工程师改了一版FPGA逻辑BAR0从4KB变成64KB你的宏就得跟着改而且改漏了就会出现访问越界内核报“BUG: unable to handle kernel paging request”。更稳的做法是直接问硬件pci_resource_len(pdev, bar)返回BAR区域长度pci_resource_start(pdev, bar)返回物理起始地址。很多老练的驱动作者根本不硬编码长度全部运行时读取。resource_size_t len pci_resource_len(pdev, 0); void __iomem *bar pci_iomap(pdev, 0, len);BAR长度信息在枚举阶段就从BAR寄存器里读到了只不过BAR低4位是标志位表示是否为IO空间、64位等真正的长度是寄存器值取反加一这一层脏活内核已经帮你做完了你用pci_resource_len拿到的就是干净的长度。6.2 大BAR和64位BAR的处理64位BAR会占用相邻的两个BAR槽位比如BAR0和BAR1合起来表示一个64位MMIO窗口。这种情况下pci_resource_len(pdev, 0)会给一个很大的值而pci_resource_len(pdev, 1)会返回0。如果你遇到BAR1长度是0不要惊讶它就是被BAR0“吃掉”了映射时只需要处理BAR0。还有一类设备寄存器特别多固件会把BAR映射到4G物理地址之上。在32位内核上这类设备几乎没法用因为内核地址空间不够映射那么高端的内存。64位内核则天然没问题这也是“Above 4G Decoding”选项在BIOS里如此重要的原因。写驱动时要注意pci_iomap拿到的指针是否正常不正常多半就是32位内核吃到高地址BAR。6.3 中断处理程序里别做重活PCIe设备的MSI中断本质上就是“写一个内存写事务到CPU的MSI寄存器”它的送达和响应非常快但代价是中断处理上下文不适合做复杂操作。很多硬件设计者为了省事把“DMA完成”和“链路状态变化”都通过同一个中断向量上报驱动得在handler里快速读取状态、把工作交给下半部tasklet/workqueue/threaded irq去处理。如果你的probe里注册中断时用了IRQF_SHARED那handler里第一步必须判断中断是否属于自己——因为共享中断线路上可能挂了好几个设备。判断标准通常是读设备的中断状态寄存器如果不是自己的中断就返回IRQ_NONE。我见过有驱动不检查就返回IRQ_HANDLED导致同一根线上的网卡驱动收不到中断系统网络瘫痪最后定位到“两个驱动抢一个中断”的惨案。6.4 设备树和ACPI在PCI里扮演的角色平台设备Platform Device的驱动经常要和设备树打交道PCI驱动里却很少直接解析设备树节点。这是由PCI的枚举机制决定的PCI设备不需要设备树来“描述”它自己就能把自己描述清楚设备树里一般只需要描述Root Complex本身。但ACPI在很多x86平台上会补充PCI设备的电源管理策略、中断路由信息等这部分内容在/sys/firmware/acpi/tables里普通驱动开发通常不用直接接触内核的pci子系统已经把它们处理透明了。真到要处理PCIe热插拔、D3cold电源管理的时候你才会回头来看ACPI方法现阶段知道这个边界就够用。写在系列第一篇的最后这套Linux PCI驱动框架分析系列我会按“框架总览 - 枚举与资源 - 中断与DMA - 热插拔与电源管理 - 性能调优”的顺序往下写。第一篇我刻意压制了具体硬件细节把重点放在“总线框架和驱动骨架”上因为这是你读后续所有内容的地基。我个人的体会是PCI驱动本身不难难的是你脑子里有没有那棵“总线树”。每一次访问BAR地址你要知道这是固件在总线树上分配出来的一段窗口每一次中断到来你要知道这是硬件通过MSI机制投递过来的一封加急信。把PCI子系统当成一个尽责的物业公司它管着每个房间设备的钥匙BAR和门铃中断而驱动工程师是那个真正住进去的用户——物业把钥匙给你、门铃接好还不够你得自己学会开门、装家具、处理水管爆裂。后续文章我们继续把这些“家装手艺”一样一样练扎实。