简介:面向Linux驱动开发者的Xilinx PCIe驱动开发参考资料,以XDMA驱动为主线,梳理PCIe体系结构、设备枚举、驱动模型、中断处理、DMA配置、固件加载等关键环节,适合需要为FPGA板卡编写或移植Linux驱动的工程师与学习者。压缩包共27个文件,包含12个header头文件、7个C源文件及5个Makefile构建脚本,另有图片与运行结果文件,整体124KB,体量小巧便于快速查阅。头文件和源文件覆盖探测函数、寄存器映射、用户空间交互等核心代码,Makefile降低编译门槛。资源已有1227人学习下载,可作为调试PCIe驱动(如观察lspci、dmesg输出)时的参考,也能辅助理解XDMA驱动框架并适配不同FPGA设计,对进阶驱动开发有实用价值。
1. 从 linux_driver.rar 说开去:Linux PCIe 驱动到底在解什么题
很多工程师的硬盘里都躺着一个叫 linux_driver.rar 的压缩包,解开后是某个 PCIe 板卡的 Linux 驱动源码。但解开包只是开始,真正的题是:拿到一份陌生设备的驱动代码,怎么在目标内核上编过、装上、跑通,还要扛住掉卡、降速、AER 报错这些线上问题。Linux 下的 PCIe 驱动,本质是一个把设备的 BAR 空间、中断、DMA 资源交给内核和设备模型的「接线员」,同时也得负责把硬件异常翻译成内核能理解的事件。这篇文章就顺着这条线,把 PCIe 驱动从枚举匹配到资源分配,从稳定性验证到热插拔处理的落地路径一次讲透。适合正在调 PCIe 网卡、FPGA 板卡或国产平台加速卡驱动的工程师,也适合刚入驱动开发、想搞清 PCIe 子系统工作方式的新手。
2. 枚举到 probe:PCIe 驱动是怎么被内核找到并唤醒的
2.1 枚举阶段发生了什么:从 vendor/device ID 到 driver_override
先给结论:Linux 启动时的 PCIe 枚举由 PCI 子系统完成,和驱动模块加载是两回事。枚举是硬件扫描,驱动匹配是软件绑定,两个阶段在时间上分开,通过 vendor ID、device ID 和 class code 关联。pcie 枚举过程结束时,每个 PCIe endpoint 都会在 /sys/bus/pci/devices/ 下生成一个目录,里面有 vendor、device、subsystem_vendor 等文件。驱动模块加载时,内核拿这些 ID 去比对 pci_driver 里 id_table。
实际操作时,我先在启动完成的机器上执行 lspci -nn,确认设备被枚举出来,并记下它的[8086:1533]这类 ID。如果 lspci 里看不到设备,后面驱动再怎么写都白搭。这里有个容易翻车的地方:有些 PCIe bridge 或 switch 默认 disable 了下游端口,设备物理插着但系统枚举不到。这时候要去 BIOS 或引导参数里找原因,比如先加 pcie_aspm=off 再做对照。另一个常见坑是国产平台上非标准 class code 的设备被隐藏,导致驱动匹配不上,此时可以用 driver_override 强制指定驱动。sysfs 下echo "pcie_mini" > /sys/bus/pci/devices/0000:01:00.0/driver_override再触发 bind,就能绕过 class code 不匹配的问题。
2.2 最小 pci_driver 骨架:注册、匹配与 probe 入口
驱动的入口就是一个 pci_driver 结构体,配合 MODULE_DEVICE_TABLE 支持热插拔时的模块自动加载。下面是最小骨架:
/* pcie_mini.c - Linux PCIe 最小驱动骨架 */ #include <linux/module.h> #include <linux/pci.h> static int mini_probe(struct pci_dev *dev, const struct pci_device_id *id) { /* probe 返回 0 表示绑定成功 */ return 0; } static void mini_remove(struct pci_dev *dev) { } static const struct pci_device_id mini_pci_tbl[] = { /* 0x10ee 是常见 FPGA 厂商的 Vendor ID,板卡 Device ID 按实际替换 */ { PCI_DEVICE(0x10ee, 0x7021) }, { } }; MODULE_DEVICE_TABLE(pci, mini_pci_tbl); static struct pci_driver mini_driver = { .name = "pcie_mini", .id_table = mini_pci_tbl, .probe = mini_probe, .remove = mini_remove, }; module_pci_driver(mini_driver); MODULE_LICENSE("GPL");mini_pci_tbl 里用 PCI_DEVICE(vendor, device) 做匹配,0x10ee 是示例用的 FPGA vendor ID,0x7021 是板卡里的具体 device ID,读者按自己手头硬件替换。module_pci_driver 宏会自动展开成 module_init 和 module_exit,不用手写入口,少一个出错点。probe 返回 0 表示这个驱动认领了设备,返回负数(比如 -ENODEV)内核就换下一个驱动试。probe 里不要做耗时操作,PCIe 设备初始化通常都在 probe 上下文执行,超过几百毫秒会被 lockup detector 盯上,更会被用户投诉启动慢。device table 里的 subsystem ID 字段可以留空表示通配所有子设备,但同一 vendor/device 有多个修订版时,建议把 revision 字段纳入匹配条件,避免 A 版跑得好好的、B 版一上就出怪问题。
2.3 我的选型习惯:模块参数、device table 与多设备兼容
写驱动前,先确定三件事:设备是单 endpoint 还是挂在 switch 后面多 endpoint;上层协议是走网络子系统、块子系统,还是私有字符设备;要不要同时支持多个相同类型的卡。这三点决定 id_table 怎么写、设备私有数据怎么组织。
多设备场景下最稳妥的做法是每个 probe 到的设备分配一个私有结构体,用 pci_set_drvdata 挂到 pci_dev 上。千万别用全局变量硬扛,第二个设备 probe 时会把第一个设备的资源覆盖掉,两个设备一起用的时候现场会很惨。模块参数方面,我一般固定留三个:disable_msix 用来对比中断模式;nr_queues 控制队列数压力测试用;debug 控制 printk 输出级别。PCIe 驱动的时序问题很难光靠看代码定位,必须能在 probe 的关键步骤后打印寄存器值。
驱动初始化顺序有个铁律:使能设备 -> 设 DMA 掩码 -> 映射 BAR -> 申请中断 -> 初始化硬件 -> 注册子系统接口。反过来做,用户态程序一进来就会碰到一个没准备好的设备。顺便提一句,虚拟机里装 Linux 镜像再用 virtio 网卡,走的同样是 PCIe 枚举和设备模型,这个顺序在里面同样适用,理解了这一层,调试 virtio-pci 的驱动也不会觉得陌生。
3. 把设备用起来:BAR 映射、MSI-X 与 DMA 资源的落地配置
3.1 BAR 空间映射:ioremap 与 readl/writel 的正确的打开方式
PCIe endpoint 的寄存器空间通过 BAR 暴露给软件。驱动要访问这些寄存器,先读出 BAR 的地址和长度,再把物理地址映射到内核虚拟地址空间。Linux 里推荐用 pcim_iomap,而不是直接 ioremap(pci_resource_start())。原因很简单:pcim_iomap 是 devres 版本,probe 失败或 remove 时自动释放,不会泄漏映射,而且它内部处理了 I/O 端口和 memory 空间的差异,不用驱动关心。
/* BAR 映射的推荐写法 */ void __iomem *bar0; int bar_len; bar_len = pci_resource_len(pdev, 0); if (bar_len <= 0) return -ENXIO; bar0 = pcim_iomap(pdev, 0, bar_len); if (!bar0) return -ENOMEM; /* 读取版本寄存器,偏移 0x00 放版本号是多数板卡的惯例 */ u32 ver = readl(bar0 + 0x00); dev_info(&pdev->dev, "FPGA version 0x%x\n", ver);readl 是 32 位读,对应 writel 是 32 位写。寄存器偏移 0x00 放版本号是绝大多数板卡的惯例,但 FPGA 板卡要特别注意位宽和字节序:有些 IP 核把版本号放在 0x04,有些实现成 64 位寄存器,readl 只读低 32 位就会拿到错值。另一个坑是 BAR 长度:PCIe 规范要求 BAR 长度是 2 的幂,但固件里 mask 写错会导致 pci_resource_len 跟实际不符合,这时要回固件上修,驱动里怎么绕都绕不过去。至于 ioremap 还是 pcim_iomap,我的建议是只在老内核上才用 ioremap,新环境一律 pcim_iomap,少写一堆错误处理。
注意:readl/writel 的地址一定是映射后的虚拟地址,不是 BAR 物理地址,也不是 dma_handle。这个混用是新手翻车高发区。
3.2 中断选型:MSI-X 优先,INTx 留作回退
PCIe 设备的中断有三档:MSI-X、MSI、INTx。新设备应该优先 MSI-X,支持每个队列独立中断,还能避开共享中断的互相干扰。申请的顺序是先 pci_alloc_irq_vectors 试探,再按返回值决定回退策略。
/* 先申请 MSI-X,失败自动回退 MSI,再回退 INTx */ int nvec = pci_alloc_irq_vectors(pdev, 1, 4, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_INTX); if (nvec < 0) return nvec; for (int i = 0; i < nvec; i++) { int irq = pci_irq_vector(pdev, i); /* 最后一个参数传私有结构体,不能传 NULL */ if (request_irq(irq, mini_irq_handler, 0, "pcie_mini", dp)) return -EIO; }pci_alloc_irq_vectors 的三个参数分别是最小向量数、最大向量数和中断类型 flag。PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_INTX 表示按优先级逐个尝试。返回值是实际拿到的向量数,可能小于最大值。现代内核用这个 API 就对了,pci_enable_msix 那套老接口少碰。申请中断时最后一个参数必须传私有数据结构而不是 NULL,否则 handler 里只能靠全局变量索引设备,多实例瞬间翻车。中断 handler 里只做快事:读中断状态寄存器、清中断、唤醒 tasklet 或 workqueue,把耗时处理扔出去。
3.3 DMA 缓冲区:dma_alloc_coherent 与流式映射的分工
PCIe 驱动的 DMA 按用途分两类。一致性映射用于驱动和硬件都要持续访问的缓冲区,比如命令队列和描述符环,用 dma_alloc_coherent;流式映射用于一次性数据搬运,比如网卡 skb 或用户态 IO 缓冲区,用 dma_map_single。
/* 一致性 DMA 缓冲区,硬件和 CPU 共享且无需手动刷 cache */ dma_addr_t dma_handle; void *buf; buf = dma_alloc_coherent(&pdev->dev, 4096, &dma_handle, GFP_KERNEL); if (!buf) return -ENOMEM; /* 把设备视角 DMA 地址分别写入寄存器低 32 位和高 32 位 */ writel(lower_32_bits(dma_handle), bar0 + 0x10); writel(upper_32_bits(dma_handle), bar0 + 0x14);返回的 dma_handle 是设备视角的 DMA 地址,不是 CPU 物理地址。IOMMU 开启时两者往往不一致,千万别拿它做 virt_to_phys 或 phys_to_virt 转换。lower_32_bits 和 upper_32_bits 是处理 64 位地址的标准手段,前提是设备寄存器支持分开写高低位。如果板卡只实现 32 位地址接口,高 32 位寄存器可能根本不存在,写进去的数据会被忽略,此时只能把 DMA 掩码设成 32 位。dma_map_single 的典型场景是把一个 skb 的 data 区映射给网卡 DMA,注意流式映射必须配对 dma_unmap_single,否则长时间跑下去 DMA 地址空间会耗光,这个泄漏比内存泄漏还难查。
3.4 一个可抄的 probe 完整示例
把上面的碎片拼起来,做一个最小完整的 probe:
static int mini_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct mini_dev *dp; int ret, nvec; /* 使能设备,devres 会自动管理后续释放 */ ret = pcim_enable_device(pdev); if (ret) return ret; /* 开启总线主控,否则硬件无法发起 DMA */ pci_set_master(pdev); /* 先试 64 位掩码,失败回退 32 位 */ ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64)); if (ret) { ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(32)); if (ret) return ret; } dp = kzalloc(sizeof(*dp), GFP_KERNEL); if (!dp) return -ENOMEM; dp->pdev = pdev; pci_set_drvdata(pdev, dp); /* 映射 BAR0 */ dp->bar0 = pcim_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!dp->bar0) return -ENOMEM; /* 申请一个 MSI-X 向量,失败自动回退 */ nvec = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSIX | PCI_IRQ_MSI); if (nvec < 0) return nvec; ret = request_irq(pci_irq_vector(pdev, 0), mini_irq_handler, 0, "pcie_mini", dp); if (ret) return ret; /* 分配 4KB 一致性 DMA 缓冲区 */ dp->dma_buf = dma_alloc_coherent(&pdev->dev, 4096, &dp->dma_handle, GFP_KERNEL); if (!dp->dma_buf) return -ENOMEM; /* 最后再初始化硬件,把队列基地址写入 BAR 寄存器 */ mini_hw_init(dp); return 0; }顺序是有讲究的:pcim_enable_device 内部做了 pci_enable_device 并注册 devres;pci_set_master 忘记调用的话,设备发起 DMA 会直接被总线拒绝;DMA 掩码先设 64 再回退 32,因为有些设备支持 64 位但固件默认 32 位,主动升级掩码能避免 IOMMU 下的地址截断。request_irq 注册的中断没走 devres,probe 失败时要手动 free_irq,后续代码最好把释放逻辑集中在 remove 里,避免每个错误分支重复写。
4. 避坑指南:掉卡、降速、AER 报错与热插拔的排查顺序
4.1 掉卡与降 speed/lane:先从链路协商找原因
现象:驱动加载时 lspci 显示 5GT/s x4,跑几分钟后变成 2.5GT/s x1,或者设备直接从 lspci 里消失。原因可以列一串:链路协商不稳定、供电不足、PCIe 时钟源质量差、BIOS 里 ASPM 节能策略和驱动不配合。排查顺序建议固定在「物理 -> BIOS -> 内核 -> 驱动」这条路线上。
先看 dmesg 里有没有 pcieport 报 link down,再用 lspci -vvv 盯当前链路状态。ASPM 是最常见的软件干预点,内核启动参数 pcie_aspm=off 一把关掉最省事,也可以只对该设备关,通过 sysfs 写 LinkCap 和 LinkCtl。还有一种情况是物理接触不良,PCIe 信号完整性非常敏感,机箱搬动一下就会掉卡。这种玄学问题,先排除物理层再谈软件,否则驱动改得再好也白搭。有些人会尝试解锁 PCIe 2.0 绕过链路协商问题,但我不建议在生产环境这么干,根因没解决,换一台机器还会翻车。
4.2 AER 报错:分清致命错误、非致命错误与可恢复错误
现象:dmesg 连续刷pcieport 0000:xx:xx.0: PCIe Bus Error: severity=Uncorrected (Fatal)。原因可能是设备发起非法访问、UR 包、ECRC 错误等。处理 AER 的第一步不是去看代码,而是分清楚 severity 类型:
- Corrected:比如 ECRC 错误但硬件能纠正,驱动通常不需要介入,统计即可;
- Uncorrected Non-Fatal:比如事务超时,驱动往往需要重置队列或重新初始化硬件;
- Uncorrected Fatal:链路已挂,一般只能热复位或重新枚举。
排查套路是先看 AER 报错的 device 和 error status 寄存器,再决定加 pci=noaer 临时屏蔽还是认真处理。很多 FPGA 板卡在 DMA 地址写错时会踩 Fatal 错误,典型根因是驱动把 dma_handle 的高 32 位写到了低 32 位寄存器,地址直接飞到某块系统内存区域,马上就是 UR 和 Fatal。这类问题的根永远在驱动,不在链路。如果 AER 报错集中出现在驱动加载或卸载瞬间,优先怀疑驱动访问了未映射的 BAR 偏移。
注意:pci=noaer 只能作为临时手段,它能盖住问题但盖不住故障。带着 Fatal 错误上线,迟早要在用户现场炸。
4.3 热插拔场景下驱动必须处理的三个状态
现象:支持热插拔的背板把卡拔出后,驱动没走 graceful 流程,内核 panic;插入新卡后 probe 不执行。原因:驱动没注册 pci_error_handlers 的回调,或者没处理 surprise removal。驱动侧至少覆盖三个状态:拔出前的通知、remove 回调的资源回滚、reset 后的重新初始化。
值得注意,pciehp 和 acpiphp 的通知路径不同。消费级 ExpressCard 常用 pciehp,服务器背板常用 acpiphp。两个子系统绑定的事件对象不一样,调试前先确认自己设备挂在哪条路径下。我习惯先用echo 1 > /sys/bus/pci/devices/0000:01:00.0/remove模拟热移除,观察驱动的 remove 执行顺序,再考虑真实插拔测试。第一次就上真背板测,出问题都不知道该看内核还是看背板。
4.4 兼容性问题的常用排查链路
现象:同一驱动的同一二进制,在 A 主板上正常,在 B 主板上不 probe,国产 Linux 发行版镜头上尤其常见。原因分类:BIOS 端口没 enable、中断路由表缺 ACPI entry、IOMMU 开启后 DMA 地址被拦截。排查链路我习惯从外到内走五步:
- lspci -nn 确认设备枚举 ID 正确;
- dmesg | grep pcie 看链路是否 up,speed/lane 是否符合预期;
- cat /sys/bus/pci/devices/0000:.../enable 确认设备状态;
- 进 BIOS 关掉 ASPM 和 IOMMU 做对照实验,关掉 IOMMU 就正常,多半是 DMA mask 没设置到位;
- 用 lspci -xxx 抓配置空间,确认固件有没有把不该 disable 的 bit 置上。
这类问题要留证据:lspci -vvv 输出、完整 dmesg 时间戳、内核版本和 config。另外注意国产 Linux 发行版的镜像内核版本差异很大,外部编译的 .ko 常报 version magic 或 Unknown symbol,这属于模块与内核头文件不匹配,重新在目标内核源码树下编译一遍通常能解决。
5. 稳定性验证:用 lspci、AER 统计与压力脚本证明驱动能上线
5.1 lspci -vvv 解读:speed/lane/ASPM 的实际状态
要让驱动从「能跑」变成「能上线」,先学会读 lspci -vvv。重点看四个字段:LnkSta(当前链路速度和宽度)、LnkCap(设备支持的极限)、DevCtl 里的 ASPM 设置、AERCap 是否开启。LnkSta 的LnkSta: Speed 5GT/s, Width x4表示当前跑了 5GT/s 四通道,如果卡标称 8GT/s 但这里显示 2.5GT/s,说明上电协商阶段就掉速了,驱动救不回来,只能查物理和固件。
有个细节:lspci 读的是实时寄存器,链路降速后 LnkSta 会跟着变。所以掉卡后的第一动作是保存 lspci -vvv 输出,这就是现场的一手证据。我通常把这个输出连同 dmesg 一起归档,文件名带上时间戳,回滚排查时对比才有意义。
5.2 AER 日志统计与脚本化触发
上线前按以下步骤做 AER 基线和回归。先确认内核开了 CONFIG_PCIEAER,再用脚本轮询 dmesg 里的 AER 计数,配合每个测试用例记录前后差值。
#!/bin/bash # 统计 dmesg 中 AER 错误计数,落盘避免环形缓冲覆盖 while true; do dmesg | grep -c "PCIe Bus Error" sleep 10 done >> aer_count_$(date +%m%d_%H%M).log注意 dmesg 环形缓冲可能把早期日志冲掉,长时间压力测试要改用 dmesg -w 落盘或 journalctl -k。统计时不要只数 Corrected 错误,还要记录 device 和 severity 分布。我的验收标准:Corrected 少量可接受;Non-Fatal 每万轮不超过 1 次;Fatal 一次都不能有。这个标准不高,但能拦住大多数赶工上线的驱动。
5.3 压力测试脚本的套路
PCIe 驱动的压力测试分三条线:枚举压力、数据面压力、异常注入。枚举压力是反复 rmmod 和 modprobe 各 200 次,观察 probe/remove 是否泄漏;数据面压力是启用多队列并发跑 24 小时;异常注入则是故意写错 DMA 地址或触发中断风暴,观察 AER 是否兜得住。
# 冒烟测试:触发 DMA 回环并读取统计 modprobe pcie_mini nr_queues=4 debug=1 for i in $(seq 1 1000); do # 触发一次 DMA 回环,具体寄存器按板卡手册调整 devmem 0x0 32 0x1 sleep 1 cat /sys/kernel/debug/pcie_mini/stat done这段脚本只能算冒烟,真正上线前至少要完整环境跑 24 小时,最好带上温箱。同时打开 IOMMU 做一轮对照,很多 DMA 掩码设错的问题只会在 IOMMU 开启时暴露。跑长稳的时候,用 nohup 或 setsid 把脚本挂在后台,别开着终端傻等,ssh 一断脚本就没了。
5.4 与 xilinx pcie 等 FPGA 方案联调时的关注点
厂商给的 linux_driver.rar 里,FPGA 方案大多是围绕 Xilinx DMA/Bridge Subsystem 的样例驱动改的。联调重点关注 DMA 地址位宽:不少 FPGA IP 只实现 32 位地址接口,驱动设了 64 位掩码,数据写到高地址时总线直接挂。常见现象是高地址位读回为 0,解决办法是把 dma_set_mask 设为 32 位,或者换一个带地址扩展的 IP 配置。
另一个高频坑是 FPGA 加载时序。PCIe endpoint 冷启动时固件还没加载完,配置读取会失败,驱动 probe 碰到 -110 超时。这种情况 probe 应返回 -EPROBE_DEFER,等固件就绪后内核会重新 probe。如果板卡没有 ready 信号,退而求其次,modprobe 后延时几秒再激活设备,实测能缓解大部分问题,但这不是长久之计。仿真阶段可以用 PCIe 仿真平台在开发板上模拟链路各状态,但仿真通过不等于真机通过,信号完整性那部分模型很难仿准,最终还是得上真机压测。
6. 进阶:热插拔状态机与驱动卸载顺序的细节
6.1 热插拔驱动的状态机实现
支持热插拔的驱动,不能只有一个 probe/remove,要在私有结构体里维护状态机。常见状态为:UNINIT、PROBED、ACTIVE、SUSPENDED、REMOVING。u 热移除事件到来时,先进入 REMOVING,停止所有数据通路,清中断,最后释放 DMA 缓冲区。状态机至少覆盖三个入口:probe 初始化为 PROBED;加载完成后转 ACTIVE;收到 remove 或 surprise removal 事件时按顺序回滚。用原子变量保护状态切换,避免中断上下文和用户态操作同时改状态。
6.2 remove 与 shutdown 的调用顺序坑
很多驱动在 remove 里释放中断和 DMA,但忽略 shutdown。系统关机或重启时走的是 shutdown 而不是 remove,如果 shutdown 里不停 DMA,设备可能在断电瞬间还在写内存,导致开机后文件系统损坏。shutdown 回调里只需做一件事:把设备安静下来,关中断、停 DMA、mask 错误上报。不需要释放内存,反正系统要关了。这个细节我在不止一个项目里翻过车,看起来像偶发的磁盘损坏,实际是驱动的 DMA 没停干净。
还有一个顺序坑:remove 里先 free_irq 再注销子系统接口,还是反过来?正确的顺序是先注销接口,确保用户态程序不再进入设备,再关中断,最后释放 DMA。反过来会有用户态操作打到已经关闭的中断上,轻则 -EIO,重则死锁。我自己被这个顺序坑过一回,跟用户态同事排了两天才发现是 remove 里先关了中断,导致并发 close 阻塞着不返回。从那以后,我写驱动的 remove 都固定按「接口下线 -> 停硬件 -> 放中断 -> 放 DMA -> 释放私有结构体」这个顺序来,probe 则严格逆序。希望你少走这个弯路。
把热插拔状态机和 remove/shutdown 的顺序写清楚,驱动才算真正完整。PCIe 驱动调试没有银弹,无非是枚举、资源、中断、DMA 这几件事,配合 AER 和链路状态做好验证,多数问题都能在进现场之前暴露出来。希望帮到你。
本文还有配套的精品资源,点击获取