☰
Linux PCIe驱动开发实战:设备匹配、probe调用与配置空间访问
2026/9/26 12:50:50 网站建设 项目流程

1. 从probe函数被调用说起:PCI设备与驱动是怎么"相亲"成功的

很多人看PCI驱动框架,第一遍能看懂pci_register_driver注册了个struct pci_driver,第二遍能看懂probe函数里读BAR、映射寄存器,但真正卡住的地方往往是一个很朴素的问题:内核到底是怎么知道"这块卡该由这个驱动来管"的?这个匹配过程发生在什么时候?为什么我改了vendor id就匹配不上了?

这一篇接着上一部分的总线枚举往下讲,重点落在设备与驱动的匹配机制、probe的调用时机、以及配置空间访问的底层路径上。如果你正在写一个PCIe设备的字符驱动,或者在做嵌入式平台上把FPGA挂到PCIe Root Port后面的活儿,这些内容基本是绕不过去的。我假设你已经知道PCI配置空间有64字节头部、知道BAR是基地址寄存器,如果这些还不熟,建议先把上一部分补完再回来。

先说结论性的东西:Linux的PCI核心层本质上是一个总线-设备-驱动模型的标准实现,和platform总线那套是同一个骨架。pci_bus_type这个bus_type结构体把match、probe、remove这些回调串起来,设备注册时触发一次匹配,驱动注册时再触发一次匹配,谁后到谁触发扫描。理解了这个"两次触发"的对称性,很多时序上的疑惑就自然解开了。

1.1 匹配的判据不止vendor和device

大多数人第一反应是匹配靠vendor和device两个ID,实际上pci_match_device走的判据要细得多。内核里维护的是pci_device_id数组,每个表项可以指定:

  • vendor、device:厂商ID和设备ID
  • subvendor、subdevice:子系统厂商ID和子系统设备ID
  • class、class_mask:设备类别码和掩码
  • driver_data:驱动私有数据,匹配成功后可以通过pci_get_drvdata之类的方式拿到

匹配逻辑是逐项比对的,PCI_ANY_ID表示通配。这里有个容易踩的点:class匹配和vendor/device匹配是"或"的关系还是"与"的关系?实际代码里,如果表项指定了vendor/device,就先比这两个;如果这两个是通配,才去看class。所以你不能指望写一个"vendor匹配A且class匹配B"的复合条件,得靠class_mask自己控制哪些位参与比较。

我见过一个真实的坑:某同事想让驱动同时管两种不同class的设备,于是在一个表项里把vendor写成PCI_ANY_ID、class写成其中一个值,结果另一种class的设备死活匹配不上。原因就是他以为class字段能填多个值,实际上一个表项只能填一个class,要覆盖多种得写多个表项。这种细节文档里往往一笔带过,但调试的时候能耗掉你半天。

1.2 匹配成功后probe是怎么被调用的

匹配成功只是第一步,真正干活的是probe。调用链大致是:设备注册或驱动注册触发device_attach或driver_attach,最终走到bus_probe_device,再调用pci_device_probe,里面通过pci_match_device找到对应的pci_device_id,然后调用驱动自己的probe回调。

这里有个关键点:probe的返回值决定设备是否被"绑定"。返回0表示成功,设备状态变成added;返回负数表示失败,内核会尝试下一个匹配的驱动。这个机制意味着你可以写多个驱动去匹配同一块设备,让它们按注册顺序竞争,先成功的赢。听起来很美好,但实际项目里我强烈不建议这么干,因为竞争顺序依赖注册时机,很容易出现"这次开机A驱动赢了、下次B驱动赢了"的诡异现象。

probe函数里通常要做这几件事:使能设备(pci_enable_device)、申请BAR资源(pci_request_regions)、映射BAR到内核虚拟地址(pci_iomap)、设置DMA掩码(dma_set_mask)、注册字符设备或其它子系统接口。顺序很重要,尤其是pci_enable_device必须在访问BAR之前调用,否则读出来的可能是全F。

提示:pci_enable_device和pci_enable_device_mem的区别在于前者会同时使能IO和内存空间,后者只使能内存。对于纯MMIO的PCIe设备,用_mem版本更精确,也能避免一些平台上IO空间不可用导致的失败。

2. 配置空间访问:内核到底怎么读到那64个字节

