这个系列写到这里,终于要真正动代码了。前面 14 天我们聊了不少 AI Infra 的宏观话题,比如 GPU 池化、分布式存储、网络拓扑、调度器设计,但一直没有碰最底层的设备栈。今天我把话题拉到地面,写一个能跑的最小 PCIe 驱动:不搞中断、不搞 DMA、不搞多队列,只做一件事——让内核认识这个 PCIe 设备,然后从设备的配置空间和 BAR 里把关键信息读出来打印到内核日志里。
这个选题在 AI Infra 语境下是有明确意义的。无论是 NVIDIA GPU、DPU、RDMA 网卡,还是 NVMe SSD,几乎全部通过 PCIe 总线接入 CPU。你在用户态看到的一切“AI 算力”,底层最后都要走这条总线。而驱动这一层,往往是最不被 AI 工程师重视、却最容易出问题的环节。今天这篇就是把这个黑盒撬开一个小口子:看懂一个 PCIe 驱动由哪几块组成、每块代码在做什么、怎么把它真正跑起来。
文章会从硬件侧的基础概念讲起,然后给出一份可以直接编译加载的内核模块代码,再带你完整跑一遍、解析日志,最后是我实际调试过程中踩过的坑和排查思路。适合对 Linux 内核驱动基本没写过、但想入门 PCIe 的读者。如果你有 C 语言基础和一点点 Linux 命令行操作经验,这篇文章就是为你的第一次内核态开发准备的。
1. 为什么 AI Infra 系列第一次写代码,先选 PCIe 驱动
1.1 从上层应用到底层设备的必然路径
做 AI Infra 的人每天都在跟 GPU、NVMe、高速网卡打交道,但大部分人停留在“用工具看状态”的层面。nvidia-smi能看到 GPU 温度和利用率,nvme list能看到盘符和容量,ethtool -i能看到网卡固件版本,这些工具背后的信息从哪来?答案是设备驱动,而设备驱动的第一步,就是 PCIe 枚举和匹配。
AI 服务器的硬件拓扑几乎可以简化成一句话:CPU 通过 PCIe 总线挂满各种加速设备。GPU 是 PCIe 卡,NVMe SSD 是 PCIe 卡,ConnectX 网卡是 PCIe 卡,甚至很多 AI 推理卡本身就是 PCIe 形态的 FPGA 板卡。所以对于 AI Infra 从业者来说,PCIe 不只是“一条电脑里的总线”,而是整个异构计算体系的骨架。
当上层应用出现“GPU 掉卡”“NVMe 变慢”“网卡丢包”这类问题时,最终排查路径大多会沉到 PCIe 这一层。你会看到lspci -vvv输出里的链路速率、BAR 地址、中断分配、ACS 开关等等。如果你完全不懂 PCIe 驱动的工作机制,这些输出就是天书。反过来,一旦你亲手写过一次 PCIe 驱动,再回头看这些工具输出,就会清晰很多。
1.2 “最小”的定义:砍掉哪些东西,保留哪些东西
很多内核驱动的教材一上来就是完整的字符设备驱动加中断加 DMA,代码上千行,还没搞清楚设备是怎么被内核找到的,就被各种注册函数淹没了。这不符合学习规律。我这里的“最小”,指的是“能完成一次完整生命周期的最小闭环”。
什么是完整的生命周期?驱动被加载、内核找到对应的 PCIe 设备、调用驱动的 probe 函数、probe 里做最基本的硬件初始化并确认设备是可用的、驱动卸载时做反向操作、把设备干净地交还内核。这是所有 PCIe 驱动的骨架,GPU 驱动、网卡驱动、NVMe 驱动,无论后面挂了多少子系统,这个骨架是不变的。
所以这份最小驱动里,我刻意砍掉了以下东西:
- 不注册中断处理函数。中断涉及 MSI/MSI-X 配置、中断上下文、共享中断协调,代码量和调试难度会翻倍。
- 不做 DMA。DMA 需要分配一致性内存、配置 DMA mask、处理缓存一致性,这个后续单独写一篇更合适。
- 不创建 /dev 设备节点。那需要引入 file_operations、cdev、class 等一整套字符设备框架。
- 不做真正的“业务功能”,比如收发包、读写盘、跑 CUDA kernel。驱动的目的是让设备能和内核对话,业务功能是之后的事情。
保留的东西是:模块入口/出口、PCI 设备 ID 表、probe/remove 回调、BAR 读取和 ioremap、寄存器打印。这一套逻辑全部走通,你就已经具备阅读任何真实 PCIe 驱动的基础了。
1.3 驱动开发和其他软件开发最大的不同
驱动开发有一个很反直觉的地方:耐心花几个小时看文档、理清 API 调用顺序,比急着敲代码重要得多。内核模块一旦跑在 ring 0,没有用户态进程那种“崩了重启一下就好”的容错空间,一个野指针就能把整个系统带崩,数据丢失是分分钟的事。
所以我在写这篇的时候,默认你是 Linux 内核模块的新手。我会把每一步为什么这么做解释清楚,不是“照着抄能过就行”的那种教程,而是“下次你写别的驱动,也知道该查什么、该按什么顺序写”。记住了,驱动开发的本质不是写业务逻辑,而是管理硬件资源和生命周期。
2. 写驱动前必须理解的 PCIe 硬件视角
2.1 配置空间:驱动和硬件之间的第一本“身份证”
PCIe 设备上电之后,硬件会暴露一片最多 4KB 的配置空间(经典 PCI 只用了 256 字节,PCIe 扩展到了 4KB)。这片空间里有设备的身份信息和资源需求,包括 Vendor ID、Device ID、Class Code、BAR 寄存器、中断引脚等。
驱动和硬件第一次打交道,靠的就是这些信息。Vendor ID 是设备厂商的唯一标识,比如 NVIDIA 的 Vendor ID 是 0x10DE,Intel 是 0x8086。Device ID 是具体型号编号。内核里的pci_device_id表就是拿这两个值去匹配的:当驱动加载时,内核 PCI 子系统会把该驱动声明的 ID 表和当前总线上所有设备的 ID 逐一对比,匹配成功就会调用驱动的 probe 函数。
你可能会问,不就是一组 ID 吗,为什么需要专门的匹配机制?因为一个系统里可能挂了很多不同厂商、不同型号的设备,驱动必须准确地找到“属于自己的那个设备”。比如你写了一个针对某型号 FPGA 加速卡的驱动,不匹配就乱绑,把网卡驱动加载到 GPU 上,系统直接就乱了。简单说,ID 匹配就是防止“认错亲”。
2.2 BAR 与 MMIO:设备给 CPU 留的窗口
设备光有身份还不够,CPU 得能和它交换数据。PCIe 设备通过 BAR(Base Address Register)向系统声明自己需要一段地址空间。这段空间在物理上映射到设备的内部寄存器或显存,CPU 访问这段地址,就相当于直接操作设备内部状态,这种访问方式叫 MMIO(Memory-Mapped I/O)。
在最小驱动里,我会读取 BAR0 的地址和长度,然后用ioremap把这段物理地址映射到内核虚拟地址空间,之后就可以通过读写这段虚拟地址来操作设备寄存器。这正是很多热词里提到的“PCIe 转网口”“PCIe 板卡”类硬件的访问原理——无论板卡功能多复杂,CPU 看到的永远是寄存器集合,区别只是这些寄存器代表什么。
要注意的是,BAR 地址是 BIOS(或内核的 PCI 子系统)在启动阶段分配好的。驱动本身不分配 BAR,只是拿到结果并映射。所以写驱动前,先用lspci -vvv看看设备的 BAR 分布,能省很多调试时间。
2.3 枚举、链路训练和驱动之间的关系
如果你查过“PCIe 枚举过程”“LTSSM 的 Configuration 阶段”这类关键词,会发现启动过程中 PCIe 总线已经做了大量底层工作。BIOS/UEFI 在开机时扫描 PCIe 总线,分配总线号、分配 BAR、配置桥设备,这个过程叫枚举。LTSSM 是物理层的链路状态机,负责把两个 PCIe 设备之间的物理链路从 Detect 推到 L0 工作状态。
这些过程和驱动有什么关系?关系是:驱动能看到的一切,都是建立在枚举完成、链路已经 Up 的前提下的。驱动不会去初始化物理层,也不会自己枚举总线,它只需要通过标准接口读取已经配置好的资源,然后做设备级初始化。
这也解释了为什么很多驱动开发工具和教程都会强调“先用 lspci 确认设备能被识别”。如果链路训练失败或枚举出问题,设备在系统里根本不存在,驱动写得再好也无从匹配。反过来,只要lspci能看到设备,说明底层已经替你搞定了一大堆物理层和链路层的工作,你可以在一个相对稳定的基础上做开发。换句话说,写 PCIe 驱动不是从零搭房子,而是在已经浇筑好的地基上盖楼。
2.4 中断、DMA、ATS 等高级功能先知道存在即可
最小驱动不做中断和 DMA,但你需要先知道它们的存在,因为后续所有真实驱动都会碰。中断机制上,现代 PCIe 设备基本都用 MSI/MSI-X,替代了传统的 INTx 引脚中断。DMA 方向上,设备可以不经过 CPU 直接访问内存,这就是为什么 AI 训练里数据从 SSD 到 GPU 可以绕过 CPU 拷贝——本质上都是 PCIe 总线上的 DMA 操作。
还有 P2P(Peer-to-Peer),GPU 直接读写另一张 GPU 的显存,或者直接访问 NVMe 的缓冲区,也是走 PCIe 的地址转换。这些高级特性在最小驱动里都看不到,但是理解了 BAR、配置空间、ID 匹配、probe/remove 这套基础框架之后,你再去看nvidia-smi、nvme驱动代码、RDMA 网卡驱动,就会觉得熟悉,因为它们的最底层都是同一套骨架。
3. 最小 PCIe 驱动代码实现与逐段解析
3.1 环境准备:内核头文件 + 编译器
写内核模块之前,需要确认环境里装了内核头文件和编译工具链。以 Ubuntu/Debian 为例,先执行:
uname -r sudo apt install linux-headers-$(uname -r) build-essential编译内核模块不需要完整的内核源码树,只需要对应的头文件和编译配置。头文件版本必须和当前运行的内核匹配,否则编译出来的 .ko 文件在 insmod 时会被拒绝,提示版本 magic 不匹配。这个坑我踩过好几次,每次升级内核之后都容易忘掉。
如果你用的是自己的编译内核,或者交叉编译环境,对应的头文件路径会不同,但原理一致:内核模块不是一个独立可执行文件,它要链接进内核空间,所以必须用内核的编译系统(Kbuild)来构建,而不是普通的 gcc 直接编。
3.2 完整代码:一个能编译能加载的最小 PCIe 驱动
下面这段代码就是本次的核心,目标设备是 QEMU 虚拟机提供的 edu 教学设备。这个设备的 Vendor ID 是 0x1234,Device ID 是 0x11e8,专为学习 PCI 驱动设计,BAR0 映射到一段简单的寄存器空间,非常适合新手实验。如果你手头有真实的带 PCIe 接口的板卡,把 ID 改成你自己的设备 ID 即可适配。
// mypci.c - 最小 PCIe 驱动示例 #include <linux/module.h> #include <linux/pci.h> #include <linux/io.h> // 设备 ID 表:Vendor ID 0x1234,Device ID 0x11e8 // 这是 QEMU edu 设备的 ID,用来做实验非常合适 static struct pci_device_id mypci_ids[] = { { PCI_DEVICE(0x1234, 0x11e8) }, { 0, } }; MODULE_DEVICE_TABLE(pci, mypci_ids); // 保存 ioremap 映射出来的虚拟地址,后面 remove 时要用来 iounmap struct mypci_dev { void __iomem *bar0; unsigned long bar0_start; unsigned long bar0_len; }; // probe:内核发现设备 ID 匹配后调用的初始化函数 static int mypci_probe(struct pci_dev *dev, const struct pci_device_id *id) { struct mypci_dev *mdev; u32 id_reg; int ret; dev_info(&dev->dev, "mypci: probe called, vendor=0x%04x device=0x%04x\n", dev->vendor, dev->device); // 1. 使能设备:打开 Memory/IO 访问位,允许总线主控等 ret = pci_enable_device(dev); if (ret) { dev_err(&dev->dev, "mypci: pci_enable_device failed, ret=%d\n", ret); return ret; } // 2. 请求设备资源,防止其他驱动重复占用 ret = pci_request_regions(dev, "mypci"); if (ret) { dev_err(&dev->dev, "mypci: pci_request_regions failed, ret=%d\n", ret); goto err_disable; } // 3. 设置为总线主设备(对 DMA 是必需的,这里先演示正确姿势) pci_set_master(dev); // 4. 配置 DMA mask(即使不做 DMA 也要设置,否则后续使用会出问题) ret = dma_set_mask_and_coherent(&dev->dev, DMA_BIT_MASK(32)); if (ret) { dev_err(&dev->dev, "mypci: dma_set_mask failed, ret=%d\n", ret); goto err_regions; } // 5. 读取 BAR0 的物理地址和长度 mdev = kzalloc(sizeof(*mdev), GFP_KERNEL); if (!mdev) { ret = -ENOMEM; goto err_regions; } mdev->bar0_start = pci_resource_start(dev, 0); mdev->bar0_len = pci_resource_len(dev, 0); if (!mdev->bar0_start || !mdev->bar0_len) { dev_err(&dev->dev, "mypci: BAR0 not assigned\n"); ret = -ENODEV; goto err_free; } dev_info(&dev->dev, "mypci: BAR0 phys=%pa len=%lu\n", &mdev->bar0_start, mdev->bar0_len); // 6. ioremap:将物理地址映射到内核虚拟地址,便于读写 mdev->bar0 = ioremap(mdev->bar0_start, mdev->bar0_len); if (!mdev->bar0) { dev_err(&dev->dev, "mypci: ioremap failed\n"); ret = -ENOMEM; goto err_free; } // 7. 从 BAR0 偏移 0 读取设备的 ID 寄存器(edu 设备约定) id_reg = ioread32(mdev->bar0 + 0x00); dev_info(&dev->dev, "mypci: read ID register from BAR0: 0x%08x\n", id_reg); // 8. 把 mdev 指针保存到设备结构中,remove 阶段需要用 dev_set_drvdata(&dev->dev, mdev); dev_info(&dev->dev, "mypci: probe done successfully\n"); return 0; err_free: kfree(mdev); err_regions: pci_release_regions(dev); err_disable: pci_disable_device(dev); return ret; } // remove:驱动卸载或设备拔出时调用的清理函数 static void mypci_remove(struct pci_dev *dev) { struct mypci_dev *mdev = dev_get_drvdata(&dev->dev); if (!mdev) return; dev_info(&dev->dev, "mypci: remove called, cleaning up\n"); if (mdev->bar0) iounmap(mdev->bar0); kfree(mdev); pci_release_regions(dev); pci_disable_device(dev); dev_info(&dev->dev, "mypci: remove done\n"); } static struct pci_driver mypci_driver = { .name = "mypci", .id_table = mypci_ids, .probe = mypci_probe, .remove = mypci_remove, }; module_init(mypci_init); module_exit(mypci_exit); static int __init mypci_init(void) { return pci_register_driver(&mypci_driver); } static void __exit mypci_exit(void) { pci_unregister_driver(&mypci_driver); } MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("Minimal PCIe driver example");这里有个小细节,dev_info里的%pa是专门打印phys_addr_t类型的格式符,需要传入指针。用%lx直接传unsigned long在 32 位和 64 位平台上的行为不一样,容易出问题。这种格式符细节,写用户态程序的人根本不会注意,但在内核代码里却很重要,因为内核需要同时在多种架构上编译运行。
3.3 Makefile:用 Kbuild 而不是裸 gcc
内核模块必须用内核的构建系统来编译,直接gcc -c mypci.c是编不出来的,因为缺少内核模块编译所需的特殊标志和链接处理。正确的 Makefile 长这样:
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在源码目录执行make,得到mypci.ko。然后sudo insmod mypci.ko加载,sudo rmmod mypci卸载。整个过程其实比你想象中要简单,真正的难点不在敲这几行代码,而是在调试时搞明白“为什么 probe 没被调用”“为什么 ioremap 之后读出来全是 0xff”。
3.4 代码要点:理解 probe 和 remove 的对称性
把这段代码拆成几个关键动作来看。pci_enable_device 是第一个必须做的事,它打开设备命令寄存器里的 memory/IO decode 位。如果没有这一步,设备虽然被枚举到了,但它的寄存器空间并不会响应 CPU 的访问,读出来的值会是全 1,也就是 0xffffffff,这通常意味着“设备不存在或不可访问”。
pci_request_regions 的作用是登记资源占用。它相当于在资源管理表里给这段 BAR 空间贴了个标签:“这个区域归 mypci 驱动管”。这样如果另一个驱动也想去操作这个设备,内核会发现冲突并阻止,避免多个驱动同时控制同一个设备造成混乱。
ioremap 是把物理地址映射到内核虚拟地址空间的关键函数。这里有个很容易混淆的点:CPU 不能直接访问物理地址吗?理论上可以,但现代内核运行在虚拟内存环境下,驱动不能直接用物理地址去读写,必须经过页表转换成虚拟地址。ioremap就是建立这个映射关系的。卸载驱动时必须用iounmap释放,否则映射一直存在,多次加载卸载之后地址空间会泄漏。
probe 函数和 remove 函数是严格对称的:probe 里做了什么分配,remove 里就必须做什么释放。我见过不少驱动(包括一些商业驱动)在 probe 路径上出错时没有做好资源清理,或者 remove 函数里漏掉了某一步,结果就是设备热拔插之后系统变得不稳定。写驱动时时刻记住这个对称性,能省掉很多麻烦。
4. 实操过程:在 QEMU 里把驱动跑起来
4.1 为什么选择 QEMU + edu 设备做实验
在真机实验环境不理想的情况下,我强烈建议先用 QEMU 虚拟机来做第一次 PCIe 驱动开发。原因很直白:安全。一个刚写出来的驱动,probe 里很可能有各种问题,比如会不会访问非法地址、会不会死循环、会不会把内核搞 panic。在虚拟机上出了这些问题,重启一下虚拟机就行;在真机上如果驱动错误地操作了显卡或 NVMe 控制器的寄存器,轻则设备不可用,重则数据损坏,这个风险不值得冒。
QEMU 提供了一个专门的 edu 教学设备,用命令行参数就能挂到虚拟总线上:
qemu-system-x86_64 -m 2G -smp 2 -kernel /boot/vmlinuz-$(uname -r) \ -initrd /boot/initrd.img-$(uname -r) \ -append "root=/dev/vda1 console=ttyS0" \ -drive file=ubuntu.qcow2,format=qcow2,if=virtio \ -device edu如果你用的是正常的带显示界面的虚拟机,直接-device edu即可。启动后进入系统,lspci应该能看到类似00:04.0 Unclassified device [00ff] QEMU EDU Device [1234:11e8]的输出。确认设备存在,接下来就可以加载驱动了。
4.2 编译加载并解析内核日志
在源码目录执行:
make sudo insmod mypci.ko然后查看内核日志:
dmesg | tail -20正常情况下你会看到类似这样的输出(时间戳因系统而异):
[ 1024.123456] mypci: loading out-of-tree module taints kernel. [ 1024.123512] mypci: probe called, vendor=0x1234 device=0x11e8 [ 1024.123548] mypci: BAR0 phys=0xfebf0000 len=4096 [ 1024.123584] mypci: read ID register from BAR0: 0x11e8 [ 1024.123611] mypci: probe done successfully先解释第一行里的 “taints kernel”。这不是错误,是内核标记“这个内核被非官方模块污染了”。加载没有经过内核源码树编译的模块都会出现这个提示,意思是出现问题后内核维护者可能不会帮你排查,因为你用了非主线代码。实际开发中这个提示忽略即可。
然后看到 probe 函数被调用,打印出 vendor/device ID,说明 ID 匹配成功。接着打印 BAR0 的物理地址和长度,这个地址由 QEMU 的 PCI 枚举逻辑自动分配。最后从 BAR0 偏移 0 读出来 0x11e8,这就是 edu 设备的 ID 寄存器值,说明 ioremap 成功且 MMIO 读写已经打通。
到了这一步,整个链路就算对上了:内核 PCI 子系统成功匹配设备、驱动 probe 被调用、BAR 资源可用、寄存器读写正常。这已经是一个完整的“最小闭环”。
4.3 卸载流程验证
执行sudo rmmod mypci,再看 dmesg:
[ 1088.654321] mypci: remove called, cleaning up [ 1088.654357] mypci: remove done这里注意,remove 是拔出设备或卸载驱动时调用的。如果 remove 函数没有正确调用pci_release_regions和pci_disable_device,最直接的后果是:下次 insmod 的时候pci_request_regions会失败,因为上一轮驱动没有释放资源。这种问题不会导致系统崩溃,但会让你反复加载卸载之后突然“莫名奇妙”装不上了。所以驱动开发的调试节奏是:加载、看日志、卸载、看日志,每一步的对称性都要检查到位。
4.4 没有 QEMU edu 设备时的替代验证方案
手头没有 QEMU,也没有真实 PCIe 设备,还能不能验证这个驱动?可以,但需要用一点技巧——强制绑定内核不认识的设备 ID。假设你的机器上有某个 PCIe 设备,你想试试驱动框架是否正常,但又不想改代码重新编译,可以用 sysfs 接口:
# 查看当前有哪些 PCIe 设备 lspci -n # 向驱动声明“我支持这个新的 ID” echo "8086 1234" | sudo tee /sys/bus/pci/drivers/mypci/new_idnew_id是 PCI 驱动框架提供的一个动态接口,往里面写入 vendor/device ID,驱动会立刻重新扫描总线并尝试匹配。这个功能在驱动开发阶段非常有用,可以通过它快速验证 probe 路径。但正式发布驱动时,绝对不能依赖这个接口,必须把正确的 ID 写进pci_device_id表里,否则设备一重启就又不识别了。
你还可以手动控制 bind/unbind:
# 找到设备 BDF(Bus/Device/Function),例如 0000:00:04.0 echo "0000:00:04.0" | sudo tee /sys/bus/pci/drivers/mypci/bind echo "0000:00:04.0" | sudo tee /sys/bus/pci/drivers/mypci/unbind手动 bind 会在不卸载模块的情况下强制触发 probe。调试驱动时非常常用,因为不用反复 insmod/rmmod,只需绑定和解绑不同设备即可。我经常用这个方式在真机上用一套驱动代码测试多个同型号设备。
5. 常见问题与排查技巧实录
5.1 加载失败与日志排查速查表
写这个最小驱动的过程中,我遇到了不少问题,下面整理成了速查表,按高频到低频排列。这张表同样适用于你以后写任何 PCIe 驱动。
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| insmod 报错 “Invalid module format” | 内核头文件版本与运行内核不匹配 | uname -r和ls /lib/modules/对比;重新安装对应版本 linux-headers |
| 加载后 dmesg 没有任何 probe 日志 | 设备 ID 不匹配,probe 没被调用 | lspci -n确认设备的 vendor/device ID;和pci_device_id表对比 |
| BAR 读出来全是 0xffffffff | pci_enable_device没调用,或 BAR 本身没被分配 | 确认代码里调用了 enable;用lspci -vvv查看 BAR 地址是否非零 |
ioremap返回 NULL | 物理地址无效,或地址长度异常 | 检查pci_resource_start/pci_resource_len返回值 |
| probe 返回错误但系统没崩 | probe 路径上某个操作失败 | 在 probe 的每个错误分支里加dev_err,错误码会打印在 dmesg 中 |
| rmmod 之后资源没释放 | remove 函数漏掉了某个对称操作 | 检查 iounmap、pci_release_regions、pci_disable_device 是否成对出现 |
| 加载导致内核直接 panic | 访问了非法地址或错误释放内存 | 检查 ioread32/iowrite32 的地址是否在 BAR 长度范围内;检查 kfree 和 iounmap 的顺序 |
| dmesg 看不到日志(非 root) | 日志被 kernel.dmesg_restrict 限制 | sudo dmesg查看,或sudo journalctl -k看日志 |
5.2 大坑实录:为什么 probe 没被调用
这是新手写 PCIe 驱动时最常见的困扰。模块加进去了,dmesg 里也有 “loading out-of-tree module” 的提示,但就是看不到probe called的日志。我第一次遇到时先怀疑驱动代码写错了,检查了好几遍 probe 函数,最后才发现是设备 ID 填错了。
我用的真实 PCIe 网卡是 0x10EC:0x8168,但lspci -n显示的是 0x10EC:0x8168,看起来没问题?其实我少看了一位。后来把设备 ID 表改成和 lspci 完全一致,probe 立刻就被调用了。
这个坑告诉我们要养成一个习惯:动手写驱动之前,先lspci -nn把你目标设备的完整 ID 抄下来,不要靠记忆力。内核的匹配机制非常死板,ID 对不上就直接忽略,不会给任何提示。这其实是保护机制,防止驱动错绑到别的设备上。
5.3 大坑实录:ioremap 成功后读回来全是 0xff
另一个让我印象深刻的问题是,probe 被正常调用,BAR0 地址打印也正常,但ioread32读出来全是 0xffffffff。这个值很像是“总线访问失败”。
排查过程是这样的:先怀疑是 BAR 地址问题,用lspci -vvv查看,BAR0 确实被分配了合理的地址。然后怀疑 ioremap 的地址和长度不对,打印出来也正常。最后才发现,问题出在pci_enable_device的调用顺序上。
我在一个早期版本里把pci_enable_device放在了 BAR 读取之后,导致操作寄存器时设备还没被真正“使能”。虽然配置空间能读到 BAR,但 MMIO 访问返回的永远是 0xff。把 enable 调到最前面之后,问题立刻消失。这个教训是:PCI 驱动的操作顺序不是可选的,enable -> request_regions -> set_dma_mask -> ioremap -> 访问寄存器,这个顺序是无数前人用踩坑换来的标准姿势,不要轻易打乱。
5.4 真机调试安全建议
如果你决定从 QEMU 毕业,到真机上测试自己的 PCIe 驱动,那我要多说几句安全建议。
第一,不要在数据盘上做实验。pci_request_regions只是资源登记,并不能阻止你的驱动错误地读写设备的 DMA 缓冲区或者错误的寄存器。最坏情况是驱动把一个正在工作的 NVMe 控制器搞坏,盘上的数据可能就保不住了。
第二,优先选择一张独立的、不带业务流量的 PCIe 网卡或专用测试卡做实验。我曾经因为手边没有测试设备,把驱动绑到了服务器管理口的网卡上,结果驱动加载后网卡链路直接断开,机器差点远程失联。从那以后我都习惯先确认设备在系统中的“业务角色”,再决定能不能动。
第三,养成先看lspci -vvv再动手的习惯。设备当前工作在什么链路速率、中断被分配到哪里、BAR 地址是否正常、有没有驱动绑定,这些信息在动手前就应该搞清楚。驱动开发不是猜谜游戏,所有状态都有迹可循。
6. 后续可以怎么扩展
从最小驱动到真实可用的 PCIe 驱动,中间就隔三件事:中断、DMA、上层业务接口。中断方面,下一步可以先做 MSI-X 支持,在 probe 里申请中断向量,然后写一个简单的 interrupt handler,配合一个cat /proc/interrupts就能看到中断计数增长。DMA 方面,可以先做一个简单的 ring buffer,在驱动的控制下把数据从设备传到内存,这里会涉及dma_alloc_coherent和内存屏障的使用,复杂度会明显上升。
如果你做的是网卡类设备,最终目标是把驱动挂到内核的网络子系统上,实现net_device_ops里的ndo_open、ndo_start_xmit等回调。如果你是做 GPU 或 FPGA 加速卡驱动,那就要往 DRM 子系统或者自定义的用户态访问路径上走。
这些扩展方向的共同规律是:无论功能多复杂,probe 里的生命周期管理、资源申请顺序、错误路径清理,始终是决定驱动稳定性的核心。把最小驱动吃透,后面往任何方向走都不会觉得突兀。
我自己的习惯是,每接触一种新设备,都先写一个最小驱动,把 probe/remove、BAR 信息、ID 信息全打出来,确认基本通信没问题,再往里面加功能。这个习惯帮我规避了无数次“上层模块写得很好,但底层通信本来就没打通”的尴尬局面。你把这个最小驱动亲手跑通一遍之后,再去看那些几千行的商业驱动代码,心态会完全不一样——因为你知道它的骨架在哪里,知道哪些代码是核心、哪些是外围的细节。