☰
Linux PCI驱动框架精讲:从总线设备模型到最小驱动实现
2026/9/27 13:57:36 网站建设 项目流程

搞Linux驱动的人,早晚都会撞上PCI。无论是网卡、显卡、NVMe固态盘,还是FPGA加速卡,PCI/PCIe几乎就是板卡设备与CPU、内存之间唯一的那条通道。我第一次做PCI驱动时,面对lspci输出里那一堆根端口、BAR、链路状态,死活找不到驱动该从哪里下手。后来把Linux PCI驱动框架的骨架理清楚了,才发现它其实是有固定章法的:三条主线——总线、设备、驱动,再加上一个核心——资源管理。这篇文章是系列第一篇,先把整个框架的宏观结构和关键数据通路拆开讲明白。适合驱动入门不久、正准备碰PCI设备的读者,也适合那些已经写过字符设备驱动、想往更复杂的总线设备驱动方向进阶的人。

PCI驱动框架不像字符设备驱动那样只要注册file_operations就完事。字符设备驱动是你主动“建接口”给用户态用,而PCI驱动是你“被动等设备来找你”。内核的PCI子系统负责枚举设备、分配地址空间、管理中断,然后把一个已经初始化好的struct pci_dev交到你手上,你的任务只是在这个结构体基础上做probe、注册、读写、释放。逻辑上更顺,但前提是你得先理解这套框架是怎么把设备和驱动“撮合”在一起的。

1. 从整体框架看Linux PCI驱动:总线、设备、驱动三件事

1.1 为什么Linux需要一套统一的PCI驱动框架

你可以把PCI总线想象成房产中介市场。设备是房源,驱动是想找房住的租客,而PCI总线模型就是那个中介。没有中介的时候,房东得挨个去问谁想租,租客也得挨个去敲门,系统里一旦接了几十个设备,这事儿就彻底乱套了。Linux的总线驱动模型(bus_type)解决的就是这个撮合问题:设备在总线上挂牌,驱动在总线上登记,总线负责按规则匹配,匹配成功就把设备交给驱动。

这套设计带来的最大收益是可复用。比如一颗网卡芯片,既可以挂在PCI总线上,也可以直接挂到片上系统(SoC)的内部总线上。驱动作者只需要把与总线相关的部分剥离开,核心的逻辑(比如寄存器操作、数据包收发)可以通过struct net_device之类的抽象层复用。PCI总线和platform总线虽然挂在不同的bus_type下,但驱动模型的上层机制完全一致。理解了这一点,你后面再看SPI、I2C、USB驱动,会发现全都是同一个套路。

1.2 先从硬件模型说起:总线、设备、功能

PCI设备在硬件上不是简单的一个“芯片”,而是按“总线-设备-功能”三层组织的。你在Linux下执行lspci,看到的01:00.0这样的编号,意思就是总线01上的设备00的功能0。功能号从0到7,所以一颗物理芯片最多可以拆成8个逻辑设备。常见的多功能设备比如带集成声卡的显卡,一个功能是VGA控制器,另一个功能是HDMI音频控制器,它们在驱动层就是两个独立的pci_dev。

每个PCI功能都有独立的配置空间。传统PCI配置空间是256字节,PCIe扩展到了4KB。驱动和设备打交道,第一步几乎都是读配置空间。前64字节是标准头部,里面最重要的字段包括Vendor ID(厂商)、Device ID(设备型号)、Command/Status寄存器、Class Code(设备类别),以及后面我们要重点讲的BAR(Base Address Register)。很多新手一上来就准备用ioremap去访问设备,却不知道设备在系统里是否被正确枚举、BAR地址是多少,这是后续一系列莫名其妙的register dump的根本原因。

1.3 三层角色:核心层、总线侧、驱动侧

Linux PCI框架在代码上可以分成三层,我建议你心里时刻装着这张图:

  • 核心层(drivers/pci/):提供枚举、资源分配、电源管理、热插拔等通用机制。你经常用的pci_register_driver、pci_enable_device、pci_request_regions都在这一层。
  • 总线侧实现:负责实际读写配置空间。x86上通过I/O端口访问PCI Configuration Mechanism,ARM上则通过ECAM(Enhanced Configuration Access Mechanism)映射到内存地址空间。这些差异被抽象成pci_ops,核心层不用关心底下是x86还是ARM。
  • 设备驱动层:就是你要写的部分。比如drivers/net/下的网卡驱动、drivers/gpu/下的显卡驱动,里面基本都是struct pci_driver的probe/remove回调。