配置空间是PCI的灵魂,所有枚举、匹配、资源分配都建立在能正确读写配置空间的基础上。但"怎么读"这件事,在不同平台上差别巨大,这也是移植驱动时最容易出问题的地方。

2.1 两种访问机制:CAM和ECAM

传统PCI用的是CAM(Configuration Access Mechanism),通过两个IO端口0xCF8和0xCFC来间接访问。往0xCF8写一个包含bus、device、function、register偏移的地址,然后从0xCFC读写数据。这种方式一次只能访问4字节,而且依赖IO端口,在ARM等没有IO空间的架构上根本用不了。

PCIe引入了ECAM(Enhanced Configuration Access Mechanism),把配置空间直接映射到一段内存区域(通常是MMCONFIG),每个function占4KB,直接按地址读写就行。ECAM的地址计算公式是:

address = mmconfig_base + (bus << 20) + (device << 15) + (function << 12) + offset

这个公式值得记一下,因为调试时经常需要手动算地址去dump配置空间。bus占8位、device占5位、function占3位,所以每个bus占1MB(2^20),每个device占32KB(2^15),每个function占4KB(2^12)。

内核里对应的是pci_mmcfg_read和pci_mmcfg_write,而传统方式对应pci_conf1_read。具体用哪个由pci_root_bridge的raw_ops或pci_ops决定,ACPI平台一般通过MCFG表告诉内核ECAM的基地址。

2.2 为什么你的配置空间读出来全是0xFF

这是新手最常遇到的问题。读出来全F,通常意味着访问路径根本没打通。可能的原因按概率排序:

现象可能原因排查方法
全0xFF设备未上电或链路未训练查lspci是否能看到设备
全0xFFECAM基地址配错检查ACPI MCFG表或设备树
全0xFF访问了不存在的bus/device确认拓扑结构
部分字段异常设备处于D3状态先pci_enable_device
读到的值不稳定并发访问未加锁用pci_read_config_*而非直接读内存

我印象最深的一次是某块FPGA加速卡,lspci能看到设备,但驱动probe里读vendor id读出来是0xFFFF。折腾半天发现是链路训练还没完成就去读了,加了个延时就好了。PCIe的链路训练是硬件行为,软件层面能做的就是等,或者查Link Status寄存器确认链路是否up。

2.3 配置空间读写的内核接口

内核提供了一整套封装好的接口,写驱动时应该用这些而不是自己算地址:

int pci_read_config_byte(struct pci_dev *dev, int where, u8 *val); int pci_read_config_word(struct pci_dev *dev, int where, u16 *val); int pci_read_config_dword(struct pci_dev *dev, int where, u32 *val); int pci_write_config_byte(struct pci_dev *dev, int where, u8 val); /* word和dword版本类似 */

这些接口内部会处理锁、会检查设备是否存在、会走正确的访问路径。直接操作ECAM内存虽然快,但绕过了这些保护,除非你在做非常底层的调试,否则不建议。

有个细节:pci_read_config_*系列在设备已经被移除(比如热插拔)的情况下会返回错误码,而直接读内存可能读到垃圾值。所以错误处理一定要做,不能假设读一定成功。

3. BAR资源分配:内核是怎么给设备"分房子"的

BAR(Base Address Register)是PCI设备向系统申请地址空间的窗口。设备通过BAR告诉系统"我需要多大一块地址空间、是内存还是IO、是32位还是64位",系统则负责分配实际地址并写回BAR。这个过程叫资源分配,是PCI枚举里最复杂也最容易出问题的环节。

3.1 BAR的探测:怎么知道设备要多大空间

BAR的低位有一些只读位,用来标识类型和是否可预取,高位是可写的地址位。探测大小的经典做法是:先读原值保存,往BAR写全1,再读回来,根据读回的值算出空间大小,最后恢复原值。

举个例子,一个32位内存BAR,往里面写0xFFFFFFFF后读回来是0xFFFFF000,说明低12位是只读的(标识位),可写位有20位,那么空间大小就是2^20 = 1MB。这个"写全1读回"的技巧是PCI规范里定义的,内核的pci_read_bases就是干这个的。

