搞Linux驱动也写了几年前后踩过不少坑绕了不少弯路。新手入行无论是看网卡驱动、GPU驱动还是各种PCIe外设第一关往往就是“看懂PCI驱动框架”。这玩意儿不像字符设备驱动随便一两百行就能跑起来它牵扯到总线模型、配置空间、资源映射、中断处理一大堆概念知识密度很高。所以我把“Linux PCI驱动框架分析”做成一个系列这篇是第一期先带你把整体的框架和脉络捋清楚。这个系列主要解决三个问题第一PCI设备在Linux里到底是怎么被“发现”的第二驱动代码的骨架是怎么搭起来的每个结构体和回调函数背后是什么逻辑第三照着这套框架你怎么最快写出一个能加载、能匹配、能跑通probe的最小驱动。适合刚接触驱动开发的人也适合写过I2C/SPI驱动、想往PCIe方向转的人。核心就一句话把PCI驱动的骨架吃透后面看任何复杂驱动都不慌。1. 为什么做PCI驱动先得把框架搞清楚1.1 PCI驱动在开发里的实际地位先聊一个很现实的问题Linux下驱动种类那么多字符设备、块设备、网络设备、平台设备……为什么PCI驱动这么重要总绕不开原因很简单现代计算机里性能要求高的外设几乎都挂在PCIe总线上。显卡、网卡、NVMe固态盘、声卡甚至某些采集卡、加密卡都是PCIe接口。你写的是“PCI驱动”本质上是在跟这些设备的硬件打交道而Linux内核为了把这些设备统一管理起来专门做了一套“PCI子系统”。这套子系统剥离了不同设备的具体功能只关注“设备怎么被识别、资源怎么分配、数据和中断怎么交互”。理解了这一点你就明白为什么很多设备驱动的代码前面一大半都是雷同的。早些年我拿到的第一个PCI驱动工程当时不认识probe、不熟悉struct pci_driver看半天跟看天书一样。后来想明白了前期那一大段代码全是在“打通软件和硬件的通道”跟具体设备干什么没有直接关系。等你把框架那套东西跑通剩下的事情才轮到具体的寄存器操作、DMA搬运、中断处理。这也是我写这个系列的基础逻辑。第一讲只聊框架把“通道”作为主线。后面几讲再去展开DMA、MSI中断、内核线程化中断、性能调优这些高级话题。1.2 Linux“设备/总线/驱动”这盘棋再看框架本身Linux的设备管理核心是三段式模型设备device、总线bus、驱动driver。这个模型并非PCI独有但PCI是应用得最充分、也最典型的案例。打个比方把总线比作“相亲平台”设备是“征婚者”驱动是“应征者”。“征婚者”把自己的基本信息挂在平台上比如姓名厂商ID、设备ID、条件BAR资源大小、中断号当“应征者”入场时平台负责双方匹配一旦匹配成功驱动和设备就“绑定”在一起开始干活。而Linux里的bus结构体就是那个负责牵线搭桥的红娘。这套三段式模型的好处是解耦。硬件被抽象成统一的device软件被抽象成统一的driver驱动不需要关心设备是怎么挂上总线的总线不需要关心驱动到底怎么读写设备。实际开发中你只需要做两件事把自己的设备填好属性挂到总线上这一般由PCI核心子系统自动完成再写一个带匹配列表的驱动注册到总线上这是驱动开发者的主要工作。所以写PCI驱动本质上是两个步骤第一步理解PCI总线作为“红娘”的内部机制第二步学会写一个合格的“应征者”驱动让它能被正确匹配、成功绑定、正常解除绑定。2. Linux PCI子系统的底子总线与设备是怎么来的2.1 硬件侧的PCI拓扑与配置空间写驱动之前必须先花十分钟搞懂硬件侧的PCI拓扑。因为Linux PCI子系统的软件结构几乎就是在“模拟”硬件结构。PCI/PCIe体系中设备挂在总线Bus上每条总线下面可以有多个设备Device每个设备又可以包含多个功能Function。所以一个PCI设备在系统中的地址通常写成“总线号:设备号.功能号”也就是常见的00:1f.3这种格式。每个PCI功能在硬件上都有一个配置空间Configuration Space前256字节是标准头里面装了厂商IDVendor ID、设备IDDevice ID、类别码、中断引脚、BAR寄存器等关键信息。这些信息是软件识别设备、分配资源的依据。Linux启动或PCI子系统初始化时有个总线枚举的过程。它沿着PCI总线树一站一站扫描读取每个设备的配置空间为每个功能创建一个struct pci_dev然后挂在对应的pci_bus下。这个过程里内核还会读取设备声明的资源需求——比如某设备的BAR0声明了需要16KB内存空间那内核就从该总线下的地址窗口里分配一块16KB的物理地址给它。这块内容很重要但也别钻太深。你只需要记住设备在Linux里表现为一个pci_dev它在哪、要多大资源、中断怎么走都在枚举阶段被记录和分配好了。驱动的工作是基于这些信息去使用设备而不是去告诉内核“我这设备需要什么”。2.2 Linux里PCI设备模型的注册与呈现把视角切到代码层。Linux PCI子系统初始化时会注册一个名为pci_bus_type的总线类型这是所有PCI设备的“家”。设备枚举结束后每个pci_dev都会被“挂”到这个总线上。这里我提一下struct pci_dev这个结构体。它里面字段很多但驱动开发常用的也就这么多dev.bus指向所属的PCI总线类型用于匹配驱动vendor、device厂商ID和设备ID匹配时的主要依据class设备类别比如网卡、存储、显示适配器resource[]设备占用的资源描述包括BAR空间、I/O空间等irq设备使用的中断号传统INTx中断模式下使用以及用于MSI/MSI-X中断的msi_enabled等状态位新设备被挂到PCI总线之后内核会立刻触发一次“找驱动”的动作。它遍历该总线上所有已注册的pci_driver比对设备信息和驱动声明的id_table一旦找到匹配项就调用驱动的probe函数。这个注册-匹配机制是整个PCI驱动框架的枢纽。你写的驱动代码基本就是一个“懂事”的struct pci_driver告诉内核你能管哪些设备以及匹配成功后怎么初始化。2.3 驱动与设备的匹配机制拆解驱动如何被匹配上很多时候你会发现自己的probe就是没被调用设备在lspci里也存在驱动也加载了问题出在哪多半就是匹配规则不清楚。PCI驱动的匹配主要依赖struct pci_device_id。看一个典型例子static const struct pci_device_id demo_ids[] { { PCI_DEVICE(PCI_VENDOR_ID_INTEL, 0x1234) }, { PCI_DEVICE(0x10ec, 0x8139) }, { 0 } }; MODULE_DEVICE_TABLE(pci, demo_ids);这段代码表达了“我支持Intel厂商ID、设备号1234的设备的也支持瑞昱的8139网卡”。匹配时内核会逐一取出设备的vendor/device值和列表里的每一项做比对全部相等才算匹配成功。这里有几个实用经验。第一匹配的粒度可以更细比如通过PCI_DEVICE_SUB(ven, dev, subven, subdev)匹配子厂商ID和子设备ID用于区分同一芯片的不同板卡版本。第二如果不设置id_table驱动的probe理论上也能被调用——可以使用driver_override机制强制指定某个驱动接管设备但实际工程中很少这么干。第三驱动加载时MODULE_DEVICE_TABLE会让内核生成模块支持的设备ID列表这样当设备插入时即使驱动还没加载内核也能通过udev之类的事件机制自动加载对应模块。这一步少了很容易出现“设备在但驱动没有被自动加载”的情况。3. 上手写驱动前必须吃透的四样核心结构3.1 struct pci_driver驱动的骨架绝大多数PCI驱动的开头都是同一个模板定义一个struct pci_driver填上名字、ID表、probe、remove然后注册。static struct pci_driver demo_pci_driver { .name demo_pci, .id_table demo_ids, .probe demo_probe, .remove demo_remove, }; module_pci_driver(demo_pci_driver);这个结构体就是驱动的简历。name是驱动的名字会在/sys/bus/pci/drivers/demo_pci下出现用来做手工绑定操作。id_table指向上面说的匹配表。probe是“面试通过后的入职手续”remove是离职交接。值得一提的是module_pci_driver这个宏会统一帮你补全module_init和module_exit省去手写注册/注销函数的麻烦。用宏的好处不止是代码短还能避免开发者忘了注销时漏掉分类清理函数。3.2 struct pci_device_id硬件与驱动的握手前面已经提过匹配这里再补充一点它在整个框架中的角色。struct pci_device_id其实就是从硬件配置空间到软件驱动之间的“握手协议”。这个结构体每个字段都有取舍空间我列一张表方便你查字段作用匹配时怎么用vendor厂商ID与配置空间Vendor ID强匹配device设备ID与配置空间Device ID强匹配subvendor子系统厂商ID配合PCI_DEVICE_SUB使用subdevice子系统设备ID配合PCI_DEVICE_SUB使用class设备类别与配置空间Class Code匹配class_mask类别掩码指定哪个位段需要参与匹配driver_data私有数据匹配成功后传入probe的id参数实际工作中driver_data这个字段很有用。同一颗芯片常因版本不同而BAR数量、寄存器布局稍有差异这时你在ID表里为不同版本填写不同driver_dataprobe时直接根据传入的id-driver_data选择初始化路径比反复读取寄存器判断版本要高效得多。3.3 probe 和 remove 里到底该干什么probe是驱动初始化设备的入口它的职责非常明确使能设备、申请资源、初始化硬件让设备进入可工作状态。这里有个顺序问题我见过不少人一上来就写寄存器操作结果设备都没使能导致读回的全是0xFF。一个典型的probe流程大概是调用pcim_enable_device()使能设备它会完成设备电源管理和PCI命令寄存器配置等工作。通过pci_request_regions()申请设备占用的资源区域这一步相当于告诉内核“这块地址空间由我管理”防止其他驱动误用。用pcim_iomap()把BAR物理地址映射到内核虚拟地址空间后续代码就能直接通过指针读写设备寄存器。读取必要的配置信息比如版本号、功能位按照ID的driver_data做分支初始化。申请中断注册中断处理函数。如果设备需要参与内核网络/块设备/字符设备框架最后再调用对应的注册接口。remove做的事正好反过来注销设备接口、释放中断、解除映射、释放资源。很多人容易遗漏的一个点是如果probe在第4步失败前面已经申请的资源会不会泄漏好消息是pcim_*系列接口是Managed Device Resource它们是devres机制的体现设备销毁或probe失败时会自动释放。所以我的建议是能用pcim_*就用pcim_*能少写大量错误处理代码。3.4 中断与DMA资源申请两个绕不开的大坑PCI设备几乎都要用中断传统INTx方式沿着PCI中断线走到CPU的IOAPIC而现代设备普遍支持MSI/MSI-X。对驱动而言区别在于申请函数和中断号来源。INTx模式下中断号直接存放在pdev-irq直接request_irq(pdev-irq, handler, IRQF_SHARED, ...)就行。因为PCI中断是电平触发的、可共享的所以务必加上IRQF_SHARED标志。MSI模式下你先调用pci_alloc_irq_vectors()申请向量再用pci_irq_vector()拿到具体中断号。开启MSI能让性能提升很多并且避免IRQF_SHARED的干扰所以新驱动一律推荐优先MSI/MSI-X。DMA资源是另一个大坑。DMA要用的地址是“总线地址”跟CPU物理地址、虚拟地址都不一样。现代平台上通常有IOMMU如x86的VT-d内核通过DMA API做一层转换。驱动里常见的操作是dma_handle dma_alloc_coherent(pdev-dev, size, dma_addr, GFP_KERNEL);这一步能确保分配的缓冲区在物理上连续、能被设备通过总线地址访问。别自作聪明直接拿kmalloc的地址往设备寄存器里填在开启了IOMMU的机器上几乎必然出错。关于DMA的缓冲管理很多驱动框架默认做比较好的设备会用环形缓冲区加dma_map_single/dma_unmap_single进行流式映射这些内容等后续文章中再展开。4. 实操从零搭建一个最小PCI驱动工程4.1 准备实验环境和目标设备理论说得再多不如动手敲一遍。这里我以一个假设的PCI设备为例它的厂商ID是0x10ec瑞昱设备ID是0x8139经典的8139网卡芯片。为什么选8139因为它的寄存器简单、资料多是个完美练手对象而且很多虚拟机里都自带这个设备。实验环境建议用一台Linux虚拟机发行版不限内核5.x以上装好build-essential、make和当前内核头文件。确认环境里能编译内核模块用虚拟机的好处是随便折腾系统挂了重建就行。启动虚拟机后先跑一下lspci -nn看目标设备是否可见lspci -nn如果输出里能看到类似Realtek Semiconductor Co., Ltd. RTL-8139/8139C/8139C (rev 10) [10ec:8139]这一行那就说明设备存在ID也清楚了。4.2 驱动代码实现与逐段讲解新建一个目录比如demo_pci写一个demo_pci.c。这个驱动做的事情很简单匹配并probe成功后在内核日志里打印设备资源信息然后注册一个简单的字符设备方便用户态通过read/write访问。这里先把框架写出来重点讲核心环节#include linux/module.h #include linux/pci.h #include linux/interrupt.h #define DRV_NAME demo_pci static int demo_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; ret pcim_enable_device(pdev); if (ret 0) { dev_err(pdev-dev, failed to enable device\n); return ret; } ret pci_request_regions(pdev, DRV_NAME); if (ret 0) { dev_err(pdev-dev, failed to request regions\n); return ret; } if (pci_resource_flags(pdev, 0) IORESOURCE_MEM) { unsigned long bar0_start pci_resource_start(pdev, 0); unsigned long bar0_len pci_resource_len(pdev, 0); dev_info(pdev-dev, BAR0: start0x%lx len%lu\n, bar0_start, bar0_len); } return 0; } static void demo_pci_remove(struct pci_dev *pdev) { dev_info(pdev-dev, device removed\n); } static const struct pci_device_id demo_pci_ids[] { { PCI_DEVICE(0x10ec, 0x8139) }, { 0 } }; MODULE_DEVICE_TABLE(pci, demo_pci_ids); static struct pci_driver demo_pci_driver { .name DRV_NAME, .id_table demo_pci_ids, .probe demo_pci_probe, .remove demo_pci_remove, }; module_pci_driver(demo_pci_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Minimal PCI driver demo);逐段解释几个要点。pcim_enable_device()是pci_enable_device()的“托管版”它做的事包括检查command寄存器、调用电源管理接口、使能设备的总线主控能力。即使后面出错了内核也能自动补齐资源释放的善后工作这是工程上推荐的主要写法。pci_request_regions()是资源占用申报。它的作用是确保这段BAR空间是“我”的别人不能来抢。注意这里有一个非常常见的坑如果你在驱动拉起来后还要用lspci -vvv或直接读写配置空间其实不申请也行但规范驱动一定要做。拿到BAR信息后代码里用的是pci_resource_start/len/flags系列函数。这里的pci_resource_flags返回的可能是IORESOURCE_MEM或IORESOURCE_IO。它们分别对应设备挂在内存空间还是IO地址空间。现代PCIe设备基本都挂在IORESOURCE_MEM。4.3 Makefile、编译与加载验证代码写好后在同目录创建Makefileobj-m demo_pci.o KDIR ? /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean然后执行make。编译成功后你会得到一个demo_pci.ko。按顺序加载并验证sudo insmod demo_pci.ko dmesg | tail -20 sudo rmmod demo_pci正常情况第一次加载后dmesg里会看到demo_pci: 加载驱动的相关日志以及类似下面的输出demo_pci 0000:02:01.0: BAR0: start0xfebc0000 len16384注意前面的0000:02:01.0是设备的完整定位具体数字跟你机器上的实际拓扑有关。如果到这里一切正常说明你的驱动已经成功经过了枚举、匹配、probe三个阶段PCI驱动的框架流程算是完整地跑通了一遍。再验证一下驱动是否“活着”看sysfscat /sys/bus/pci/drivers/demo_pci/0000:02:01.0/device这条命令会输出设备ID如果不出错说明驱动和设备的绑定关系已经建立。5. 实战中反复踩的几个坑5.1 BAR映射失败与地址空间问题很多人第一次写PCI驱动probe里不做pci_request_regions直接ioremap一旦设备恰好背后还有别的驱动或者资源冲突就会出现注册失败、读写不稳定甚至系统卡死的情况。用一句话总结这个问题的根源Linux的资源管理是有台账的。你不申报就不知道自己用的地址是不是还被别人惦记着。所以顺序必须是先申请资源再映射空间否则都属于自己给自己埋雷。pcim_iomap()之后返回的地址应当用readl/writel来访问。这里有两个小细节你要知道。第一寄存器访问务必使用readl/writel而不是直接解引用指针一是因为它们带内存屏障能保证访问顺序二是因为部分平台上有MMIO访问的特殊要求直接解引用可能被编译器优化乱序。第二BAR0的地址可能是64位非连贯地址需要检查IORESOURCE_MEM_64标志判断是否需要分成两个BAR来读取。早年我调试一块10G网卡时BAR掩码那一步没处理好结果把BAR0当成32位来读所有寄存器都乱套了。5.2 中断申请失败与INTx/MSI的选择设备绑定成功、BAR映射也正常但跑起数据通路后频繁丢中断或者probe在申请中断时返回-EINVAL这是很典型的MSI与INTx选择问题。老式平台、虚拟化环境、部分PCIe-PCI桥后的设备MSI支持不完整。强制开启MSI可能出现申请成功了但中断向量不可用的情况。我的建议是在probe里用pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_ALL_TYPES)尝试申请任意可用中断向量然后通过pci_irq_vector()获取中断号。这个接口会自动尝试MSI-X、MSI、INTx的降级顺序省去一大堆条件判断。另一个常见坑是中断处理函数用了IRQF_TRIGGER_*显式指定触发方式。PCI中断的触发方式在设备枚举时已经约定好不应该在驱动的request_irq里再指定电平或边沿触发。你只能传IRQF_SHARED剩下的交给底层处理。强行指触发方式在有些主板上会导致中断风暴或者设备彻底不响应。5.3 手工绑定与设备占用问题在调试阶段设备可能已经被内核自带的另一个驱动接管了比如8139芯片很可能被8139cp或8139too驱动抢先匹配你的demo_pci根本轮不到probe。遇到这种情况别慌。先用lspci -k看看设备当前被哪个驱动绑定lspci -k -s 02:01.0如果被其他驱动占用先把那个驱动卸载掉或者直接通过sysfs解绑echo 0000:02:01.0 /sys/bus/pci/drivers/8139too/unbind echo 0000:02:01.0 /sys/bus/pci/drivers/demo_pci/bind这里有个细节解绑、绑定操作要求驱动模块里必须有对应的remove和probe实现否则sysfs接口会拒绝操作。我们的demo工程里这两个回调都有了所以可以顺利通过这种手工方式验证。如果设备没有那么抢手也可以反过来通过driver_override强制接管echo 0000:02:01.0 /sys/bus/pci/devices/0000:02:01.0/driver_override echo demo_pci /sys/bus/pci/devices/0000:02:01.0/driver_override echo 0000:02:01.0 /sys/bus/pci/drivers_probe嗯先说明一点driver_override写成设备路径那个操作在较老的内核里有效新内核也支持但更常见的做法是直接写驱动名。总之目标是同一个让系统知道“这个设备必须由我指定的这个驱动来接管”。5.4 日志排查三板斧调试PCI驱动日志是最好的线索。我调试例程时一般三招走天下dmesg看内核输出重点看pci、demo_pci相关的行。lspci -vvv看设备的当前状态比如Command寄存器的Memory Space Enable、Bus Mastering位是否置位。如果这两项没置位说明设备还没被正确使能。/sys/bus/pci/devices/0000:02:01.0/目录内的资源文件确认BAR的物理地址是否分配成功。要是这三种检查做完还没头绪那就大胆在probe里加dev_info打印一层层确认走没走到那一步。多数情况你会发现问题不是大方向错而是某个小细节没满足。按这套思路写下来一个PCI驱动的骨架应该在你脑子里清晰了。说句实在话写了这些年驱动我最大的体会是框架这玩意儿光看是看不熟的一定要亲手编译一次、加载一次、故意写错再排查一次才能真正内化成自己的东西。哪怕你的设备不是8139甚至不是真实的网络设备只要顺着这个probe流程走一遍遇到的坑都踩一遍后面看任何复杂的PCIe驱动都会顺畅得多。