1. 从设备枚举到驱动匹配,Linux PCI 框架怎么把硬件“管起来”
上一篇文章我们把 PCI 总线模型的基本盘梳理了一遍,从 PCI 配置空间、地址分配到 BAR 机制,算是把硬件侧的地图给画出来了。这一篇继续往下走,重点放在 Linux 内核里 PCI 驱动框架的核心运行逻辑:设备是怎么被发现和登记的?驱动又是以什么方式“认领”设备的?我们在写一个 PCI 驱动的时候,内核到底帮我们做了哪些事、哪些事必须自己干?
如果你之前写过简单的字符设备驱动,比如混杂设备、platform 驱动,你会发现 PCI 驱动写起来有一种明显的“框架感”——pci_driver、pci_device_id、probe、remove,这些元素几乎是固定套路。但这种固定套路背后依赖的是一整套总线模型,也就是 Linux 设备模型里的bus_type、device、driver三件套在 PCI 总线上的具体实例化。
内核里的 PCI 子系统可以分成三大层:PCI 核心(drivers/pci/)、PCI 总线驱动(即 host bridge 驱动,负责控制器硬件初始化)以及具体的 PCI 设备驱动(比如网卡、显卡、存储控制器驱动)。我们写驱动主要打交道的其实是第三层,但前两层决定了你的设备能不能被系统正确识别和配置。
在 x86 平台上,PCI 配置空间的访问主要靠CONFIG_ADDRESS(0xCF8)和CONFIG_DATA(0xCFC)两个 I/O 端口,内核通过pci_conf1_read这类函数完成配置读写;而在 ARM 平台上,访问方式通常是内存映射的 ECAM 机制,走的是pci_ecam_ops。这些差异被内核抽象成了struct pci_ops指针,挂在struct pci_host_bridge上,驱动开发者基本不用关心具体访问手段。
但真正决定 PCI 驱动体验的,是接下来这套设备枚举和驱动匹配的流程。这一篇我先把框架主线条梳理清楚,然后第三篇会专门深入 probe 路径下的资源分配细节。
2. 设备树是怎么长出来的:PCI 枚举和 pci_dev 对象的诞生
2.1 枚举的起点:Host Bridge 和 Bus 的组织方式
在系统启动阶段,PCI 子系统通过pci_direct_init(x86 下带 ACPI 时走pci_acpi_init)或者设备树节点解析(ARM 下走pci_host_common_probe)找到根总线(Root Bus),也就是 bus 0。这个根总线对应一个struct pci_bus实例,它有一个ops指针指向具体的配置访问方法。
pci_scan_root_bus函数是枚举的入口,它传入了根总线的资源和struct pci_ops,然后调用pci_scan_child_bus开始递归扫描。这里有个很容易忽略的点:PCI 枚举并不是简单地遍历“每个槽位都读一下 VID/DID”,而是要遵循一个层级规则,先读 bus 0 上的设备,然后通过 bridge 的配置空间拿到次级总线的编号,再往下一级扫。
从驱动开发者的角度看,这段流程的意义在于:当你看到系统起来后在lspci里列出了设备的 BDF 号,那已经是内核按照 PCI 桥的primary/secondary/subordinate三个总线号字段一级级走出来的结果。一个设备能出现在 BDF 里,前提是它的父桥已经被正确枚举。
2.2 pci_dev 的诞生:从配置空间到内存对象
每个被扫描到的 PCI 设备会被分配一个struct pci_dev结构体,这个结构体是 PCI 设备在内核中的“身份档案”。它记录了总线号、设备号、功能号、vendor ID、device ID、class code、IRQ 线、BAR 资源数组等一系列信息。
关键点在于:pci_dev的创建过程会调用pci_setup_device,里面会读取并解析配置空间里的关键字段,包括 header type、class code、subsystem ID,甚至还会调用pci_read_irq获取中断线的默认配置值。这些信息后续就是驱动匹配和资源申请的基础。
这里我想强调一个实际工作里容易踩的坑:PCIe 设备是支持 Multiple Functions 的,一个物理插槽上最多可以有 8 个 function,每个 function 都会有一个独立的pci_dev。内核扫描时按 function 号从 0 到 7 逐个尝试读取 vendor ID,如果读到 0xFFFF 就认为该 function 不存在。这看似简单,但如果你的硬件设计里 function 0 是空的、只有 function 1 存在,那没问题,PCIe 规范允许;但很多老式 PCI 设备在枚举代码里对这类“空洞”支持并不友好。所以硬件设计上,最好还是把设备放在 function 0。
2.3 ACPI 和 DT 的角色:不是所有资源都靠枚举
在 x86 平台,PCI 的地址空间分配并非完全靠“读 BAR + 分配资源”自动完成,ACPI_CRS方法会声明主桥的资源窗口,内核在枚举时会把 BAR 请求的资源限制在这个窗口内。这也是为什么你做一个 x86 的 PCIe 板卡时,如果 BIOS 里预留的 bus number 或者 MMIO 窗口不够,会出现设备枚举“半死不活”——能看到设备,但 BAR 分配失败。
在 ARM 平台,设备树里的ranges属性指定了 CPU 地址到 PCI 地址的映射关系。比如:
pcie0: pcie@fe000000 { reg = <0x0 0xfe000000 0x0 0x1000000>; bus-range = <0x0 0x1>; ranges = <0x81000000 0x0 0xf9000000 0x0 0xf9000000 0x0 0x100000>, /* I/O */ <0x82000000 0x0 0xf9100000 0x0 0xf9100000 0x0 0x1e00000>; /* MEM */ };系统起来后 PCI 核心会去解析这段 ranges,据此为子设备分配地址。曾经遇到过一个实际案例,ranges 里 MEM 窗口大小只给了 4MB,结果板卡上一张显存稍大的 GPU 直接起不来——BAR 申请不到足够空间。这不是驱动的问题,是主桥资源窗口的问题。
3. 匹配机制详解:pci_driver 怎么“认领”自己的设备
3.1 pci_device_id 是匹配的钥匙
每个 PCI 驱动必须声明一个struct pci_device_id数组,用来描述该驱动支持的设备。最常见的写法是用PCI_DEVICE宏,按 vendor ID 和 device ID 精确匹配:
static const struct pci_device_id my_pci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(pci, my_pci_ids);匹配的本质是遍历设备已有的 vendor/did 信息,与驱动表逐项比对。但内核还支持更灵活的匹配方式:PCI_ANY_ID表示任意值都匹配、class code 掩码匹配、subvendor/subdevice 匹配。比如你维护一款多型号共用的控制器驱动,可以通过 class code 匹配来“一把抓”,然后在probe里再根据 revision 细化行为。
我在实际项目里见过一种隐患:厂商把不同代际的产品用了同一个 VID/DID,但寄存器行为有差异。这种场景下,精确匹配反而会让驱动错误加载到不兼容的硬件上。我的建议是,代码里除了 VID/DID 精确匹配,还要在 probe 里检查pci_dev->revision或者 subsystem ID,做二次校验。
3.2 probe 什么时候被调用
probe函数的触发条件是“总线上的设备完成注册”并且“有驱动与之匹配成功”。但实际上这个顺序有一个实现细节:设备是先于驱动存在的。系统启动时先扫描总线、创建设备,随后 PCI 核心调用driver_attach尝试绑定;如果驱动是后来以模块方式加载的,pci_register_driver会立即触发一次该驱动与所有现存设备的匹配循环。
这里要注意:probe是在进程上下文执行的,可以睡眠。这给了驱动开发者极大的便利——可以在 probe 里放心地请求资源、注册中断、创建 sysfs 属性,甚至发起耗时的固件加载。但有一条红线:不要在 probe 里做可能导致系统 hang 的操作,比如无超时地等待硬件某个状态位。
3.3 一个 PCI 驱动的骨架长什么样
static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_dev *dev; int ret; dev = kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; ret = pcim_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "Failed to enable PCI device\n"); kfree(dev); return ret; } ret = pcim_iomap_regions(pdev, BIT(0), "mydev"); if (ret) return ret; dev->base = pcim_iomap_table(pdev)[0]; pci_set_drvdata(pdev, dev); ret = devm_request_irq(&pdev->dev, pci_irq_vector(pdev, 0), my_dev_isr, 0, "mydev", dev); if (ret) return ret; return 0; } static void my_pci_remove(struct pci_dev *pdev) { struct my_dev *dev = pci_get_drvdata(pdev); /* 清理工作 */ } static struct pci_driver my_pci_driver = { .name = "mydev", .id_table = my_pci_ids, .probe = my_pci_probe, .remove = my_pci_remove, }; module_pci_driver(my_pci_driver);这段代码里我用到了pcim_enable_device、pcim_iomap_regions、devm_request_irq——这三个都是以“managed”方式操作的 API。它们的共同特点是资源生命周期跟随struct device,设备注销时自动释放。这能有效规避 probe 中途失败导致的资源泄漏问题。新代码里我强烈建议优先用这些 devres 接口,而不是传统的pci_enable_device+pci_request_regions+free_irq手写配对。
4. 关键资源获取与访问姿势:BAR、中断、DMA 的实操细节
4.1 BAR 资源:从配置空间到虚拟地址的完整链路
设备上了总线、驱动进了 probe,接下来最要紧的事就是把 BAR 对应的地址资源要过来。BAR 决定的是设备在 PCI 地址空间里的位置,而我们 CPU 要访问它,需要经过 host bridge 的地址翻译。
pcim_iomap_regions(pdev, BIT(0), "mydev")干的事情有两件:一是对第 0 个 BAR 做资源请求(相当于占座),二是建立 ioremap 映射。它背后等价于pci_request_region+ioremap。之后你可以通过pcim_iomap_table(pdev)[0]拿到映射后的虚拟地址。
值得留意的细节是:BAR 的编号 0-5 对应六个地址段,但并非每个 BAR 都必须被使用。很多简单的设备只用一个 BAR 0。此外,64 位 BAR 会占用两个 BAR 编号,比如 BAR0 和 BAR1 合起来表达一个 64 位地址,所以你在lspci -v里看到某个设备只列出 3 个 BAR,可能不是设备资源少,而是用了 64 位 BAR。
BAR 的 size 在硬件设计时就固定了,内核枚举时通过“写全 1 再读回”的方式探测大小。注意:你在驱动里永远不应该尝试在运行时修改 BAR 值,那是 PCI 核心和 firmware 的领地。驱动只需要关心拿到了哪个 BAR、怎么映射、以及之后如何读写。
4.2 中断配置:从 INTx 到 MSI/MSI-X
PCI 传统中断是 INTx 引脚中断,通过 PCI 桥逐级向上汇聚,最终在中断控制器上产生一个共享 IRQ。共享意味着你的 ISR 必须正确判断“这个中断是不是我的设备发出的”,判断依据通常是读设备的中断状态寄存器,如果发现不是自己设备的中断,要返回IRQ_NONE。
PCIe 时代强烈建议使用 MSI/MSI-X。MSI 是一种内存写消息中断,设备直接把中断消息写到 CPU 指定的地址,绕过了引脚路由,并且每个 function 可以有多个中断向量。MSI-X 更进一步,支持最多 2048 个独立向量,对高性能网卡和多队列设备至关重要。申请方法是:
ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_MSIX);这里第一个参数是设备指针,第二三个参数分别是申请向量的最小值和最大值,第四个参数指定允许的中断类型。申请成功后,用pci_irq_vector(pdev, 0)拿到对应的 Linux IRQ 号。
我踩过一个很典型的坑:有些老设备不支持 MSI,驱动里写死PCI_IRQ_MSI会导致pci_alloc_irq_vectors返回负值,而你没检查返回值,接着就去注册中断了。结果是中断根本不来。正确做法是检查返回值,如果 MSI 失败可以降级到 INTx,利用PCI_IRQ_LEGACY兜底:
ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_MSIX | PCI_IRQ_LEGACY);这样至少保证能拿到一个可用的中断向量。
4.3 DMA 方向的隐藏合同
PCI 设备做 DMA 时,地址是总线地址(bus address),不是 CPU 物理地址。通常 x86 上 IOMMU 未开启时总线地址等于物理地址,但 ARM 上几乎必然有偏移。你千万别直接拿virt_to_phys的结果去设置设备的 DMA 描述符。
正确做法是用 DMA API:
dma_addr_t dma_handle; void *buf; buf = dma_alloc_coherent(&pdev->dev, size, &dma_handle, GFP_KERNEL);dma_alloc_coherent保证了缓冲区在 CPU 和 DMA 两侧的一致性,返回两个值:CPU 能用的虚拟地址和 DMA 引擎要用的总线地址。设备描述符里填dma_handle,CPU 访问用buf。
如果追求更高性能,可以用dma_map_single/dma_unmap_single做流式映射,配合 DMA 方向标志DMA_FROM_DEVICE或DMA_TO_DEVICE。这里有个容易出错的地方:dma_map_single映射的是物理内存里的某个内存块,要求该内存不在栈上、不是高内存、长度不能超限。内核里有CONFIG_DEBUG_SG之类的调试选项,建议开发时打开 DMA API debug,能帮你抓到很多潜在越界问题。
5. 实操实录:手写一个最小 PCI 字符设备驱动
5.1 实现目标
为了让你把前面这些框架概念串起来,我在这里带你把一个最小可用的 PCI 字符设备驱动完整写一遍。这个驱动的目标是:识别一个虚拟的 PCI 设备(VID 0x10EE,DID 0x7014,这里用 Xilinx 的虚拟设备号做演示),然后将它的 BAR0 映射,并通过 read/write 接口让用户态可以直接读写一段寄存器空间。
完整代码量比较大,我挑关键路径展示。
首先是驱动的 id 表和框架注册,这个前面已经给过,不再重复。
然后是probe里的初始化流程:
static int demo_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct demo_dev *dev; int ret; dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; ret = pcim_enable_device(pdev); if (ret < 0) return ret; ret = pcim_iomap_regions(pdev, BIT(0), DRV_NAME); if (ret < 0) return ret; dev->bar0 = pcim_iomap_table(pdev)[0]; if (!dev->bar0) return -ENOMEM; mutex_init(&dev->lock); pci_set_drvdata(pdev, dev); /* 注册字符设备 */ dev->major = register_chrdev(0, DRV_NAME, &demo_fops); if (dev->major < 0) return dev->major; dev_info(&pdev->dev, "loaded, bar0=%pR, major=%d\n", &pdev->resource[0], dev->major); return 0; }注意后段我用register_chrdev注册了一个字符设备,这个老接口虽然简单,但在新内核里推荐使用alloc_chrdev_region+cdev_add的组合,便于配合 class 自动创建设备节点。这里为了保持示例简短,选择了老接口,生产代码建议用 cdev 接口。
然后是 file_operations 的读写:
static ssize_t demo_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { struct demo_dev *dev = file->private_data; u32 reg; int ret; if (count < 4) return -EINVAL; reg = ioread32(dev->bar0 + *ppos); ret = copy_to_user(buf, ®, 4); if (ret) return -EFAULT; *ppos += 4; return 4; }用户态程序通过lseek指定要读取的寄存器偏移,然后read4 个字节。ioread32是 PCI 设备访问推荐使用的 I/O 读写函数,比直接readl多一层端序和内存屏障的考量。
5.2 编译与加载验证
用内核模块方式编译,Makefile 就两行:
obj-m += demo_pci.o编译通过后 insmod。接着用lspci -v确认设备状态,再用modinfo查看驱动加载信息。我实际测试时常用这组命令组合:
insmod demo_pci.ko dmesg | tail -20 ls /dev/demo_dev lspci -v -s 01:00.0 cat /proc/iomem | grep demo排查时重点关注/proc/iomem里是否出现了驱动名字对应的地址段,如果有说明资源申请成功。还有/proc/interrupts看中断是否注册成功,以及走的是不是 MSI。
5.3 设备节点创建问题
register_chrdev接口不会自动创建设备节点。你需要在模块加载后手动mknod /dev/demo_dev c 240 0,或者用 udev 规则自动创建。维护性更好的做法是引入class_create和device_create:
static struct class *demo_class; demo_class = class_create(DRV_NAME); device_create(demo_class, &pdev->dev, MKDEV(major, 0), NULL, "demo_dev");这样设备节点会出现在/dev/demo_dev,而且归到真实的 PCI device 目录下,udev 能自动处理权限。我在实际项目里一般都这么做,省去大量手动 mknod 的麻烦。
6. 常见问题与排查技巧实录
6.1 设备识别了却没有驱动绑定
lspci能看到设备但dmesg里完全没有你的驱动加载信息,通常原因在 id 表。最常见的是 VID/DID 写反,或者MODULE_DEVICE_TABLE缺失导致模块无法被 udev 识别。用modinfo my_drv.ko | grep alias检查是否生成了类似pci:v00001234d00005678*的 alias。没有这个 alias,modprobe 自动加载基本没戏。
还有一种奇怪情况:同一设备有多 function,驱动只匹配了 function 0,结果 function 1 没有绑定。此时可以在 id 表里用PCI_CLASS匹配之类的通用方式,或者在 probe 成功后对同一pci_dev的下一 function 做手动关联。
6.2 中断风暴和 IRQ 超时
如果驱动加载后 CPU 占用飙升到 100%,第一嫌疑就是中断风暴。打开 /proc/interrupts 看哪个 IRQ 计数在疯狂增长。若确认是你的设备,多半是硬件中断状态没有正确清除,或者 ISR 没有做到“边沿清除”。调试时可以先把中断屏蔽,只轮询状态寄存器,确认硬件行为正常后再打开中断。
另一个常见问题是申请了 MSI 但设备仍然发出了 INTx 中断。原因是设备支持 MSI 不代表固件默认开启。pci_alloc_irq_vectors成功不代表设备就自动切换到了 MSI 模式,某些老硬件需要额外写设备的控制寄存器来使能 MSI capability。你可以在 enable 之后读回 MSI 控制寄存器,确认 MSI Enable 位被置起。
6.3 BAR 映射失败或访问异常
BAR 分配失败时lspci -v里资源一栏是空的,显示[virtual]或者none。这种情况多数是主桥窗口资源耗尽,而不是驱动问题。此前遇到过一个服务器平台,BIOS 只给 PCIe 预留了 2GB 的 MMIO 窗口,插上两张需要 1GB BAR 的加速卡就有一张分配失败。排查时用dmesg | grep -i pci看有没有no space之类的报错,定位到窗口资源问题后要么进 BIOS 调大窗口,要么给主桥打补丁扩展资源。
访问异常表现为 ioread32 读回全 F 或者触发外部 abort。全 F 通常是地址不对——要么 BAR 没分配成功,要么映射的虚拟地址不是设备地址。触发 abort 的情况在 ARM 上比较危险,它会直接导致内核 panic 或者 oops。调试时优先确认cat /proc/iomem里地址段和设备的手册期望值是否一致。
6.4 热插拔场景要注意资源生命周期
PCIe 热插拔时,驱动的remove会被调用。如果你在 remove 里释放了某些 DMA 缓冲区,但硬件还没来得及停止 DMA,设备可能在 remove 之后继续访问内存,导致系统崩溃。
一个经验做法:remove 里先禁用设备的 DMA 操作(比如停掉 DMA 引擎、屏蔽中断),再做资源清理。如果设备已经完全不可信,宁可把 remove 里的操作做得“保守一点”,确保不会访问到已经释放的内存。
6.5 devm 和传统 API 混用的问题
我看到不少代码把pci_enable_device和devm_request_irq混用,这本身没问题,但你要清楚资源释放的顺序和时机。devm 资源在 device 释放时统一回收,而pci_enable_device必须配合pci_disable_device手动对称调用。如果你在某处提前 return 而忘掉 disable,设备会一直处于 enable 状态,电源管理也会出问题。建议要么全用 devm 系列(pcim_enable_device+devm_request_irq+devm_kzalloc),要么全用传统配对,别混搭太散。
我在实际项目里见过一个诡异的 bug:驱动反复 rmmod/insmod 之后,设备的中断再也申请不到了,dmesg 显示irq XX: nobody cared。原因就是某个路径上pci_disable_device没被调用,IRQ 仍然被占用。用 devm 全套方案后这个问题再也没出现过。
6.6 工具链和调试环境建议
调试 PCI 驱动,除了lspci、setpci、dmesg,我还离不开这几件法宝:
pci=realloc内核启动参数:当 BIOS 分配的 bus number 或 MMIO 窗口不合理时,强制内核重新分配。radeon/amdgpu这类带 debugfs 的 GPU 驱动:很多 bus 级问题可以直接参考它们的 sysfs 输出。perf和bpftrace:如果怀疑中断或 DMA 路径有性能问题,可以追踪pci_*内核函数调用次数。
调试 ARM 平台时,设备树里的status = "disabled"忘记改掉是一个非常容易被忽略的低级错误——内核枚举时直接跳过,任何驱动都不会被加载。先查设备树再查驱动,能省下大量时间。
7. 关于命名空间和 sysfs 的一些经验
PCI 驱动开发过程中,sysfs 是观察框架运行状态最直观的窗口。每个 PCI 设备在/sys/bus/pci/devices/0000:01:00.0/下都有目录,里面有 vendor、device、irq、resource、driver_override 等属性文件。我想特别提一下driver_override这个属性:它允许你把一个设备强制绑定到某个驱动,哪怕 id table 不匹配。这在调试时特别有用:
echo "my_driver" > /sys/bus/pci/devices/0000:01:00.0/driver_override echo "0000:01:00.0" > /sys/bus/pci/drivers/my_driver/bind这套组合可以绕过 id 表匹配,强制加载驱动。跟new_id属性不同,driver_override是“点对点”绑定,不会影响其他设备。在新内核里它比new_id更受推荐。
还有一个和 PCI 框架伴生的概念是电源管理。PCI 设备的pci_set_power_state可以控制设备进入 D0-D3 等电源状态。如果你不想在设备运行时误触发省电状态,可以在 probe 里调用pci_set_master(pdev)之后显式设置pdev->dev.pm或者使用pm_runtime_forbid禁止自动 runtime suspend。很多 PCIe 网卡驱动被“莫名其妙断网”的 bug 困扰,最后定位到是 runtime PM 在链路空闲时把设备切到了低功耗状态。
8. 后面几篇会继续深挖的方向
PCI 驱动框架这个东西,越挖越深。这一篇围驱动程序员的视角把匹配、资源、中断、DMA 的主干讲清楚了,但还有几条支线没有展开:PCIe 的 AER 错误处理、SR-IOV 虚拟化支持、ACS 隔离、P2P DMA、以及基于 DMA-Buf 的显存共享。第三篇我打算专门讲 probe 路径下的资源分配失败场景和 Device Tree 动态分区,因为这块是 ARM 平台做定制板卡最常咨询的问题;第四篇会涉及 MSI 中断控制器在 ARM 上的实现差异。
我自己的经验是,写 PCI 驱动最忌讳“上来就抄网卡的 probe 代码”。因为不同设备类的链路需求完全不同,存储设备关心队列深度和中断亲和性,视频设备关心 DMA 连续性,网卡关心 multi-queue 和 page recycling。理解了框架各环节的职责边界之后,你会发现抄代码只能解决一时的功能问题,架构不合理的设计迟早会回过头来找你。先把pci_dev、pci_driver、pci_bus这组核心对象的关系摸透,后面所有衍生功能都是锦上添花。