从零手写最小PCIe驱动:QEMU模拟设备与MMIO读写实战

发布时间:2026/9/24 9:08:08
从零手写最小PCIe驱动:QEMU模拟设备与MMIO读写实战 1. 从零理解 PCIe 驱动到底在做什么1.1 为什么选 PCIe 驱动作为入门切入点做 AI Infra 的人迟早要跟设备驱动打交道。不管是 GPU、NPU、高速网卡还是 NVMe 盘它们跟主机通信的底层通道基本都是 PCIe。你平时调 CUDA、跑 RDMA、看lspci输出背后全是 PCIe 在撑着。但很多人对 PCIe 的认知停留在“插槽”“带宽”“Gen4/Gen5”这些标签上真让你写一个能加载、能读写设备寄存器的驱动可能就卡住了。我选“最小 PCIe 驱动”作为这个系列第一次写代码的主题原因很直接它足够小小到一个文件就能写完又足够完整完整到能覆盖 Linux 设备模型、PCI 枚举、MMIO 映射、字符设备注册这几条核心链路。你把这一个驱动吃透后面看nvme、ixgbe、甚至vfio-pci的代码都会有一种“原来都是同一套骨架”的感觉。这里说的“能跑”我给自己定的标准是三条第一insmod之后dmesg能看到设备被 probe第二能通过/dev节点读到设备的配置空间信息第三能对设备的 BAR 空间做一次 MMIO 读写在 QEMU 里用模拟设备验证。不追求真实硬件上的中断处理、DMA 这些先把最核心的“发现设备—映射资源—暴露接口”跑通。1.2 先搞清楚 PCIe 枚举和设备识别的底层逻辑在写代码之前必须把 PCIe 枚举这件事讲清楚否则你写出来的驱动就是照抄模板出了问题完全不知道怎么查。PCIe 的拓扑是一棵树。Host Bridge 是根下面挂 Root Complex再往下是 Switch 和 Endpoint。系统上电后固件BIOS/UEFI或者操作系统会从总线 0、设备 0、功能 0 开始扫描逐个读取每个设备的配置空间。配置空间里有 Vendor ID 和 Device ID这两个是识别设备身份的关键。扫描的过程就是不断往0xCF8/0xCFC这两个 IO 端口写地址、读数据或者在现代系统上用 MMIO 方式访问 ECAM 区域。每个 PCIe 设备有 256 字节的基础配置空间PCIe 还扩展到了 4KB。前 64 字节是标准头部里面有 Command、Status、BARBase Address Register等关键字段。BAR 决定了设备需要多大的地址空间以及是 MMIO 还是 IO 空间。内核在枚举阶段会把这些 BAR 读出来分配好物理地址然后驱动 probe 的时候直接拿来用。注意你在驱动里读到的pci_resource_start()返回的是物理地址不能直接解引用必须先ioremap成虚拟地址才能访问。这是新手最容易踩的坑之一。1.3 驱动的最小骨架包含哪些部分一个 PCIe 驱动在 Linux 下的最小骨架其实就四块pci_driver 结构体声明 id_table告诉内核“我支持哪些 Vendor/Device”以及 probe 和 remove 回调。probe 函数设备匹配成功后调用负责使能设备、申请 BAR 资源、映射 MMIO、注册字符设备。remove 函数卸载时释放资源顺序跟 probe 相反。module_init / module_exit注册和注销 pci_driver。这四块拼起来就是一个能加载、能识别设备、能暴露接口的驱动。剩下的中断、DMA、电源管理都是在这个骨架上加东西。我建议第一次写的时候就盯着这四块写别贪多。2. 环境准备与 QEMU 模拟设备搭建2.1 为什么用 QEMU 而不是真机真机上写 PCIe 驱动有个很现实的问题你没有合适的“空白设备”给你练手。随便拿一块网卡或显卡驱动早就被内核自带的占了你写的驱动根本 probe 不上。而且真机上一旦驱动写崩轻则设备不工作重则系统卡死调试成本极高。QEMU 的好处是它允许你创建一个虚拟 PCIe 设备Vendor ID 和 Device ID 随便你定内核里没有现成驱动跟它匹配你的驱动就能独占它。而且 QEMU 的-device参数可以挂各种模拟设备配合edu这个教学设备简直是写驱动的理想靶子。我实测下来用 QEMU 做 PCIe 驱动开发迭代速度比真机快十倍不止。改一行代码重新insmod几秒钟就能看到结果。2.2 QEMU 启动参数与设备挂载先准备一个能跑的最小 Linux 环境。我用的是 Ubuntu 22.04 的 cloud image内核版本 5.15这个版本对 PCIe 和 QEMU 的支持都很成熟。启动命令大概长这样qemu-system-x86_64 \ -m 2G \ -smp 2 \ -kernel /boot/vmlinuz-5.15.0-91-generic \ -initrd /boot/initrd.img-5.15.0-91-generic \ -append root/dev/sda consolettyS0 \ -drive fileubuntu.img,formatraw \ -device edu \ -nographic这里的关键是-device edu。edu是 QEMU 内置的一个教学用 PCI 设备Vendor ID 是0x1234Device ID 是0x11e8。它有一个 BAR0 的 MMIO 区域里面有几个寄存器可以做读写测试。这个设备在内核里没有默认驱动正好留给我们。提示如果你用的是较新的 QEMUedu设备依然存在但建议确认一下qemu-system-x86_64 -device help | grep edu有没有输出。没有的话换-device pci-testdev也行原理一样。启动后进系统先跑lspci -nn确认设备在不在lspci -nn | grep 1234 01:00.0 Class 00ff: 1234:11e8看到这一行说明设备已经被枚举到了总线号 01设备号 00功能号 0。接下来就是写驱动去认领它。2.3 内核头文件与编译环境准备写内核模块需要内核头文件。在 Ubuntu 上sudo apt update sudo apt install linux-headers-$(uname -r) build-essential装完之后/lib/modules/$(uname -r)/build应该指向内核源码树。写一个最简单的 Makefileobj-m my_pcie_drv.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这个 Makefile 是内核模块的标准写法M$(PWD)告诉内核构建系统去当前目录找模块源码。编译出来是.ko文件insmod加载。3. 最小 PCIe 驱动代码逐段拆解3.1 驱动结构体与 id_table 定义先看最核心的匹配部分#include linux/module.h #include linux/pci.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define VENDOR_ID 0x1234 #define DEVICE_ID 0x11e8 #define DRV_NAME my_pcie_drv static struct pci_device_id my_pci_ids[] { { PCI_DEVICE(VENDOR_ID, DEVICE_ID) }, { 0, } }; MODULE_DEVICE_TABLE(pci, my_pci_ids);PCI_DEVICE宏展开后就是填 Vendor 和 Device 字段。MODULE_DEVICE_TABLE的作用是让内核在模块加载时知道这个驱动支持哪些设备同时也会生成别名信息方便modprobe自动加载。这里有个细节pci_device_id结构体里还有subvendor、subdevice、class等字段。如果你只想匹配特定子系统的设备可以填这些。但第一次写用 Vendor Device 就够了。3.2 probe 函数资源申请与 MMIO 映射probe 是整个驱动最关键的部分我把它拆成几步来看。第一步使能设备static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id) { int ret; resource_size_t bar0_start, bar0_len; void __iomem *bar0; ret pci_enable_device(pdev); if (ret) { dev_err(pdev-dev, pci_enable_device failed\n); return ret; }pci_enable_device会唤醒设备、分配中断资源、设置 Command 寄存器里的 IO/Memory Enable 位。不调用这个后面访问 BAR 会直接失败。第二步申请 BAR 区域ret pci_request_region(pdev, 0, DRV_NAME); if (ret) { dev_err(pdev-dev, pci_request_region BAR0 failed\n); goto err_disable; } bar0_start pci_resource_start(pdev, 0); bar0_len pci_resource_len(pdev, 0); dev_info(pdev-dev, BAR0 start%pa len%pa\n, bar0_start, bar0_len);pci_request_region是向内核声明“这块 BAR 我占了”防止别的驱动来抢。pci_resource_start和pci_resource_len拿到物理地址和长度。第三步映射 MMIObar0 ioremap(bar0_start, bar0_len); if (!bar0) { dev_err(pdev-dev, ioremap failed\n); ret -ENOMEM; goto err_release; }ioremap把物理地址映射成内核虚拟地址之后用readl/writel访问。这里必须用ioremap而不是memremap或直接指针因为 MMIO 区域有特殊的访问语义编译器不能优化、CPU 不能缓存。第四步保存私有数据pci_set_drvdata(pdev, bar0);pci_set_drvdata把 bar0 指针存到 pdev 的私有字段里remove 的时候用pci_get_drvdata取回来。这是驱动里传递上下文的标准做法。3.3 字符设备注册与用户态接口光映射了 MMIO 还不够用户态得能访问。我注册一个字符设备static dev_t dev_num; static struct cdev my_cdev; static struct class *my_class; static int my_open(struct inode *inode, struct file *file) { return 0; } static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { struct pci_dev *pdev ...; /* 从全局或容器取 */ void __iomem *bar0 pci_get_drvdata(pdev); u32 val; if (*ppos 4) return 0; val readl(bar0); if (copy_to_user(buf, val, sizeof(val))) return -EFAULT; *ppos sizeof(val); return sizeof(val); }readl(bar0)读 BAR0 偏移 0 处的 32 位寄存器。copy_to_user把数据拷回用户态。这里*ppos的处理是标准字符设备读逻辑防止重复读。注册流程放在 probe 里ret alloc_chrdev_region(dev_num, 0, 1, DRV_NAME); cdev_init(my_cdev, my_fops); cdev_add(my_cdev, dev_num, 1); my_class class_create(THIS_MODULE, DRV_NAME); device_create(my_class, NULL, dev_num, NULL, DRV_NAME);这样/dev/my_pcie_drv就出来了用户态cat或dd就能读到寄存器值。3.4 remove 函数与资源释放顺序remove 的顺序必须跟 probe 严格相反否则会出资源泄漏或者 use-after-freestatic void my_pci_remove(struct pci_dev *pdev) { void __iomem *bar0 pci_get_drvdata(pdev); device_destroy(my_class, dev_num); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); iounmap(bar0); pci_release_region(pdev, 0); pci_disable_device(pdev); }先销毁用户态可见的设备节点再注销字符设备然后解除 MMIO 映射释放 BAR最后 disable 设备。这个顺序不能乱尤其是iounmap必须在pci_release_region之前否则映射还在但物理区域已经释放访问会出问题。4. 编译加载与实测验证4.1 编译与 insmod 加载Makefile 写好之后直接makemake make -C /lib/modules/5.15.0-91-generic/build M/home/user/pcie_drv modules CC [M] /home/user/pcie_drv/my_pcie_drv.o LD [M] /home/user/pcie_drv/my_pcie_drv.ko编译通过后加载sudo insmod my_pcie_drv.ko dmesg | tail -20正常的话能看到my_pcie_drv 0000:01:00.0: BAR0 start00000000febf0000 len0000000000010000这说明 probe 成功BAR0 拿到了 64KB 的 MMIO 空间。4.2 用户态读取寄存器验证/dev/my_pcie_drv应该已经存在ls -l /dev/my_pcie_drv crw------- 1 root root 240, 0 ... /dev/my_pcie_drv用dd读 4 个字节sudo dd if/dev/my_pcie_drv bs4 count1 2/dev/null | xxd 00000000: 00000000edu设备 BAR0 偏移 0 处默认是 0读到 0 说明 MMIO 通路是通的。如果你想验证写可以在驱动里加一个write回调往偏移 0 写值再读回来。edu设备的 BAR0 偏移 0 是可读写寄存器写进去能读出来。4.3 卸载与清理验证sudo rmmod my_pcie_drv dmesg | tail -5没有报错、/dev/my_pcie_drv消失、lsmod | grep my_pcie为空说明 remove 正常执行。如果rmmod报Device or resource busy多半是用户态还有进程占着/dev节点lsof /dev/my_pcie_drv查一下。5. 常见问题与排查技巧实录5.1 probe 不触发的几种原因最常见的问题是insmod之后dmesg里什么都没有。排查思路现象可能原因排查方法无任何日志id_table 不匹配lspci -nn确认 Vendor/Device有 probe 但失败BAR 申请冲突cat /proc/iomem看区域是否被占加载报错内核版本不匹配modinfo看 vermagic我踩过一次坑QEMU 里edu设备的 Device ID 我记成了0x11e9结果死活 probe 不上。后来lspci -nn一看是0x11e8改过来立刻就好了。所以写驱动第一步永远是确认设备 ID。5.2 MMIO 读写返回全 0xFF 或崩溃readl返回0xFFFFFFFF通常意味着 MMIO 映射有问题。检查两点一是pci_enable_device有没有调用二是ioremap的地址和长度对不对。如果ioremap长度传了 0映射会失败但可能不报错访问时直接崩。还有一种情况是 BAR 是 IO 空间而不是 MMIO 空间。pci_resource_flags(pdev, 0) IORESOURCE_MEM可以判断。如果是 IO 空间得用ioport_map而不是ioremap。edu设备的 BAR0 是 MMIO所以用ioremap没问题。5.3 卸载时资源泄漏的排查rmmod之后如果dmesg里有WARNING: ... at ...多半是资源没释放干净。用cat /proc/iomem | grep my_pcie看 BAR 区域还在不在ls /dev/my_pcie_drv看设备节点有没有删掉。我建议在 remove 里每一步都加dev_info卸载时看日志确认执行顺序。实操心得写驱动时养成“probe 里申请什么remove 里就释放什么顺序反过来”的习惯。我一般会在纸上画一个申请/释放的栈probe 是 pushremove 是 pop这样不容易漏。5.4 QEMU 设备不出现的排查如果lspci里根本看不到1234:11e8先确认 QEMU 启动参数里-device edu有没有写对。然后确认 QEMU 版本支持这个设备。有些精简版的 QEMU 编译时没开edu换-device pci-testdev试试。另外如果你用的是 ARM 架构的 QEMUPCIe 拓扑可能不一样lspci的输出会有差异但驱动代码本身是架构无关的。6. 从最小驱动到真实 AI Infra 场景的延伸6.1 这个骨架在真实设备驱动里的对应关系你可能会问这个edu设备的驱动跟真实 GPU、NPU 驱动差多远答案是骨架一模一样只是细节多了几个数量级。真实驱动在 probe 里做的事情无非是在我这个骨架基础上加中断申请pci_alloc_irq_vectors、DMA 掩码设置dma_set_mask、固件加载request_firmware、多个 BAR 映射、寄存器初始化序列。remove 里对应地释放中断、释放 DMA、卸载固件。你把最小驱动跑通再看drivers/gpu/drm/下的代码会发现结构完全对得上。6.2 用 sysfs 替代字符设备的更轻量方案字符设备注册那套代码其实有点重。如果只是想暴露几个寄存器给用户态看用 sysfs 更简单static ssize_t reg_show(struct device *dev, struct device_attribute *attr, char *buf) { struct pci_dev *pdev to_pci_dev(dev); void __iomem *bar0 pci_get_drvdata(pdev); return sprintf(buf, 0x%08x\n, readl(bar0)); } static DEVICE_ATTR_RO(reg);在 probe 里device_create_file(pdev-dev, dev_attr_reg)用户态cat /sys/bus/pci/devices/0000:01:00.0/reg就能读到。这种方式代码量少一半适合调试阶段快速验证。6.3 后续可以继续深入的方向这个最小驱动跑通之后有几个方向可以继续挖一是加中断处理用request_irq注册中断服务程序在 QEMU 里可以用edu设备的中断触发来验证二是加 DMA用dma_alloc_coherent分配一致性内存让设备直接读写三是用vfio-pci把设备透传到用户态这在 AI Infra 里做用户态驱动比如 DPDK、SPDK时非常常见。我个人在实际操作中的体会是PCIe 驱动这东西看一百遍不如自己写一遍。哪怕只是让readl返回一个非零值那个瞬间你对“驱动”两个字的理解就完全不一样了。后面再去看那些复杂的 AI 加速卡驱动心里会有底得多。