Linux PCI驱动框架精讲:从设备模型到资源分配实战
搞 Linux 驱动的人迟早会撞上 PCI。不管是在 x86 服务器上插一块万兆网卡还是给 ARM 开发板接一块 FPGA 加速卡PCI/PCIe 几乎是绕不开的系统总线。我第一次写 PCI 驱动时手里只有芯片手册里几页寄存器描述翻开内核源码看到 pci_driver、pci_device_id、probe 这些名字第一反应是“这不就是平台设备驱动换了个马甲吗”。等到真正跑通枚举和资源分配流程才发现 PCI 这套框架硬核的地方不是代码格式而是它对硬件资源的管理方式。这篇是这个系列的第一篇目标是把 Linux PCI 驱动框架的主干讲清楚设备模型怎么组织、核心数据结构里的每个字段为什么要存在、一套最小驱动要怎么写通以及那些让新人抓狂的 PCI 资源报错到底从哪里查起。适合刚接触 PCI 驱动的内核开发新人也适合做嵌入式 SoC 集成的工程师对照着理清架构。1. 整体设计为什么 Linux PCI 驱动长这样1.1 先从总线视角理解 PCI 存在的意义PCI 总线要解决的核心问题只有三个发现设备、描述设备、访问设备。可以把整条 PCI 总线想成酒店里的走廊每个 PCI 设备是房间里住的客人电源和复位是入住时发的房卡配置空间就是房卡上印的房号。系统一上电PCI 核心并不知道走廊里有哪些设备它需要通过枚举机制去扫描每条总线上的每个槽位读取 vendor ID、device ID、class code 这些身份信息再给每个设备分配独立的地址窗口。只有等这一步完成驱动程序才能安全地访问设备。PCIe 在这个基础上做了大改动不再像老 PCI 那样让所有设备共享一条并行总线而是采用树形交换拓扑每条链路都是全双工点对点连接。CPU 一侧的 Root Port 相当于树的根通过 Switch 连接多个 Endpoint。这套模型对驱动作者最大的价值在于你写的 pci_enable_device、pci_iomap 这类 API底层会自动帮你完成对配置空间的访问、对 BAR 空间的映射硬件拓扑被处理成一个又一个逻辑上的“总线/设备/功能”三元组。你不需要关心数据到底走了哪条物理链路只需要关心设备有哪些资源、怎么访问这些资源。这也是为什么 Linux 的 PCI 驱动框架看起来比 platform 驱动要“重”不少。platform 总线上的设备通常由设备树或 ACPI 静态描述驱动写出来就是“我知道有这么一个设备我去操控它”。PCI 总线则天生具备动态发现能力设备是“自己报告”存在的内核需要先把它安顿好再把控制权交给驱动。理解这一层差异后面看 probe 的调用时机就不会犯迷糊。1.2 总线驱动模型匹配是核心机制Linux 设备模型用 device、driver、bus 三件套来组织所有设备PCI 只是这套模型里的一个具体实例。每条总线维护两个链表总线上已经枚举出来的设备链表和挂载到这条总线上的驱动链表。驱动注册进内核时总线会拿着注册驱动去扫描设备链表看看有没有设备愿意跟它走。具体怎么“看对眼”由总线的 match 回调决定。平台总线一般用设备树里的 compatible 字符串或者 ACPI 的 HID/CID 去匹配PCI 总线则完全走 ID 表匹配。这个差异背后其实是两种生态的差别PCI 有 PCI-SIG 统一维护的 vendor ID 和 device ID设备身份在出厂时就固化了ID 本身就构成权威标识。写 PCI 驱动的第一件事永远是查手册确认设备 ID再把它填进 pci_device_id 表。没有这个 ID驱动写得再漂亮内核也不会把它和设备关联起来。匹配成功的直接结果是触发 probe 回调。probe 是驱动真正接手设备的入口所有初始化、资源申请、中断配置都从这里开始。需要注意的是匹配发生在“设备已经被枚举并挂到总线”这个前提之后。如果设备因为固件配置或资源不足没有被枚举成功或者 BAR 分配失败probe 根本不会被调用。排查‘驱动不加载’时第一步永远是先确认设备有没有正常出现在 lspci 输出里而不是一头扎进自己的代码。1.3 与字符设备驱动框架的衔接很多 PCI 设备在用户态看起来就是一个字符设备。拿经常被拿来举例的 v4l2 驱动框架说一个 PCIe 接口的采集卡它的驱动模型通常是两层。底层是 PCI 总线驱动负责枚举、BAR 映射、DMA、中断、寄存器读写上层是 v4l2 子系统和字符设备接口负责把数据组织成统一的视频流格式再暴露给用户态的 ffmpeg 或自定义 SDK。这两层协作关系很清晰PCI 驱动解决“怎么和硬件通信”字符设备框架解决“怎么和用户态通信”。这两套机制通过 file_operations 回调、ioctl 以及内核提供的 mmap 机制汇合。probe 阶段把映射好的 BAR 虚拟地址保存到设备的私有数据里file_operations 里的 read/write 再通过这些地址触达硬件。如果你学过普通字符设备驱动理解 PCI 驱动不用重复学一遍字符设备体系只需要把注意力放在“硬件侧初始化”和“资源管理”上。这也是内核里 v4l2、net、usb 等子系统常见的组织方式总线驱动提供传输通道上部子系统提供能力抽象。2. 核心数据结构与初始化流程2.1 匹配的钥匙pci_driver 与 pci_device_idstruct pci_driver 是每个 PCI 驱动都要注册的入口结构。里面最重要的成员是 name、id_table、probe、remove另外还有可选的 suspend、resume、shutdown、err_handler。id_table 指向一个 pci_device_id 数组数组必须以空表项结束。下面是最简形态static const struct pci_device_id my_pci_ids[] { { PCI_DEVICE(0x10ee, 0x7011) }, { } }; MODULE_DEVICE_TABLE(pci, my_pci_ids); static struct pci_driver my_pci_driver { .name my_accel_drv, .id_table my_pci_ids, .probe my_pci_probe, .remove my_pci_remove, }; module_pci_driver(my_pci_driver);PCI_DEVICE(vendor, device) 这个宏会自动填充 vendor 和 device 两个字段并把类别掩码清零。MODULE_DEVICE_TABLE 的作用特别容易被忽略它会在编译时生成模块别名让 modprobe 可以根据系统里设备的 modalias 自动加载对应模块。如果少了这一行即使驱动写得完全正确插上设备后系统也不会自动加载只能手动 insmod调试时还总以为是设备没被发现。这里有个经验之谈早期调试时 id_table 里宁可少写几个 ID先把 vendor 和 device 对准。因为包括 subvendor、subdevice、class_mask 在内的匹配字段越多匹配条件越严格有时候明明设备在总线上就是因为你把类别掩码填多了导致匹配不上。先用最简单的 PCI_DEVICE 宏跑通再慢慢加条件。ID 表也不是“随便想一个”填进去的必须在 lspci -nn 或设备手册里确认真实的数值。2.2 硬件世界的抽象pci_dev 与配置空间当 PCI 核心完成枚举内核会为每个设备建立一个 struct pci_dev这个结构里保存了从配置空间读出的 vendor、device、revision、class以及一组描述地址窗口的 struct resource也就是我们常说的 BAR。每个 BAR 都是一个地址窗口设备通过它接收 CPU 发起的内存访问或 I/O 访问。BAR 的大小不是驱动说了算是设备硬件固化在寄存器里的。枚举阶段内核会向 BAR 写入全 1再读回数值解码掩码从而计算出窗口大小然后为它寻找合适的地址区域进行分配。访问配置空间需要使用 pci_read_config_word、pci_read_config_dword 这一组 API访问 BAR 空间中映射出来的寄存器则用 readl、writel。有一个常见的坑拿到 BAR 的物理地址后直接强转为指针去读这在普通 x86 上可能碰巧能用但在启用 IOMMU、或者存在地址重映射的 SoC 平台上结果就是读到一堆乱值甚至直接触发异常。正确做法是先 pci_iomap把物理地址通过内核页表映射成虚拟地址再在这个虚拟地址上做读写。框架给你这层映射 API 是有充分理由的别绕过它。还有一点容易忽略PCI 配置空间里默认的访问宽度是 32 位。读写 16 位、8 位寄存器时Linux 的配置访问 API 会严格限制对齐和宽度。在调试早期我习惯先把整个配置空间用 lspci -xxx 或 setpci 抓一遍再对着芯片手册逐项核对很多奇怪现象其实来自设备没有进入正常工作状态而不是驱动代码逻辑错误。2.3 从 Root Port 到 EndpointPCIe 总线拓扑PCIe 拓扑是一棵典型的树。CPU 侧的 Root Port 是树根向下通过链路接到 SwitchSwitch 的上行端口继续往上接下行端口接 Endpoint 或者更下一级的 Switch。每经过一个桥总线号都会重新编号所以一个设备的完整标识是 BDF也就是 bus:device.function 三段。你在 lspci 输出里会看到一排 pcieport 设备它们本身就是 PCI 设备有 vendor ID 和 device ID系统会为它们加载自带的 pcieport 驱动。理解这棵树对排查资源问题特别关键。所谓“PCI 资源不足”本质上是树上某个桥节点往下能分配的窗口枯竭了。每个桥设备都有三个窗口bus number 窗口、MMIO 窗口、I/O 窗口。如果桥的 MMIO 窗口只有 64KB而桥下面接了一个需要 1MB 空间的设备那这个设备无论怎么排地址空间都不够分。从拓扑的角度去读启动日志里的资源分配过程整个 PCI 框架就不是一堆 API 的堆砌而是一套可理解的地址路由系统。2.4 从注册到 probe 的完整时序加载模块时 pci_register_driver 被调用内核先把驱动挂到 PCI 总线的驱动链表上然后立刻对总线上已经存在的每个设备执行 ID 匹配。匹配成功后内核会分配设备相关的私有数据调用 pci_call_probe最终执行驱动的 probe 回调。热插拔时流程类似PCIe 热插拔事件会触发总线重新枚举新设备挂到总线上随后触发驱动匹配和 probe。这里有一个值得记住的细节同一时刻可能有多个设备都匹配同一个驱动probe 会被多次调用。驱动里的全局变量一定要考虑并发安全不能假设“只 probe 一次”。反过来如果一个设备被多个驱动同时匹配内核通过 driver_override 等机制可以指定优先级但这属于高级话题。平时只需要理解一件事probe 是“每次设备与驱动配对成功”时都会执行的入口不是模块初始化时执行一次就完事。3. 一组最小 PCI 驱动的完整实现3.1 地基代码从注册开始下面是一个能编译、能加载、能在设备匹配后进 probe 的最小模块。头文件只需要 module.h、pci.h、io.h因为这里只用到了 PCI 核心 API 和 MMIO 访问函数。#include linux/module.h #include linux/pci.h #include linux/io.h #include linux/interrupt.h #define MY_VENDOR_ID 0x10ee #define MY_DEVICE_ID 0x7011 static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id); static void my_remove(struct pci_dev *pdev); static const struct pci_device_id my_ids[] { { PCI_DEVICE(MY_VENDOR_ID, MY_DEVICE_ID) }, { } }; MODULE_DEVICE_TABLE(pci, my_ids); static struct pci_driver my_driver { .name my_accel, .id_table my_ids, .probe my_probe, .remove my_remove, }; module_pci_driver(my_driver); MODULE_LICENSE(GPL);module_pci_driver 这个宏会自动展开成 module_init 和 module_exit分别调用 pci_register_driver 和 pci_unregister_driver。手写这两个函数也可以但宏能少写不少样板代码出错概率也低。如果驱动还需要处理 suspend/resume、错误恢复同样在 pci_driver 里挂对应回调就行框架会在合适的时机调用它们。3.2 probe 里的权力与责任probe 回调里要做的事可以归纳成四类使能设备、请求资源、映射 BAR、使能 DMA。顺序不能乱下面是带着完整错误处理路径的写法。static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id) { void __iomem *bar0; int ret; ret pci_enable_device(pdev); if (ret) return ret; ret pci_request_regions(pdev, my_driver.name); if (ret) goto err_disable; bar0 pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!bar0) goto err_release; pci_set_master(pdev); pci_set_dma_mask(pdev, DMA_BIT_MASK(64)); pci_set_consistent_dma_mask(pdev, DMA_BIT_MASK(64)); dev_info(pdev-dev, probed, bar0 va%px\n, bar0); pci_set_drvdata(pdev, bar0); return 0; err_release: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); return -ENOMEM; }为什么必须先 pci_enable_device因为设备可能处于掉电或未激活状态这个调用会确保设备能响应 MMIO 访问。不调用它后面 pci_iomap 出来地址也可能读不到有效数据。pci_request_regions 则是强调资源所有权相当于告诉内核“这块地址窗口归我管了”避免两个驱动抢同一个 BAR。很多新手省略这步也能跑但热插拔和多个驱动共存时就容易出事。pci_set_master 打开 DMA 主控权网卡、采集卡、加速卡这类设备必须有。忘了这一步驱动看着一切正常但 DMA 传输永远不完成。DMA mask 设置同样重要64 位设备如果只声明支持 32 位地址内核会额外分配 bounce buffer传输性能和设计指标能差出一个数量级。错误处理路径必须按照资源申请的反顺序释放这个惯例不是强迫症而是为了避免资源泄漏。3.3 中断、读寄存器与用户态入口中断申请建议使用 pci_alloc_irq_vectors 这个 API它会自动根据设备的 MSI-X、MSI、INTx 能力选择一个合适的中断方案。对现代 PCIe 设备尽量让中断落到 MSI/MSI-X传统 INTx 是共享中断线处理起来麻烦得多。ret pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_ALL_TYPES); if (ret 0) { dev_err(pdev-dev, irq alloc failed\n); return ret; } ret request_irq(pci_irq_vector(pdev, 0), my_isr, IRQF_SHARED, my_accel, pdev); if (ret) { dev_err(pdev-dev, request_irq failed\n); pci_free_irq_vectors(pdev); return ret; }这里 IRQF_SHARED 不是随便写的。传统 INTx 本来就是共享中断即使你的驱动想独占PCIe AER 和链路事件也可能复用同一个中断号。所以 ISR 的第一件事必须自检设备的中断状态寄存器确认是不是自己的中断。如果不做这个检查共享中断场景下会疯狂触发 spurious interruptCPU 占用率直接拉满。对用户态来说最常见的方式是把 PCI 设备注册成一个 miscdevice 或者标准字符设备在 file_operations 里通过 read、write、ioctl 触达 probe 阶段保存下来的 BAR 虚拟地址。要做大块数据搬运就会用到 DMA 映射和 mmap一般把 dma_alloc_coherent 分配的缓冲区映射给用户态。v4l2 驱动、net 驱动本质上都是这个模式的延伸只是把 read/write 换成了子系统统一的 ops。3.4 remove 的逆序释放也别写错模块卸载或者设备热拔出时remove 回调会被调用。正确顺序是先让用户态的访问停止比如注销 miscdevice再释放中断再 iounmap 掉 BAR 映射pci_release_regions 释放资源最后 pci_disable_device 关掉设备。顺序搞反了在热插拔场景下很容易出现 use-after-free。很多驱动 bug 都出在错误路径和 remove 路径这两套逻辑不一致。我自己的习惯是写一个统一的清理函数让 probe 错误路径和 remove 共用减少重复代码也避免修了一个地方忘了另一个地方。模块卸载时报 -EBUSY通常是还有文件句柄没有释放或者引用计数没减干净先查自己的 fd 管理和引用计数逻辑而不是怀疑内核。4. 实战问题排查资源、枚举与映射4.1 热词里的 pci out of resources 到底怎么回事内核日志里出现 error: insufficient PCI resources detected!!! PCI out of resources condition 是启动和热插拔阶段很典型的失败信息。翻译成人话就是系统里有 PCI 设备需要地址窗口但总线域里已经没有足够空间分给它了。常见原因有三个我整理成一张速查表。可能原因典型现象排查方向固件给桥分配的窗口太小只有特定槽位不断报错lspci -vvv 看桥的 Bus resource Range设备 BAR 请求空间过大单个设备要求 1GB 以上 MMIOdmesg 里看 BAR size 日志桥路由配置不合理枚举到某个链路时中断尝试 pcirealloc、检查 ACPI 的 bus number 分配pcirealloc 这个内核参数值得单独提一句。它让内核忽略固件分配的结果自己重新分配所有桥资源。很多资源不足问题加上它就能解。但副作用是改变了所有设备的内存地址分配老系统里可能让某些固件写死的 DMA 地址失效所以生产环境要谨慎先在测试机完整验证。另外也可以临时卸载一些不用的设备模块释放窗口看报错是否消失这是判断“空间不够”还是“设备异常”的快捷办法。4.2 驱动不加载先看设备在不在总线排查驱动加载问题我的习惯是严格按顺序来先跑 lspci -nn确认设备被枚举出来并且显示正确的 vendor/device 号码再 cat /sys/bus/pci/devices/0000:xx:xx.x/vendor 和 /device和代码里的 ID 比对最后 dmesg | grep pci 看看枚举阶段有没有报 BAR 分配失败或者 ACPI 错误。有个容易被忽略的坑设备可能带多个 function你只给 function 0 写了驱动实际工作的是 function 1。功能号不参与 ID 匹配但驱动需要把所有相关 function 都初始化起来否则设备功能不完整。如果设备确实在 lspci 里但驱动就是不被 probe先检查设备的 driver 字段是不是已经被别的驱动占用了。内核默认的 driver_override 机制会改变匹配结果彻底排查清楚之前别急着怀疑编译器优化。4.3 AXI 映射 PCIe 场景下的地址规划热词里 axi memory mapped to pci express 是 FPGA 和 SoC 开发中会遇到的场景。ARM 通过 AXI 访问 PCIe 空间中映射出来的 BAR或者反过来PCIe 主机访问 FPGA 内部的 AXI 寄存器。这块最大的坑是地址偏移和地址域不匹配。AXI 总线上的 0x1000 经过 RC 转换后主机侧看到的是 PCIe 地址域中某个窗口的偏移而不是直接等于 CPU 物理地址 0x1000。反过来也一样FPGA 里看到的地址偏移是基于 BAR 内偏移的不是主机侧完整地址。遇到数据读写不对先查这几项BAR 大小是否足够、映射后的偏移是否对准、字节序是否一致、IOMMU 是否参与了地址转换。我在调试中碰到过不少“寄存器写不进”的问题最后发现是 FPGA 端把地址译码逻辑放在了 BAR 窗口之外根本不是驱动的问题。这类问题的最好排查方式是把 lspci -vvv 里的 BAR 范围、CPU 侧访问的虚拟地址、FPGA 端接收到的 AXI 地址三份信息对齐看任何一位对不上都会导致数据错乱。4.4 一套日常排查工具链最后分享一套我常用的排查组合lspci -vvv 看设备状态、BAR、中断、链路setpci 直接读写配置空间适合做故障注入和寄存器验证/proc/iomem 和 /proc/ioports 查全局资源占用/proc/interrupts 查中断号和中断亲和性dmesg 全程跟踪内核日志。其中 /sys/bus/pci/devices/0000:xx:xx.x/resource 这个文件很实用它描述内核实际分配给设备的范围。和 BAR 寄存器里的值结合起来看能发现“设备要求的窗口”和“内核分配的窗口”之间是不是存在冲突。DMA 相关的问题再用 perf 或者总线分析仪去抓。这套流程用下来百分之八十的 PCI 问题能在十分钟内定位到枚举、资源、驱动还是设备本身。这个系列讲到这里主干框架算是立起来了。我个人做 PCI 相关驱动这几年最大的体会是别急着写代码先在系统里把 lspci -vvv 的输出读透。设备树和配置空间不会骗人资源窗口、链路状态、中断信息全在里面真正耽误项目进度的往往是一个 BAR 分配不到位或者一个 MSI 没有开起来而不是内核 API 背得熟不熟。下一篇我会把中断与 DMA 展开细讲到时候结合这套最小驱动一起看应该能直接解决实际板卡上的吞吐瓶颈。如果你手上正好有一块 PCIe 设备建议现在就打开终端把 lspci -vvv 跑一遍看看 Root Port 和 Endpoint 到底各自占用了哪些资源从这棵树的根开始往下读整个框架就通了一半。