Linux PCI/PCIe驱动开发实战:从枚举到BAR映射与故障排查

发布时间:2026/10/7 19:44:16
Linux PCI/PCIe驱动开发实战:从枚举到BAR映射与故障排查 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_tablestatic struct pci_device_id mypci_ids[] { { PCI_DEVICE(0x1234, 0x5678) }, { 0 } }; MODULE_DEVICE_TABLE(pci, mypci_ids);PCI_DEVICE(0x1234, 0x5678)要求vendor ID匹配0x1234device 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。我们驱动模块叫mypciMakefile写成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_DEVICECPU写数据后让设备读。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|LnkCtlLnkCap是硬件支持的最大能力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_EXP0x30.w0x2000写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: cant find device of ID0000:03:00.0AER错误分两类Corrected硬件已经纠正不影响功能比如链路带宽变化、内部错误计数。Uncorrected又分Non-fatal和FatalNon-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变成空。排查过程先看dmesgdmesg | 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_EXP0x10.w0x0000同时在内核启动参数加pcie_aspmoff重启后观察一周故障没再出现。后续建议厂商把BIOS里ASPM设置为Disabled并在驱动probe里主动禁用物理链路的ASPM避免省电策略导致的链路训练异常。这类问题最迷惑的点是“设备没有彻底消失”只是链路降速导致上层业务偶发超时。所以排查PCIe稳定性问题看一眼LnkSta比什么都快。5. 一些让你少踩坑的实操心得5.1 常见错误和排查速查表把我在内核驱动开发和运维现场遇到过的高频问题整理成一个表方便你直接对着查。错误现象可能原因排查/解决办法probe返回-16-EBUSYBAR资源被占用或设备已经被其他驱动绑定lspci -vvv查看Kernel driver in use卸载冲突驱动读BAR返回0xffffffff设备未enable或BAR资源未分配先调pci_enable_device检查BIOS/内核pcirealloc配置中断不触发驱动没用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%的疑难杂症其实都是资源没申请、顺序没搞对、链路降速这老三样。希望这份整理能帮你少熬几个夜。