如果之前写过V4L2框架,你会发现它和PCI框架是叠加关系而不是竞争关系。V4L2解决的是“视频设备怎么暴露给用户态”的问题,PCI解决的是“这个设备在总线上怎么被发现、怎么访问”的问题。一个摄像头驱动可以是platform设备接入,再用V4L2上传视频流;也可以是PCIe采集卡,同样走V4L2。框架之间是分层协作,不是二选一。

2. 驱动框架的核心数据结构:PCI设备的底层世界

2.1 pci_dev:内核眼中一个PCI设备长什么样

struct pci_dev是PCI子系统为每个设备创建的实例,它在设备被枚举时诞生,在设备被移除时销毁。这个结构体字段很多,但真正需要重点关注的其实就那么几个:

字段作用驱动使用场景
vendor / device厂商ID和设备ID设备匹配、日志输出
class设备类别(网卡、显卡、存储等)分类识别,特殊匹配
devfn设备号和功能号编码构造BDF地址
irq分配给设备的中断号中断处理注册
resource[]六个BAR对应的地址资源ioremap、DMA地址获取
bus所属总线遍历拓扑、调试输出

其中resource[]是头上的一块重点。每个BAR描述了设备暴露给系统的一段地址窗口,可能是内存空间,也可能是I/O空间。内核在枚举时会为每个BAR分配一个物理地址区间,并把这段区间的信息塞进pci_dev->resource[bar_index]。驱动要做的事情就是通过pci_resource_start(pdev, 0)和pci_resource_len(pdev, 0)拿到BAR0的物理地址和长度,然后调用ioremap把它映射到内核虚拟地址空间,之后就能像读写内存一样读写设备寄存器了。

我在实际调试中最常遇到的一个误区:有些人直接拿BAR的物理地址去读,不进ioremap。在x86上因为IO地址空间被直接映射,靠运气也许能读到;到了ARM上,物理地址不能直接访问,不死机都算运气好。所以请记住,ioremap这一步不是可选项,是必须项。

2.2 pci_driver与设备匹配表:驱动是怎么被认领的

设备有了,驱动程序在哪儿?struct pci_driver就是驱动侧的注册单元。它的核心成员是id_table、probe和remove。id_table的类型是struct pci_device_id数组,里面列出了这个驱动支持的所有设备。最常见的写法是:

static const struct pci_device_id demo_pci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { PCI_DEVICE(0x1234, 0x5679) }, { 0, } }; MODULE_DEVICE_TABLE(pci, demo_pci_ids);

PCI_DEVICE(vendor, device)宏展开后会生成一个匹配规则:只要配置空间里的Vendor ID和Device ID对上,这条规则就命中。除此之外,匹配还可以精确到subvendor和subdevice,这两个字段在配置空间里代表“这块板卡出厂时写死的子系统ID”。同样的主芯片,A厂家和B厂家做出来的板卡可能功能不同,驱动就可以用子设备ID区分,做到一个驱动只认自己家的卡,不误接管别家的产品。

MODULE_DEVICE_TABLE这行很多人会漏写。它在编译时会生成模块的别名信息,比如pci:v00001234d00005678sv*sd*bc*sc*i*。udev和modprobe依赖这个别名在设备接入时自动加载对应模块。没有这行,模块只能手动insmod,热插拔场景下基本等于残废。

2.3 pci_ops和pci_bus:访问配置空间的底层通道

上面说的枚举、匹配、资源分配,不可能凭空完成,总要有人去读配置空间。pci_ops就是这层抽象,只有两个回调:read和write。x86平台的实现通过I/O端口0xCF8/0xCFC访问配置空间,ARM平台则通过pci_ecam_ops把配置空间映射到内存地址。核心层逻辑不关心平台差异,反正调用pci_bus_read_config_word时,走到最后一定是通过pci_ops去访问。

struct pci_bus代表一条实际存在的PCI总线。每条总线上挂着若干pci_dev,还可能挂着下级总线(通过PCI桥),于是形成一个树形结构。内核里从总线0开始,不断扫描桥后面的空间,递归地发现所有设备。你熟悉的/sys/bus/pci/devices/目录,本质就是把这棵设备树暴露出来。每次看到0000:00:1c.0这样的sysfs文件名,前面的0000就是PCI domain,也就是host bridge的编号。多路服务器上会有多个domain,每个domain下面各自有独立的PCI拓扑。

