简介:面向Linux驱动开发与嵌入式系统工程师的PCI驱动开发参考资料,以PCI9054桥接芯片为例,系统讲解在Linux内核2.4环境下PCI驱动的设计思路与实现流程。内容涵盖PCI总线规范、PCI9054的配置空间与BAR0~BAR5基址寄存器、字符设备驱动框架、设备探测与资源分配、初始化与中断处理,以及将驱动静态编译进内核的方法,并给出硬件测试要点,适合需要从零搭建PCI设备驱动的开发者参考。资源为单篇PDF论文,共1个文件,压缩包约304KB,内容精炼,便于快速浏览核心知识点。已有124人浏览学习,可用于快速理解PCI驱动开发的关键框架与实现步骤。
1. Linux下的PCI驱动开发:先匹配设备,再谈读写寄存器
Linux 下的 PCI 驱动开发,是内核里套路最固定的一类工作。x86 服务器上的网卡、数据采集卡、GPU 卡,驱动要做的核心事情都一样:用 Vendor ID / Device ID 匹配设备,拿到 BAR 空间,使能 DMA 与中断,再把能力暴露给用户态。很多人卡在不知道从哪下手,其实内核已经完成了总线枚举和配置空间解析,驱动作者只需要填好pci_driver结构体,让probe在设备出现时被正确调用。这篇文章用最小可编译的代码把这条路走通:先讲框架和生命周期,再落地 Makefile 与资源访问,然后处理 DMA 和中断这两个性能关键点,最后给出调试和验证手段。
2. PCI驱动开发的核心:pci_driver 结构体与 probe 生命周期
2.1 设备与驱动怎么匹配:Vendor ID 与 pci_device_id
先不写代码,得弄清内核如何把一段硬件和一段驱动「撮合」在一起。PCI 设备的配置空间里固定存放着 16 位 Vendor ID 和 Device ID,这是出厂烧死的身份标识。内核在启动时扫描 PCI 总线,读出每个设备的 ID,再遍历所有已注册的pci_driver,用驱动声明的id_table与设备 ID 做匹配。匹配成功就调用probe,失败则继续等下一个驱动,或者交给modprobe自动加载对应模块。
这个机制决定了开发的第一步不是写读写函数,而是确认你的板卡 ID。用lspci -n能看到设备编号:
lspci -n # 输出示例:01:00.0 0200: 8086:1539 (rev 04) # 前四位 8086 是 Vendor ID,后四位 1539 是 Device ID第 2 个字段0200是 Class Code,表示这是一张以太网控制器。开发时把这两个 ID 填进pci_device_id数组,驱动就能和设备对上。如果lspci里根本没有这个 BDF(Bus:Device.Function),问题就不在驱动,而在 BIOS/UEFI 的 PCI 枚举阶段,得先解决设备不出来这件事,再谈驱动开发。
2.2 一个最小 pci_driver 结构体
pci_driver是驱动与 PCI 总线层之间的唯一接口,内核文档里叫 low-level driver interface。关键成员不多,但每个回调用时机必须清楚:probe在设备与驱动匹配成功时调用,remove在设备拔出或驱动卸载时调用,shutdown在系统关机时调用,suspend/resume用于电源管理。大部分数据采集卡、网卡驱动只实现前两个就够用。
#include <linux/module.h> #include <linux/pci.h> #define MY_VENDOR_ID 0x1234 #define MY_DEVICE_ID 0x5678 static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id) { pr_info("my_pci: probe called for %04x:%04x\n", id->vendor, id->device); return 0; } static void my_remove(struct pci_dev *pdev) { pr_info("my_pci: remove called\n"); } static struct pci_device_id my_ids[] = { { PCI_DEVICE(MY_VENDOR_ID, MY_DEVICE_ID) }, { 0 } }; MODULE_DEVICE_TABLE(pci, my_ids); static struct pci_driver my_driver = { .name = "my_pci", .id_table = my_ids, .probe = my_probe, .remove = my_remove, }; module_pci_driver(my_driver); MODULE_LICENSE("GPL");四个容易被忽略的细节。第一,MODULE_DEVICE_TABLE不只是给内核用的,它让模块带上别名信息,udev 在设备出现时才能根据 sysfs 里的 modalias 自动modprobe对应模块;缺了它,手动insmod没问题,热插拔场景不会自动绑定。第二,PCI_DEVICE()宏只匹配 Vendor/Device,忽略 Subsystem ID;如果同一 Device ID 下存在多个子型号、行为却不同,改用PCI_DEVICE_SUB()精确到 Subsystem Vendor 和 Subsystem Device。第三,.name不参与匹配,只是驱动目录名,但最好和模块名一致,不然/sys/bus/pci/drivers/下的目录会让排错的人困惑。第四,module_pci_driver展开后自动生成module_init和module_exit,分别对应pci_register_driver和pci_unregister_driver,省掉两段样板代码。
2.3 probe 里按什么顺序拿资源
probe是驱动开发的重头戏。很多人第一步就搞混:pci_enable_device是把 PCI 配置空间 Command 寄存器里的 Memory/IO 访问使能位置位,不等于已经占用了资源;pci_request_regions才是向内核申请这段区域的所有权,防止两个驱动同时操作同一个 BAR。
static int my_probe(struct pci_dev *pdev, const struct pci_device_id *id) { void __iomem *regs; int err; err = pcim_enable_device(pdev); /* 设备使能,带有自动清理 */ if (err) return err; err = pcim_iomap_regions(pdev, BIT(0), "my_pci"); /* 申请并映射 BAR0 */ if (err) return err; regs = pcim_iomap_table(pdev)[0]; /* 拿到映射后的虚拟地址 */ pci_set_master(pdev); /* 开启 bus master,DMA 前置 */ return 0; }这里刻意用了pcim_前缀的接口而不是裸的pci_enable_device+pci_request_regions+ioremap组合。pcim_系列会将资源挂到设备生命周期上,后面代码出错或设备拔出时内核自动释放,不会泄漏。pci_set_master只做一件事:置位 PCI Command 寄存器的 Bus Master 位,允许设备主动发起 DMA。不做 DMA 的驱动可以不调,但绝大多数 PCI 设备的性能都依赖 DMA,建议放在 probe 成功路径上。顺序不能乱:先使能,再申请映射,最后开 Master,反了会出现资源申请失败或 DMA 超时。
3. 从零编译可加载的 PCI 驱动:Makefile、模块参数与 BAR 访问
3.1 Kbuild 外部模块的 Makefile 写法
PCI 驱动开发不一定需要把代码编进内核,外部模块方式加载完全够用,调试迭代还更快。Kbuild 体系下最小 Makefile 只要三条规则:
obj-m := my_pci.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean不需要自己写gcc规则。make -C进入内核源码目录读取顶层 Makefile,M=$(PWD)让 Kbuild 切回当前目录编译外部模块。前置条件是系统装了 kernel-devel 或 linux-headers 包,版本必须和当前运行的内核一致。obj-m表示目标是可加载模块,写成obj-y就是编进内核镜像;一个模块由多个源文件组成时用my_pci-objs := core.o ring.o聚合。编出来的my_pci.ko直接insmod,配合uname -r检查内核版本是最常见的排错起点。
3.2 用模块参数覆盖设备 ID
PCI 驱动开发中常遇到「手上有好几款板卡,ID 表写死之后要反复改源码」。常见做法是用module_param提供运行时覆盖能力:
static unsigned short vendor_override; static unsigned short device_override; module_param(vendor_override, ushort, 0444); module_param(device_override, ushort, 0444); MODULE_PARM_DESC(vendor_override, "force vendor ID to probe"); static int __init my_init(void) { if (vendor_override && device_override) { my_ids[0].vendor = vendor_override; my_ids[0].device = device_override; } return pci_register_driver(&my_driver); } module_init(my_init);module_param第三个参数0444决定 sysfs 权限,0444 表示用户态只读。加载时用insmod my_pci.ko vendor_override=0x8086 device_override=0x1539,驱动就会去匹配另一张卡,适合开发期验证不同板卡。生产环境不建议留这个后门,一不留神可能绑到别人的网卡上。这里需要自己写module_init,因为module_pci_driver不会给你插入参数覆盖逻辑的机会。
3.3 用 ioremap 与 readl/writel 访问寄存器
所谓「读写寄存器」,读写的是映射之后的虚拟地址。BAR 里存放的是物理地址,必须用ioremap转成内核虚拟地址再访问;直接对物理地址解引用会触发 page fault,这是新手最容易踩的坑。
static void __iomem *regs; err = pcim_iomap_regions(pdev, BIT(3), "my_pci"); /* 请求并映射 BAR3 */ if (err) return err; regs = pcim_iomap_table(pdev)[3]; u32 version = readl(regs + 0x00); /* 读版本寄存器 */ pr_info("my_pci: hw version 0x%08x\n", version); writel(0x01, regs + 0x10); /* 写控制寄存器 */ readl(regs + 0x10); /* 回读,确保写到达设备 */经常被忽略的是最后一行回读。PCI 的写操作是 posted write,CPU 发出写指令后不等设备响应就返回,在某些 FPGA 卡上表现为「写了但设备还没处理,紧接着读状态却读到旧值」。readl自带 memory barrier,回读一次能保证之前的写操作已经被设备接收。另一个常见坑是 BAR 类型:有的设备 BAR0 是 IO 空间而不是 Memory 空间,这时要用pci_iomap替代ioremap,它会根据 BAR 类型自动选择内存映射或 IO 端口映射方式。
加载后验证资源分配:
sudo insmod my_pci.ko dmesg | tail -n 20 cat /proc/iomem | grep -i my_pci ls -l /sys/bus/pci/drivers/my_pci//sys/bus/pci/drivers/my_pci/下出现设备 BDF 符号链接说明匹配成功;没有就去核对lspci -n的 ID。
4. DMA 与中断:PCI驱动开发的性能核心
4.1 一致性映射还是流式映射
设备通过 Bus Master 直接读写主存,驱动要做的就是分配一段物理上连续的缓冲区、把物理地址告诉设备。内核提供两种映射方式,选错会导致数据损坏或性能劣化。
一致性映射用dma_alloc_coherent,分配的内存 CPU 和设备都能访问,硬件保证缓存一致性,适合周期性交换的控制结构、描述符环:
struct my_hw_desc *desc; dma_addr_t desc_dma; desc = dma_alloc_coherent(&pdev->dev, sizeof(*desc), &desc_dma, GFP_KERNEL); if (!desc) return -ENOMEM; writel(lower_32_bits(desc_dma), regs + DMA_ADDR_LOW); writel(upper_32_bits(desc_dma), regs + DMA_ADDR_HIGH);lower_32_bits/upper_32_bits是把 64 位 DMA 地址拆成两个 32 位寄存器写入的标准做法,兼容 32 位和 64 位寻址的 BAR。流式映射用dma_map_single配合dma_unmap_single,适合一次性收发的大块数据,比如网卡收发包;它不保证缓冲区连续,也不做缓存同步,驱动必须在操作前后显式调用dma_sync_single_for_cpu/dma_sync_single_for_device。判断依据很简单:缓冲区生命周期长、CPU 持续读写,用一致性映射;一次性大块传输、用完即弃,用流式映射。把流式映射用在环形缓冲区上会频繁同步,性能反而更差。
4.2 中断注册:从 INTx 到 MSI-X
PCI 中断有两条路线:传统 INTx 是共享中断线,多个设备共用同一个 IRQ,中断处理函数得先读硬件状态确认中断是否属于自己;MSI 是设备直接往 CPU 发送中断消息,每个设备有独立中断号,没有共享干扰。现代 PCI 驱动开发基本都用 MSI-X,因为它支持多队列,每个队列可以绑不同 CPU。
int irq; err = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI); if (err < 0) err = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_LEGACY); irq = pci_irq_vector(pdev, 0); err = request_threaded_irq(irq, my_hard_irq, my_thread_fn, IRQF_SHARED, "my_pci", pdev);pci_alloc_irq_vectors的参数是最少向量数、最多向量数、支持的中断类型标志。只传PCI_IRQ_MSI表示仅接受 MSI;加PCI_IRQ_MSIX允许 MSI-X;带PCI_IRQ_LEGACY则允许回退 INTx。多队列设备把最小和最大向量数都设为队列数,就能一次拿到多个中断号。request_threaded_irq的第三个参数是线程化中断处理:硬中断里只做最小确认(读中断状态寄存器、清中断),耗时工作放到内核线程,避免把中断上下文卡死;硬中断返回IRQ_WAKE_THREAD时内核才唤醒线程函数。
4.3 常用 PCI 驱动开发 API 参数速查
| 函数 | 作用 | 关键参数含义 |
|---|---|---|
pcim_enable_device(pdev) | 使能设备 Memory/IO 访问 | 相比非 pcim 版本带自动清理 |
pcim_iomap_regions(pdev, mask, name) | 申请并映射指定 BAR | mask 是BIT(n),n 为 BAR 序号 |
pcim_iomap_table(pdev)[n] | 取第 n 个 BAR 的映射地址 | 必须在 iomap_regions 之后调用 |
pci_set_master(pdev) | 置位 Bus Master | DMA 前置条件 |
dma_alloc_coherent(dev, size, dma, gfp) | 分配一致性 DMA 内存 | gfp 常用GFP_KERNEL,中断上下文用GFP_ATOMIC |
pci_alloc_irq_vectors(pdev, min, max, flags) | 分配中断向量 | flags 取PCI_IRQ_MSIX/PCI_IRQ_MSI/PCI_IRQ_LEGACY |
pci_irq_vector(pdev, n) | 取第 n 个中断号 | 配合irq_set_affinity_hint可做绑核 |
一个常见错误是把pci_set_master放在 DMA 内存分配之后。设备没开 Bus Master 时,DMA 地址即便写进设备寄存器,设备发出的读写请求也会被总线拒绝,表现为 DMA 超时。标准顺序是:使能设备 → 申请并映射 BAR →pci_set_master→ 分配 DMA 内存 → 注册中断。调换任意两步都可能踩到「资源申请失败」或「中断触发但数据没来」的玄学问题。
5. 调试三板斧:dmesg 时序、动态调试与 udev 固定设备节点
5.1 用 dmesg 确认 probe 与 remove 的调用时序
PCI 驱动开发里最常用的工具就是dmesg。加载模块、绑定设备、卸载模块三个动作各留一条日志,就能判断生命周期是否完整:
dmesg -wT sudo sh -c 'echo 1 > /sys/bus/pci/devices/0000:01:00.0/remove' sudo sh -c 'echo 1 > /sys/bus/pci/rescan'remove文件写入 1 模拟热拔出,内核会调用驱动的remove回调;rescan写入 1 触发重新枚举,probe应该再次被调用。如果rescan后没有 probe 日志,多半是id_table没匹配上,或上一个remove里资源没释放干净。
5.2 打开动态调试,不用重新编译
内核开了CONFIG_DYNAMIC_DEBUG时,pr_debug可以在运行时按文件、函数、行号精确开关:
echo "file drivers/my_pci.c +p" > /sys/kernel/debug/dynamic_debug/control sudo cat /proc/sys/kernel/printk注意printk级别:pr_info对应 6 级,默认控制台不一定显示,dmesg里一定能看到;如果dmesg里也没有,先查模块是否真的加载了,执行lsmod | grep my_pci。调试 DMA 超时这类问题,在中断处理函数和 DMA 完成回调里各加一条带时间戳的pr_info,拿dmesg -T对比时间差,是最直接的定位手段。
5.3 用 udev 固定设备节点
PCI 驱动开发最后一步往往是把/dev下的设备节点固定下来。动态主设备号会导致每次加载节点号漂移,用户态脚本没法依赖。在/etc/udev/rules.d/99-my_pci.rules里写:
KERNEL=="my_pci*", SUBSYSTEM=="my_pci", OWNER="root", GROUP="staff", MODE="0660", SYMLINK+="my_pci0"SYMLINK保证用户态始终访问/dev/my_pci0,MODE=0660配合GROUP="staff"把访问权限限制在特定用户组。改完规则执行udevadm control --reload和udevadm trigger,再insmod模块验证节点是否按预期生成。驱动里创建字符设备时用class_create注册一个struct class,SUBSYSTEM才会是my_pci,否则 udev 规则匹配不到。链路验证的方法很简单:用户态打开/dev/my_pci0,用ioctl触发一次寄存器回读,拿dmesg对照驱动里打印的硬件版本号,这一步通了,整条 PCI 驱动开发链路就算闭环。
本文还有配套的精品资源,点击获取