1. 从一张“掉卡”工单说起:PCI设备驱动到底在管什么
前阵子帮朋友排查一台工控机的故障,现象很典型:系统跑着跑着,一块通过PCIe插槽扩展的网卡就“消失”了,lspci里还能看到设备,但ifconfig里网口没了,dmesg里刷出一串 AER 报错。他第一反应是硬件坏了,换了块卡,问题依旧。最后定位下来,是驱动在链路训练和错误恢复上的处理不够健壮,加上主板 BIOS 里 ASPM 配置和卡本身兼容性有冲突。
这个案例几乎把 PCI 设备驱动开发里最容易踩的坑都串起来了:枚举、配置空间、BAR 映射、中断、DMA、错误处理、热插拔。很多人学 Linux 驱动是从字符设备入手的,觉得 PCI 驱动“高不可攀”,其实它只是多了一层“设备发现与资源分配”的机制,核心的 file_operations、中断处理、DMA 那一套是相通的。这篇文章我就按一个从业者的视角,把 Linux PCI 设备驱动从原理到实操完整拆一遍,尽量把那些文档里不写、但实际调试中一定会遇到的东西讲透。
适合谁看?如果你已经写过简单的字符设备驱动,想往 PCI/PCIe 方向进阶;或者你是嵌入式、服务器运维、内核调试方向,经常要和lspci、dmesg、AER 报错打交道,这篇内容应该能帮你把零散的知识点串成一条线。我会尽量用生活化的类比解释协议概念,同时给出可以直接参考的代码骨架和调试命令。
2. PCI/PCIe 驱动整体设计与思路拆解
2.1 为什么 PCI 驱动不能像字符设备那样“自己注册自己”
字符设备驱动的套路是:模块加载时主动register_chrdev,然后等用户空间open。设备是“静态存在”的,驱动知道自己在管谁。PCI 驱动完全不是这个逻辑——设备是总线枚举出来的,驱动是被“匹配”上去的。
打个比方:字符设备驱动像是你开了一家店,挂上招牌等客人上门;PCI 驱动像是你是一个维修工,坐在劳务市场里,总线(相当于中介)拿着设备清单挨个问“谁会修这个型号”,你举手说“我会”,然后才被派活。这个“举手”的动作,就是pci_driver结构体里的id_table和probe回调。
所以 PCI 驱动的骨架天然是“注册驱动 → 总线匹配 → probe 被调用 → 申请资源 → 初始化硬件 → 注册用户接口”这条链路。理解这一点,后面所有代码结构就顺了。
2.2 三个核心结构体的分工
写 PCI 驱动绕不开三个结构体,我把它们的分工列成表,方便对照记忆:
| 结构体 | 角色 | 关键成员 | 生命周期 |
|---|---|---|---|
struct pci_driver | 驱动“身份证” | name、id_table、probe、remove | 模块加载到卸载 |
struct pci_dev | 内核眼中的设备 | vendor、device、irq、resource[] | 设备插入到移除 |
struct pci_device_id | 匹配规则 | vendor、device、subvendor、class | 静态定义 |
pci_device_id是匹配的“暗号”。内核枚举到设备后,拿设备的 vendor/device ID 去和每个驱动的 id_table 比对,匹配上了就调用对应驱动的 probe。这里有个细节:PCI_DEVICE宏只匹配 vendor+device,而PCI_DEVICE_CLASS是按类别匹配,后者适合写“通用型”驱动,比如你要管所有类型的存储控制器。
2.3 方案选型:为什么优先用pci_register_driver而不是手动遍历
有些老代码会手动pci_get_device去遍历总线找设备,然后自己初始化。这种写法现在基本不推荐,原因有三:
- 热插拔支持差:手动遍历只在加载那一刻找一次,设备后插入就漏了;
pci_register_driver是事件驱动的,新设备插入会自动触发 probe。 - 资源竞争:多个驱动抢同一个设备时,总线匹配机制有锁保护,手动遍历容易出竞态。
- 电源管理割裂:
pci_driver里可以挂.suspend/.resume回调,手动遍历的驱动很难和系统电源管理框架对接。
所以除非你有非常特殊的场景(比如要在一个驱动里管多个不同类设备),否则老老实实用pci_register_driver。
3. 核心细节解析与实操要点
3.1 配置空间:设备的“简历”和“控制面板”
PCI 设备的配置空间是 256 字节(PCIe 扩展到 4KB),前 64 字节是标准头部,后面是能力结构(Capability)。标准头部里最关键的是这几个字段:
- Vendor ID / Device ID:厂商和设备型号,匹配用。
- Command / Status:控制位,比如使能 IO、Memory、Bus Master。
- BAR0~BAR5:基地址寄存器,决定设备要多少地址空间。
- Interrupt Line / Pin:中断信息。
读配置空间用pci_read_config_byte/word/dword,写用pci_write_config_*。这里有个新手常踩的坑:写 Command 寄存器使能 Bus Master 之前,DMA 是跑不起来的。很多人 probe 里忘了这一步,结果 DMA 传输一直超时,查半天以为是地址映射问题。
/* 使能 Memory 空间和 Bus Master,DMA 必需 */ pci_set_master(pdev); /* 内部就是置位 Command 的 Bus Master 位 */pci_set_master这个封装函数建议直接用,它比手动读改写更安全,内部处理了锁和状态检查。
3.2 BAR 映射:把设备的“窗口”接到内核地址空间
BAR 是设备向系统申请的地址窗口。设备上的寄存器、FIFO、DMA 描述符都挂在这个窗口里。驱动要做的是把这个物理地址映射到内核虚拟地址,之后用readl/writel访问。
映射流程:
- 用
pci_resource_start(pdev, bar)拿到物理起始地址。 - 用
pci_resource_len(pdev, bar)拿到长度。 - 用
pci_resource_flags判断是 IO 还是 Memory 空间。 - 用
ioremap或pci_iomap映射。
res_start = pci_resource_start(pdev, 0); res_len = pci_resource_len(pdev, 0); if (!res_start || !res_len) { dev_err(&pdev->dev, "BAR0 invalid\n"); return -ENODEV; } /* 先申请资源,防止被别的驱动抢 */ if (pci_request_region(pdev, 0, "my_pci_driver")) { dev_err(&pdev->dev, "BAR0 request failed\n"); return -EBUSY; } base = pci_iomap(pdev, 0, res_len); if (!base) { pci_release_region(pdev, 0); return -ENOMEM; }注意:
pci_request_region一定要在pci_iomap之前调用。我见过有人先映射再申请,结果两个驱动映射了同一块 BAR,访问时数据错乱,排查了很久。
3.3 中断处理:从 INTx 到 MSI/MSI-X
PCI 设备的中断经历了 INTx(传统引脚中断)→ MSI → MSI-X 的演进。MSI-X 支持最多 2048 个独立中断向量,对多队列网卡、NVMe 这类高吞吐设备几乎是标配。
申请中断的推荐写法:
/* 优先尝试 MSI-X,失败退 MSI,再退 INTx */ nvec = pci_alloc_irq_vectors(pdev, 1, MAX_VECTORS, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (nvec < 0) return nvec; for (i = 0; i < nvec; i++) { irq = pci_irq_vector(pdev, i); ret = request_irq(irq, my_isr, 0, "my_pci", priv); if (ret) goto err_free_vectors; }pci_alloc_irq_vectors这个接口比老的pci_enable_msix好用太多,它自动处理了降级逻辑。中断处理函数里要注意:MSI-X 的每个向量是独立的,不要假设所有中断都走同一个 handler,多队列场景下每个队列一个向量,handler 里要通过dev_id区分。
3.4 DMA 与一致性映射
DMA 是 PCI 驱动性能的关键。核心概念是“设备看到的地址”和“CPU 看到的地址”可能不一样,中间隔着 IOMMU。所以不能直接把kmalloc返回的虚拟地址丢给设备,必须用 DMA API 转换。
两种典型用法:
- 一致性映射(
dma_alloc_coherent):适合长期存在的描述符环,CPU 和设备都能访问,不需要手动同步。 - 流式映射(
dma_map_single/dma_map_sg):适合一次性数据传输,用完要dma_unmap。
/* 一致性映射,用于 DMA 描述符环 */ desc_ring = dma_alloc_coherent(&pdev->dev, ring_size * sizeof(struct desc), &desc_phys, GFP_KERNEL); if (!desc_ring) return -ENOMEM; /* 把 desc_phys 写进设备寄存器,设备就能找到描述符环了 */ writel(desc_phys, base + REG_DESC_BASE);实操心得:
dma_alloc_coherent分配的内存默认是写合并的,如果你需要强顺序保证(比如门铃寄存器),要用writel配合wmb()内存屏障,别指望编译器帮你保序。
4. 实操过程与核心环节实现
4.1 完整驱动骨架:从模块加载到设备探测
下面给一个可以直接编译的最小 PCI 驱动骨架,我把它拆成几个部分讲。
#include <linux/module.h> #include <linux/pci.h> #include <linux/interrupt.h> #define DRV_NAME "my_pci_drv" #define BAR_INDEX 0 struct my_priv { struct pci_dev *pdev; void __iomem *base; int irq; struct dma_ring *ring; dma_addr_t ring_phys; }; static struct pci_device_id my_pci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, /* 替换成实际 vendor/device */ { 0, } }; MODULE_DEVICE_TABLE(pci, my_pci_ids); static irqreturn_t my_isr(int irq, void *dev_id) { struct my_priv *priv = dev_id; u32 status = readl(priv->base + REG_INT_STATUS); if (!(status & INT_MASK)) return IRQ_NONE; /* 清中断,处理业务 */ writel(status, priv->base + REG_INT_STATUS); return IRQ_HANDLED; } static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_priv *priv; int ret, nvec; ret = pci_enable_device(pdev); if (ret) return ret; pci_set_master(pdev); /* 使能 Bus Master,DMA 必需 */ ret = pci_request_region(pdev, BAR_INDEX, DRV_NAME); if (ret) goto err_disable; priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) { ret = -ENOMEM; goto err_release; } priv->pdev = pdev; pci_set_drvdata(pdev, priv); priv->base = pci_iomap(pdev, BAR_INDEX, 0); if (!priv->base) { ret = -ENOMEM; goto err_release; } nvec = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (nvec < 0) { ret = nvec; goto err_unmap; } priv->irq = pci_irq_vector(pdev, 0); ret = request_irq(priv->irq, my_isr, 0, DRV_NAME, priv); if (ret) goto err_vectors; /* 硬件初始化:复位、配置、启动 */ writel(0x1, priv->base + REG_CTRL); dev_info(&pdev->dev, "probe ok, irq=%d\n", priv->irq); return 0; err_vectors: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, priv->base); err_release: pci_release_region(pdev, BAR_INDEX); err_disable: pci_disable_device(pdev); return ret; } static void my_remove(struct pci_dev *pdev) { struct my_priv *priv = pci_get_drvdata(pdev); writel(0x0, priv->base + REG_CTRL); /* 先停硬件 */ free_irq(priv->irq, priv); pci_free_irq_vectors(pdev); pci_iounmap(pdev, priv->base); pci_release_region(pdev, BAR_INDEX); pci_disable_device(pdev); } static struct pci_driver my_pci_driver = { .name = DRV_NAME, .id_table = my_pci_ids, .probe = my_probe, .remove = my_remove, }; module_pci_driver(my_pci_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("your name"); MODULE_DESCRIPTION("Minimal PCI driver skeleton");这个骨架里,probe的错误处理路径是重点。注意看err_*标签的顺序:资源申请和释放必须严格逆序,先申请的(enable_device)最后释放,后申请的(irq)先释放。这是驱动开发里最容易出内存泄漏和资源残留的地方。
4.2 参数计算:BAR 大小怎么确定
BAR 的大小不是猜的,是设备硬件决定的。配置空间里 BAR 的低位有标志位(bit0 表示 IO/Memory,bit1/2 表示类型),高位是地址。确定大小的标准做法是:往 BAR 写全 1,再读回来,能置 1 的位就是可寻址范围。
内核已经帮我们封装好了,pci_resource_len返回的就是设备实际申请的长度。但调试时你可能需要自己算,比如判断设备是不是要了 64KB 空间:
# 在用户空间看 BAR 信息 lspci -vv -s 01:00.0 | grep -A2 "Region 0" # 输出类似:Region 0: Memory at f7c00000 (64-bit, non-prefetchable) [size=64K]如果size显示的是[size=16M]但你只映射了 64KB,访问超出部分就会触发异常。所以pci_iomap时长度参数建议传 0,让内核自动用pci_resource_len的值,避免手算错误。
4.3 实操现场:用 QEMU 验证驱动
没有真实硬件也能练。QEMU 支持-device参数模拟 PCI 设备,配合edu设备(QEMU 自带的教学用 PCI 设备)非常适合练手:
qemu-system-x86_64 \ -kernel bzImage \ -append "console=ttyS0 root=/dev/sda" \ -drive file=rootfs.img,format=raw \ -device edu \ -nographicedu设备的 vendor/device ID 是0x1234:0x11e8,把它填进id_table就能匹配上。edu有 BAR0(寄存器)、BAR1(DMA 测试)、中断,麻雀虽小五脏俱全,拿来验证 probe、中断、DMA 全流程非常合适。
启动后在系统里执行:
lspci -nn | grep 1234 # 01:00.0 Class 00ff: 1234:11e8 dmesg | grep my_pci_drv # [ 12.3] my_pci_drv 0000:01:00.0: probe ok, irq=24看到probe ok就说明匹配和初始化成功了。
5. 常见问题与排查技巧实录
5.1 掉卡、降速、AER 报错怎么查
这是 PCIe 稳定性问题里最高频的一类。现象是设备时好时坏,dmesg里出现AER: Corrected error或Uncorrected error。排查思路按这个顺序走:
| 现象 | 可能原因 | 排查命令 |
|---|---|---|
| 设备消失 | 链路训练失败 | lspci -vv看 LnkSta |
| 降速到 Gen1 | 信号完整性差 | lspci -vv | grep LnkSta |
| AER 报错 | 硬件/BIOS 兼容 | dmesg | grep -i aer |
| DMA 超时 | Bus Master 未使能 | 检查pci_set_master |
lspci -vv里的LnkSta字段会显示当前链路速度和宽度,比如Speed 8GT/s, Width x4。如果设备标称 Gen3 x8,实际跑在 Gen1 x1,那基本是物理层问题——金手指氧化、插槽接触不良、走线太长。我遇到过一块卡插在转接卡上,转接卡质量差导致降速,直插主板就正常了。
AER 报错的处理要分 Corrected 和 Uncorrected。Corrected 是可纠正错误,硬件自动恢复了,但频繁出现说明链路质量在恶化;Uncorrected 是致命错误,通常会导致设备不可用。内核有pci=noaer参数可以关掉 AER 报告,但这只是掩盖问题,不建议在生产环境用。
5.2 probe 没被调用?先查匹配
新手最常见的问题:驱动加载了,lsmod能看到,但probe死活不执行。九成是id_table没匹配上。排查步骤:
lspci -nn确认设备的 vendor:device ID。- 检查
id_table里的宏用的是PCI_DEVICE还是PCI_DEVICE_CLASS,两者匹配字段不同。 - 看
dmesg有没有no driver found之类的提示。 - 确认设备没有被其他驱动先占用了(
lspci -k看Kernel driver in use)。
lspci -k -s 01:00.0 # Kernel driver in use: my_pci_drv # Kernel modules: my_pci_drv如果Kernel driver in use显示的是别的驱动,说明你的驱动没抢到,要么改 id_table 更精确,要么先卸载那个驱动。
5.3 中断收不到的几个隐蔽原因
中断不触发,除了硬件问题,软件上常见这几个坑:
- 中断没使能:设备侧的
REG_INT_EN没打开,或者 Command 寄存器的中断禁用位没清。 - MSI-X 向量没配对:
pci_alloc_irq_vectors申请了 4 个向量,但只给第一个request_irq,后面三个没注册,设备往那些向量发中断就丢了。 - 中断共享冲突:INTx 是共享的,
request_irq时IRQF_SHARED标志要带上,且 handler 里必须判断是不是自己的中断,不是就返回IRQ_NONE。 - 清中断顺序错:先清设备侧状态再返回
IRQ_HANDLED,如果先返回再清,可能丢中断。
独家技巧:调试中断时,先看
/proc/interrupts里对应 IRQ 的计数有没有涨。涨了说明中断到了 CPU,问题在 handler;不涨说明中断根本没上来,问题在设备侧或路由配置。
5.4 卸载模块时卡死或报错
rmmod卡死,通常是remove回调里有死锁或等待。常见原因:
- 在
remove里等一个永远不会完成的中断或 DMA。 - 忘了
free_irq,中断还在触发,访问已释放的内存。 pci_iounmap之后还有代码访问base。
正确的remove顺序是:先停硬件(写控制寄存器)→ 关中断(free_irq)→ 释放中断向量 → 取消映射 → 释放 region → disable device。每一步都要确保没有后续访问。
6. 进阶话题:热插拔与电源管理
6.1 热插拔支持:不只是加个回调
PCIe 热插拔(Hot-Plug)在服务器和存储场景很常见。驱动要支持热插拔,核心是正确处理remove和probe的对称性——设备拔出时remove被调用,重新插入时probe再来一遍。听起来简单,但实际有几个坑:
- 用户态接口要能感知:如果你注册了字符设备,设备拔出后
open的 fd 怎么办?标准做法是在remove里标记设备下线,让后续read/write返回-ENODEV,而不是直接崩溃。 - 引用计数:
remove被调用时可能还有用户态在操作,要用引用计数或completion等待操作结束。 - 资源清理要彻底:热插拔会反复 probe/remove,任何一次泄漏累积起来都会出问题。
内核提供了pci_dev_put/pci_dev_get来管理设备引用,配合pci_stop_and_remove_bus_device可以主动触发移除流程。
6.2 电源管理回调:suspend/resume 怎么写
pci_driver里的.suspend和.resume回调,在系统休眠时被调用。写这两个回调的原则是:
suspend里保存设备状态,停 DMA,关中断,让设备进入低功耗态。resume里恢复寄存器,重新使能中断和 DMA,恢复设备状态。
static int my_suspend(struct pci_dev *pdev, pm_message_t state) { struct my_priv *priv = pci_get_drvdata(pdev); /* 停 DMA */ writel(0, priv->base + REG_DMA_CTRL); /* 保存关键寄存器 */ priv->saved_ctrl = readl(priv->base + REG_CTRL); pci_save_state(pdev); pci_set_power_state(pdev, PCI_D3hot); return 0; } static int my_resume(struct pci_dev *pdev) { struct my_priv *priv = pci_get_drvdata(pdev); pci_set_power_state(pdev, PCI_D0); pci_restore_state(pdev); writel(priv->saved_ctrl, priv->base + REG_CTRL); return 0; }注意:
pci_set_power_state切到 D3hot 后,配置空间还能访问,但 BAR 里的寄存器可能就读不到了。所以保存寄存器状态要在切电源状态之前做。
7. 调试工具链与实战命令速查
7.1 用户空间排查命令
这些命令我几乎每天都会用到,整理成速查表:
# 查看所有 PCI 设备及 ID lspci -nn # 查看详细配置空间、链路状态、驱动绑定 lspci -vv -s 01:00.0 # 查看设备树形结构 lspci -t # 查看内核驱动绑定情况 lspci -k # 查看配置空间原始数据 hexdump -C /sys/bus/pci/devices/0000:01:00.0/config # 查看中断分布 cat /proc/interrupts # 查看 AER 错误统计 cat /sys/bus/pci/devices/0000:01:00.0/aer_dev_correctable/sys/bus/pci/devices/下面每个设备目录里都有丰富的属性文件,比如resource(BAR 映射)、enable(使能状态)、driver(绑定的驱动符号链接)。调试时直接读这些文件比翻代码快得多。
7.2 内核侧调试技巧
- 动态调试:
echo 'module my_pci_drv +p' > /sys/kernel/debug/dynamic_debug/control,配合pr_debug可以按需打开日志,不用重编译。 - ftrace:
echo function > /sys/kernel/debug/tracing/current_tracer,然后cat trace_pipe,能看到 probe/remove 的调用栈。 - KASAN:如果怀疑内存越界,编译内核时开
CONFIG_KASAN,驱动里的越界访问会被精确报出来。
我个人的习惯是,probe 里关键节点都加dev_dbg,平时不开,出问题时用动态调试打开,既不污染日志又能快速定位。
8. 写在最后:几个踩坑换来的经验
PCI 驱动开发最忌讳“想当然”。我见过太多人栽在几个看似不起眼的地方:忘了pci_set_master导致 DMA 不工作、remove里资源释放顺序错导致 rmmod 卡死、id_table匹配太宽泛抢了别的设备。这些问题在文档里往往一笔带过,但实际调试时能耗掉你一整天。
我的建议是,每写一个 PCI 驱动,先把probe和remove的对称性画出来,申请了什么资源就对应释放什么,顺序严格逆序。然后用 QEMU 的edu设备把全流程跑通,再上真实硬件。真实硬件上先验证枚举和 BAR 映射,再加中断,最后加 DMA,一层一层来,出问题容易定位。
另外,lspci -vv和dmesg是你最好的朋友。任何 PCIe 稳定性问题,先看链路状态和 AER 日志,八成的问题都能从这两个地方找到线索。至于那些“掉卡”“降速”的玄学问题,很多时候不是驱动代码的锅,而是硬件兼容性和 BIOS 配置,该换插槽换插槽,该调 ASPM 调 ASPM,别死磕代码。