3. 初始化与设备枚举流程:系统是怎么“看见”PCI设备的

3.1 枚举的起点:从UEFI/ACPI/设备树到pci_host_probe

PCI枚举的起点因平台而异。x86平台上,BIOS/UEFI在启动阶段已经把PCI设备枚举好了,并通过ACPI表把资源分配结果告诉内核。内核做的更多是“接管”而不是“从零开始”。ARM平台上则通常需要设备树或ACPI描述PCIe控制器,然后通过pci_host_probe显式发起枚举流程。

不管入口怎么变,枚举的核心动作是一样的:从总线0开始,逐条总线扫描,发现设备就读它的Vendor ID,如果读到0xFFFF说明这个槽位上没有设备,继续下一个;如果读到有效值,就为它创建pci_dev,然后继续读它的Header Type。如果设备是PCI桥(Header Type为0x01),就为它分配一条新的总线编号,然后递归扫描桥后面的空间。这个过程本质上是一个深度优先遍历。

对驱动开发者来说,枚举是“系统替你做完的事”,你没太多可干预的。但理解它有一个实际价值:当你把一块新板卡插上开机后发现lspci里压根看不到它,问题就出在枚举之前的链路训练或者配置空间访问上,这时候再怎么写驱动都是白费功夫。我调试新硬件的第一条铁律就是:先确认lspci能看到设备,再谈写驱动。

3.2 资源分配:BAR、桥窗口与地址空间

设备枚举完后,内核要为每个BAR分配物理地址区间。这个过程叫resource allocation。BAR硬件上有个很巧妙的设计:写入0xFFFFFFFF再读回,读到的值可以算出这个BAR需要多大的地址空间,低地址位里则是属性信息(内存还是IO、prefetchable与否)。设备出厂时这块“窗口大小”就被定死了,内核只能在这个大小限制下找地方放。

分配资源时内核还要考虑桥设备。PCI桥上有一组窗口寄存器,决定了下游设备发出的地址能被转发到上游还是被忽略。如果桥的窗口没有正确设置,下游设备的BAR即使有地址,CPU也访问不到。所以整个资源分配是一个从根总线往下逐层传递的过程:根总线把自己的地址空间分给桥设备一个窗口,桥设备再把这个窗口切成更小的片分给下游设备。

新硬件踩坑的重灾区就在这里。插上多张PCIe板卡后,经常出现“error: insufficient pci resources detected!!!”之类的问题。这通常是BIOS给PCI域分配的地址空间不足,或者PCIe桥的窗口没有覆盖到设备BAR。后面第四章我会专门讲排查思路,这里先记住一个关键词:pci=realloc,这个内核参数允许内核在启动时重新分配PCI资源,很多资源不足问题改这个参数就能缓解。

3.3 从match到probe:驱动和设备绑定的瞬间

设备和驱动什么时候碰头?早期启动时内核先把设备全部枚举出来,挂到PCI总线上。驱动要么编译进内核,要么以模块形式加载,加载时调用pci_register_driver,把pci_driver注册到同一根总线上。总线驱动模型立刻执行一次遍历,把总线上所有设备都和这个新注册驱动的id_table做比对,匹配上了就进入pci_device_probe。

有人会问,那如果设备是后来热插拔插进去的怎么办?PCIe热插拔事件触发内核创建设备并加到总线后,同样会触发一次与所有已注册驱动的匹配。所以“设备和驱动谁先来”不重要,重要的是它们最后都挂在了同一根pci_bus_type上,总线的match函数负责在任何一个新成员加入时重新撮合。

probe被调用的时候,驱动拿到的pci_dev已经完成了基本资源配置,但注意:设备还没使能、寄存器区域还没申请。所以几乎每个PCI驱动的probe开头都有这样一段标配动作:

err = pci_enable_device(pdev); if (err) return err; err = pci_request_regions(pdev, "demo_pci"); if (err) { pci_disable_device(pdev); return err; }

pci_enable_device会打开设备上的IO和Memory访问开关,同时处理电源管理状态,让设备开始响应总线访问。很多板卡设计不严谨,寄存器地址虽然分配了,但Command寄存器里的Memory Space bit没有置位,这时候直接访问BAR读回来全是0xFFFFFFFF。pci_request_regions则先把BAR对应的资源在系统里标记为“已被占用”,防止别的驱动误抢。每次看到有人probe里没做这两步,却抱怨读寄存器读不到东西,我都想让他们回来把这段背下来。