这里有个坑:64位BAR要成对处理。一个64位BAR占用两个连续的BAR寄存器,低32位在BAR n,高32位在BAR n+1。探测的时候要一起读、一起写,否则算出来的大小是错的。内核里通过PCI_BASE_ADDRESS_MEM_TYPE_64这个标志位来判断是不是64位BAR。

3.2 资源分配失败的典型场景

"insufficient PCI resources detected"这个报错很多人见过,本质是可用的地址窗口不够分。常见原因:

  • 主板BIOS预留的MMIO窗口太小,挂的设备太多
  • 多个大BAR设备(比如显存、大容量FPGA)争抢有限窗口
  • 桥设备的窗口没有正确配置,导致下游设备的请求无法路由

排查思路是先看/proc/iomem,确认哪些地址段被占用、哪些是空闲的。然后看lspci -vv里每个设备的BAR大小和分配到的地址。如果发现某个桥下面的设备BAR没分配,往往是桥的memory window没打开。

在嵌入式平台上,这个问题更常见,因为很多SoC的PCIe控制器默认窗口就很小。解决办法通常是在设备树里调整ranges属性,把窗口开大。但要注意,窗口大小受限于SoC的地址映射能力,不是想开多大就多大。

3.3 驱动里怎么正确使用BAR

probe函数里拿到BAR资源后,标准流程是:

/* 1. 使能设备 */ ret = pci_enable_device(pdev); if (ret) return ret; /* 2. 申请资源,防止被其他驱动抢 */ ret = pci_request_regions(pdev, "my_driver"); if (ret) { pci_disable_device(pdev); return ret; } /* 3. 映射BAR0到内核虚拟地址 */ bar0 = pci_iomap(pdev, 0, 0); if (!bar0) { pci_release_regions(pdev); pci_disable_device(pdev); return -ENOMEM; } /* 4. 设置DMA掩码 */ ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));

pci_iomap的第三个参数是映射长度,传0表示映射整个BAR。对于只需要访问部分寄存器的场景,可以传具体长度节省虚拟地址空间。但要注意,映射长度必须是页对齐的,内核会自动向上取整。

注意:pci_iomap返回的地址要用ioread32/iowrite32系列访问,不能直接解引用。在ARM64上直接解引用可能因为内存属性不对而触发异常。这一点和x86上"直接读写也行"的习惯不同,跨平台代码一定要用标准接口。

4. 中断处理:从INTx到MSI/MSI-X的演进

PCI设备的中断机制经历了从INTx到MSI再到MSI-X的演进。理解这个演进,对写出高效、可靠的驱动很关键。

4.1 INTx的共享之痛

传统INTx中断是电平触发、可共享的。多个设备可以共用一根中断线,中断来了之后,每个注册了该线的驱动都要去读自己的状态寄存器,判断是不是自己的中断。这个"轮询判断"的过程效率低,而且容易出bug——如果某个驱动忘了清中断状态,就会一直触发,把系统拖死。

INTx的另一个问题是中断路由复杂。中断线要经过桥、经过中断控制器,中间任何一环配置不对,中断就到不了CPU。调试INTx问题往往要一路查下去,非常痛苦。

4.2 MSI:消息信号中断

MSI(Message Signaled Interrupt)把中断变成了内存写操作。设备往一个特定的地址写一个特定的数据,就相当于发了一次中断。这个地址和数据由系统在设备初始化时配置到设备的MSI Capability结构里。

MSI的好处很明显:不需要共享中断线、不需要读状态寄存器判断、延迟更低。MSI支持最多32个向量,但要求地址对齐——如果设备请求32个向量,系统必须分配32个连续的向量号,分配不到就降级到16个、8个……直到1个。

这个"必须连续"的限制在实际中经常导致问题:系统碎片化之后,连续向量不好找,设备可能只能拿到很少的向量。MSI-X就是为了解决这个问题而生的。

4.3 MSI-X:每个向量独立

MSI-X支持最多2048个向量,而且每个向量独立配置地址和数据,不要求连续。每个向量在MSI-X Table里占16字节,Table本身是一块BAR空间,设备通过它来配置每个向量的目标地址和数据。

驱动里申请MSI-X中断的标准流程:

