很多写Linux驱动的朋友第一次接触PCI设备时,估计都被那一堆结构体和回调函数整得有点发懵。我早期调试一块PCIe视频采集卡时,明明lspci已经能看到设备了,内核日志里却死活没有probe的动静,后来排查了半天才发现是id_table没配对。Linux PCI驱动框架听起来是个很大很玄的词,但拆开看其实就是几件事:硬件设备怎么被发现、驱动怎么和它匹配、匹配成功之后怎么分配和访问资源、中断又该怎么处理。这篇文章我就把整个PCI驱动框架的骨架完整梳理一遍,从一个最小可运行的驱动入手,把数据结构、匹配流程、BAR访问和中断处理讲透,适合刚接触内核驱动、或者写过platform驱动想对比PCI差异的读者。
1. 从"lspci能看到设备却没人认领"说起
1.1 一次典型的PCI驱动调试现场
先说个我自己的真实经历。有一块PCIe转多串口卡,插到服务器上之后,系统启动日志里能够看到PCIe链路协商正常,lspci也能看到设备ID,但操作系统就是没有给它分配一个可用的驱动实例。我当时觉得特别诡异:设备在总线上是"活"的,为什么内核不理会它?
后来一看,/sys/bus/pci/devices/0000:03:00.0/目录下面只有一个孤零零的vendor、device文件,没有driver这个软链接,也就是说设备没有被任何驱动认领。而我的内核模块已经加载了,dmesg里也有register_driver成功的日志。问题出在设备匹配阶段——驱动注册时携带的id_table里写的vendor/device和卡片实际的ID不一致,所以总线核心在遍历设备时直接跳过了。
这个场景很典型。PCI驱动框架的一个特点就是,设备和驱动的配对不是靠名字,而是靠一组硬件ID。搞清楚配对规则,很多"设备不认领"的问题就解决了一半。
1.2 驱动框架要解决的核心问题
Linux的PCI子系统之所以值得单独拿出来分析,是因为它和platform总线、I2C/SPI这类设备完全不同。PCI设备具备三大能力:
- 动态可发现:系统启动时,PCI核心通过配置空间读取(CONFIG_READ)扫描总线,发现设备并分配BDF(Bus/Device/Function)编号,不需要驱动主动去"创建"设备。
- 资源自描述:设备的BAR(Base Address Register)空间在配置空间里有专门描述,驱动加载后直接读取BAR,就知道该到哪里访问寄存器、中断号是多少。
- 热插拔与多层级:PCIe桥可以扩展出多条总线,形成树状结构,驱动需要适配这种动态变化。
所以PCI驱动框架其实是在回答四个问题:
- 内核如何发现设备、建立
pci_dev对象? - 驱动如何表达自己"能管哪些设备"?
- 匹配成功后,驱动如何安全地访问设备的资源?
- 发生中断、DMA传输时,驱动如何与内核协作?
这四个问题正好对应pci_bus、pci_driver、pci_dev这三个核心结构体,以及一系列辅助API。下面先从"户口本"说起。
2. PCI设备在内核中的"户口本":核心数据结构
2.1 pci_dev:一个设备从枚举到消亡的全程记录
struct pci_dev { struct pci_bus *bus; /* 设备挂在哪条总线上 */ struct pci_bus *subordinate; /* 如果设备是桥,指向下级总线 */ unsigned int devfn; /* 设备号和功能号的编码 */ unsigned short vendor; /* 厂商ID */ unsigned short device; /* 设备ID */ unsigned short subsystem_vendor; unsigned short subsystem_device; unsigned int class; /* 设备类别:存储、网络、多媒体等 */ u8 revision; /* 版本号 */ struct resource resource[DEVICE_COUNT_RESOURCE]; /* BAR映射 */ unsigned int irq; /* 中断号 */ ... };你可以把pci_dev理解成设备在内核中的"户口本"。PCI核心扫描总线时,发现一个设备就会创建这样一个结构体,登记它的vendor、device、class、irq等信息。注意这里的resource[DEVICE_COUNT_RESOURCE],它保存的是设备6个BAR区域(BAR0-BAR5)对应的物理地址和长度。驱动之后要访问设备寄存器,就是从这里拿到地址。
有个细节容易忽略:devfn编码了设备号和功能号,对于多功能设备,同一个物理设备会有多个pci_dev实例,对应不同Function,各自有独立的BAR和中断。开发时看到0000:03:00.0这样的地址,最后一位就是Function号。
2.2 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); ... };驱动作者真正要打交道的,其实是pci_driver。它定义了驱动叫什么名字、能匹配哪些设备(id_table)、匹配成功后干什么(probe)、设备拔出或驱动卸载时干什么(remove)。
注册方式很简单:
static struct pci_driver demo_driver = { .name = "demo_pci_driver", .id_table = demo_ids, .probe = demo_probe, .remove = demo_remove, }; module_pci_driver(demo_driver);module_pci_driver是一个宏,展开后就是标准的module_init和module_exit,内部调用pci_register_driver和pci_unregister_driver。你不需要手动管理注册时机,只需要保证id_table正确。
2.3 pci_bus:总线的层级与桥的关系
pci_bus代表一条PCI/PCIe总线。真正的PCIe系统往往不止一条总线:处理器通过Root Complex连接到总线0,PCIe桥(Bridge)再往下扩展出总线1、总线2……形成树状结构。pci_bus结构体里的parent、children、devices链表,就是把这棵树组织起来的关键。
驱动很少直接操作pci_bus,但理解总线层级对排查问题很有用。比如你看到一个设备地址是0000:02:00.0,意思是它在总线2上。如果你在lspci -t输出的树状图里发现某段设备集体消失,多半是桥或者链路训练出了问题,跟驱动本身没关系。
这三个结构体的关系可以这样对应:
| 结构体 | 比喻 | 驱动关注度 |
|---|---|---|
pci_dev | 设备户口本 | 高,probe之后的主要操作对象 |
pci_driver | 岗位招聘广告 | 高,你得告诉内核你能匹配谁 |
pci_bus | 总公司与分公司的组织架构 | 低,多数驱动不用管 |
3. 设备与驱动的"相亲"过程:匹配机制
3.1 ID table怎么填才算对
设备驱动匹配的核心是pci_device_id表。最常见的使用方式:
static const struct pci_device_id demo_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(pci, demo_ids);PCI_DEVICE(vendor, device)宏展开后会填充vendor和device字段,匹配规则默认是精确匹配这两个ID。但如果你的设备有多个子型号,或者你的驱动想接管某一类设备,就要用到更灵活的匹配方式。
PCI_DEVICE_CLASS宏可以按设备类别匹配。比如你想匹配所有串口控制器,可以用PCI_DEVICE_CLASS(PCI_CLASS_COMMUNICATION_SERIAL, ~0)。但注意类别匹配要谨慎,很容易误伤,比如同一个class里可能包含你不想接管的设备。更稳妥的方法是同时限定vendor:
{ PCI_DEVICE_CLASS(PCI_CLASS_COMMUNICATION_SERIAL, 0xFFFFFF) }subsystem_vendor和subsystem_device用来匹配具体板卡。同一个主芯片可能有不同厂家的板子,有的厂商希望用自家驱动,就可以用PCI_DEVICE_SUB宏精确匹配子系统ID。实际调试时,我建议优先用vendor+device精确匹配,确认驱动能绑定之后再考虑放宽条件。
3.2 probe回调到底什么时候被调用
probe的触发时机有两个:
- 设备先被发现,驱动后注册:系统启动时PCI核心扫描总线,创建一批
pci_dev挂到总线上。你之后加载驱动模块,调用pci_register_driver,内核会遍历总线上所有设备,匹配id_table,成功就立即调用probe。 - 驱动先注册,设备后被插入:热插拔场景。设备插入后PCI核心创建
pci_dev,然后触发总线上的驱动匹配,调用probe。
所以,probe被调用的本质是设备与驱动在总线上相遇。理解这一点,你就能明白为什么probe里不应该有太耗时的操作——它可能发生在系统启动的早期,也可能发生在设备热插拔的流程中,耗时太长会影响整个系统的设备枚举。
3.3 同一设备多个驱动冲突怎么办
一个PCI设备同一时刻只能被一个驱动绑定。如果你加载了两个驱动,它们的id_table都包含同一个vendor/device,后加载的驱动不会抢走已经绑定的设备,而是直接跳过。这种情况下你想要某个特定的驱动接管设备,有几种处理方式:
- 先卸载已经绑定的驱动模块;
- 在新驱动里避免和已有驱动的
id_table重叠; - 使用
/sys/bus/pci/drivers/<driver>/bind和unbind手动绑定解绑; - 给设备加Driver Override,指定强制使用某个驱动。
我在调试时会频繁用到bind/unbind,尤其是反复改驱动代码的场景。把设备解绑再重新绑定,比卸载整个模块要快,也更精准。
4. 动手写一个最小PCI驱动:代码骨架与API选择
4.1 头文件与模块框架
一个最小的PCI驱动只需要两三个头文件:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/pci.h> #include <linux/io.h>linux/pci.h是必须的,所有的PCI核心API都在里面。linux/io.h提供ioread32、iowrite32、pci_iomap之类的内存访问函数。
完整的模块框架下面就直接给出,这个是我实测跑过的精简版本,去掉了具体的业务逻辑,只保留PCI驱动的核心生命周期。
static int demo_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; resource_size_t bar_start, bar_len; void __iomem *bar0; /* 1. 启用PCI设备 */ ret = pci_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "pci_enable_device failed: %d\n", ret); return ret; } /* 2. 请求BAR资源,避免和其他驱动冲突 */ ret = pci_request_regions(pdev, "demo_pci_driver"); if (ret) { dev_err(&pdev->dev, "pci_request_regions failed: %d\n", ret); pci_disable_device(pdev); return ret; } /* 3. 读取BAR0的物理地址和长度 */ bar_start = pci_resource_start(pdev, 0); bar_len = pci_resource_len(pdev, 0); if (bar_len == 0) { dev_err(&pdev->dev, "BAR0 length is 0\n"); goto release_regions; } /* 4. 映射到内核虚拟地址空间 */ bar0 = pci_iomap(pdev, 0, bar_len); if (!bar0) { dev_err(&pdev->dev, "pci_iomap failed\n"); goto release_regions; } /* 5. 开启总线主控能力,DMA才可用 */ pci_set_master(pdev); /* 这里可以继续ioread32/iowrite32操作寄存器 */ dev_info(&pdev->dev, "demo probe OK, BAR0 at %pa\n", &bar_start); return 0; release_regions: pci_release_regions(pdev); pci_disable_device(pdev); return -ENOMEM; } static void demo_remove(struct pci_dev *pdev) { /* 反序释放probe里申请的资源 */ pci_release_regions(pdev); pci_disable_device(pdev); } static const struct pci_device_id demo_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { } }; MODULE_DEVICE_TABLE(pci, demo_ids); static struct pci_driver demo_driver = { .name = "demo_pci_driver", .id_table = demo_ids, .probe = demo_probe, .remove = demo_remove, }; module_pci_driver(demo_driver); MODULE_LICENSE("GPL");4.2 probe/remove实现要点
probe函数是驱动的主入口,在它里面完成硬件初始化。remove则相反,负责释放资源。很多刚入门的朋友会在probe里漏掉pci_enable_device,这个函数干的事情比你想象的多:
- 确认设备没有被BIOS或者其他驱动禁用;
- 设置命令寄存器中的I/O空间和内存空间使能位;
- 开启设备的总线主控等能力。
如果跳过它直接pci_iomap再ioread32,轻则读到全0xFF,重则触发总线错误导致系统卡死。至于pci_request_regions,它是为了检查BAR区域是否被其他驱动占用,同时通过/proc/iomem或/sys把资源登记下来,方便排查问题。
4.3 为什么pci_enable_device要放在第一步
这一点我想展开说一下。pci_enable_device函数内部会做pcibios_enable_device,最终操作的是PCI配置空间里的PCI_COMMAND寄存器。PCI规范里,设备上电后默认是不响应内存访问和I/O访问的——这是出于安全考虑,防止设备在资源冲突时直接访问内存。只有驱动明确请求,内核才会把PCI_COMMAND_MEMORY和PCI_COMMAND_IO位置1,此时BAR才算真正"能用"。
所以顺序非常关键:
pci_enable_device让设备进入可用状态;pci_request_regions确认资源归属并登记;pci_iomap做CPU物理地址到虚拟地址的映射;pci_set_master开启设备作为总线主控的能力,后续做DMA才不会被总线拒绝。
如果先pci_iomap再pci_enable_device,逻辑上并不会立刻报错,但访问寄存器时可能出现不可预料的返回值。同类的坑我在platform驱动里也踩过,只是platform总线没有这么严格的启用流程,容易让人放松警惕。
4.4 访问BAR空间的正确姿势
拿到bar0虚拟地址之后,操作寄存器很简单:
u32 val; val = ioread32(bar0 + 0x00); /* 读取偏移0处的寄存器 */ iowrite32(val | 0x1, bar0 + 0x00); /* 写入修改后的值 */为什么不直接解引用指针?因为BAR映射出来的地址是设备寄存器,不是普通内存。有些设备寄存器写入会有副作用,或者需要严格按顺序访问,用ioread32/iowrite32内核会在必要的时候插入内存屏障,避免编译器和CPU过度重排访问顺序。
如果你拿到的是I/O端口型BAR,pci_iomap依然可以处理,不过后续要用inb/outb系列函数。多数现代PCIe设备都是内存型BAR,直接用mmio方式访问即可。另外,很多新手会混淆pci_resource_start返回的物理地址和ioremap之后的虚拟地址。物理地址只能传给内核API,驱动代码里操作寄存器必须用虚拟地址。
5. 中断处理:从INTx到MSI的取舍
5.1 中断号从哪里来
PCI设备的中断号不是设备自己"发明"的,而是由PCI核心在枚举阶段分配好,存在pci_dev->irq里。对于传统INTx中断,这通常是系统通过APIC路由表分配的一个IRQ号。驱动只需要把它传给request_irq即可。
但对于现代PCIe设备,我更推荐使用MSI/MSI-X。MSI的本质是设备直接向CPU发送中断消息,不再依赖传统的中断线,能有效避免多设备共享INTx时的中断风暴问题。MSI的中断号不会自动出现在pci_dev->irq里,需要用API主动申请。
5.2 request_irq还是MSI?实测建议
有这样一个现实案例:一块双口网卡,如果两个口都用传统INTx,它们往往会共享同一个IRQ线。极端情况下,任何一个端口产生中断,驱动都要进去判断是不是自己的设备在处理。如果存在多个设备共享同一条中断线,还会出现中断风暴,CPU占用率异常高。
MSI/MSI-X把每个中断源独立开来,每个队列、每个端口都能拿到独立的中断号,处理起来清爽很多。pci_alloc_irq_vectors这个API可以帮你一次性申请一个或多个中断向量:
int nr_irqs = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (nr_irqs < 0) { /* 回退到传统INTx */ nr_irqs = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_INTX); }然后从pci_irq_vector(pdev, 0)拿到实际中断号,传给request_irq。要说我的建议,只要设备支持MSI,优先用MSI。只有MSI申请失败时才考虑INTx,而且INTx的request_irq记得加IRQF_SHARED标志,因为传统中断线几乎一定会共享。
5.3 中断初始化与DMA的联动
一个完整的初始化流程,通常是:
/* 在probe里调用 */ ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (ret < 0) return ret; ret = request_irq(pci_irq_vector(pdev, 0), demo_isr, 0, "demo_pci_driver", dev_id); if (ret) goto free_vectors;dev_id参数很关键。中断处理函数里拿到它,可以把struct demo_dev *之类的私有数据传进来,避免使用全局变量。写ISR的时候要注意,中断处理函数里只能做快速响应和必要的数据搬运,耗时的数据解析应该放到tasklet或workqueue里,否则会拖垮整个系统的中断响应。
DMA部分我在这里只提一个原则:使用dma_alloc_coherent分配一致性的DMA缓冲区,或者dma_map_single映射流式缓冲区,并且在配置DMA前先调用dma_set_mask_and_coherent设置设备DMA寻址范围。64位设备就用DMA_BIT_MASK(64),如果设置失败,说明设备或平台限制只能做32位DMA,这时候要明确降级,否则DMA会写坏地址。
6. 调试PCI驱动时绕不开的几道坎
6.1 lspci -vvv 输出怎么看
lspci -vvv是第一个必用工具。重点看这几行:
Region 0: Memory at ... [size=4K]:对应的就是BAR0,如果显示[disabled],说明设备还没被pci_enable_device;Kernel driver in use: demo_pci_driver:当前接管设备的驱动,为空就是没人认领;IRQ 10:传统INTx分配到的中断号,MSI/MSI-X设备会显示MSI-X支持;Capabilities:里面能看设备支持MSI、MSI-X、Power Management等能力,帮助判断为什么MSI申请失败。
比如你看到Region 0: Memory at <ignored>,多半是设备固件没初始化好,或者BAR配置被篡改。这种情况先看设备是否被BIOS正确枚举,再考虑是不是驱动自己动了配置空间。
6.2 /sys/bus/pci 下的信息
/sys/bus/pci/devices/0000:03:00.0/目录里有很多有用的文件:
vendor、device:设备ID;resource:BAR资源信息;config:配置空间的二进制镜像,可以用hexdump查看;irq:当前使用的中断号;driver:如果设备被绑定,这个软链接会指向对应驱动目录。
调试时最常用的操作是:
echo 0000:03:00.0 > /sys/bus/pci/drivers/demo_pci_driver/bind echo 0000:03:00.0 > /sys/bus/pci/drivers/demo_pci_driver/unbind手动绑定解绑,比反复insmod/rmmod整个模块高效得多,尤其是你在调试probe函数的时候。
6.3 常见问题:probe不被调用、BAR读不到、中断不触发
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| probe不被调用 | id_table不匹配、设备已被其他驱动绑定 | 对比lspci -n里的vendor/device,确认id_table;检查/sys/bus/pci/devices/.../driver |
| BAR读取全为0xFF | pci_enable_device未调用或失败;设备固件异常 | 确认pci_enable_device返回值,查看lspci -vvv的Region状态 |
| ioread32导致死机 | 设备BAR未映射、访问了不存在的偏移 | 校验pci_iomap返回值,确认BAR长度是否足够 |
| 中断一直触发或完全不触发 | INTx未注册为共享、MSI申请失败后未回退 | 检查request_irq的IRQF_SHARED标志,查看/proc/interrupts里的中断计数 |
| DMA写坏内存 | dma_set_mask失败后未降级、缓冲区未正确映射 | 确认DMA mask设置成功,使用dma_alloc_coherent分配缓冲区 |
这些坑我在不同板卡上都遇到过。其中"DMA写坏内存"是最难排查的,表面现象可能是文件系统损坏或者随机进程core dump,实际上根源是DMA越界。建议新板卡调试时,先把DMA缓冲区用特殊pattern填满,在传输结束后校验内容,能快速定位是否越界。
另外补一句经验:写PCI驱动时不要一上来就对着datasheet猛喷寄存器操作,先把probe里最基础的pci_enable_device、pci_request_regions、pci_iomap跑通,再逐步叠加中断和DMA。这样每一层出问题都比较容易定位。我个人见过太多因为跳过基础检查,最后整个系统hang死的案例了。
回到开头那个"设备不认领"的问题,现在的你应该有清晰的排查路径了:lspci -n看ID,ls /sys/bus/pci/devices/.../driver看绑定情况,检查驱动id_table,再用bind手动拉起驱动,看dmesg里probe的输出。PCI框架本身已经把从总线扫描、设备创建到驱动匹配的所有脏活累活都做完了,驱动作者要做的只是填对ID、写好probe、按顺序启用设备,仅此而已。系列第一篇先把框架讲清楚,后面我再专门展开MSI-X、SR-IOV、PCIe AER错误处理这些进阶话题。