4. 手写最小PCI驱动骨架:从0到能读到寄存器

4.1 最小驱动的完整代码

代码这东西光看不如自己跑一遍。下面这个驱动是给一块虚拟PCI设备写的,设备ID是0x1234/0x5678。probe时使能设备、申请BAR0、ioremap,然后注册成一个miscdevice,用户态程序只要open和read,就能读到设备寄存器里的数据。这个骨架足够小,也足够反映一个真实PCI驱动的完整生命周期。

#include <linux/module.h> #include <linux/pci.h> #include <linux/miscdevice.h> #include <linux/fs.h> #include <linux/uaccess.h> #define DEMO_BAR_NR 0 #define DEMO_REGS_LEN 0x1000 static void __iomem *demo_reg_base; static ssize_t demo_pci_read(struct file *fp, char __user *buf, size_t len, loff_t *off) { u32 val; loff_t pos = *off; if (pos + len > DEMO_REGS_LEN) len = DEMO_REGS_LEN - pos; if (len < sizeof(u32)) return -EINVAL; val = readl(demo_reg_base + pos); if (copy_to_user(buf, &val, sizeof(val))) return -EFAULT; *off = pos + sizeof(val); return sizeof(val); } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .read = demo_pci_read, }; static struct miscdevice demo_miscdev = { .minor = MISC_DYNAMIC_MINOR, .name = "demo_pci", .fops = &demo_fops, }; static const struct pci_device_id demo_pci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { 0, } }; MODULE_DEVICE_TABLE(pci, demo_pci_ids); static int demo_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { unsigned long start; unsigned long len; int err; err = pci_enable_device(pdev); if (err) return err; err = pci_request_regions(pdev, "demo_pci"); if (err) goto disable_device; start = pci_resource_start(pdev, DEMO_BAR_NR); len = pci_resource_len(pdev, DEMO_BAR_NR); if (!start || !len) { err = -ENODEV; goto release_regions; } demo_reg_base = ioremap(start, len); if (!demo_reg_base) { err = -ENOMEM; goto release_regions; } err = misc_register(&demo_miscdev); if (err) goto unmap; dev_info(&pdev->dev, "demo pci driver probed: BAR0=%pa\n", &start); return 0; unmap: iounmap(demo_reg_base); release_regions: pci_release_regions(pdev); disable_device: pci_disable_device(pdev); return err; } static void demo_pci_remove(struct pci_dev *pdev) { misc_deregister(&demo_miscdev); iounmap(demo_reg_base); pci_release_regions(pdev); pci_disable_device(pdev); } static struct pci_driver demo_pci_driver = { .name = "demo_pci", .id_table = demo_pci_ids, .probe = demo_pci_probe, .remove = demo_pci_remove, }; module_pci_driver(demo_pci_driver); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("Minimal PCI driver example");

4.2 关键接口的调用顺序与原因

上面这套动作的顺序不是随手写的,每一步都有它的道理。先把probe里失败路径的处理单独看一下,这几行很容易被人抄错:

  • pci_enable_device失败后直接返回,因为后续动作都建立在设备可用这个前提上。
  • pci_request_regions失败后要pci_disable_device,把前面打开的设备开关关回去。
  • ioremap失败后要释放已经申请的regions,然后再disable device。
  • misc_register失败后要iounmap,顺序反过来。

一句话总结:probe里每申请一个资源,从失败点往前的所有资源都要逐一释放。这跟C语言里嵌套锁必须逆序释放是一个道理。很多驱动bug就出在边界条件上——资源申请到一半失败,但某个步骤忘了回滚。写驱动的时间长了,你会发现80%的空指针崩栈都是这类问题。

另外我特意用了readl而不是直接对指针取值。MMIO访问必须走专门的访问函数,readl会处理好内存屏障和字节序问题,尤其是ARM平台上,普通C语言的内存访问可能被编译器优化或者被CPU重排,导致读到的寄存器值有问题。这是个老生常谈但总有人踩的坑。

4.3 Makefile、加载与验证

编译这个模块没什么特殊之处,标准的内核模块Makefile就能搞定:

obj-m += demo_pci.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

编译完以后,如果机器上有真实的匹配设备,直接insmod demo_pci.ko,观察dmesg应该能看见“demo pci driver probed”的日志,/dev目录下会出现demo_pci节点。如果没有真实设备,那就得靠QEMU这类虚拟化环境模拟一个PCI设备来验证。我在开发早期经常用QEMU里预留的PCI设备配合自定义vendor/device id做验证,这不涉及任何真实硬件风险,很适合用来测试驱动框架逻辑。