/* 申请MSI-X向量 */ nvec = pci_alloc_irq_vectors(pdev, 1, max_vec, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (nvec < 0) return nvec; /* 为每个向量注册处理函数 */ for (i = 0; i < nvec; i++) { irq = pci_irq_vector(pdev, i); ret = request_irq(irq, my_handler, 0, "my_driver", my_dev); }

pci_alloc_irq_vectors的flags参数可以指定优先级,内核会按MSI-X、MSI、INTx的顺序尝试。这种"尽力而为"的策略让驱动能适配各种硬件能力,是很实用的设计。

有个经验:MSI-X的向量数不是越多越好。每个向量都要注册中断处理函数、都要占用系统资源。对于大多数设备,按队列数或CPU核数申请就够了。我见过有驱动申请了256个向量,结果系统里中断号不够用,反而影响了其他设备。

5. 一个完整PCIe字符驱动的骨架与调试心得

前面讲了匹配、配置空间、BAR、中断,现在把这些串起来,给一个可以直接参考的驱动骨架。这个骨架基于字符设备,适合做数据采集卡、FPGA加速卡这类需要用户态交互的场景。

5.1 驱动结构设计

核心数据结构:

struct my_pci_dev { struct pci_dev *pdev; void __iomem *bar0; struct cdev cdev; dev_t devno; struct class *cls; struct device *dev; int irq; spinlock_t lock; wait_queue_head_t wq; /* 设备状态、缓冲区等 */ };

probe里按顺序做:使能设备、申请region、映射BAR、申请中断、注册字符设备。remove里逆序释放。这个"正反配对"的原则要严格遵守,否则卸载模块时会泄漏资源。

5.2 用户态接口设计

字符设备通常提供open、release、read、write、ioctl、mmap这几个操作。对于寄存器访问,mmap把BAR映射到用户态是最方便的方式,但要注意权限和边界检查——不能让用户态映射到不该访问的区域。

ioctl适合做控制类操作,比如启动采集、停止采集、配置参数。命令码要用_IOR/_IOW宏定义,保证类型安全。

5.3 调试中积累的几个实用技巧

技巧一:用lspci -vvv看全貌。这个命令能显示设备的配置空间、能力结构、BAR分配、链路状态。遇到问题先看它,比盲目读代码快得多。

技巧二:/sys/bus/pci/devices/下面有每个设备的详细信息。比如resource文件显示BAR分配情况,config文件是配置空间的二进制dump,driver符号链接指向绑定的驱动。这些信息在脚本里很好用。

技巧三:probe失败时先看dmesg。内核在probe失败时会打印错误码,-ENODEV通常是匹配问题,-ENOMEM是资源问题,-EIO是访问问题。根据错误码缩小范围。

技巧四:中断不触发时先确认/proc/interrupts。看中断号有没有注册、计数有没有增长。如果计数一直是0,说明设备根本没发中断,问题在设备侧或链路侧;如果计数在涨但处理函数没进,说明注册有问题。

技巧五:DMA问题优先查掩码和一致性。dma_set_mask_and_coherent设置不对,会导致DMA地址超出设备能力范围,表现为数据错乱或DMA失败。在64位系统上跑32位设备时尤其要注意。

5.4 常见问题速查表

问题可能原因解决方向
probe不调用匹配表不对检查vendor/device/class
probe返回-ENODEV设备未使能先pci_enable_device
BAR映射失败资源未申请先pci_request_regions
读寄存器全F设备未上电检查链路和电源
中断不触发MSI未使能检查pci_alloc_irq_vectors
DMA数据错乱掩码不匹配检查dma_set_mask
卸载模块崩溃资源未释放检查remove路径

这套框架我在几个项目里反复用过,从数据采集卡到自定义FPGA加速卡,基本结构都是一样的。真正花时间的从来不是写代码,而是搞清楚硬件的行为——链路什么时候训练好、中断什么时候能发、DMA地址有什么限制。这些信息一半来自手册,一半来自实测。手册上没写的,就得靠lspci、dmesg、/proc/interrupts这些工具一点点试出来。

我个人在实际操作中的体会是,PCI驱动调试最忌讳"想当然"。你以为设备上电就能读配置空间,实际上链路训练要时间;你以为申请了中断就能收到,实际上MSI使能位没打开;你以为BAR映射了就能访问,实际上内存属性不对会触发异常。每一个"以为"背后都可能藏着一个坑,而填坑的唯一办法就是动手验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询