1. PCI 设备驱动到底在驱动什么
很多人第一次接触 Linux PCI 驱动,脑子里冒出来的第一个问题就是:我写个字符设备驱动,注册个 file_operations 就能跑,为什么还要专门搞一套 PCI 驱动框架?这个问题不搞清楚,后面看代码就是死记硬背。
PCI 设备和字符设备最大的区别在于:字符设备是“逻辑设备”,你告诉内核“我有个设备,主设备号是 240,你帮我挂到 /dev/xxx 上”,内核就认了。但 PCI 设备是“物理设备”,它插在主板的总线上,有自己独立的配置空间、BAR 地址、中断线,内核必须先“发现”它,给它分配资源,然后才能谈驱动的事。
打个比方:字符设备驱动像是你在小区门口摆了个摊位,告诉物业“我在这儿卖东西”,物业给你登记一下就完事了。PCI 驱动则像是你要在商场里开一家店,商场管理方得先确认你的铺位在哪、水电怎么接、消防通道怎么走,全部谈妥了才让你开门营业。
所以 PCI 驱动的核心任务可以拆成三块:
- 设备发现与资源分配:内核启动时枚举 PCI 总线,读取每个设备的配置空间,知道它需要多少内存空间、多少 I/O 空间、用哪个中断号。
- 驱动匹配与绑定:驱动通过
pci_driver结构体声明“我能处理哪些设备”,内核把设备和驱动配对,调用驱动的 probe 函数。 - 硬件操作与抽象:在 probe 里完成 BAR 映射、中断注册、DMA 配置,然后向上层提供字符设备、块设备或网络设备接口。
这三块缺一不可。你只写字符设备部分,设备根本找不到;你只写 PCI 部分,用户空间没法用。所以一个完整的 PCI 驱动,本质上是一个“两层驱动”:底层对接 PCI 总线,上层对接用户空间或内核子系统。
注意:很多初学者会把
pci_register_driver和register_chrdev混在一起写,结果 probe 函数里又去注册字符设备,导致设备节点重复创建或者资源泄漏。正确的做法是在 probe 里完成所有硬件初始化,在 remove 里逆序释放,字符设备的注册和注销也放在这两个函数里。
2. 从枚举到 probe:PCI 设备是怎么被内核认出来的
2.1 PCI 配置空间长什么样
要理解 PCI 驱动,必须先理解 PCI 配置空间。每个 PCI 设备都有一块 256 字节(PCIe 是 4096 字节)的配置空间,里面记录了厂商 ID、设备 ID、类代码、BAR 地址、中断引脚等关键信息。
配置空间的前 64 字节是标准头部,布局如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0x00 | 2 | Vendor ID | 厂商编号,0xFFFF 表示设备不存在 |
| 0x02 | 2 | Device ID | 设备编号,由厂商分配 |
| 0x04 | 2 | Command | 命令寄存器,控制 I/O、内存、总线主控使能 |
| 0x06 | 2 | Status | 状态寄存器,反映能力列表、中断状态等 |
| 0x08 | 1 | Revision ID | 版本号 |
| 0x09 | 1 | Class Code | 类代码,高字节表示大类 |
| 0x0C | 1 | Cache Line | 缓存行大小 |
| 0x0D | 1 | Latency Timer | 延迟定时器 |
| 0x0E | 1 | Header Type | 头部类型,0 表示普通设备 |
| 0x10 | 24 | BAR0-BAR5 | 基地址寄存器,记录设备需要的地址空间 |
| 0x2C | 2 | Subsystem Vendor | 子系统厂商 ID |
| 0x2E | 2 | Subsystem ID | 子系统设备 ID |
| 0x3C | 1 | Interrupt Line | 中断线编号 |
| 0x3D | 1 | Interrupt Pin | 中断引脚 |
内核在枚举阶段会遍历所有总线号和设备号,读取 Vendor ID。如果读到 0xFFFF,说明这个位置没有设备;否则就认为发现了一个 PCI 设备,继续读取其他字段。
2.2 枚举过程的内核实现
Linux 内核的 PCI 枚举从pci_scan_bus开始,核心逻辑在drivers/pci/probe.c里。整个流程大致是:
- 从总线 0 开始,逐个扫描设备号 0-31。
- 对每个存在的设备,读取配置空间头部。
- 如果是桥设备(Header Type 为 1),递归扫描下级总线。
- 为每个设备分配
pci_dev结构体,填充配置空间信息。 - 调用
pci_assign_resources为 BAR 分配实际地址。
这里有个关键点:BAR 的地址不是设备自己决定的,而是内核在枚举时分配的。设备上电后 BAR 里可能是 0 或者固件预设的值,内核会读取 BAR 的大小需求,然后从可用地址空间里划一块给它。
BAR 大小怎么算?以 32 位内存 BAR 为例:
- 向 BAR 写入 0xFFFFFFFF。
- 读回 BAR 的值。
- 把读回的值按位取反再加 1,得到 BAR 的大小。
- 恢复 BAR 的原始值。
比如读回 0xFFFFF000,取反得 0x00000FFF,加 1 得 0x1000,说明这个 BAR 需要 4KB 空间。这个计算过程在pci_read_bases函数里实现。
2.3 驱动匹配的两种方式
设备被发现后,内核需要找到对应的驱动。匹配方式有两种:
方式一:ID 表匹配
驱动在pci_driver结构体里定义一个pci_device_id数组,列出自己能处理的 Vendor ID 和 Device ID 组合。内核遍历所有驱动,逐个比对。
static const struct pci_device_id my_pci_ids[] = { { PCI_DEVICE(0x1234, 0x5678) }, { PCI_DEVICE(0x1234, 0x5679) }, { 0, } }; MODULE_DEVICE_TABLE(pci, my_pci_ids);PCI_DEVICE宏展开后就是vendor和device两个字段。内核在pci_match_device里做比对,匹配成功就调用驱动的 probe。
方式二:类代码匹配
有些驱动不关心具体厂商,只关心设备类别。比如所有 USB 控制器(类代码 0x0C03)都可以用同一个驱动。这时候可以用PCI_DEVICE_CLASS宏:
{ PCI_DEVICE_CLASS(PCI_CLASS_SERIAL_USB_EHCI, 0xFFFFFF) }第二个参数是掩码,0xFFFFFF 表示类代码的 24 位全部参与匹配。
实操心得:如果你在调试自己的 PCI 驱动,发现 probe 死活不调用,第一件事就是
lspci -nn看设备的 Vendor ID 和 Device ID,然后检查你的 ID 表有没有写错。我踩过最坑的一次是把 Vendor ID 和 Device ID 写反了,查了半天才发现。
3. 手把手写一个 PCI 驱动骨架
3.1 驱动结构体的定义与注册
一个最小的 PCI 驱动需要三个核心部分:ID 表、probe 函数、remove 函数。先看完整骨架:
#include <linux/module.h> #include <linux/pci.h> #include <linux/fs.h> #include <linux/cdev.h> #define DEVICE_NAME "my_pci_dev" static int major; static struct class *my_class; static struct cdev my_cdev; struct my_pci_priv { void __iomem *bar0; resource_size_t bar0_len; int irq; }; static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_pci_priv *priv; int ret; /* 1. 使能 PCI 设备 */ ret = pci_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "pci_enable_device failed\n"); return ret; } /* 2. 申请 BAR 资源 */ ret = pci_request_region(pdev, 0, DEVICE_NAME); if (ret) { dev_err(&pdev->dev, "pci_request_region failed\n"); goto err_disable; } /* 3. 映射 BAR0 到内核虚拟地址 */ priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) { ret = -ENOMEM; goto err_release; } priv->bar0_len = pci_resource_len(pdev, 0); priv->bar0 = pci_iomap(pdev, 0, priv->bar0_len); if (!priv->bar0) { ret = -ENOMEM; goto err_release; } /* 4. 设置 DMA 掩码 */ ret = pci_set_dma_mask(pdev, DMA_BIT_MASK(32)); if (ret) { dev_err(&pdev->dev, "DMA mask setup failed\n"); goto err_unmap; } /* 5. 注册字符设备 */ ret = alloc_chrdev_region(&major, 0, 1, DEVICE_NAME); if (ret) { goto err_unmap; } cdev_init(&my_cdev, &my_fops); my_cdev.owner = THIS_MODULE; ret = cdev_add(&my_cdev, MKDEV(major, 0), 1); if (ret) { goto err_chrdev; } my_class = class_create(THIS_MODULE, DEVICE_NAME); device_create(my_class, NULL, MKDEV(major, 0), NULL, DEVICE_NAME); pci_set_drvdata(pdev, priv); dev_info(&pdev->dev, "probe success, bar0=%pR\n", &pdev->resource[0]); return 0; err_chrdev: unregister_chrdev_region(MKDEV(major, 0), 1); err_unmap: pci_iounmap(pdev, priv->bar0); err_release: pci_release_region(pdev, 0); err_disable: pci_disable_device(pdev); return ret; } static void my_pci_remove(struct pci_dev *pdev) { struct my_pci_priv *priv = pci_get_drvdata(pdev); device_destroy(my_class, MKDEV(major, 0)); class_destroy(my_class); cdev_del(&my_cdev); unregister_chrdev_region(MKDEV(major, 0), 1); pci_iounmap(pdev, priv->bar0); pci_release_region(pdev, 0); pci_disable_device(pdev); } static struct pci_driver my_pci_driver = { .name = DEVICE_NAME, .id_table = my_pci_ids, .probe = my_pci_probe, .remove = my_pci_remove, }; module_pci_driver(my_pci_driver);这段代码看起来简单,但每一步都有讲究。我逐个拆解。
3.2 pci_enable_device 到底做了什么
pci_enable_device不是简单地“打开设备”,它做了三件事:
- 唤醒设备:如果设备处于低功耗状态,把它切回 D0 状态。
- 使能 I/O 和内存空间:设置配置空间 Command 寄存器的 bit 0 和 bit 1。
- 分配中断线:如果设备使用传统中断,确保中断线已经分配。
如果设备支持 PCIe 的 ASPM 或者电源管理,这个函数还会处理相关寄存器。所以千万不要跳过这一步直接去读 BAR,否则可能读到全 0 或者触发总线错误。
对应的pci_disable_device会逆序关闭这些使能位,但不会把设备切回低功耗状态。如果你需要省电,得额外调用pci_set_power_state(pdev, PCI_D3hot)。
3.3 BAR 映射的三种方式和选择依据
BAR 映射有三种方式:
| 方式 | 函数 | 适用场景 | 特点 |
|---|---|---|---|
| 自动映射 | pci_iomap | 大多数情况 | 自动判断是 I/O 还是内存 BAR |
| 手动映射 | ioremap | 需要特殊属性时 | 需要自己判断 BAR 类型 |
| 资源管理 | devm_ioremap | 现代驱动推荐 | 设备卸载时自动释放 |
pci_iomap内部会检查 BAR 的标志位:如果是IORESOURCE_IO,调用ioport_map;如果是IORESOURCE_MEM,调用ioremap。返回的指针用void __iomem *类型,必须用ioread32/iowrite32系列函数访问,不能直接解引用。
注意:
pci_iomap的第三个参数是映射长度。如果你传 0,它会映射整个 BAR 空间。但有些设备 BAR 很大(比如 256MB),全映射会浪费内核虚拟地址空间。建议只映射你实际需要的部分。
3.4 中断注册的两种模式
PCI 设备的中断注册分两种情况:
传统 INTx 中断:
ret = request_irq(pdev->irq, my_interrupt, IRQF_SHARED, DEVICE_NAME, priv);IRQF_SHARED表示共享中断线。PCI 总线支持中断共享,多个设备可以挂在同一个 IRQ 上。你的中断处理函数必须检查是不是自己的设备触发了中断,不是的话返回IRQ_NONE。
MSI/MSI-X 中断:
ret = pci_alloc_irq_vectors(pdev, 1, 4, PCI_IRQ_MSI | PCI_IRQ_MSIX); if (ret > 0) { for (i = 0; i < ret; i++) { request_irq(pci_irq_vector(pdev, i), my_msi_handler, 0, DEVICE_NAME, priv); } }MSI 的好处是不共享中断线,每个设备有独立的中断向量,延迟更低,也不会有中断共享的误判问题。PCIe 设备优先用 MSI-X,不支持的话退回 MSI,再不行才用 INTx。
实操心得:调试中断问题时,先
cat /proc/interrupts看你的设备有没有注册成功。如果 IRQ 号是 0 或者负数,说明中断分配失败,大概率是 BIOS 没给设备分配中断线,或者设备不支持 INTx。这时候试试强制启用 MSI:在驱动里加pci_enable_msi(pdev)。
4. 掉卡、降速、AER:PCIe 稳定性问题排查实录
4.1 掉卡的常见原因和定位方法
“掉卡”是 PCIe 驱动调试中最常见的问题,表现为设备突然从lspci列表里消失,或者驱动报 I/O 错误。原因通常有三类:
第一类:物理链路问题
金手指氧化、插槽接触不良、线缆松动。这种情况在服务器上尤其常见,因为振动会导致插槽接触电阻变化。定位方法是lspci -vv看 Link Status 寄存器,如果LnkSta显示Downgraded或者Speed 2.5GT/s而设备支持 8GT/s,说明链路协商失败了。
第二类:电源管理问题
ASPM 配置不当会导致链路进入 L1 状态后无法唤醒。排查方法是:
lspci -vv | grep -i aspm如果看到ASPM L1 Enabled但设备频繁掉线,可以尝试在内核启动参数里加pcie_aspm=off关闭 ASPM。
第三类:驱动资源冲突
两个驱动抢同一个 BAR 或者中断线。这种情况会在dmesg里看到BAR 0: can't reserve或者irq XX: nobody cared。
排查掉卡问题的标准流程:
lspci -nn确认设备是否还在总线上。dmesg | tail -50看内核有没有报 AER 错误。lspci -vv -s XX:XX.X看 Link Status 和 DevSta 寄存器。cat /sys/bus/pci/devices/XXXX:XX:XX.X/config读配置空间,确认 Vendor ID 是否变成 0xFFFF。
4.2 AER 错误的解读与处理
AER(Advanced Error Reporting)是 PCIe 的高级错误报告机制。当链路出现可纠正或不可纠正错误时,硬件会记录到 AER 寄存器,内核的 AER 驱动会打印出来。
常见的 AER 错误:
| 错误类型 | 含义 | 严重程度 | 处理建议 |
|---|---|---|---|
| Correctable Error | 可纠正错误 | 低 | 记录日志,通常不影响功能 |
| Uncorrectable Non-Fatal | 不可纠正但非致命 | 中 | 驱动可能需要重置设备 |
| Uncorrectable Fatal | 不可纠正且致命 | 高 | 链路可能已断开,需要热复位 |
dmesg里典型的 AER 日志长这样:
pcieport 0000:00:1c.0: AER: Corrected error received: 0000:00:1c.0 pcieport 0000:00:1c.0: AER: PCIe Bus Error: severity=Corrected, type=Physical Layer如果看到severity=Corrected,一般不用太担心,可能是信号完整性导致的偶发错误。但如果频繁出现,说明链路质量有问题,需要检查硬件。
如果看到severity=Fatal,设备大概率已经掉线了。这时候需要:
- 用
setpci读取设备的 Link Control 寄存器,尝试触发链路重训练。 - 如果重训练失败,只能通过热复位或者重新枚举总线来恢复。
注意:AER 错误处理涉及 PCIe 规范里的复杂状态机,不要试图在驱动里手动清 AER 寄存器,除非你非常清楚自己在做什么。内核的 AER 驱动已经处理了大部分情况,驱动只需要在
pci_error_handlers里实现error_detected、slot_reset、resume三个回调即可。
4.3 降速和降 lane 的排查思路
PCIe 链路训练时,双方会协商速度和 lane 数量。如果协商结果低于设备能力,就是“降速”或“降 lane”。
排查步骤:
- 确认设备支持的最大速度和 lane 数:
lspci -vv看LnkCap字段。 - 确认当前协商结果:
lspci -vv看LnkSta字段。 - 如果
LnkSta低于LnkCap,检查LnkCap2和LnkCtl2里的 Target Link Speed 设置。 - 用
setpci强制重训练:
setpci -s XX:XX.X CAP_EXP+10.w=20:20这条命令把 Link Control 寄存器的 bit 5(Retrain Link)置 1,触发链路重训练。
降速的常见原因:
- 信号完整性差:PCB 走线阻抗不匹配、过孔太多、线缆太长。
- 参考时钟抖动:时钟源质量差,导致接收端眼图闭合。
- 电源噪声:供电纹波太大,影响 SerDes 性能。
- 固件配置错误:BIOS 里把链路速度限制在了 Gen1。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 设备不在 lspci 列表 | 物理连接断开、电源未上 | lspci -nn、dmesg | 检查插槽、更换线缆 |
| probe 不调用 | ID 表不匹配、驱动未加载 | lsmod、modprobe -v | 核对 Vendor/Device ID |
| BAR 映射失败 | 资源冲突、BAR 未分配 | cat /proc/iomem | 检查 BIOS 设置、释放冲突资源 |
| 中断收不到 | IRQ 未分配、MSI 未使能 | cat /proc/interrupts | 启用 MSI、检查中断共享 |
| DMA 传输错误 | DMA 掩码不匹配、IOMMU 限制 | `dmesg | grep -i dma` |
| 设备频繁掉线 | ASPM 问题、电源不稳 | `lspci -vv | grep ASPM` |
| AER 错误刷屏 | 链路信号质量差 | `dmesg | grep AER` |
5. 热插拔与电源管理:PCIe 驱动的高级话题
5.1 热插拔的支持条件
PCIe 热插拔需要硬件和软件同时支持。硬件上,插槽必须有独立的电源控制和存在检测引脚;软件上,内核需要启用CONFIG_HOTPLUG_PCI和CONFIG_HOTPLUG_PCI_PCIE。
热插拔的核心机制是pciehp驱动,它监听插槽的状态变化,收到事件后调用pci_scan_slot重新枚举设备,或者调用pci_stop_and_remove_bus_device移除设备。
驱动要支持热插拔,必须正确实现remove函数,确保设备被移除时所有资源都能释放。如果remove里有死循环或者等待硬件响应的操作,热插拔就会卡住。
实操心得:测试热插拔时,不要直接拔设备,先用
echo 0 > /sys/bus/pci/slots/XX/power关闭插槽电源,再echo 1 > .../power重新上电。这样能模拟真实的插拔流程,又不会损坏硬件。
5.2 电源管理的三个层次
PCIe 电源管理分三个层次:
层次一:设备电源状态(D0-D3)
D0 是全开,D3hot 是低功耗但还能响应配置空间访问,D3cold 是完全断电。驱动通过pci_set_power_state切换状态。
层次二:链路电源状态(L0-L3)
L0 是正常工作,L1 是低功耗待机,L2/L3 是深度睡眠。ASPM 控制链路何时进入低功耗状态。
层次三:系统电源状态(S0-S5)
S0 是正常工作,S3 是挂起到内存,S4 是挂起到硬盘,S5 是关机。系统状态切换时,内核会遍历所有设备,调用驱动的suspend和resume回调。
驱动实现电源管理需要定义dev_pm_ops:
static const struct dev_pm_ops my_pci_pm_ops = { .suspend = my_pci_suspend, .resume = my_pci_resume, .runtime_suspend = my_pci_runtime_suspend, .runtime_resume = my_pci_runtime_resume, };然后在pci_driver里赋值.driver.pm = &my_pci_pm_ops。
suspend里要保存设备寄存器状态,resume里恢复。如果设备支持唤醒事件,还要调用device_set_wakeup_enable和enable_irq_wake。
5.3 热插拔与电源管理的冲突处理
热插拔和电源管理有时候会打架。比如设备正在进入 D3hot 状态,这时候插槽收到移除事件,remove函数被调用,但设备还没完全断电,寄存器访问可能返回错误。
处理这种冲突的原则是:
- 在
remove开头检查设备是否可访问,用pci_device_is_present判断。 - 如果设备已经不可访问,跳过寄存器操作,直接释放软件资源。
- 在
suspend里加锁,防止热插拔事件并发修改设备状态。
static void my_pci_remove(struct pci_dev *pdev) { struct my_pci_priv *priv = pci_get_drvdata(pdev); if (pci_device_is_present(pdev)) { /* 设备还在,正常关闭硬件 */ writel(0, priv->bar0 + CTRL_REG); } /* 释放软件资源 */ ... }6. 调试工具与实战技巧
6.1 lspci 的高级用法
lspci是最常用的 PCI 调试工具,但很多人只会lspci一下看列表。其实它的高级选项非常有用:
# 显示设备的 Vendor/Device ID 和类代码 lspci -nn # 显示详细配置空间,包括 BAR、中断、链路状态 lspci -vv # 只显示指定设备 lspci -s 03:00.0 -vv # 以十六进制显示配置空间原始数据 lspci -xxx -s 03:00.0 # 显示 PCIe 链路能力与状态 lspci -vv | grep -A 10 "LnkCap\|LnkSta"lspci -vv的输出里,重点看这几个字段:
Control: I/O+、Mem+、BusMaster+ 表示使能状态。Status: Cap+ 表示支持能力列表,Intx+ 表示支持传统中断。Region 0: BAR0 的地址和大小。Capabilities: 能力列表,包括 Power Management、MSI、PCIe。LnkCap和LnkSta: 链路能力和状态。
6.2 setpci 的读写操作
setpci可以直接读写 PCI 配置空间,调试时非常有用:
# 读 Vendor ID setpci -s 03:00.0 0.w # 读 BAR0 setpci -s 03:00.0 10.l # 写 Command 寄存器,使能内存空间 setpci -s 03:00.0 4.w=0002 # 触发链路重训练 setpci -s 03:00.0 CAP_EXP+10.w=20:20注意:
setpci直接操作硬件寄存器,写错地址可能导致系统崩溃。操作前先用lspci -xxx备份原始值,出问题了可以恢复。
6.3 内核调试信息的获取
dmesg是查看内核日志的标准工具,但 PCI 相关的日志分散在各个子系统里。可以用以下命令过滤:
# 查看 PCI 枚举日志 dmesg | grep -i "pci" # 查看 AER 错误 dmesg | grep -i "aer" # 查看驱动 probe 日志 dmesg | grep -i "my_pci_dev" # 查看中断相关日志 dmesg | grep -i "irq"如果dmesg输出太多,可以用dmesg -T显示时间戳,或者dmesg -w实时监控。
6.4 实战技巧:用 sysfs 调试 PCI 设备
sysfs 提供了丰富的 PCI 设备调试接口:
# 查看设备资源 cat /sys/bus/pci/devices/0000:03:00.0/resource # 查看驱动绑定状态 ls -l /sys/bus/pci/devices/0000:03:00.0/driver # 手动解绑驱动 echo 0000:03:00.0 > /sys/bus/pci/drivers/my_pci_driver/unbind # 手动绑定驱动 echo 0000:03:00.0 > /sys/bus/pci/drivers/my_pci_driver/bind # 触发设备移除 echo 1 > /sys/bus/pci/devices/0000:03:00.0/remove # 触发总线重新扫描 echo 1 > /sys/bus/pci/rescan手动解绑和绑定是调试 probe/remove 函数的利器。你可以在不重启系统的情况下反复测试驱动的加载和卸载,大大加快调试速度。
实操心得:调试 probe 失败时,先
echo 0000:03:00.0 > .../unbind解绑,然后dmesg -c清空日志,再echo 0000:03:00.0 > .../bind绑定,最后dmesg看完整的 probe 日志。这样日志干净,不会被之前的输出干扰。
7. 从零到一:一个完整 PCI 驱动的开发流程
7.1 开发环境搭建
开发 PCI 驱动需要一台有 PCIe 插槽的机器,最好支持热插拔,方便反复测试。软件环境:
- Linux 内核源码(版本要和目标系统一致)
- 交叉编译工具链(如果是嵌入式开发)
pciutils包(提供 lspci、setpci)kernel-devel包(提供内核头文件)
编译驱动用 Makefile:
obj-m += my_pci_driver.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean7.2 开发步骤拆解
第一步:确认设备信息
用lspci -nn拿到 Vendor ID 和 Device ID,用lspci -vv看 BAR 和中断信息。
第二步:写最小驱动骨架
先只实现 probe 和 remove,probe 里只打印日志,不操作硬件。加载驱动,确认 probe 被调用。
第三步:添加 BAR 映射
在 probe 里映射 BAR0,读取一个已知的寄存器(比如设备 ID 寄存器),确认映射正确。
第四步:添加中断处理
注册中断处理函数,在中断里打印日志。触发设备中断(比如写一个控制寄存器),确认中断被调用。
第五步:添加字符设备接口
注册字符设备,实现 read/write/ioctl,让用户空间可以操作设备。
第六步:添加 DMA 支持
如果设备支持 DMA,配置 DMA 掩码,申请 DMA 缓冲区,实现数据传输。
第七步:添加电源管理
实现 suspend/resume 回调,测试系统挂起和恢复。
第八步:测试热插拔
如果硬件支持,测试热插拔场景,确保 remove 能正确释放资源。
7.3 测试与验证方法
驱动开发完成后,需要从多个维度验证:
功能测试:用用户空间程序读写设备,确认数据正确。
压力测试:连续读写大量数据,检查有没有内存泄漏或者数据错误。
异常测试:模拟设备掉线、中断丢失、DMA 错误,检查驱动的容错能力。
电源测试:反复挂起恢复,检查设备是否正常工作。
热插拔测试:反复插拔设备,检查驱动能否正确加载和卸载。
注意:压力测试时要用
dmesg -w实时监控内核日志,一旦出现BUG、Oops、WARNING立即停止,这些往往意味着驱动有严重的并发或者内存问题。
8. 几个容易踩的坑和避坑建议
8.1 资源释放的顺序问题
probe 里申请资源的顺序和 remove 里释放资源的顺序必须相反。比如:
probe 顺序:pci_enable_device→pci_request_region→pci_iomap→request_irq→cdev_add
remove 顺序:cdev_del→free_irq→pci_iounmap→pci_release_region→pci_disable_device
如果顺序错了,比如先pci_disable_device再free_irq,中断处理函数可能在设备已经禁用的情况下被调用,导致内核崩溃。
8.2 并发访问的保护
PCI 设备的寄存器可能被多个上下文访问:中断处理函数、用户空间 ioctl、内核定时器。必须用自旋锁或者互斥锁保护。
spin_lock_irqsave(&priv->lock, flags); writel(value, priv->bar0 + REG); spin_unlock_irqrestore(&priv->lock, flags);如果操作可能睡眠(比如等待硬件响应),用互斥锁而不是自旋锁。
8.3 DMA 缓冲区的对齐问题
DMA 缓冲区必须按缓存行对齐,否则会出现缓存一致性问题。用dma_alloc_coherent分配的缓冲区自动对齐,用kmalloc分配的则需要手动对齐。
buf = dma_alloc_coherent(&pdev->dev, size, &dma_handle, GFP_KERNEL);dma_alloc_coherent返回的缓冲区在 CPU 和设备看来是一致的,不需要额外的缓存刷新操作。
8.4 中断处理函数的返回值
中断处理函数必须正确返回IRQ_HANDLED或IRQ_NONE。如果是共享中断,不是自己的中断必须返回IRQ_NONE,否则内核会认为中断被处理了,其他设备的中断可能丢失。
static irqreturn_t my_interrupt(int irq, void *dev_id) { struct my_pci_priv *priv = dev_id; u32 status = readl(priv->bar0 + INT_STATUS); if (!(status & MY_INT_BIT)) return IRQ_NONE; /* 处理中断 */ writel(status, priv->bar0 + INT_STATUS); return IRQ_HANDLED; }8.5 模块卸载时的引用计数
如果用户空间打开了设备节点,模块不能被卸载。需要在open里调用try_module_get(THIS_MODULE),在release里调用module_put(THIS_MODULE)。否则rmmod时会出现“模块正在使用”的错误。
static int my_open(struct inode *inode, struct file *file) { if (!try_module_get(THIS_MODULE)) return -ENODEV; return 0; } static int my_release(struct inode *inode, struct file *file) { module_put(THIS_MODULE); return 0; }9. 性能优化与进阶方向
9.1 减少寄存器访问次数
PCIe 寄存器访问通过配置空间或者 MMIO,每次访问都有总线开销。如果驱动里频繁读写寄存器,性能会受影响。优化方法:
- 批量读写:如果硬件支持,一次读写多个寄存器。
- 缓存只读寄存器:把不常变的寄存器值缓存在内存里。
- 合并写操作:把多个写操作合并成一次总线事务。
9.2 使用 MSI-X 提升中断性能
MSI-X 支持多个中断向量,每个向量可以绑定到不同的 CPU 核心。对于多队列设备(比如网卡),可以用pci_alloc_irq_vectors申请多个向量,然后用irq_set_affinity_hint把中断分散到不同核心。
ret = pci_alloc_irq_vectors(pdev, 1, num_queues, PCI_IRQ_MSIX); for (i = 0; i < ret; i++) { request_irq(pci_irq_vector(pdev, i), handler, 0, name, priv); irq_set_affinity_hint(pci_irq_vector(pdev, i), &cpu_mask); }9.3 DMA 性能调优
DMA 传输的性能取决于几个因素:
- 传输大小:大块传输比小块传输效率高,但延迟也大。
- 描述符环大小:描述符环太小会导致频繁中断,太大则增加内存占用。
- 缓存一致性:使用
dma_alloc_coherent避免缓存刷新开销。
如果设备支持分散/聚集 DMA(Scatter-Gather),可以用dma_map_sg映射多个缓冲区,一次传输完成。
9.4 后续可以扩展的方向
这个 PCI 驱动骨架可以继续扩展:
- 添加 sysfs 属性,暴露设备状态和统计信息。
- 添加 debugfs 接口,方便调试。
- 支持多设备实例,用
ida_alloc管理设备编号。 - 添加 ioctl 接口,支持更复杂的用户空间控制。
- 集成到内核子系统,比如注册为网络设备、块设备或者 IIO 设备。
我个人在实际操作中的体会是,PCI 驱动开发最难的不是写代码,而是理解硬件的行为。数据手册里的一句话,可能对应着驱动里几十行的初始化序列。遇到问题先查手册,再查内核源码,最后才去论坛提问。很多时候,手册里已经写清楚了,只是你没注意到。