Linux下写PCI设备驱动,很多人第一反应是去翻LDD3那本经典。书没毛病,但真到项目里你会卡住的往往不是语法,而是对PCI/PCIe这套总线到底怎么发现设备、怎么分配资源、驱动又是怎么跟硬件“对上眼”的没有整体概念。我早年接手一块PCIe FPGA加速卡,插上去lspci能看到设备,模块一加载就报错,折腾两天把枚举、BAR资源和probe逻辑捋清楚之后才真正开了窍。这篇文章就把这些硬骨头拆开揉碎讲一遍,覆盖PCIe枚举、配置空间、BAR、pci_driver框架、最小驱动实现,以及热插拔、AER、掉卡降速这类线上故障排查。
对这方向感兴趣的读者,不管是做内核驱动开发、嵌入式Linux,还是日常运维要定位硬件告警,都能从里面直接拿走可用方案。我写的所有代码和命令都是自己在x86_64平台上验证过的,你按步骤来就能复现。
1. 先搞明白PCI/PCIe在系统中的位置
1.1 从总线拓扑到配置空间
PCI/PCIe设备在系统里不是孤立存在的。你看到的0000:03:00.0这种编号,实际是一条完整定位链:前四位是domain(段),一般x86上固定是0000,接着是bus号、device号、function号,缩写就是BDF。你可以把它理解成设备的门牌号。
数据传输路径大概是这样的:CPU要访问PCIe设备,先经过Root Complex(根联体),再走Root Port(根端口),接着是Switch/桥,最后才到Endpoint(端点设备)。对驱动开发者来说,这条链路里最直接的就是每个设备都有一个配置空间,这是软件和硬件对话的基础。
传统PCI配置空间是256字节,PCIe扩展到了4KB。前面64字节的头部结构最重要,里面放着:
- Vendor ID和Device ID:识别设备是谁家的、什么型号。
- Class Code:设备分类,比如网卡、显卡、存储控制器。
- BAR寄存器:设备寄存器或者内存区域的地址窗口。
- Status/Command寄存器:控制IO和内存访问开关。
驱动第一步就是读这些字段,判断设备是否存在、资源在哪。内核已经把这套封装好了,但你要知道背后读的是配置空间。
1.2 枚举过程到底做了什么
每次开机,BIOS/UEFI或者内核都会对整个PCI总线做一次枚举。流程并不复杂,但涉及几个重要动作:
- 扫描总线上的每个设备号,往配置空间写一个“读就绪”命令,然后回读Vendor ID。
- 如果读到0xFFFF,说明这个槽位没有设备,跳过。
- 如果有设备,就进一步读取Device ID、Class Code、BAR大小等。
- 分配总线号和MMIO/IO资源,把分配好的地址写回BAR。
- 在sysfs里创建
/sys/bus/pci/devices/0000:03:00.0/这样的节点。
这里面的关键点在于BAR大小是怎么探测的。内核会先向BAR写入全1,再读回来,根据读到的位数判断需要多大地址空间。这也是为什么驱动里配置空间不能乱写,写坏了资源就乱了。
枚举完成后,设备已经在总线上“亮灯”,但还没有驱动接管。接下来就进入驱动匹配阶段。
1.3 为什么说BAR是驱动和硬件之间的“门牌号”
每个PCI设备最多有6个BAR寄存器(BAR0到BAR5)。BAR里存的是设备内部寄存器或内存被映射到系统物理地址空间的起始地址和属性。比如一张网卡,它的控制寄存器在BAR0,收发包描述符环形队列在BAR1,驱动要知道这些地址才能干活。
BAR的位宽和属性取决于硬件设计:
- 有些BAR是32位的,有些是64位的(占用两个BAR位置)。
- 有些BAR标记为prefetchable,表示可以缓存、适合做批量DMA。
- 有些标记为non-prefetchable,一般放控制寄存器。
驱动拿到BAR物理地址后,不能直接用指针去访问,必须通过ioremap把这个物理地址映射到内核虚拟地址空间。然后才能用ioread32、iowrite32这类接口读写。
我碰到过不少新手,读BAR打印出来是0xffffffff,就怀疑硬件坏了。多数情况是设备还没enable,或者说BAR资源被固件和内核重新分配后没有更新到sysfs。后面实操部分会细讲。
2. 驱动框架:pci_driver是怎么和硬件“对上眼”的
2.1 pci_driver结构体和pci_device_id匹配逻辑
Linux内核一个驱动要绑定PCI设备,核心是定义一个struct pci_driver,然后把设备ID表和回调函数填进去。结构体长这样:
static struct pci_driver mypci_driver = { .name = "mypci", .id_table = mypci_ids, .probe = mypci_probe, .remove = mypci_remove, };name就是驱动名字,加载后会在/sys/bus/pci/drivers/mypci出现。真正决定驱动能不能看上设备的是id_table:
static struct pci_device_id mypci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { 0 } }; MODULE_DEVICE_TABLE(pci, mypci_ids);PCI_DEVICE(0x1234, 0x5678)要求vendor ID匹配0x1234,device ID匹配0x5678。内核遍历总线上所有设备,发现某个设备的vendor/device和这个表里某一项一致,就会调用对应驱动的probe函数。
这里有个易忽略的细节:MODULE_DEVICE_TABLE不只是给内核看的。它会把ID表写进模块的.modinfo段,模块管理器根据这些信息生成“该模块支持哪些设备”的别名。有了它,热插拔时系统才知道自动加载哪个驱动。如果你只写了ID表但漏了这行宏,手动insmod能用,但无法实现自动绑定。
2.2 probe/remove生命周期:设备发现与卸载
probe是驱动生命周期里最关键的入口。设备匹配成功后由内核进程调用,返回0表示驱动成功接管设备,返回负数表示失败。失败后驱动和设备的绑定关系不成立,用户会在dmesg看到类似probe of 0000:03:00.0 failed with error -16的信息。
probe函数里通常按顺序做这几件事:
- 调用
pci_enable_device启用设备,打开内存、IO、总线主控等能力。 - 调用
pci_request_regions申请BAR资源,防止多个驱动重复占用。 - 调用
pci_iomap映射BAR到虚拟地址。 - 申请中断、初始化DMA、注册字符设备等。
对应地,remove函数负责把probe里申请的所有资源逆序释放。顺序错了会在卸载模块或者设备拔出时死给你看。
很多线上故障是probe失败后残留资源。比如probe申请了中断,但后续初始化DMA出错,没有释放中断就直接返回错误,第二次加载模块时就会发现中断号被占用。
PCI设备还有电源管理回调,例如suspend/resume。驱动需要在挂起时停止DMA、保存寄存器状态;恢复时重新初始化。NVMe盘这类设备做热插拔和休眠唤醒时,如果驱动没实现这些回调,很容易出现设备消失后无法恢复的问题。
2.3 融合字符设备框架:让用户空间也能访问
PCI驱动通常要跟用户态配合,最常用的方式是注册一个字符设备。这样用户程序可以open、read、write、ioctl,控制硬件工作。
先看注册字符设备这段代码:
static int mypci_register_chrdev(struct pci_dev *pdev) { int ret; dev_t devno; ret = alloc_chrdev_region(&devno, 0, 1, "mypci"); if (ret < 0) return ret; cdev_init(&mypci_cdev, &mypci_fops); mypci_cdev.owner = THIS_MODULE; ret = cdev_add(&mypci_cdev, devno, 1); if (ret < 0) { unregister_chrdev_region(devno, 1); return ret; } mypci_devno = devno; mypci_class = class_create("mypci"); device_create(mypci_class, NULL, devno, NULL, "mypci%d", pdev->devfn); return 0; }alloc_chrdev_region动态分配主设备号,cdev_add把文件操作集挂上去,device_create在/dev下创建设备节点。这样用户态就能用open("/dev/mypci0")访问了。
文件操作集里最常见的两个回调是unlocked_ioctl和mmap。ioctl用来下发控制命令,比如启动DMA、读状态;mmap用来把BAR区域映射给用户态,省去read/write的数据拷贝。但做mmap映射时要小心,BAR区域类型如果是non-prefetchable,映射后需要确保用户态访问不会触发机器检查异常。
3. 实操:写一个最小PCI驱动并跑起来
3.1 环境准备和Makefile
写驱动之前先确认环境上装了内核头文件。Ubuntu/Debian系可以这样装:
sudo apt install linux-headers-$(uname -r)红帽系用:
sudo dnf install kernel-devel kernel-headers然后建一个目录放源码和Makefile。我们驱动模块叫mypci,Makefile写成:
obj-m := mypci.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean内核模块不需要链接用户态库,编译时KDIR指向内核源码树,Kbuild会自动处理。
3.2 注册、probe里该做什么
一个最小可加载的驱动框架长这样:
#include <linux/module.h> #include <linux/pci.h> #include <linux/io.h> #define MY_VENDOR_ID 0x1234 #define MY_DEVICE_ID 0x5678 static int mypci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; ret = pci_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "enable device failed\n"); return ret; } ret = pci_request_regions(pdev, "mypci"); if (ret) { dev_err(&pdev->dev, "request regions failed\n"); goto err_disable; } pci_set_master(pdev); dev_info(&pdev->dev, "mypci probe ok, bar0=%llx\n", (unsigned long long)pci_resource_start(pdev, 0)); return 0; err_disable: pci_disable_device(pdev); return ret; } static void mypci_remove(struct pci_dev *pdev) { pci_release_regions(pdev); pci_disable_device(pdev); }注意pci_set_master的作用是打开总线主控能力,DMA必须依赖这个。不解锁,设备无法主动发起读写请求,所有DMA操作都会静默失败。
pci_request_regions可能返回-EBUSY,就是因为BAR资源已经被别的驱动申请了。出现这种情况先查/sys/bus/pci/devices/0000:03:00.0/resource,确认有没有其他驱动绑定。
模块加载入口:
static struct pci_device_id mypci_ids[] = { { PCI_DEVICE(MY_VENDOR_ID, MY_DEVICE_ID) }, { 0 } }; MODULE_DEVICE_TABLE(pci, mypci_ids); static struct pci_driver mypci_driver = { .name = "mypci", .id_table = mypci_ids, .probe = mypci_probe, .remove = mypci_remove, }; module_pci_driver(mypci_driver); MODULE_LICENSE("GPL");module_pci_driver宏帮我们生成了module_init和module_exit,省事。如果你的设备同时要做节点注册、dma设置,probe里再往后加就行。
3.3 访问BAR、申请中断、DMA映射的实操代码
probe里拿到映射地址后,读写硬件寄存器就是几行代码的事:
void __iomem *bar0; bar0 = pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!bar0) { ret = -ENOMEM; goto err_release_regions; } writel(0x1, bar0 + 0x00); // 写控制寄存器 u32 val = readl(bar0 + 0x04); // 读状态寄存器寄存器读写要注意字节序,PCI设备寄存器一般按小端设计,所以writel/readl就够了。但一些特殊寄存器如果按大端实现,你就得自己处理。
中断申请推荐使用MSI(X)而不是传统INTx,因为MSI性能更好,不需要共享中断线。申请方式:
int nr_vecs; nr_vecs = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_MSIX); if (nr_vecs < 0) { nr_vecs = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_INTX); }然后把请求到的irq传给request_irq(pci_irq_vector(pdev, 0), handler, 0, "mypci", dev_id)。dev_id必须传设备私有数据,释放和中断处理都要用同一个指针,释放时free_irq(pci_irq_vector(pdev, 0), dev_id)。
DMA这块是很多驱动不稳定的重灾区。最简单的做法是给每个descriptor分配一致性内存:
struct my_dma_desc *desc; dma_addr_t dma_handle; desc = pci_alloc_consistent(pdev, sizeof(*desc), &dma_handle); desc->buf_addr = cpu_to_le64(dma_handle); writel(lower_32_bits(dma_handle), bar0 + 0x10);pci_alloc_consistent返回的内存经过一致性映射,CPU和设备都能访问,而且不需要担心缓存一致性问题。代价是内存比较大时浪费严重。大数据量的包传输一般用pci_map_single做流式映射,但要保证方向正确:
- DMA_TO_DEVICE:CPU写数据后让设备读。
- DMA_FROM_DEVICE:设备写数据后CPU读。
方向错了会出现看起来数据发出去但收到的是垃圾。硬件DMA传完后要调用pci_unmap_single释放映射,否则IOMMU开启的机器上DMA地址资源会慢慢耗尽。
3.4 编译加载和验证方法
编译以后,加载模块:
make sudo insmod mypci.ko如果硬件ID匹配,probe就会执行。验证驱动是否绑定成功:
lspci -vvv -s 03:00.0看到Kernel driver in use: mypci就说明驱动已经接管。查看内核日志:
dmesg | grep mypci会看到我们dev_info输出的bar0地址。
模块卸载测试:
sudo rmmod mypci dmesg | tail卸载后lspci里Kernel driver in use会消失,说明remove执行正常。反复加载卸载测试是驱动开发第一课,很多隐藏的资源泄漏要在这个环节暴露。
还可以查看模块依赖关系和设备节点:
lsmod | grep mypci ls -l /dev/mypci*如果/dev下没节点,说明字符设备注册部分有问题,优先看class_create和device_create返回值。
4. 热插拔、AER与“掉卡降速”问题排查实录
4.1 PCIe热插拔机制与驱动侧配合
PCIe热插拔不是靠驱动实现的,它是由硬件加上ACPI固件,还有内核的pciehp模块协同完成的。当你在带电状态下拔出NVMe硬盘,固件会触发一个热插拔事件,内核收到后调用设备挂起、司机清理逻辑,最后移除sysfs设备节点。
对驱动来说,热插拔真正的考验是remove函数。设备拔出时不会先通知驱动“我快没了”,而是硬件链路直接断开,内核再调用remove。如果remove里还有DMA操作没有停,就会访问已经失效的BAR地址,可能造成内核崩溃或者机器检查异常。
所以驱动设计上要做到:DMA操作有超时机制,硬件拔出时能快速停止队列;remove里先禁中断、再停DMA、最后释放资源。顺序不能反,先释放IRQ再停DMA会导致中断处理程序访问已经不存在的硬件。
4.2 掉卡、降Speed/Lane的常见原因
线上最典型的PCIe故障是设备“掉卡”和“降速”。降速的意思是设备本来跑在PCIe 3.0 x8,某一天重启后变成PCIe 2.0 x2,或者运行中链路自动降级。
查看当前链路状态用:
lspci -vvv -s 03:00.0 | grep -E "LnkSta|LnkCap|LnkCtl"LnkCap是硬件支持的最大能力,LnkSta是当前协商结果。正常情况下两者应该一致或接近。如果LnkSta显示Speed 2.5GT/s、Width x1,而硬件明明支持8GT/s x16,基本就是链路训练出了问题。
常见原因有几个:
- 金手指/PCIe插槽氧化或有异物,接触电阻变大。
- 供电不足,大功率显卡或加速卡在负载拉高后链路不稳定。
- PCIe Gen设置被BIOS强制限制,比如平台只允许Gen3,但设备默认Gen4。
- PCB信号完整性差,高速信号抖动超标。
- 某些主板和特定设备之间有兼容性问题,需要在BIOS锁定Gen值。
先排除物理接触和供电,再考虑用setpci临时修改目标链路速度来测试:
setpci -s 03:00.0 CAP_EXP+0x30.w=0x2000写0x2000表示把这个设备的目标链路速度设置为Gen3。修改后需要重新训练链路,用lspci确认是否恢复。这个操作只是临时的,重启后恢复默认值,适合用来验证是不是Gen4协商问题。
4.3 AER错误与现代稳定性问题
AER是PCIe高级错误报告机制。现代主板和内核默认开启,设备出错后dmesg会刷出类似这样的信息:
pcieport 0000:00:01.0: AER: Corrected error received: 0000:03:00.0 pcieport 0000:00:01.0: AER: can't find device of ID0000:03:00.0AER错误分两类:
- Corrected:硬件已经纠正,不影响功能,比如链路带宽变化、内部错误计数。
- Uncorrected:又分Non-fatal和Fatal,Non-fatal可能只是某个功能失效,Fatal通常设备直接挂掉。
排查AER错误第一步是找到责任设备。上面日志里0000:03:00.0就是出错的Endpoint。如果错误反复出现,查看:
dmesg | grep -i "AER" cat /sys/bus/pci/devices/0000:03:00.0/aer_dev_correctable_lost cat /sys/bus/pci/devices/0000:03:00.0/aer_dev_uncorrectable_seen前一个表示可纠正错误丢失计数,后一个记录不可纠正错误是否产生过。这些sysfs节点在部分内核版本不一定存在,但大多数主线内核都有。
出现AER错误不要第一时间想着“关掉AER”或者忽略。要先看错误源是不是自己的设备,再判断错误类型。如果是Uncorrected且指向具体BAR地址,往往说明驱动访问了越界地址,或者设备固件有bug。我的习惯是拿到错误日志后,把驱动里的寄存器访问地址跟硬件寄存器手册逐一核对。
4.4 用lspci、dmesg快速定位故障案例
分享一个实际案例。运维反馈一台双路服务器的FPGA加速卡每隔几天就掉一次,业务中断几秒后恢复,lspci看到设备还在,但Kernel driver in use从myfpga变成空。
排查过程:
- 先看dmesg:
dmesg | grep -E "pciehp|AER|myfpga" | tail -100发现关键日志:AER: Uncorrected (Non-Fatal) error received: 0000:05:00.0,同时伴随myfpga 0000:05:00.0: DMA timeout。
- 用lspci看链路状态:
lspci -vvv -s 05:00.0 | grep -E "LnkSta|LnkCtl|ASPM"当前LnkSta已经变成2.5GT/s x1,明显降速。ASPM状态是Enabled,说明系统电源管理打开了ASPM。
- 判断是ASPM问题还是硬件问题。先用setpci把ASPM禁用:
setpci -s 05:00.0 CAP_EXP+0x10.w=0x0000同时在内核启动参数加pcie_aspm=off,重启后观察一周,故障没再出现。
- 后续建议厂商把BIOS里ASPM设置为Disabled,并在驱动probe里主动禁用物理链路的ASPM,避免省电策略导致的链路训练异常。
这类问题最迷惑的点是“设备没有彻底消失”,只是链路降速,导致上层业务偶发超时。所以排查PCIe稳定性问题,看一眼LnkSta比什么都快。
5. 一些让你少踩坑的实操心得
5.1 常见错误和排查速查表
把我在内核驱动开发和运维现场遇到过的高频问题整理成一个表,方便你直接对着查。
| 错误现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| probe返回-16(-EBUSY) | BAR资源被占用,或设备已经被其他驱动绑定 | lspci -vvv查看Kernel driver in use,卸载冲突驱动 |
| 读BAR返回0xffffffff | 设备未enable,或BAR资源未分配 | 先调pci_enable_device;检查BIOS/内核pci=realloc配置 |
| 中断不触发 | 驱动没用MSI,或dev_id传递有误 | 确认使用pci_alloc_irq_vectors和pci_irq_vector;打开/proc/interrupts看中断计数 |
| DMA数据全坏或超时 | 没有pci_set_master,或DMA方向错误 | 确认probe里调过pci_set_master;检查pci_map_single方向参数 |
| rmmod时崩溃 | remove释放顺序错误,地址已经释放又被访问 | 按“禁中断、停DMA、释放BAR、disable设备”顺序倒序清理 |
| AER大量可纠正错误 | 链路信号质量差,或设备固件异常 | 检查LnkSta降速、金手指清洁、关闭ASPM测试 |
| 热插拔后系统卡死 | 驱动remove里没有停止DMA | 给DMA操作加超时,设备拔出后立即回收队列 |
这个表我建议打印出来贴工位上。很多问题不是原理深,而是“忘了某个固定动作”。
5.2 我踩过的几个坑
第一个坑:最早写驱动只在probe里做了pci_enable_device,没做pci_request_regions,结果pci_iomap返回的地址读出来全是0。后来看代码发现相邻设备驱动把BAR0资源占用了,我还没申请就硬访问,内核不拦你才怪。从此规定自己:enable、request、iomap三件套必须成对出现。
第二个坑:有一次给多队列网卡写驱动,probe里申请了4个MSI-X中断,但remove里只释放了vector对应的IRQ,忘了pci_release_irq_vectors。结果模块卸载后,再次加载时pci_alloc_irq_vectors老是失败。查了半天才想明白,MSI-X资源没彻底回收。
第三个坑:寄存器写操作没有加内存屏障。CPU对PCIe设备的写操作是异步的,写完寄存器立刻去读DMA状态,读到的可能是旧值。更麻烦的是编译器可能把写操作重排到后面。解决办法很简单,在关键寄存器操作之间加wmb()或者用writel自带的屏障,虽然会牺牲一点性能,但稳定性优先。
第四个坑:中断处理函数里没有判断“是不是我的设备”。PCIe设备的中断可以共享,如果我在handler里不读硬件状态寄存器确认中断来源,收到别人设备的中断也返回IRQ_HANDLED,导致对方的handler被饿死,整个中断子系统表现就是“看起来驱动卡死”。现在我的handler第一行永远是读硬件状态寄存器,判断不是自己的中断就返回IRQ_NONE。
最后说说体会:Linux PCI驱动这块,文档再多都不如自己写一个最小驱动跑一遍。你只有亲手把一个设备从枚举到probe、再到DMA送数据走通,才会真正理解配置空间、BAR和pci_driver这套机制。遇到问题别急着改代码,先用lspci、dmesg把现场情况搞清楚,80%的疑难杂症其实都是资源没申请、顺序没搞对、链路降速这老三样。希望这份整理能帮你少熬几个夜。