
1. 为什么这个系列决定从读论文切换到写代码AI Infra 每日一问这个系列写到现在前面十几天的内容基本都在聊概念、架构、协议流程比如 PCIe 枚举过程、LTSSM 状态机、Configuration 阶段的报文流转图这些。读者反馈也越来越集中能不能来点真的能跑的东西光看协议文档和状态图和亲手把一个驱动加载进内核、看到 dmesg 里打出自己的设备信息完全是两种体感。这篇就是整个系列第一次动手写代码目标定得非常克制写一个能加载、能探测设备、能正确读取 PCIe 配置空间、能优雅卸载的最小 PCIe 驱动。不搞 DMA不搞中断不搞数据传输那些是后面几天的事。先把驱动活着这件事跑通把 PCIe 驱动的基本骨架立起来后面所有复杂功能都是在这个骨架上长出来的。这个定位适合谁两种人。一种是刚接触 Linux 驱动开发、被一堆内核 API 和结构体劝退的新手这篇可以当作第一个能跑通的 PCIe 驱动模板另一种是搞 AI Infra 基础设施、日常工作主要写用户态代码、但需要理解设备驱动工作方式的人。毕竟 AI 加速卡的底座就是 PCIe搞清楚驱动怎么和设备对话对理解整条 I/O 链路非常有帮助。先说清楚这篇不做什么不碰 DMA 映射不碰 MSI-X 中断不碰 BAR 空间读写。这些一旦铺开文章长度会失控而且新手直接面对这些概念容易懵。最小驱动的好处是每一行代码都能找到对应关系它读的是什么、写的是什么、内核为什么需要它这么做全部能被解释清楚。我自己的体会是很多人卡在驱动开发入门不是因为内核 API 记不住而是不知道一个驱动到底该做什么、不该做什么。所以这篇文章会用大量篇幅讲代码背后的逻辑而不是单纯贴一段代码让你编译完就完事。代码只是骨架理解驱动与 PCIe 设备的握手过程才是真正值钱的部分。注意本文所有实验基于 x86_64 架构的 Linux 系统内核版本 5.15 LTS。不同内核版本的 API 略有差异但核心框架是稳定的。2. 先建立一个关键认知PCIe 驱动到底在驱动什么很多初学者对驱动这个词有误解以为驱动是驱动设备工作的软件。这个理解在 PCIe 场景下需要修正一下PCIe 驱动不是驱动设备而是代表设备与内核对话。设备插到主板上之后硬件层面已经通过 LTSSM 状态机完成了链路训练从 Detect 到 L0物理链路已经通了。PCIe 枚举过程也就是 PCI bus scan会把设备挂在总线拓扑上给设备分配 BDFBus:Device.Function编号然后读取设备配置空间里的 Vendor ID、Device ID、Class Code 等信息。到了这一步设备已经活着了即使没有任何驱动它也能被lspci看到。那驱动的作用是什么驱动的核心任务有三块第一向内核声明这个设备归我管。通过 Vendor ID 和 Device ID 的匹配驱动告诉内核这个设备我能处理请你把设备交给我。第二初始化设备到可用状态。包括设置 PCIe 命令寄存器开启总线主控、开启内存空间、开启 I/O 空间、申请 BAR 资源、配置 DMA 掩码、注册中断处理函数等。最小驱动里只做前一部分而且是内核辅助完成的。第三提供操作接口给用户态或内核态的其他模块使用。比如网卡驱动的ndo_start_xmit、NVMe 驱动的 block 层接口、GPU 驱动的 DRM 接口。换句话说PCIe 的链路训练是硬件自动完成的PCI 枚举是内核通用代码完成的驱动负责的是枚举完成之后设备如何被系统使用这件事。这里有一个理解 PCIe 驱动框架的绝佳类比把 PCIe 设备想象成一个租客内核是中介平台驱动是这个租客的专属经纪人。平台内核先把房源设备挂出来登记基本身份信息设备 ID经纪人驱动看到信息后说这个租客我熟我来服务然后开始处理租客的各种需求配置、中断、数据流。没有经纪人租客依然在那里只是没人替他办事。struct pci_driver这个结构体就是经纪人递给平台的名片。平台只看这个名片上的匹配规则一旦匹配成功就调用名片上的probe函数。static struct pci_driver minimal_pcie_driver { .name minimal_pcie, .id_table minimal_pcie_ids, .probe minimal_pcie_probe, .remove minimal_pcie_remove, };这个结构体一共就四个关键字段每一个都对应一个明确的问题name是驱动名出现在/sys/bus/pci/drivers/目录下id_table是匹配规则决定哪些设备归我管probe是设备匹配成功后的初始化入口remove是设备被拔掉或驱动卸载时的清理出口。内核的 PCI 子系统看到这个名片后遍历总线上的设备用id_table里的 Vendor ID 和 Device ID 与设备配置空间里的值逐一比对。匹配成功调用probe驱动卸载或设备热拔调用remove。整个生命周期就是围绕这张名片展开的。3. 最小驱动的完整代码与逐段拆解代码量很小完整文件大概 120 行。先贴出来然后逐段拆解为什么这么写每行代码在内核里做什么事。这里用的是内核模块的标准写法不涉及设备树、ACPI 这些平台相关的东西纯 PCIe 驱动框架。3.1 头文件与许可证内核模块的身份三件套#include linux/module.h #include linux/pci.h #include linux/kernel.h MODULE_LICENSE(GPL); MODULE_AUTHOR(AI Infra Daily); MODULE_DESCRIPTION(Minimal PCIe driver for AI Infra learning series);第一个头文件是关于内核模块本身的模块参数、模块元信息、加载卸载辅助函数都在里面。第二个头文件是 PCI 子系统的核心头文件struct pci_driver、struct pci_device_id、pci_register_driver()这些关键类型和函数的声明都在这里。第三个是pr_info、pr_err这些打印函数所在的头文件。MODULE_LICENSE(GPL)不是走过场。内核里很多符号是 GPL-only 的如果模块不声明 GPL某些核心函数根本无法链接。MODULE_AUTHOR和MODULE_DESCRIPTION则是纯粹的元数据加载后可以通过modinfo查看。3.2 设备 ID 表告诉内核我认识哪些设备#define VENDOR_ID_TEST 0x1234 #define DEVICE_ID_TEST 0x5678 static struct pci_device_id minimal_pcie_ids[] { { PCI_DEVICE(VENDOR_ID_TEST, DEVICE_ID_TEST) }, { 0 } }; MODULE_DEVICE_TABLE(pcie, minimal_pcie_ids);PCI_DEVICE宏展开后是{ .vendor VENDOR_ID_TEST, .device DEVICE_ID_TEST, .subvendor PCI_ANY_ID, .subdevice PCI_ANY_ID }。意思是只要设备配置空间里的 Vendor ID 是0x1234、Device ID 是0x5678不管子系统厂商 ID 和子系统设备 ID 是什么都匹配。为什么要单独写一个MODULE_DEVICE_TABLE这里有个新手不容易察觉的坑这个宏主要作用不是给内核运行时用的而是给depmod工具用的。模块编译完成后depmod会扫描每个模块的MODULE_DEVICE_TABLE生成模块与设备 ID 的对应关系。当系统检测到新设备时udev通过这个对应关系自动加载对应模块。如果不写这行宏手动insmod也能正常 work因为probe还是会调用。但系统无法自动加载模块这就不算一个完整的驱动了。这个 ID 表还有一个关键细节必须用{ 0 }作为数组结尾标记。pci_match_id遍历时靠这个空条目判断结束。漏掉它内核会越界读取内存轻则日志刷屏重则直接 panic。注意本文用的 Vendor ID0x1234和 Device ID0x5678是虚拟测试 ID。真实硬件有真实的 ID比如 Intel 网卡的 Vendor ID 是0x8086。后续实验会用一个虚拟 PCIe 设备来测试这个驱动实际项目中请填写你自己设备的真实 ID。3.3 probe 函数设备绑定后的初始化入口static int minimal_pcie_probe(struct pci_dev *dev, const struct pci_device_id *id) { int ret; ret pci_enable_device(dev); if (ret) { pr_err(minimal_pcie: pci_enable_device failed ret%d\n, ret); return ret; } pci_set_master(dev); pr_info(minimal_pcie: probe successful\n); pr_info(minimal_pcie: vendor0x%04x device0x%04x\n, dev-vendor, dev-device); pr_info(minimal_pcie: irq%d\n, dev-irq); pr_info(minimal_pcie: resource[0] start0x%llx len%llu\n, dev-resource[0].start, (unsigned long long)(dev-resource[0].end - dev-resource[0].start 1)); return 0; }probe函数收到两个参数struct pci_dev *dev指向匹配到的 PCIe 设备struct pci_device_id *id指向匹配的 ID 条目。这两个参数是理解 PCIe 驱动的关键。第一个必须调用的函数是pci_enable_device()。这个函数内部做了很多事情把设备的 PCI 命令寄存器里的 Memory Space Enable 位和 I/O Space Enable 位置位让设备可以响应内存和 I/O 访问同时还会做一些电源管理状态的处理把设备从 D3 状态恢复到 D0 全功率状态。忘记调用这个函数后面读写 BAR 空间时大概率会遇到总线错误Unsupported Request。第二个是pci_set_master()开启设备的 bus mastering 能力。翻译成白话允许设备作为总线主控发起 DMA 传输。虽然最小驱动不涉及 DMA但这个设置是 PCIe 设备初始化的标准动作。如果设备将来要做 DMA这一步必须提前做好。调用这个函数本质上是在写 PCI Command Register 里的 Bus Master Enable 位。dev-resource[0]是设备的 BAR0 寄存器信息。probe阶段内核已经把 BAR 空间映射到 CPU 的物理地址空间resource[0].start就是 BAR0 对应的物理起始地址。最小驱动不访问 BAR 空间但打印出来可以看到设备的地址资源对确认设备被正确枚举有帮助。打印里用%llx和%llu格式化resource[0].start时注意类型转换。resource_size_t在内核里实际是phys_addr_t32 位系统下可能是u3264 位系统下是u64。直接传%llx会有类型不匹配的编译警告所以统一转成unsigned long long再打印。3.4 remove 函数优雅退出比加载更重要static void minimal_pcie_remove(struct pci_dev *dev) { pci_clear_master(dev); pci_disable_device(dev); pr_info(minimal_pcie: device removed\n); }remove函数的职责很清晰释放probe里申请的所有资源把设备恢复到初始状态。这里做了两件事pci_clear_master()清掉总线主控位pci_disable_device()关闭设备的内存和 I/O 空间访问。很多人写驱动只关心probe怎么写remove随便糊弄。这是非常危险的。实际工作中遇到的大部分诡异问题——比如驱动卸载后系统 hang、模块重复加载后设备无法工作——都是remove没做好导致的。设备被拔掉后没有正确关闭内核再枚举新设备时可能读到脏状态。3.5 模块入口与出口驱动的开关static int __init minimal_pcie_init(void) { int ret; ret pci_register_driver(minimal_pcie_driver); if (ret) { pr_err(minimal_pcie: pci_register_driver failed ret%d\n, ret); return ret; } pr_info(minimal_pcie: driver registered\n); return 0; } static void __exit minimal_pcie_exit(void) { pci_unregister_driver(minimal_pcie_driver); pr_info(minimal_pcie: driver unregistered\n); } module_init(minimal_pcie_init); module_exit(minimal_pcie_exit);模块加载时module_init指定的minimal_pcie_init被调用里面只有一个核心动作把struct pci_driver注册进内核的 PCI 子系统。pci_register_driver()是一个同步过程。函数内部会遍历当前总线上的所有设备用id_table做匹配匹配成功就立即调用probe。也就是说模块加载的那一刻probe可能已经执行过了。这一点和很多人的直觉不同——不是设备插入时触发probe而是驱动注册时就会对所有已存在的设备做一次匹配。pci_unregister_driver()相反先对该驱动管理的所有设备调用remove然后从总线驱动链表中摘除该驱动。__init和__exit这两个宏是内核的内存优化手段。__init标记的函数在模块加载完成后所占用的内存会被内核释放回收。__exit标记的函数如果模块被编译进内核而不是作为.ko文件占用的内存会被丢弃因为这些代码永远不执行。4. 编译配置与 Makefile内核模块的构建细节内核模块不能直接用 gcc 编译必须使用内核的构建系统Kbuild。原因是模块里引用的几乎所有内核符号地址都要在编译时由内核提供这要求编译过程与当前运行内核的配置和符号表严格对齐。4.1 Makefile 的标准写法obj-m minimal_pcie.o KDIR : /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean这套 Makefile 的写法非常固定我见过很多人在这个阶段卡住核心问题在于不理解-C $(KDIR)的含义或者KDIR指向的目录不存在。/lib/modules/$(uname -r)/build是一个符号链接指向当前内核源代码树或者至少是内核头文件和构建系统。如果没有安装内核开发包这个目录不存在编译会直接报错no such file or directory。Ubuntu/Debian 系安装内核头文件的命令sudo apt install linux-headers-$(uname -r)RHEL/CentOS/Fedora 系sudo dnf install kernel-devel kernel-headersM$(PWD)告诉 Kbuild 在哪个目录查找源码编译完成后.ko文件也会生成在该目录。4.2 编译中常见报错与排除方法make编译时最常见的报错是版本头不匹配/lib/modules/5.15.0-xxx-generic/build: No such file or directory这是内核头文件没装按上面命令装就行。另一个常见问题是error: unknown type name pci_ers_result_t这个通常是因为头文件包含顺序问题或者内核版本太老。如果目标是 5.15按本文代码不会遇到。如果在更老的内核上编译需要额外包含linux/aer.h等头文件。还有一种情况是编译成功了但insmod报Invalid module format。最大可能是模块编译用的内核头文件版本和当前运行内核不一致。用uname -r确认版本再检查/lib/modules/目录下是否有同名目录基本能定位问题。4.3 加载、验证、卸载的标准流程编译成功后目录下会生成minimal_pcie.ko。依次执行# 加载模块 sudo insmod minimal_pcie.ko # 确认驱动注册 ls /sys/bus/pci/drivers/minimal_pcie/ # 确认设备绑定 ls /sys/bus/pci/drivers/minimal_pcie/ # 输出中应该看到类似 0000:01:00.0 的设备号 # 查看内核日志 sudo dmesg | tail -20dmesg中应该能看到类似这样的输出minimal_pcie: driver registered minimal_pcie: probe successful minimal_pcie: vendor0x1234 device0x5678 minimal_pcie: irq16 minimal_pcie: resource[0] start0xfb000000 len1048576注意日志顺序driver registered在前probe successful在后验证了前面说的pci_register_driver会对已存在设备做同步匹配的机制。接着验证设备确实被驱动接管了# 查看设备当前使用的驱动 lspci -k -s 01:00.0如果看到Kernel driver in use: minimal_pcie说明绑定成功。如果没有设备可能已经被其他驱动占用需要先解绑echo 0000:01:00.0 | sudo tee /sys/bus/pci/drivers/原来的驱动/unbind最后卸载模块sudo rmmod minimal_pcie # 确认移除 sudo dmesg | tail -10 # 应该看到 driver unregistered 和 device removed卸载后可以再用lspci -k检查设备会显示回Kernel driver in use: 无。5. 用 QEMU 虚拟 PCIe 设备做无硬件验证文章写到这里最大的问题是读者手上不一定有 Vendor ID 为0x1234、Device ID 为0x5678的硬件。而写驱动的乐趣在于看到它真的跑起来没有设备就成了一纸空文。这里分享一个我在没有真实硬件时常用的方案用 QEMU 虚拟一个 PCIe 设备再配合 QEMU 的-device pci-testdev来充当实验对象。pci-testdev是 QEMU 内置的一个测试设备专门用于验证 PCI 设备模拟的正确性。我们先用一个更直接的方案修改最小驱动的 ID 表匹配 QEMU 默认虚拟设备的 ID。QEMU 在-machine q35模式下默认会创建一些虚拟 PCIe 设备其中有一个设备 ID 是固定的。更简单的办法是直接写一个 QEMU 设备模型但那是另一个话题。这里先提供最快速路径# 启动一个 q35 架构的 QEMU 虚拟机 qemu-system-x86_64 \ -machine q35 \ -m 2G \ -smp 2 \ -kernel /boot/vmlinuz-$(uname -r) \ -initrd /boot/initrd.img-$(uname -r) \ -append consolettyS0 root/dev/sda1 \ -device pci-testdev进入虚拟机后执行lspci能看到一个 QEMU 提供的测试设备。查询它的 Vendor ID 和 Device IDlspci -n拿到的 ID 填入驱动的 ID 表重新编译加载就能看到probe被触发。这个过程和真实硬件上的体验几乎一致唯一区别是设备性能不受影响非常适合驱动开发学习。如果还想更逼真可以写一个自定义 QEMU PCIe 设备模型把 Vendor ID 设为0x1234、Device ID 设为0x5678这样驱动代码一行都不用改。QEMU 设备模型和 Linux 驱动是同一个 PCIe 协议栈的两端这种虚拟设备 真实驱动的组合是学习 PCIe 协议最黄金的搭配。6. 从最小驱动到真实驱动后面还需要补齐什么最小驱动能跑通了但这只是万里长征第一步。从最小驱动走向能用的驱动还有几块必须补齐的核心能力这里简单给个路标方便后续深入学习。中断处理是第一个要补的。PCIe 设备支持 INTx、MSI、MSI-X 三种中断机制。现代高性能设备基本都用 MSI-X因为它支持每个队列独立中断且不会共享中断线。最小驱动的probe里没有调用pci_alloc_irq_vectors这是后续第一个要加的功能。DMA 映射是第二个。设备要访问内存中的数据不能直接用 CPU 的物理地址而要通过dma_map_single或dma_alloc_coherent获取设备可用的 DMA 地址。这里涉及 IOMMU、SWIOTLB、DMA 掩码等一堆概念也是后续内容的重头戏。BAR 空间访问是第三个。resource[0]给了 BAR0 的物理地址要通过ioremap把它映射成内核虚拟地址才能访问设备的寄存器和门铃Doorbell。门铃机制在 NVMe 设备里非常典型通过写门铃寄存器通知设备有新的命令队列条目。字符设备或子系统接口是第四个。驱动最终要为用户态程序提供访问能力无论是read/write/ioctl的字符设备接口还是网络设备、块设备、GPU 子系统的专用接口。这四个方向每一个都可以单独写好几篇。后续这个系列会按顺序逐个展开同时也建议读者在线下搭一套 QEMU 环境一边看文章一边敲代码。7. 写代码这步最容易踩的坑个人经验汇总最后把这些年写 PCIe 驱动踩过的坑整理一下很多都是网上教程不会明确写出来的但是一旦踩到会浪费大量时间。坑一内核头文件与运行内核版本不匹配。安装头文件后一定要用uname -r确认版本。Ubuntu 上最常见的情况是系统升级内核后没重启头文件装的是新版本运行的还是旧内核。编译不报错但 insmod 必挂报错还是Invalid module format这种看不清问题本质的提示。排查方法很简单modinfo看一下模块的 vermagic 和uname -r是否一致。坑二pci_enable_device失败后没有检查返回值就继续往下走。如果设备被别的驱动占用或者电源状态异常这个函数会返回错误。不检查返回值继续往下执行大概率在访问 BAR 时遇到总线错误系统直接 Oops连排查的机会都没有。内核编程的铁律是能返回错误码的地方必须检查错误码。坑三ID 表忘记以{ 0 }结尾。这个前面提过。内核遍历 ID 表时没有数组长度信息只能靠空条目判断结束。漏掉这个空条目pci_match_id会越界读内存表现是模块加载时随机性的 panic非常难排查。这种 bug 属于见过一次永远忘不了的类型。坑四32 位资源长度打印时类型转换错误。resource_size_t在不同架构下类型不同直接作为%llx参数传会得到垃圾值甚至编译警告。统一转成unsigned long long再打印是最省心的做法。坑五在probe成功后没有保留设备引用就提前返回。真实驱动中probe拿到的struct pci_dev *指针要在remove或其他函数中继续使用所以通常会保存到自定义结构体中。最小驱动没有这个需求但建议从一开始就养成将设备相关信息封装进私有数据结构priv的习惯后面扩展时会轻松很多。坑六用printk而不是pr_info系列。这里的pr_info、pr_err其实是对printk的封装加入了一些编译期检查和 loglevel 控制。新代码优先用pr_*系列不要再用裸printk。坑七调试信息优先用dev_info系列而不是pr_info系列。dev_info(dev, ...)会在日志中自动带上设备 BDF 编号等上下文信息定位问题时会快很多。最小驱动代码里为了简洁用了pr_info实际生产驱动里更推荐dev_info(dev-dev, ...)。这个细节不影响功能但对调试效率影响巨大。写驱动和写用户态程序的思维方式差别极大。用户态程序出了问题可以打印个日志、断点调一下性能差点也无所谓。内核驱动出了问题轻则内核报错重则系统崩溃而且调试工具少得多。这也是为什么我始终建议学习时先用 QEMU 虚拟设备练手——在这种环境里随便崩崩完重启虚拟机就行完全不用心疼物理机。最后说一下我自己的实践建议拿到一个 PCIe 驱动开发任务不要急着写业务逻辑。先把设备枚举、驱动绑定、probe/remove 路径彻底跑通用lspci -vvv、/sys/bus/pci/devices/*/config这些通用工具反复确认设备和驱动的关系再逐步加功能。前面的基础打得越扎实后面加中断、加 DMA 这些复杂功能时越不容易陷入出了问题不知道是设备问题还是驱动问题的泥潭。