Linux PCI驱动框架详解:从设备匹配到中断处理
发布时间:2026/9/25 1:10:48来源:尧图网络
很多写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扫描总线发现设备并分配BDFBus/Device/Function编号不需要驱动主动去创建设备。资源自描述设备的BARBase 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连接到总线0PCIe桥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。实际调试时我建议优先用vendordevice精确匹配确认驱动能绑定之后再考虑放宽条件。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.hlinux/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端口型BARpci_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 ... [size4K]对应的就是BAR0如果显示[disabled]说明设备还没被pci_enable_deviceKernel 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设备IDresourceBAR资源信息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/.../driverBAR读取全为0xFFpci_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看IDls /sys/bus/pci/devices/.../driver看绑定情况检查驱动id_table再用bind手动拉起驱动看dmesg里probe的输出。PCI框架本身已经把从总线扫描、设备创建到驱动匹配的所有脏活累活都做完了驱动作者要做的只是填对ID、写好probe、按顺序启用设备仅此而已。系列第一篇先把框架讲清楚后面我再专门展开MSI-X、SR-IOV、PCIe AER错误处理这些进阶话题。
网站建设高端定制企业官网