用户态验证代码也很简单,open设备节点后read四个字节,看看返回值跟lspci里BAR地址上的寄存器值对不对得上。这里有个细节,misc设备节点在/dev/demo_pci,访问它不需要复杂的ioctl,直接file_operations就够,对新手来说比较友好。

5. 调试手段与常见问题排查

5.1 lspci输出里藏着的关键信息

lspci是所有PCI调试的开胃菜,但很多人只看了个名字。我建议养成一个习惯:新硬件到手后,先执行lspci -vvv -s 01:00.0,把完整输出存成文本。重点看几个字段:

  • Region 0: Memory at ... [size=4K]:BAR0的地址和大小,跟你驱动的ioremap地址应该一致。
  • Kernel driver in use: demo_pci:驱动是否绑定。如果显示<none>,说明匹配没成功或者驱动没加载。
  • LnkCap和LnkSta:PCIe链路的能力和当前状态。如果LnkSta里的速度只有2.5GT/s,而LnkCap明明支持8GT/s,说明链路训练没有跑满,多半是硬件问题。
  • DeviceSerialNumber之类的能力字段,帮助确认设备固件信息。

setpci也是排查的利器。你可以直接用setpci -s 01:00.0 COMMAND.w=0x06这类命令,手动修改配置空间里的Command寄存器,把Memory和IO访问开关打开。这招在设备已经被BIOS枚举但寄存器访问不到时特别好用,能快速判断是配置空间没有使能,还是驱动的ioremap地址有问题。

5.2 踩过的一个典型坑:PCI资源不足

开头提到的那句“error: insufficient pci resources detected!!!”我亲眼见过好几次,基本都是在多PCIe设备的机器上出现的。它的实际含义是:PCI桥设备想往下游分配地址空间,但上游根本没有足够的空闲窗口可用。直观理解就像一个交换机,上游口只有100G带宽,下面挂了200G的设备,必然有设备分不到带宽。

排查路径我整理成三步。第一步,dmesg | grep -i pci看报错前的上下文,定位是哪个桥哪个设备的资源分配失败。第二步,lspci -vvv -s (桥设备的BDF)看桥的BUS窗口配置。如果桥的Bus: resource里Prefetchable窗口区间明显不够覆盖下游设备的需求,基本就实锤了。第三步,进BIOS调整PCI资源相关选项。很多服务器主板和高端桌面主板都提供“Above 4G Decoding”或“MMIO High”开关,打开后设备BAR可以放到4GB以上的64位地址空间,瞬间解决问题。

如果BIOS里没有这些选项,可以试试传给内核pci=realloc,让内核在启动阶段无视BIOS已有的分配结果,重新做一遍资源分配。这个方法在不少主板上能救急,但要注意它可能改变设备原有的BAR地址,之前写死的某个物理地址可能就失效了,生产环境要评估好影响再上。

5.3 几个新手最常见问题的排查速查表

现象可能原因排查方向
probe函数没被调用id_table和设备不匹配lspci确认vendor/device,核对模块参数
读寄存器全是0xFFFFFFFFBAR没使能或ioremap失败查Command寄存器,看ioremap返回值
读寄存器直接死机越界访问,ioremap地址错误核对pci_resource_len,配好端侧访问日志
DMA传输出错缺pci_set_master,缓冲地址没换算物理地址probe里加pci_set_master,检查IOMMU/SMMU配置
中断不触发设备没使能或MSI配置错误先用INTx验证,再看MSI是否被系统支持
驱动编译报undefined pci_xxx缺少头文件或模块依赖包含linux/pci.h,检查Kconfig的depends

最后说一条我自己的习惯:定稿驱动前,在probe里用dev_info完整打印设备的BDF、vendor、device、BAR地址和长度,这样出了问题看dmesg就能快速定位,不用每次都用lspci猜。这个习惯救过我很多次,而且成本几乎为零。PCI驱动框架的内容还远不止这些,比如DMA映射、MSI/MSI-X中断、总线复位、SR-IOV,每个都可以单独开一篇。下一篇文章我打算从BAR和寄存器访问的底层细节入手,再往后讲DMA时,你会理解为什么pci_set_master几乎和pci_enable_device同样重要。

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

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

立即咨询