搞Linux驱动开发,绕不开PCI。不管你是做网卡、显卡、SSD还是各种采集卡,最终都要跟PCI/PCIe总线打交道。我早期刚接触这块的时候,对着内核代码也是一头雾水,感觉PCI驱动框架就像一团缠在一起的线,找不到头。后来在一个项目里连续调了几个月的PCIe设备驱动,把枚举、资源分配、中断处理这些环节挨个踩了一遍坑,才算真正把这套机制理清楚。
这篇是系列的第一篇,重点不讲具体的某个设备驱动怎么写,而是先把整个Linux PCI驱动框架的骨架拆开给你看——搞清楚内核是怎么管理PCI设备的、一个PCI驱动要跟哪些核心数据结构打交道、驱动和设备是怎么匹配上的。这些地基打牢了,后面写具体驱动才有底气。
这个系列适合这几类人:刚入门Linux内核驱动开发、想系统搞懂PCI子系统工作机制的;已经在写PCI驱动但遇到问题只能靠网上零碎资料排查的;以及做运维或BSP移植、经常被PCI资源报错折磨的。你不需要很深的内核功底,但最好对字符设备驱动、module加载这些基本概念有个印象,不然部分内容会有点吃力。
我会尽量用实际场景来拆解这套框架,配合代码片段和调试经验,力争让你读完能对PCI驱动从加载到工作的完整链路有一个清晰的认识。
1. 整体设计:为什么Linux要把PCI驱动拆成这么多层
1.1 从一次设备识别的"惊险一跃"说起
想象一下:你往主板的PCIe插槽里插了一张新卡,通电开机,操作系统是怎么知道这张卡是什么、需要什么资源、该用哪个驱动来伺候它的?
这个过程的起点是PCI总线的枚举机制。CPU通过PCI配置空间(Configuration Space)来访问每个PCI设备,每个PCI设备都有独立的配置空间,里面保存着厂商ID、设备ID、类别码、需要多少BAR空间、需要多少个中断引脚等关键信息。枚举阶段,内核的PCI核心层从头开始扫描总线,给每个找到的设备分配一个唯一的编号(BDF:Bus/Device/Function),读取配置空间里的信息,然后构建出一个设备节点。
这个扫描动作本身就是有讲究的。PCI总线拓扑不是一条线串到底的,它是一棵树——根节点是PCI Root Complex,下面可以挂PCI桥,桥下面又是一条新的PCI总线(二级总线),这样一级一级地挂下去。枚举的时候,内核得沿着这棵树深度优先遍历,碰到一个桥设备就往下再扫描一条总线。这个递归过程稍微出点问题,硬件设计上有点不规范,设备就可能漏枚举。我在一块工控主板上就遇到过这种情况,某个PCIe switch下的设备时灵时不灵,最后排查出来是桥设备的配置空间里二级总线号初始化有问题,可见这个环节的敏感程度。
1.2 总线-设备-驱动三角模型:PCI只是其中一个实现
Linux设备驱动模型的核心思想是分离设备与驱动,让两者通过总线来匹配。要知道,这个模型不是PCI独有的,它支撑起了整个Linux的设备体系,从I2C、SPI到USB、PCI,全都是这套逻辑在不同总线上的实例化。
简单说就三样东西:
- device(设备):描述硬件本身长什么样,有哪些资源。
- driver(驱动):描述软件逻辑,处理设备的操作。
- bus(总线):负责牵线搭桥,维护两张表——注册上来的设备表和驱动表,并提供匹配规则。
设备侧的一举一动、驱动侧的加载卸载,都会触发总线核心的匹配动作。PCI子系统的匹配规则比较直接,核心就是看硬件ID和驱动声明支持的ID是否对得上。这个三角模型最巧妙的地方在于解耦,驱动不需要关心设备是怎么插上去的(枚举是PCI核心的事),设备也不需要关心驱动内部是怎么实现的。
每次加载驱动或者热插拔设备的时候,内核都会去走一遍这个匹配流程。这套机制延伸到用户态,就成了sysfs里那串设备树目录结构,这也是为什么你在/sys/bus/pci/devices/下能看到一堆类似0000:01:00.0的目录,那正是真实设备的映射。
1.3 三层分离:核心层、总线层、驱动层各司其职
PCI驱动框架可以粗分为三层:
- 最底下是硬件层,就是真实的PCI/PCIe控制器和设备。
- 中间是PCI核心层(drivers/pci/),负责总线枚举、配置空间管理、资源分配(BAR、中断)、电源管理等基础设施能力。这一层是内核自己维护的,一般不需要开发者动。
- 最上面才是驱动层,也就是我们写的那部分:一个
struct pci_driver结构体,配上probe和remove回调,注册进内核。
为什么非要拆这么清楚?直接让驱动访问硬件不好吗?
问题在于,PCI资源是全局共享的,多个设备之间需要协调。比如BAR空间分配,系统物理地址空间是有限的,哪个设备占哪块地址不能靠驱动自己说了算,必须有一个中央管理机构——PCI核心层——来统一规划和分配。再比如中断,今天几乎全是MSI/MSI-X了,设备要申请多少个中断向量、每个向量怎么映射到CPU,这些也是核心层在背后协调。驱动如果绕过核心层直接操作这些,必然是灾难。
所以驱动作者的核心职责被压缩成两件主要的事:在probe里拿到资源、初始化硬件;在remove里把硬件停掉、释放资源。至于设备是怎么被发现、资源是怎么被分配出来的,那是核心层的事。这种"驱动只管干活、框架负责后勤"的设计,让驱动代码保持精简,也大大提高了可移植性。
2. 核心数据结构:写PCI驱动绕不开的几张“身份证”
2.1 struct pci_driver:你的驱动就是它
每个PCI驱动在内核里的代表就是struct pci_driver。可以把它理解成驱动对外展示的一张名片,内核通过它知道这个驱动叫什么、支持哪些设备、初始化入口在哪。
这个结构体的定义在include/linux/pci.h里,关键字段比想象中少得多:
struct pci_driver { const char *name; // 驱动名字 const struct pci_device_id *id_table; // 支持的设备ID表 int (*probe)(struct pci_dev *dev, const struct pci_device_id *id); void (*remove)(struct pci_dev *dev); int (*suspend)(struct pci_dev *dev, pm_message_t state); int (*resume)(struct pci_dev *dev); ... };name会出现在/sys/bus/pci/drivers/目录下,也是调试时最容易辨认的标志。id_table是驱动声明"我支持哪些设备"的清单,而probe和remove是真正干活的回调。
probe函数是驱动的"构造函数",remove是"析构函数"。设备插上时内核调用probe,驱动卸载或设备拔出时调用remove。这两个函数写得好不好,直接决定了驱动的稳定性和生命周期管理是否干净。
2.2 struct pci_device_id:驱动与设备怎么对上号
匹配机制靠struct pci_device_id,它的定义长这样:
struct pci_device_id { __u32 vendor, device; // 厂商ID和设备ID __u32 subvendor, subdevice; // 子系统厂商ID和设备ID,可用来进一步细分 __u32 class, class_mask; // 设备类别码 unsigned long driver_data; // 私有数据 };正常来说,vendor/device两个字段就能定位到某个硬件型号。但实际应用中,同一颗芯片会因为PCB设计不同而衍生出多个子型号,用一个ID表就能把整个家族都覆盖了。比如某厂商的采集卡,几个型号用同一颗主控芯片,只是接口不同,那你就可以用子设备ID来做细分,在probe里据driver_data区分具体的板卡版本。
class字段是用来按类别匹配的,比如所有的网络控制器都有相同的类别码,如果你写的是一个通用驱动,可以声明class=0x020000(网卡类),这样不管哪个厂商的网卡都能匹配上。不过实践中这种"按类别通吃"的写法风险不小,除非你对硬件非常了解,不然还是老老实实用vendor+device匹配。
2.3 struct pci_dev:设备在内存里的“全息投影”
每一个被枚举出来的PCI设备,在内核里都被表示为一个struct pci_dev。这个结构体极其庞大,你不需要全记住,但几个核心字段必须刻在脑子里:
struct pci_dev { struct pci_bus *bus; // 挂在哪条总线上 struct pci_slot *slot; // 插在哪个物理槽位 unsigned int devfn; // 设备号和功能号 unsigned short vendor, device; // ID信息 struct resource resource[PCI_NUM_RESOURCES]; // 已分配的BAR资源 struct pci_driver *driver; // 当前绑定的驱动 u8 irq; // 传统INTx中断号 ... };当你拿到一个struct pci_dev *dev,就等于拿着这个设备的全息档案。在probe里,几乎所有初始化操作都要从这个指针出发——读取配置空间、映射BAR、申请中断。后续章节还会详细展开。
2.4 资源管理:BAR空间是驱动访问硬件的钥匙
PCI设备与CPU交互,靠的就是一组叫BAR(Base Address Register)的寄存器,每个BAR描述了一段设备映射到CPU地址空间的窗口。例如某个设备有个4KB的寄存器窗口,通过BAR0暴露给CPU,驱动只要把BAR0的物理地址ioremap成虚拟地址,然后往里面写寄存器值,就能控制硬件了。
内核用struct resource来表示这些窗口,在struct pci_dev里就是resource[0]、resource[1]这些数组项。resource里的start和end就是已经被PCI核心分配好的物理地址范围。同样,如果你要访问设备上的大块存储或FIFO缓冲,往往还会用BAR映射出内存空间来,那就涉及DMA了,这块后面讲。
写驱动时的典型序列就是:
pci_enable_device()pci_request_regions()ioremap(pci_resource_start(dev, bar), pci_resource_len(dev, bar))
这三步做完,你的驱动就算拿到访问硬件的钥匙了。每一步都有它的坑位,后面实操部分会具体展开。
3. 驱动探测流程与初始化顺序详解
3.1 从pci_register_driver开始
模块加载入口里调用的pci_register_driver()是新驱动生命周期的起点。这个函数做的事可以理解为:把驱动对象挂到PCI总线上,并立即对总线上现有的每个设备做一次匹配检查。如果总线上有设备ID命中你的id_table,probe就会被调用。所以驱动加载的一瞬间,相当于系统问了一圈:"各位设备,你们谁认得他?"
反过来,如果驱动先加载,设备后插上(热插拔),那PCI核心在枚举出新设备时也会去做匹配,同样能触发probe。这种双向的触发机制,体现了设备模型以总线为枢纽的设计思路。
3.2 probe函数:一个设备绑定驱动的“入职仪式”
probe函数的典型任务就是初始化设备硬件、分配资源、注册后续要用的子系统入口。顺序通常如下:
static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; struct my_dev *mdev; // 1. 启用设备:使能PCI命令寄存器中的总线主控、IO/内存空间等 ret = pci_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "pci_enable_device failed\n"); return ret; } // 2. 申请设备的BAR资源(避免与其他驱动冲突) ret = pci_request_regions(pdev, "my_pci_driver"); if (ret) { dev_err(&pdev->dev, "pci_request_regions failed\n"); goto err_disable; } // 3. 分配私有数据 mdev = kzalloc(sizeof(*mdev), GFP_KERNEL); if (!mdev) { ret = -ENOMEM; goto err_release_regions; } pci_set_drvdata(pdev, mdev); // 4. ioremap BAR0 mdev->bar0 = pci_ioremap_bar(pdev, 0); if (!mdev->bar0) { ret = -ENOMEM; goto err_free; } // 5. 申请中断并注册handler ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_ALL_TYPES); if (ret < 0) { dev_err(&pdev->dev, "pci_alloc_irq_vectors failed\n"); goto err_unmap; } ... ... return 0; err_unmap: iounmap(mdev->bar0); err_free: kfree(mdev); err_release_regions: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); return ret; }每一步失败都要有对应的清理动作,不然模块卸载时就会留下资源泄漏。这是我写驱动时特别强调的一点:错误路径一定要成对清理,宁可多几行代码也别偷懒。
pci_enable_device()这一步容易被人忽略。它做的事是设置PCI命令字,使能设备的内存空间、IO空间和总线主控能力。如果不调用它,你后面读BAR、访问设备寄存器都可能拿不到数据。
pci_request_regions()的作用是声明这块BAR资源归本驱动独占。这样做的好处有两个:一是杜绝两个驱动同时操作同一段物理地址的混乱;二是让/proc/iomem这类资源视图能准确反映占用情况,调试时非常有用。
中断申请这里我用的是pci_alloc_irq_vectors()。老的APIpci_request_irq()配合request_irq()也能用,但推荐新接口,因为它统一了MSI/MSI-X/INTx的申请逻辑,还能让你自由选择期望的中断类型。对于现代PCIe设备,几乎都应该用MSI-X,性能和可靠性都好很多。后面讲中断时再细说。
3.3 remove函数:卸载也要讲究“善后”
卸载路径和probe完全镜像,一个不落地释放资源,顺序反着来就对了。尤其要注意:先断开中断,再释放映射,再释放BAR资源,最后disable设备。中断的handler不能让它在设备和驱动已经分离之后还有机会被调起来。
static void my_pci_remove(struct pci_dev *pdev) { struct my_dev *mdev = pci_get_drvdata(pdev); free_irq(pci_irq_vector(pdev, 0), mdev); pci_free_irq_vectors(pdev); iounmap(mdev->bar0); pci_release_regions(pdev); pci_disable_device(pdev); kfree(mdev); }这个函数的执行时机比较讲究——它可能发生在系统关机阶段、驱动模块卸载阶段或者设备热拔出阶段。不管哪种场景,它都必须足够干脆,不能长时间阻塞。记得我有一次在remove里加了延时操作,结果系统关机时愣是卡了好几秒,虽然最后能关掉,但那种体验很不专业。
4. 实操:写一个完整的PCI字符设备驱动
4.1 代码骨架与结构规划
理论讲得再多,不如跑一个能用的demo。下面我用一个精简但完整的PCI字符设备驱动来串起前面说的所有机制。假设这个设备很简单:一个4KB的BAR0寄存器空间,通过读写它就能控制硬件(现实中常用来做FPGA采集卡之类的基础控制)。
整个驱动分成三块:模块入口/出口、PCI驱动的probe/remove、文件操作接口(read/write)。设计上,文件操作接口通过struct my_dev中的字符设备结构来链接PCI设备与用户态,让open("/dev/mypci0")之后能直接读写硬件寄存器。
4.2 完整的模块代码
#include <linux/module.h> #include <linux/kernel.h> #include <linux/pci.h> #include <linux/cdev.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/io.h> #define MY_PCI_VENDOR_ID 0x10EE // 示例厂商ID #define MY_PCI_DEVICE_ID 0x1234 // 示例设备ID #define MY_BAR_COUNT 1 #define MY_DEVICE_NAME "mypci" struct my_dev { struct pci_dev *pdev; void __iomem *bar0; unsigned long bar0_len; struct cdev cdev; dev_t devno; struct device *dev; }; static dev_t my_dev_base; static struct class *my_dev_class; // ---------- 文件操作 ---------- static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *offset) { struct my_dev *mdev = file->private_data; u32 val; size_t len; if (*offset >= mdev->bar0_len) return 0; if (count > sizeof(val)) count = sizeof(val); /* 这里直接从BAR0偏移0读取一个32位寄存器给用户态 */ val = ioread32(mdev->bar0); len = min(count, sizeof(val)); if (copy_to_user(buf, &val, len)) return -EFAULT; *offset += len; return len; } static ssize_t my_write(struct file *file, const char __user *buf, size_t count, loff_t *offset) { struct my_dev *mdev = file->private_data; u32 val; if (count > sizeof(val)) count = sizeof(val); if (copy_from_user(&val, buf, count)) return -EFAULT; /* 同样写入BAR0偏移0的寄存器 */ iowrite32(val, mdev->bar0); return count; } static int my_open(struct inode *inode, struct file *file) { struct my_dev *mdev = container_of(inode->i_cdev, struct my_dev, cdev); file->private_data = mdev; return 0; } static const struct file_operations my_fops = { .owner = THIS_MODULE, .open = my_open, .read = my_read, .write = my_write, }; // ---------- PCI驱动回调 ---------- static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_dev *mdev; int ret; mdev = kzalloc(sizeof(*mdev), GFP_KERNEL); if (!mdev) return -ENOMEM; mdev->pdev = pdev; ret = pci_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "pci_enable_device failed\n"); goto err_free; } ret = pci_request_regions(pdev, MY_DEVICE_NAME); if (ret) { dev_err(&pdev->dev, "pci_request_regions failed\n"); goto err_disable; } mdev->bar0_len = pci_resource_len(pdev, 0); mdev->bar0 = pci_ioremap_bar(pdev, 0); if (!mdev->bar0) { dev_err(&pdev->dev, "ioremap failed\n"); ret = -ENOMEM; goto err_release; } /* 注册字符设备 */ cdev_init(&mdev->cdev, &my_fops); mdev->cdev.owner = THIS_MODULE; ret = cdev_add(&mdev->cdev, mdev->devno, 1); if (ret) { dev_err(&pdev->dev, "cdev_add failed\n"); goto err_unmap; } mdev->dev = device_create(my_dev_class, &pdev->dev, mdev->devno, mdev, MY_DEVICE_NAME "%u", 0); if (IS_ERR(mdev->dev)) { ret = PTR_ERR(mdev->dev); goto err_cdev_del; } pci_set_drvdata(pdev, mdev); dev_info(&pdev->dev, "my pci driver initialized\n"); return 0; err_cdev_del: cdev_del(&mdev->cdev); err_unmap: iounmap(mdev->bar0); err_release: pci_release_regions(pdev); err_disable: pci_disable_device(pdev); err_free: kfree(mdev); return ret; } static void my_pci_remove(struct pci_dev *pdev) { struct my_dev *mdev = pci_get_drvdata(pdev); device_destroy(my_dev_class, mdev->devno); cdev_del(&mdev->cdev); pci_set_drvdata(pdev, NULL); iounmap(mdev->bar0); pci_release_regions(pdev); pci_disable_device(pdev); kfree(mdev); } static const struct pci_device_id my_pci_id_table[] = { { PCI_DEVICE(MY_PCI_VENDOR_ID, MY_PCI_DEVICE_ID) }, { 0 } }; MODULE_DEVICE_TABLE(pci, my_pci_id_table); static struct pci_driver my_pci_driver = { .name = MY_DEVICE_NAME, .id_table = my_pci_id_table, .probe = my_pci_probe, .remove = my_pci_remove, }; // ---------- 模块入口/出口 ---------- static int __init my_pci_init(void) { int ret; ret = alloc_chrdev_region(&my_dev_base, 0, 1, MY_DEVICE_NAME); if (ret) return ret; my_dev_class = class_create(MY_DEVICE_NAME); if (IS_ERR(my_dev_class)) { ret = PTR_ERR(my_dev_class); goto err_unregister; } ret = pci_register_driver(&my_pci_driver); if (ret) goto err_class_destroy; return 0; err_class_destroy: class_destroy(my_dev_class); err_unregister: unregister_chrdev_region(my_dev_base, 1); return ret; } static void __exit my_pci_exit(void) { pci_unregister_driver(&my_pci_driver); class_destroy(my_dev_class); unregister_chrdev_region(my_dev_base, 1); } module_init(my_pci_init); module_exit(my_pci_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple PCI character device driver example");这段代码是一个可以直接编译的框架,把前面讲到的数据结构全部串起来了。有个容易忽略的细节:MODULE_DEVICE_TABLE(pci, my_pci_id_table)。这个宏的作用不只是声明,它会把ID表导出到模块的modinfo段,让系统在热插拔时能够仅凭模块的元信息就判断这个模块可能支持哪个设备,而不需要真正加载模块才能知道。这在现代Linux里配合udev,可以实现驱动自动加载——插上设备,系统自动拉起来对应模块。
4.3 编译与加载验证
编译用内核模块构建方式,一个简单的Makefile:
obj-m += mypci.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean如果机器上有真实硬件,modprobe之后先看dmesg有没有probe成功的日志。再检查设备节点有没有生成:
ls -l /dev/mypci0再通过sysfs确认驱动绑定关系:
ls -l /sys/bus/pci/devices/0000:01:00.0/driver这个软链接指向谁,就说明现在谁在管这个设备。我经常通过这种方式快速确认一个设备是否被预期的驱动接管。
没有真实硬件的朋友也别灰心,QEMU可以模拟PCI设备,用-device pci-testdev之类的参数就能跑出足够多的PCI设备来验证驱动逻辑。或者用内核的pci_stub模块占住一个设备,配合new_id接口测试匹配流程,这些都是我常用的调试手段。
5. 常见问题与排查技巧实录
5.1 设备居然没被枚举出来?
这可以说是最让人抓狂的问题——驱动代码根本没机会执行,因为内核压根没看到这个设备。遇到这种情况,按顺序查:
先用lspci确认硬件层面是否可见:
lspci -nn | grep -i 1234如果lspci里都看不到这个设备,那问题基本在硬件——卡没插好、PCIe链路没训练成功、或者设备进入了一个异常状态。这时候要拿lspci -vvv看链路状态,重点看LnkSta字段是否显示速度正常。
如果lspci能看到设备,但/sys/bus/pci/devices/下没有对应目录,那基本可以判断是PCI核心枚举阶段出问题了。这类问题往往和桥设备有关,可以先lspci -t看整棵总线树,确认桥有没有正确初始化。
5.2 驱动的probe函数没有被调用
设备是枚举出来了,也出现在sysfs里了,但你的probe就是没跑。这个情况大多数是ID匹配失败。检查顺序:
- 确认id_table里的vendor/device和
lspci -nn输出的一致。 - 确认驱动模块有没有成功注册。
ls /sys/bus/pci/drivers/mypci/看看目录在不在。 - 如果驱动已经注册但设备依然unbound,看一下
/sys/bus/pci/devices/0000:01:00.0/driver_override有没有被设置成别的值——这个机制会把设备强制绑定到某个驱动上。 - 还要防止设备被别的驱动抢先绑定了。
lspci -k能直接看到哪个驱动在管这个设备。
内核日志里一般会有各种线索,但probe没被调用往往日志很干净,这时候就得主动查sysfs状态。
排查驱动绑定问题时,
lspci -k和/sys/bus/pci/drivers/里的软链接是最直观的证据。不要一上来就改代码加日志,先确认"匹配失败"还是"根本没尝试匹配"。
5.3 BAR空间读出来全是0xFF
BAR地址读出来全是0xFF,这通常是设备没有真正被使能。常见原因有两个:
一是没有调用pci_enable_device()就急着去ioremap和访问。虽然ioremap本身可能不报错,但实际访问总线时根本读不到有效数据。这个坑我见过太多次了,尤其是从别的驱动抄代码时漏了这一句。
二是设备的PCI命令寄存器里的内存空间使能位没被置位。即使调用了pci_enable_device(),有些硬件在复位之后还需要一点时间才能响应,马上狂读BAR也会遇到全F的情况。对策是加一个很小的心跳延时,等设备稳定再操作。
5.4 "insufficient PCI resources"的经典问题
内核日志里跳出pci out of resources这种报错,多半不是单个设备的问题,而是整棵PCI总线在枚举阶段的资源分配失败。常见于多个设备挤在同一段地址空间、或者某个桥设备的窗口设置得不合理。
有一次我在调试一台多PCIe Switch的服务器,新插一张卡后某个旧设备直接失效了,dmesg里全是insufficient PCI resources detected。最后定位到是BIOS给Switch的下行窗口分配得不够,导致新设备枚举时无法从桥窗口里拿到可用空间。解决办法在硬件层面是更新BIOS或者调整插卡位置,软件层面有时可以通过给内核传pci=resource_alignment之类的参数来调整资源分配策略。
这类问题的排查核心就是想清楚:资源不足是哪个层次发生的?是根总线窗口不够,还是某个桥的窗口不够,还是设备自身BAR太大?lspci -vvv能看出每个桥设备的窗口范围,看懂这些数字就能很快定位问题。
5.5 中断申请失败与irq=0的陷阱
在probe里调用pci_alloc_irq_vectors返回失败,或者拿到的irq号是0,有几个方向:
- 传统INTx中断线路耗尽。现在新设备建议直接用MSI/MSI-X,这类问题基本可以绕开。
- 设备没有正确的MSI能力。老设备可能只支持INTx,如果硬件本身没有MSI能力,你又非要MSI,那肯定失败。
- BIOS/平台固件做了中断路由限制。
我在一个国产化平台上就踩过MSI的坑,平台固件对MSI的中断号映射有缺陷,写MSI中断驱动的设备只能改用INTx绕过。调试时用lspci -vvv看中断相关的Capabilities,能判断设备支持哪些中断类型。
另外,申请中断时的irq字段要来自pci_irq_vector(pdev, 0),而不是直接读dev->irq。在MSI-X场景下,dev->irq只是一个默认值,真正的中断号是动态分配的,所以一定要通过接口拿。
5.6 热拔插时的资源泄漏
remove没写好,最典型的后果就是模块卸载重载之后资源越来越少。pci_request_regions申请的资源、ioremap出来的映射、中断向量、cdev注册的设备节点,所有这些在remove里都得对称释放。写驱动的基本功就是从头到尾检查每一个成功分支,看失败路径是否都能回到一个正确的中间状态。
我给自己定的规矩是:probe里每多一个资源申请,remove里就必须多一个对应的释放动作。一行申请配一行释放,谁也别落下。
6. 几个小技巧和内核调试辅助工具
6.1 用好sysfs,驱动调试事半功倍
/sys/bus/pci/下面的信息量非常大,而且不需要root就能看大部分内容。常用目录:
/sys/bus/pci/devices/:所有PCI设备/sys/bus/pci/drivers/:所有已注册的PCI驱动/sys/bus/pci/devices/0000:01:00.0/resource:设备的BAR资源分配/sys/bus/pci/devices/0000:01:00.0/config:设备的配置空间,可以用hexdump直接查看
我在调试的时候,经常写一个小脚本,把设备的配置空间关键字段dump出来跟lspci -vvv对照着看,定位问题快得多。虽然这些工具看起来基础,但实际调试效率的提升是实打实的。
6.2 iomem和interrupts的相互印证
/proc/iomem和/proc/interrupts这两个文件也是PCI调试的好帮手。前者能看到所有PCI BAR的物理地址占位情况,后者能看到每个中断号的触发次数。当一个PCI设备的中断一直不触发,查/proc/interrupts确认中断号绑定是否正确、CPU affinity有没有被错误设置,往往能省下半天时间。
6.3 用tracepoint观察PCI核心运作
现代内核带了不少PCI相关的tracepoint,比如pci_enable_device、pci_resize_resource这些。用perf list | grep pci可以看到,配合trace-cmd或perf trace,能用极小的开销观察PCI核心的调用轨迹。这类手段在调试复杂的热插拔问题时特别有用,比无人机式的加printk要干净得多。
7. 这块框架还能怎么继续深入
这篇先把PCI驱动框架的骨架讲完了。从这个基础出发,后面可以深挖的方向还挺多的:
- PCIe配置空间里的扩展能力(MSI/MSI-X、AER、ACS等)。
- DMA API的使用,尤其是
dma_alloc_coherent和dma_map_single这套机制。 - SR-IOV虚拟化场景下的VF(Virtual Function)驱动写法。
- 电源管理和运行时PM在PCI设备上的实现。
如果这篇对你有帮助,接下来我会重点拆解PCIe的中断机制和DMA方向,因为这两个是驱动开发者真正会写复杂逻辑的地方,也是最容易出性能问题的地方。
我自己从摸着石头过河到现在能流畅地读写PCI驱动,最大的感受就是:这套框架其实并不神秘,关键是把枚举、匹配、资源分配、probe/remove这条主线理清楚。主线明白了,剩下的都是枝枝蔓蔓,遇到问题翻内核代码、查文档、看示例驱动,都能慢慢找到答案。
最后分享一个我自己的习惯:刚开始学PCI驱动时,把内核里drivers/pci/目录下的几个关键源文件从头到尾读过一遍,特别是pci-driver.c和probe.c。虽然一开始会有很多看不懂的地方,但这个底子打下来,后面看任何具体设备的PCI驱动都轻松很多。内核代码就是最好的教科书,多看几次就会从"单个函数"上升到"子系统全局"的理解层面。