
1. 动手之前这个最小PCIe驱动到底在写什么1.1 从每日一问到第一行代码为什么选 PCIe 驱动Day 15这个系列第一次正经写代码。前面十四天聊的都是概念、原理和 AI Infra 里跟 PCIe 相关的问题比如枚举过程、BAR 空间、链路训练、DMA。这些内容很重要但光聊不写总归是飘的。今天就把键盘接上写一个从零开始、能真正加载到内核里跑起来的最小 PCIe 驱动。先说清楚这个驱动到底有多小它不做中断、不做 DMA、不做流控甚至不实现什么正经业务。它只做三件事——第一让内核通过 vendor ID 和 device ID 认出设备第二在 probe 回调里把设备打开映射 BAR0 空间第三从 BAR0 里读一个 32 位寄存器出来打日志。这三点合起来就是 PCIe 驱动里最核心的流程闭环设备匹配、资源获取、寄存器访问。做完这一圈你对 PCIe 驱动的认知就不再是一堆名词而是一个看得见、摸得着的结构体和一串能落地的函数调用。有人可能会问AI Infra 从业者为什么要碰底层驱动我的理解是AI 训练和推理跑在 GPU、NVMe、RDMA 网卡这些硬件之上而这些硬件几乎全部挂在 PCIe 总线上。上层框架跑得慢、报 DMA 错误、链路易抖动最后一层层查到根因经常落在 PCIe 这一层。不懂驱动的细节你连 lspci 输出里的错误标记都只能靠猜。1.2 写代码前要搞懂的三个对象在贴代码之前我想先把三个绕不开的概念讲明白不然后面代码里每一个结构体看起来都像魔法。这三个对象是pci_driver、pci_device_id和pci_dev。pci_driver是驱动的门面。它告诉内核我叫什么名字、我能匹配哪些设备、设备发现后调哪个函数、设备移除时调哪个函数。Linux 里大部分总线驱动都是这个套路platform_driver、i2c_driver、usb_driver都是一个道理。理解了pci_driver其他总线的驱动框架基本就通了一半。pci_device_id是驱动的找设备清单里面放的是 vendor ID 和 device ID。内核在启动时已经通过 PCIe 枚举把每个设备扫描进了总线子系统之后设备会去总线上找能绑定自己的驱动匹配依据就是这张表。这里有个细节匹配不是把整张表遍历一遍就完事而是按优先级、按设备所在总线的驱动列表一个个尝试找到第一个声称我能支持这个设备的驱动就绑定上。pci_dev是内核对一个 PCIe 设备实例的抽象。它里面的 vendor、device、resource 数组等字段就是我们在lspci -vv里看到的信息的内核版本。你写驱动时拿到的struct pci_dev *pdev就是这一套东西的入口。1.3 推荐一个安全的调试环境QEMU edu 设备第一次写 PCIe 驱动我强烈建议不要直接在主力机上拿正在用的网卡、NVMe 盘去试。原因很现实probe 里一个小失误可能让整个设备从系统里消失甚至触发内核 panic数据无价。我最常用的方式是 QEMU 虚拟机里挂一块虚拟 PCIe 设备来练手。QEMU 里有一个专门为教学场景设计的设备叫 eduvendor ID 是0x1234device ID 是0x11e8BAR0 上放了好几个简单寄存器非常适合当第一个 PCIe 驱动的实验对象。启动 QEMU 时加一行参数就行qemu-system-x86_64 -m 2G -smp 2 \ -enable-kvm \ -kernel vmlinuz -initrd initrd.img \ -append root/dev/vda1 consolettyS0 \ -device edu如果你手头没有 QEMU 环境也可以找一台闲置服务器拿一块不重要的 PCIe 卡练手。先用lspci -n查到卡的 vendor 和 device ID填到驱动的 id_table 里就能匹配。我第一次鼓捣的时候就是在虚拟机上跑的后来才敢碰真实设备。经验是先看日志再动手别用生产设备做实验。2. 一个能跑的 PCIe 驱动核心代码拆解2.1 骨架代码pci_driver 和 id_table 怎么写我直接贴一份最小驱动代码重点看整体结构。这个例子按匹配 edu 设备0x1234:0x11e8来写你要是匹配其他设备只需要改id_table那一行。#include linux/module.h #include linux/pci.h #define DRV_NAME mypcie static struct pci_dev *g_pdev; static void __iomem *g_bar0; static size_t g_bar0_len; static int mypcie_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; ret pci_enable_device(pdev); if (ret) { dev_err(pdev-dev, pci_enable_device failed: %d\n, ret); return ret; } pci_set_master(pdev); g_bar0_len pci_resource_len(pdev, 0); g_bar0 pci_iomap(pdev, 0, g_bar0_len); if (!g_bar0) { dev_err(pdev-dev, pci_iomap(bar0) failed\n); pci_disable_device(pdev); return -ENOMEM; } g_pdev pdev; dev_info(pdev-dev, mypcie probe ok: bar0%p len%zu, first u320x%08x\n, g_bar0, g_bar0_len, ioread32(g_bar0)); return 0; } static void mypcie_remove(struct pci_dev *pdev) { if (g_bar0) { pci_iounmap(pdev, g_bar0); g_bar0 NULL; } pci_disable_device(pdev); g_pdev NULL; dev_info(pdev-dev, mypcie removed\n); } static const struct pci_device_id mypcie_ids[] { { PCI_DEVICE(0x1234, 0x11e8) }, { 0 } }; MODULE_DEVICE_TABLE(pci, mypcie_ids); static struct pci_driver mypcie_driver { .name DRV_NAME, .id_table mypcie_ids, .probe mypcie_probe, .remove mypcie_remove, }; module_pci_driver(mypcie_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal PCIe driver example);这段代码已经能编译并加载运行。MODULE_DEVICE_TABLE这行看起来不起眼但千万不能省它会把 id_table 导出到模块的 modinfo 里让内核在设备匹配时能拿到这张表。如果只定义了mypcie_ids数组而忘了这个宏modprobe做自动匹配会直接失效你只能靠insmod强插。2.2 probe 里发生了什么一步步说清楚probe 是驱动的核心入口。设备被枚举、ID 匹配成功后内核会调用驱动的probe回调。注意这里有个顺序问题pci_enable_device必须在访问任何资源之前调用因为它会完成设备电源管理、唤醒、中断线路分配等一系列初始化动作。很多初学者上来就pci_iomap跳过了 enable结果映射出来的地址读出来全是0xFFFFFFFF就是因为设备根本没有被真正使能。pci_set_master用来开启设备的总线主控能力。对于纯寄存器读写这个最小场景它不是必须的但我在真实驱动里几乎都会加因为不提前加后面做 DMA 测试时会踩device not master capable的坑。pci_iomap的作用是把 BAR0 对应的物理地址映射到内核虚拟地址空间后面才能用ioread32和iowrite32访问。这里有一个关键认知BAR0 是物理地址ioremap之后得到的是内核虚拟地址二者不能混用更不能用普通指针解引用的方式去访问 MMIO。最后一行的dev_info会把驱动匹配成功、BAR0 映射地址和第一个寄存器值都打印到内核日志里方便验证。pdev是内核传入的设备对象pdev-dev是 device 子系统的通用 device 节点传给 dev_info 后日志会自动带出 PCI 设备地址比如0000:00:04.0排障时一眼能定位到是哪个设备。2.3 加一个用户态可访问的字符设备接口光有 probe 打日志驱动还是只进不出。为了做到从用户态读 BAR0 寄存器我给这个最小驱动加一个字符设备接口。这样还能顺便演示驱动模型里另一条重要路径用户态 open/read 如何走到内核里的硬件访问代码。static int mypcie_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t mypcie_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { u32 value; if (count sizeof(value)) return -EINVAL; if (!g_bar0) return -ENODEV; value ioread32(g_bar0); if (copy_to_user(buf, value, sizeof(value))) return -EFAULT; return sizeof(value); } static const struct file_operations mypcie_fops { .owner THIS_MODULE, .open mypcie_open, .read mypcie_read, };然后在模块初始化函数里注册字符设备和 pci_driver。这里有个代码组织上的坑前面骨架代码用了module_pci_driver(mypcie_driver)这个快捷宏但如果你自己写了module_init和module_exit再用这个宏就会报重复定义错误。所以完整版驱动要把宏拆掉在自己的 init 函数里先注册字符设备再调用pci_register_driverexit 里顺序反过来。static int __init mypcie_init(void) { int ret; ret alloc_chrdev_region(mypcie_devno, 0, 1, DRV_NAME); if (ret 0) return ret; cdev_init(mypcie_cdev, mypcie_fops); mypcie_cdev.owner THIS_MODULE; ret cdev_add(mypcie_cdev, mypcie_devno, 1); if (ret 0) goto err_cdev; mypcie_class class_create(DRV_NAME); if (IS_ERR(mypcie_class)) { ret PTR_ERR(mypcie_class); goto err_class; } device_create(mypcie_class, NULL, mypcie_devno, NULL, DRV_NAME); ret pci_register_driver(mypcie_driver); if (ret 0) goto err_pci; return 0; err_pci: device_destroy(mypcie_class, mypcie_devno); class_destroy(mypcie_class); err_class: cdev_del(mypcie_cdev); err_cdev: unregister_chrdev_region(mypcie_devno, 1); return ret; } static void __exit mypcie_exit(void) { pci_unregister_driver(mypcie_driver); device_destroy(mypcie_class, mypcie_devno); class_destroy(mypcie_class); cdev_del(mypcie_cdev); unregister_chrdev_region(mypcie_devno, 1); } module_init(mypcie_init); module_exit(mypcie_exit);还有一点要注意新版内核里class_create只需要一个参数但老版本5.x 前期及更早需要传class_create(THIS_MODULE, DRV_NAME)。你在自己环境编译时如果报参数数量错误按版本调整一下即可。全局变量建议加前缀比如mypcie_否则内核模块里变量名冲突排查起来非常痛苦。3. 从零编译并跑起来我的实操记录3.1 Makefile 与内核头文件准备写内核模块不需要一堆工程配置一个 Makefile 就够。下面是通用模板兼容绝大多数发行版obj-m mypcie.o KERNEL_DIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean编译前先确保装了内核头文件。Debian/Ubuntu 系sudo apt update sudo apt install linux-headers-$(uname -r) build-essentialCentOS/RHEL/Fedora 系sudo yum install kernel-devel kernel-headers gcc头文件装好在源码目录执行make当前目录会生成mypcie.ko。这里有一个非常常见的坑模块编译用的内核版本必须和运行中的内核版本一致否则 insmod 会报Exec format error或者Unknown symbol一类的错误。最简单有效的确认方式是看uname -r的返回值以及/lib/modules/$(uname -r)/build这个路径是否存在。我之前就有过一次在某发行版上忘了装 headers整整排查了两个小时。3.2 加载驱动看日志在 QEMU 虚拟机里先确认 edu 设备已经被系统识别lspci -nn | grep -i 1234正常情况下会输出类似00:04.0 Unclassified device [00ff]: Device [1234:11e8]的内容。注意这里的[1234:11e8]就是 vendor:device ID和驱动 id_table 里的一致。然后加载模块sudo insmod mypcie.ko如果一切正常dmesg里会出现mypcie 0000:00:04.0: mypcie probe ok: bar0000000006ec8c000 len4096, first u320x01000000看到这一行说明驱动已经被内核识别probe 跑了BAR0 映射成功并且读到了设备寄存器值。从这一刻起你已经完成了人生中第一个能跑的最小 PCIe 驱动。听起来不复杂但这背后涉及 PCIe 枚举、资源分配、驱动模型、MMIO 访问一整条链路是一个完整的技术闭环。接下来测试用户态访问先创建设备节点sudo mknod /dev/mypcie c $(grep mypcie /proc/devices | awk {print $1}) 0 sudo dd if/dev/mypcie bs4 count1 2/dev/null | hexdump如果读到四个字节说明用户态和内核态的数据通路也通了。设备节点创建那条命令建议收藏后面每次换主设备号都要用。如果你有 udev 规则也可以让它自动在模块加载时创建节点但那是额外话题了。3.3 卸载与资源回收卸载模块用sudo rmmod mypcie这里我想特别强调 remove 回调的重要性。很多入门驱动 demo 不写 remove或者 remove 里什么都不做这在能跑阶段没问题但在真实驱动里是绝对不合格的。PCIe 设备支持热插拔驱动必须能在设备被拔出时干净地释放所有资源iounmap、disable device、释放中断、注销字符设备。否则下一次加载驱动时会踩到资源被占用的错误。我自己见过不止一次因为 remove 没写对导致同一张卡二次插拔后直接无法工作只能重启机器的情况。4. 踩坑实录最小 PCIe 驱动开发中的典型问题4.1 驱动加载了probe 却迟迟不被调用这是新手遇到最多的问题。模块 insmod 成功dmesg 里也能看到模块加载信息但自己的 probe 函数没有执行。排查顺序我一般这样走先确认 id_table 里的 vendor/device ID 和lspci -n输出一致再用lsmod确认模块确实在接着看/sys/bus/pci/drivers/下有没有创建驱动目录最后检查这个 PCI 设备是不是已经被其他驱动绑定了。对于 edu 设备基本不会碰到与其他驱动冲突的问题但如果换成了真机上的设备比如一块被官方驱动占用的网卡你再加载自己的驱动系统不会自动把设备从原驱动手里抢过来。你需要先卸载原驱动或者保证设备的 device ID 在你的新 id_table 中且原驱动不存在系统才会把你的驱动绑上去。4.2 读出来的寄存器全是 0xFF 或直接死机如果ioread32读到的值全是0xFFFFFFFF大概率是设备没有被正确 enable或者 BAR 地址映射不对。建议按顺序检查pci_enable_device是否返回 0、pci_resource_len是否为 0、pci_iomap是否返回非 NULL。但如果你读到一半系统直接死机或者报 MCE 错误往往不是代码问题而是你访问了一个设备不存在的 MMIO 地址。最常见的规避方式是先用lspci -vv确认 BAR 里记录了有效地址再决定访问哪个偏移。edu 设备 BAR0 的偏移 0 处是可以安全读的但换成真实设备不一定每个偏移都合理先查 datasheet 再写代码。4.3 虚拟地址和物理地址混着用写驱动的人最容易犯的错误是把pci_resource_start返回的物理地址直接当成读写地址。正确姿势是物理地址只用来做ioremap或pci_iomap之后所有寄存器的读写都要走映射后的虚拟地址。反过来也不要想当然地把 ioremap 得到的虚拟地址传给 DMA 引擎DMA 要的是总线地址那是另一套体系需要经过 DMA API 转换。4.4 常见问题速查现象可能原因排查方向insmod 报 Exec format error内核版本不匹配核对 uname -r 与 headersUnknown symbol 报错依赖其他模块/符号未导出看 modprobe 对应模块是否加载probe 不执行id_table 不匹配或设备被其他驱动占用检查 lspci -n、/sys/bus/pci/drivers读寄存器全为 0xFF设备未 enable 或 BAR 无效检查 pci_enable_device 返回值、pci_resource_len访问寄存器触发死机MMIO 地址超出设备实现范围先查 datasheet别乱探偏移rmmod 卡死字符设备被占用关闭打开 /dev/mypcie 的进程二次 insmod 失败remove 未释放资源检查 pci_iounmap/pci_disable_device 是否执行这张表是我自己这一路实践攒下来的虽然简单但每一项都值过真金白银的调试时间。尤其是读寄存器全为 0xFF这种问题几乎每个初学者都会遇到记住先查 enable 再查 BAR比到处翻代码有效。5. 从最小驱动到真实驱动AI Infra 视角的延伸5.1 真实驱动比最小驱动多出来的东西最小驱动离生产可用的驱动还很远但差距是可以量化的。我列一下主要的扩展方向中断处理真实设备驱动第一优先级通常是处理中断。PCIe 上现在最常用的是 MSI/MSI-X性能好而且每条中断可以绑定到不同 CPU 核适合多队列场景。你要在 probe 里调用pci_alloc_irq_vectors在 remove 里释放。DMA设备需要直接访问主机内存这涉及dma_alloc_coherent、dma_map_single、IOVA 管理以及和 IOMMU 的配合。AI Infra 里的 GPU、RDMA 网卡、NVMe 控制器性能基本都压在这套机制上。多队列网卡和 NVMe SSD 都有几十上百个队列每个队列有自己的 DMA ring驱动需要给每个队列分配内存、配置中断、做调度。这个工程量和最小驱动不是一个量级。用户态直通在 AI Infra 里GPU、FPGA 加速卡经常走 VFIO 或uio_pci_generic让用户态直接操作设备绕过内核驱动。理解最小 PCIe 驱动对理解 VFIO 的 device passthrough 也会容易很多。5.2 为什么 AI Infra 从业者要懂一点驱动开发AI Infra 这个方向很多同学日常工作是在 GPU 集群上跑训练、调参、看监控觉得写 PCIe 驱动八竿子打不着。但实际遇到性能问题时需要追根溯源很多瓶颈藏在 PCIe 层。比如 GPU 和 CPU 之间的数据搬运、NVMe 盘顺序读写的 IOPS、RDMA 网卡的大包吞吐如果链路速率降级或者有大量 CRC 错误应用层再优化都救不回来。懂一点 PCIe 驱动原理你在看lspci -vv、分析nvidia-smi或者其他监控数据的时候会多一个排查问题的视角。至少你会知道一个设备从插上主板到开始干活中间经历了枚举、资源分配、驱动匹配、enable、MMIO 映射这么多环节每个环节都可能出问题。5.3 延伸练手方向如果你今天把最小驱动跑通了接下来可以按这个顺序做几个练习给驱动加一个 debugfs 接口让用户态可以读多个寄存器偏移给驱动加 MSI-X 中断处理每当中断触发时打印一个计数在 QEMU 里给 edu 设备做一个简单的 DMA 操作观察数据搬运过程最后从 edu 这类教学设备换到真实的 NVMe 或网卡你会发现在最小驱动里练过的那套闭环在真实驱动里每一步都依然存在只是被封装得更复杂、更细致。我个人在实际操作中的体会是驱动开发最难的不是语法而是建立硬件资源和内核对象之间一一对应的心智模型。写最小驱动的价值就在于它把这套模型用最短的路径走了一遍。后面哪怕去看内核里 ixgbe、nvme 这种几万行的驱动源码你也能认出它们不过是在这个模型之上做了更多的细节处理。建议你把今天这份代码留在本地过几周再看一遍会有不一样的收获。