1. PCI驱动框架的整体设计思路
聊到Linux下的PCI驱动,很多人第一反应是“这不就是填个pci_driver结构体,然后pci_register_driver完事吗”。如果你只是写一个简单的采集卡驱动,这么理解倒也没大错。但一旦你碰到多function设备、SR-IOV、热插拔、或者需要和DMA、中断子系统深度配合的场景,就会发现这套框架远比表面看起来要复杂。我这些年调试过的PCI相关问题,从枚举失败到BAR空间映射冲突,从MSI中断收不到到DMA地址不对,几乎每一个坑都和“框架怎么设计的”这件事有关。
这一篇接着上一部分的内容往下走,重点放在驱动如何与PCI设备完成匹配、probe阶段到底发生了什么、以及资源分配背后的那套逻辑。如果你之前只停留在“照着模板改改”的阶段,那这部分内容应该能帮你把很多模糊的地方补上。
1.1 为什么PCI驱动要分成“设备”和“驱动”两半
Linux设备模型的核心思想就是设备与驱动分离。PCI子系统也不例外。总线(bus)负责把设备(device)和驱动(driver)撮合到一起,这个撮合的过程叫匹配(match)。
具体到PCI上,内核用pci_bus_type这个总线类型来管理。每枚举到一个PCI设备,内核就创建一个pci_dev结构体,挂到对应总线下面。驱动那边则注册一个pci_driver,里面带着一张id_table,相当于一张“我能支持的设备清单”。总线在两边都就绪的时候,逐条比对id_table里的条目和设备的vendor/device ID,匹配上了就调用驱动的probe函数。
这么设计的好处很直接:同一份驱动代码可以支持多个不同型号的设备,只要它们的寄存器布局兼容;反过来,一个设备也可以被多个驱动声明支持,最终由匹配优先级和加载顺序决定谁先接管。这种灵活性在服务器网卡、存储控制器这类产品线上特别常见——厂商往往用同一套驱动框架覆盖好几代芯片。
注意:匹配成功不代表驱动一定能正常工作。
probe里如果返回错误,总线会把设备和驱动解绑,然后继续尝试匹配下一个驱动。这个机制经常被忽略,但调试“驱动加载了却没反应”的问题时非常关键。
1.2pci_driver结构体里哪些字段真正重要
很多人注册驱动时习惯把结构体填得满满当当,其实常用的就那么几个字段。我按实际使用频率排个序:
name:驱动名字,会出现在/sys/bus/pci/drivers/下面,也是lspci -k显示的那个名字。起名要唯一,别和已有驱动撞车。id_table:匹配表,核心中的核心。每个条目包含vendor、device、subvendor、subdevice、class、class_mask等字段。probe:匹配成功后调用,负责初始化硬件、申请资源、注册字符设备或网络设备等。remove:设备移除或驱动卸载时调用,负责释放资源。注意这个函数不能失败,返回值会被忽略。suspend/resume:电源管理相关,不做电源管理的设备可以不填。driver.pm:指向dev_pm_ops,现代驱动更推荐用这个而不是老的suspend/resume。
id_table的匹配规则值得单独说。内核里pci_match_one_device函数的逻辑大致是:如果条目的vendor和device都是PCI_ANY_ID,那就靠class和class_mask来匹配;否则优先比对vendor/device,再看subvendor/subdevice。这里有个容易踩的坑——class_mask写错会导致匹配范围过大或过小。比如你想匹配所有存储控制器,class应该写PCI_CLASS_STORAGE_XXX,class_mask写0xffffff;如果只想匹配某个子类,mask就要相应调整。
2. probe函数里的资源分配细节
probe是整个驱动里最核心的函数,设备能不能用、性能好不好,基本在这一步就定下来了。我见过太多驱动在probe里偷懒,结果运行起来各种诡异问题。这一章把probe里该做的事按顺序拆开讲。
2.1 第一步永远是pci_enable_device
这个函数做两件事:唤醒设备(如果它处于低功耗状态),以及使能设备的I/O和内存空间访问。不调用它,后面所有对BAR空间的读写都是未定义行为,轻则读到全F,重则直接触发总线错误。
调用之后建议紧接着用pci_set_master把设备设成总线主控模式。只有设了master,设备才能发起DMA。很多新手写的驱动能读写寄存器但DMA死活不工作,十有八九就是漏了这一句。
ret = pci_enable_device(pdev); if (ret) { dev_err(&pdev->dev, "enable device failed\n"); return ret; } pci_set_master(pdev);提示:如果设备支持PCIe的ASPM或者需要特定的链路状态,可能还需要调用
pci_set_power_state和pci_enable_wake。这些在普通PCI设备上不常用,但PCIe设备上要留意。
2.2 BAR空间映射:pci_iomap还是ioremap
BAR(Base Address Register)是设备暴露给CPU的窗口。PCI设备最多有6个BAR,每个BAR要么是内存空间(MMIO),要么是I/O空间。现代设备基本都用MMIO,I/O空间已经很少见了。
映射BAR有两种常见做法:
pci_iomap(pdev, bar, len):推荐用法。它会自动处理I/O空间和内存空间的差异,返回一个可以传给ioread32/iowrite32的指针。pci_resource_start+ioremap:老式做法,只适用于内存空间。现在新代码不建议这么写。
映射之前一定要用pci_resource_len检查BAR长度,用pci_resource_flags确认是I/O还是内存。我遇到过设备BAR长度报告为0的情况,直接映射会返回NULL,然后解引用就崩了。
bar0 = pci_iomap(pdev, 0, pci_resource_len(pdev, 0)); if (!bar0) { dev_err(&pdev->dev, "cannot map BAR0\n"); goto err_disable; }映射完成后,不要假设BAR里的寄存器布局和手册完全一致。有些设备在上电后需要先写一个解锁序列,或者BAR里前几个字节是只读的ID区域。稳妥的做法是先读一遍关键寄存器,确认值符合预期再继续。
2.3 DMA掩码设置:别让设备访问到非法地址
pci_set_dma_mask和pci_set_consistent_dma_mask这两个函数决定了设备能发起DMA的地址范围。如果设备是32位的,就必须设成DMA_BIT_MASK(32);如果是64位设备,可以设成DMA_BIT_MASK(64)。
这里有个经典问题:在64位系统上,如果驱动不设置DMA掩码,内核默认给的是32位掩码。对于支持64位DMA的设备来说,这会导致它只能访问4GB以下的物理内存,性能白白浪费。反过来,如果设备只支持32位DMA,但驱动设了64位掩码,设备发起DMA时高位地址会被截断,数据就写到错误的地方去了。
ret = pci_set_dma_mask(pdev, DMA_BIT_MASK(64)); if (ret) { ret = pci_set_dma_mask(pdev, DMA_BIT_MASK(32)); if (ret) { dev_err(&pdev->dev, "no usable DMA mask\n"); goto err_unmap; } } pci_set_consistent_dma_mask(pdev, DMA_BIT_MASK(32));注意最后一行:一致性DMA掩码通常比流式DMA掩码更严格,因为一致性内存需要在启动时就预留好。很多设备的一致性DMA只能到32位,所以即使流式DMA支持64位,一致性DMA也要单独设成32位。
2.4 中断申请:MSI、MSI-X还是传统INTx
中断是PCI驱动里另一个容易出问题的地方。传统INTx是共享的,多个设备可以共用一个中断线,所以处理函数里必须判断中断是不是自己产生的。MSI和MSI-X则是设备专属的,不会共享,处理起来更简单,延迟也更低。
申请中断的推荐顺序是:先试MSI-X,再试MSI,最后退回INTx。
nvec = pci_alloc_irq_vectors(pdev, 1, 32, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (nvec < 0) { dev_err(&pdev->dev, "failed to allocate IRQ vectors\n"); goto err_dma; }pci_alloc_irq_vectors这个函数会自动帮你选择可用的中断类型,返回分配到的向量数量。拿到向量号之后,用pci_irq_vector(pdev, i)获取具体的中断号,然后request_irq注册处理函数。
注意:MSI-X支持每个向量独立屏蔽,MSI则是共享一个屏蔽位。如果你的设备需要精细的中断控制,优先用MSI-X。另外,MSI-X的向量数量在设备手册里会写明,不要申请超过硬件支持的数量。
3. 从枚举到probe的完整实操流程
前面讲的是“应该怎么做”,这一章用一个虚拟的PCI采集卡设备,把从系统启动到驱动probe完成的整个过程串一遍。你可以跟着这个流程在自己的机器上验证。
3.1 系统启动时的PCI枚举
内核启动时,PCI子系统会扫描所有总线,读取每个设备的配置空间,创建pci_dev结构体。这个过程叫枚举。枚举完成后,你可以在/sys/bus/pci/devices/下面看到所有设备,目录名是domain:bus:slot.function的格式。
ls /sys/bus/pci/devices/ # 输出示例: # 0000:00:00.0 0000:00:01.0 0000:00:1f.0 ...每个目录下面有一堆属性文件,常用的有:
| 文件 | 含义 |
|---|---|
vendor | 厂商ID |
device | 设备ID |
class | 设备类别 |
resource | BAR空间信息 |
irq | 分配到的中断号 |
driver | 当前绑定的驱动(符号链接) |
用lspci -vvv可以一次性看到这些信息,比逐个读文件方便得多。
3.2 手动绑定和解除绑定驱动
调试驱动的时候,经常需要手动把设备从驱动上解绑,或者把驱动绑到指定设备上。这两个操作通过sysfs就能完成:
# 查看设备当前绑定的驱动 ls -l /sys/bus/pci/devices/0000:01:00.0/driver # 解除绑定 echo 0000:01:00.0 > /sys/bus/pci/drivers/my_driver/unbind # 重新绑定 echo 0000:01:00.0 > /sys/bus/pci/drivers/my_driver/bind这个技巧在测试remove和probe路径时特别有用。你可以反复绑定解绑,观察驱动有没有正确释放资源。如果解绑后dmesg里出现内存泄漏警告,那说明remove函数写得不干净。
3.3 probe函数的完整执行顺序
把前面几节的内容串起来,一个规范的probe函数应该按这个顺序执行:
pci_enable_device— 使能设备pci_set_master— 设置总线主控pci_set_dma_mask/pci_set_consistent_dma_mask— 设置DMA掩码pci_iomap— 映射BAR空间pci_alloc_irq_vectors— 申请中断向量request_irq— 注册中断处理函数- 初始化硬件寄存器
- 注册字符设备/网络设备/其他子系统接口
- 保存私有数据到
pci_set_drvdata
每一步失败都要有对应的错误处理,用goto跳到相应的清理标签。这是内核代码的标准写法,虽然看起来有点啰嗦,但能保证资源不泄漏。
static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id) { struct my_priv *priv; int ret; ret = pci_enable_device(pdev); if (ret) return ret; pci_set_master(pdev); ret = pci_set_dma_mask(pdev, DMA_BIT_MASK(64)); if (ret) { ret = pci_set_dma_mask(pdev, DMA_BIT_MASK(32)); if (ret) goto err_disable; } priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) { ret = -ENOMEM; goto err_disable; } priv->bar0 = pci_iomap(pdev, 0, 0); if (!priv->bar0) { ret = -EIO; goto err_disable; } ret = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI | PCI_IRQ_LEGACY); if (ret < 0) goto err_unmap; ret = request_irq(pci_irq_vector(pdev, 0), my_isr, 0, "my_driver", priv); if (ret) goto err_irq; pci_set_drvdata(pdev, priv); return 0; err_irq: pci_free_irq_vectors(pdev); err_unmap: pci_iounmap(pdev, priv->bar0); err_disable: pci_disable_device(pdev); return ret; }提示:用
devm_系列函数(如devm_kzalloc、devm_request_irq)可以省掉一部分清理代码,内核会在设备移除时自动释放。但pci_iomap没有对应的devm_版本,还是需要手动pci_iounmap。
4. 常见问题与排查技巧实录
这一章是我这些年踩过的坑和帮别人排查过的问题的汇总。每个问题都给出症状、原因和解决方法,你可以当成速查表用。
4.1 设备枚举不到或BAR全F
症状:lspci看不到设备,或者能看到设备但读BAR返回全F。
常见原因:
- 设备供电不足或链路训练失败。PCIe设备需要链路训练成功才能被枚举,如果金手指接触不良或者供电不稳,链路就起不来。
- BIOS/UEFI里没有分配总线号。有些主板默认关闭了某些插槽,需要在BIOS里手动开启。
- 设备固件没有正确加载。某些FPGA类设备需要先加载bitstream才会出现在总线上。
排查方法:
# 查看PCIe链路状态 lspci -vvv | grep -A2 "LnkSta" # 查看内核枚举日志 dmesg | grep -i pci如果LnkSta显示Speed 2.5GT/s, Width x0,说明链路没训练成功,基本是硬件问题。
4.2 probe返回错误但看不到具体原因
症状:驱动加载了,但设备没反应,dmesg里只有一句“probe failed”。
原因:很多驱动在错误路径上只打印了笼统的错误信息,没有把具体的错误码和失败步骤打出来。
解决方法:在probe的每个错误分支加上详细的日志,包括错误码和当前步骤。更好的做法是用dev_err而不是printk,这样日志里会带上设备名,方便定位。
dev_err(&pdev->dev, "failed to map BAR0, ret=%d\n", ret);另外,/sys/bus/pci/drivers/my_driver/下面有bind和unbind文件,可以手动触发绑定,观察dmesg输出。
4.3 MSI中断收不到
症状:设备明明产生了中断,但驱动的中断处理函数没被调用。
常见原因:
- MSI使能了但设备端没有正确配置MSI地址和data。MSI的地址和data是内核在
pci_alloc_irq_vectors时写进设备配置空间的,如果设备固件在初始化时覆盖了这些值,中断就丢了。 - 中断被屏蔽了。MSI有一个全局屏蔽位,在配置空间的Message Control寄存器里。
- 中断处理函数返回了
IRQ_NONE,内核认为这个中断不是该设备产生的,直接忽略。
排查方法:
# 查看中断统计 cat /proc/interrupts | grep my_driver # 查看MSI配置 lspci -vvv -s 01:00.0 | grep -A5 MSI如果/proc/interrupts里对应中断号计数一直是0,说明中断根本没到CPU。这时候要检查设备端的MSI配置有没有被覆盖。
4.4 DMA地址错误导致数据损坏
症状:DMA传输偶尔成功偶尔失败,或者数据写到内存的随机位置。
原因:DMA掩码设置不当,或者使用了不正确的DMA API。
排查方法:
- 确认
pci_set_dma_mask的返回值,如果设置失败说明设备不支持该地址宽度。 - 用
dma_alloc_coherent申请一致性DMA内存,不要用kmalloc然后手动转物理地址。 - 流式DMA要用
dma_map_single/dma_unmap_single配对,不要漏掉unmap。
dma_addr_t dma_handle; void *cpu_addr = dma_alloc_coherent(&pdev->dev, size, &dma_handle, GFP_KERNEL); if (!cpu_addr) { dev_err(&pdev->dev, "DMA alloc failed\n"); return -ENOMEM; } // 把dma_handle写进设备寄存器注意:
dma_alloc_coherent返回的地址在设备视角和CPU视角可能不同,不要假设它们相等。设备寄存器里要写dma_handle,CPU访问用cpu_addr。
4.5 驱动卸载后资源没释放
症状:反复加载卸载驱动后,dmesg出现内存泄漏警告,或者第二次加载失败。
原因:remove函数没有释放所有申请的资源。
排查方法:
- 检查
remove里有没有调用pci_iounmap、pci_free_irq_vectors、pci_disable_device。 - 用
devm_系列函数可以自动释放大部分资源,但pci_iomap和pci_alloc_irq_vectors需要手动释放。 - 在
remove里加日志,确认每个释放步骤都执行到了。
static void my_remove(struct pci_dev *pdev) { struct my_priv *priv = pci_get_drvdata(pdev); free_irq(pci_irq_vector(pdev, 0), priv); pci_free_irq_vectors(pdev); pci_iounmap(pdev, priv->bar0); pci_disable_device(pdev); dev_info(&pdev->dev, "removed\n"); }4.6 常见问题速查表
| 问题现象 | 可能原因 | 排查命令 |
|---|---|---|
| 设备枚举不到 | 链路训练失败、BIOS未分配总线号 | lspci -vvv、dmesg |
| BAR读取全F | 设备未使能、BAR未映射 | pci_enable_device返回值 |
| probe失败无日志 | 错误路径缺少日志 | 加dev_err |
| MSI中断收不到 | MSI配置被覆盖、中断屏蔽 | /proc/interrupts、lspci -vvv |
| DMA数据损坏 | 掩码错误、API使用不当 | 检查dma_alloc_coherent |
| 卸载后泄漏 | remove未释放资源 | 检查remove函数 |
5. 几个容易被忽略的进阶细节
前面讲的都是基础,这一章补充几个在实际项目中经常遇到但文档里很少提的点。
5.1 SR-IOV场景下的PF和VF驱动
SR-IOV(Single Root I/O Virtualization)允许一个物理功能(PF)派生出多个虚拟功能(VF)。每个VF在总线上看起来都是一个独立的PCI设备,有自己的vendor/device ID。PF驱动负责配置VF的数量和资源,VF驱动则像普通PCI驱动一样工作。
这里的关键点是:PF驱动必须在VF驱动之前加载,因为VF设备只有在PF使能了SR-IOV之后才会出现。如果你发现VF设备枚举不到,先检查PF驱动有没有正确调用pci_enable_sriov。
ret = pci_enable_sriov(pdev, num_vfs); if (ret) dev_err(&pdev->dev, "failed to enable SRIOV\n");VF的vendor/device ID通常和PF不同,需要在id_table里单独列出。有些设备还支持VF和PF共用同一个驱动,通过pci_is_virtfn判断当前是PF还是VF。
5.2 热插拔支持
PCIe支持热插拔,设备可以在系统运行时插入或拔出。要支持热插拔,驱动需要处理remove被意外调用的情况,并且不能假设设备一直存在。
热插拔的触发流程是:用户按下插槽的attention button,或者通过sysfs写入power控制文件,内核通知ACPI或PCIe热插拔控制器,然后调用驱动的remove。如果驱动正在处理DMA或者持有锁,remove可能会阻塞,导致热插拔失败。
提示:在
remove里不要做耗时操作,尽量快速释放资源并返回。如果必须等待硬件状态,用msleep而不是忙等。
5.3 配置空间访问的注意事项
PCI配置空间有256字节(PCI)或4KB(PCIe)。前64字节是标准头部,后面的部分是设备特定的。访问配置空间用pci_read_config_byte/pci_write_config_byte等函数,不要直接操作pci_dev->config。
配置空间访问可能失败,尤其是在设备已经被移除的情况下。所有配置空间读写都要检查返回值:
u16 val; ret = pci_read_config_word(pdev, PCI_VENDOR_ID, &val); if (ret) dev_err(&pdev->dev, "config read failed\n");另外,PCIe设备的扩展配置空间(超过256字节的部分)需要用pci_read_config_dword配合PCI_CFG_SPACE_EXP_SIZE来访问,普通函数只能访问前256字节。
5.4 电源管理:suspend和resume的正确写法
如果设备支持电源管理,suspend里要保存设备状态、停止DMA、禁用中断,resume里要恢复寄存器、重新使能中断和DMA。顺序很重要:suspend时先停DMA再禁中断,resume时先使能中断再启动DMA。
static int my_suspend(struct device *dev) { struct pci_dev *pdev = to_pci_dev(dev); struct my_priv *priv = pci_get_drvdata(pdev); disable_irq(pci_irq_vector(pdev, 0)); // 停止DMA // 保存寄存器状态 pci_save_state(pdev); pci_disable_device(pdev); return 0; } static int my_resume(struct device *dev) { struct pci_dev *pdev = to_pci_dev(dev); struct my_priv *priv = pci_get_drvdata(pdev); pci_enable_device(pdev); pci_restore_state(pdev); // 恢复寄存器 // 启动DMA enable_irq(pci_irq_vector(pdev, 0)); return 0; }pci_save_state和pci_restore_state会保存和恢复标准配置空间,但设备特定的寄存器需要驱动自己处理。如果设备在suspend期间掉电,resume后需要重新初始化所有寄存器。
6. 调试工具和实用命令汇总
最后整理一些日常调试PCI驱动时最常用的命令和工具,放在手边随时查。
6.1 lspci的常用参数
# 显示所有设备的基本信息 lspci # 显示详细信息,包括BAR、中断、链路状态 lspci -vvv # 只显示指定设备 lspci -s 01:00.0 -vvv # 以树形结构显示 lspci -t # 显示设备的内核驱动 lspci -klspci -vvv的输出里,重点看这几项:Region 0到Region 5是BAR空间,Interrupt是中断信息,LnkSta是PCIe链路状态,Capabilities里的MSI和MSI-X是中断能力。
6.2 sysfs里的关键文件
# 查看设备资源 cat /sys/bus/pci/devices/0000:01:00.0/resource # 查看驱动绑定情况 ls -l /sys/bus/pci/devices/0000:01:00.0/driver # 查看驱动支持的设备列表 cat /sys/bus/pci/drivers/my_driver/new_idresource文件的每一行对应一个BAR,格式是start end flags。flags里如果是0x...0200表示内存空间,0x...0100表示I/O空间。
6.3 内核日志过滤
# 只看PCI相关日志 dmesg | grep -i pci # 实时查看日志 dmesg -w # 查看驱动加载日志 dmesg | grep my_driver如果日志太多,可以用dmesg -l err只看错误级别。调试时建议把CONFIG_DYNAMIC_DEBUG打开,用dynamic_debug控制特定文件的日志输出。
6.4 性能分析工具
# 查看中断分布 cat /proc/interrupts # 查看DMA映射情况 cat /sys/kernel/debug/dma-api/dump # 查看PCIe带宽 lspci -vvv | grep -i lnksta/sys/kernel/debug/dma-api/dump需要内核打开CONFIG_DMA_API_DEBUG,它会列出所有活跃的DMA映射,帮你发现泄漏的映射。
我个人在实际调试中最深的体会是:PCI驱动的问题,九成以上出在资源分配和中断配置上。寄存器读写反而很少出错,因为手册上写得清清楚楚。所以每次遇到问题,先检查pci_enable_device有没有调用、BAR有没有映射成功、DMA掩码设对了没有、中断向量申请到了没有。把这四步确认一遍,大部分问题都能定位到。另外,dmesg永远是你最好的朋友,遇到问题先看日志,比瞎猜快得多。