1. PCI 设备驱动到底在驱动什么

很多人第一次接触 Linux PCI 驱动,脑子里冒出来的第一个问题往往是:PCI 不就是插槽吗,插上卡不就能用,为什么还要写驱动?这个问题问得特别实在,也恰恰是理解整个 PCI 驱动体系最好的切入点。我刚开始做嵌入式 Linux 那会儿,也觉得 PCI 驱动是个特别玄乎的东西,直到自己真正在板子上调通第一块 PCI 网卡,才明白它到底在干什么。

PCI 总线本质上是一条“高速公路”,CPU 和外设之间通过它来传数据。但这条高速公路有个特点:它只负责“运货”,不负责“理解货物”。也就是说,PCI 总线规范只定义了电气特性、时序、配置空间格式这些底层规则,至于这块卡是网卡、显卡还是采集卡,总线本身根本不关心。真正让一块 PCI 设备“活起来”的,是操作系统里的设备驱动。驱动要做的事情,简单说就是三件: 识别设备、分配资源、提供接口 。

识别设备靠的是 PCI 配置空间里的 Vendor ID 和 Device ID 。每个 PCI 设备在出厂时都会烧录这两个 ID,驱动通过匹配这两个值来判断“这块卡归我管”。比如你在终端里敲 lspci -nn ,看到类似 8086:7aa4 这样的输出, 8086 就是 Intel 的厂商 ID, 7aa4 是具体设备 ID。热词里出现的 pci\ven_8086&dev_7aa4&subsys_7d481462&rev_11 其实就是 Windows 下设备管理器里显示的硬件 ID 格式,拆开看就是厂商 ID、设备 ID、子系统 ID 和版本号,和 Linux 下的 lspci 输出是一一对应的。

分配资源包括 I/O 端口、内存映射区域(MMIO)、中断号(IRQ) 这几样。PCI 设备通常会把寄存器映射到一段物理地址上,驱动通过 ioremap 把这段物理地址映射到内核虚拟地址空间,然后像读写内存一样读写寄存器。中断则是设备通知 CPU “我有事找你”的方式,驱动需要注册中断处理函数来响应。

提供接口就是驱动向上层暴露统一的读写、控制接口。比如网卡驱动会注册到网络子系统,块设备驱动会注册到块层,字符设备驱动则通过 file_operations 结构体暴露 open 、 read 、 write 、 ioctl 等操作。热词里提到的“字符设备驱动框架”其实就是这个层面的东西,PCI 驱动最终往往要落到某一种具体的设备框架上。

所以,PCI 设备驱动详解这个主题,核心就是把这套“识别—分配—接口”的流程讲清楚,同时把配置空间、BAR 空间、中断、DMA 这些关键机制拆开揉碎。适合谁来学?我觉得有三类人最需要:一是做嵌入式 Linux 开发的,板子上经常挂各种 PCIe 外设;二是做服务器运维的,遇到掉卡、降速、AER 报错这些问题得能定位;三是对内核驱动感兴趣、想从字符设备驱动进阶到总线驱动的人。

2. 从配置空间到 BAR 空间:PCI 驱动的核心机制拆解

2.1 配置空间:设备的身份证与资源申请表

PCI 配置空间是每个 PCI 设备都有的 256 字节(PCIe 扩展到 4KB)寄存器区域,它记录了设备的所有“身份信息”和“资源需求”。这 256 字节里,前 64 字节是标准头部,后面的部分是设备相关的。标准头部里几个关键字段必须记住:

  • Vendor ID / Device ID :厂商和设备标识,驱动匹配的依据。
  • Command Register :控制设备是否响应 I/O、内存、总线主控等操作。
  • Status Register :反映设备状态,比如是否支持能力列表。
  • BAR0~BAR5 :六个基地址寄存器,用来申请 I/O 或内存资源。
  • Interrupt Line / Interrupt Pin :中断相关信息。
  • Capabilities Pointer :指向能力列表,PCIe 设备的能力(如 MSI、PCIe 能力)都在这里。

我刚开始看配置空间的时候,觉得这些字段又杂又多,后来发现只要抓住一条主线就行: 配置空间是 BIOS 或内核枚举时读写的区域,驱动通过它来了解设备需要什么资源,然后向内核申请这些资源 。枚举过程就是内核从总线 0、设备 0、功能 0 开始,逐个读取配置空间,发现设备就记录下来,然后分配总线号和资源。

热词里“pcie枚举过程”之所以被频繁搜索,是因为枚举出问题往往表现为设备根本看不到,或者 BAR 分配失败。枚举的代码在 drivers/pci/probe.c 里,核心逻辑是递归扫描总线,读取每个设备的配置空间,如果发现桥设备就继续往下扫。这个过程在系统启动时由内核完成,但热插拔时也会触发。

2.2 BAR 空间:设备寄存器的“门牌号”

BAR 是 PCI 驱动里最容易让人迷糊的部分。每个 BAR 在配置空间里占 4 字节(64 位 BAR 占 8 字节),设备上电时 BAR 的值是设备想要的资源大小的“提示”,内核枚举时会写入实际的基地址。驱动拿到 BAR 后,需要做两件事: 读取 BAR 的起始地址和长度,然后 ioremap 到内核虚拟地址 。

举个例子,假设一块 PCI 采集卡的 BAR0 是内存类型,长度 4KB,内核分配了物理地址 0xfeb00000 。驱动里可以这样操作:

struct pci_dev *pdev;
resource_size_t bar0_start;
unsigned long bar0_len;
void __iomem *regs;

bar0_start = pci_resource_start(pdev, 0);
bar0_len = pci_resource_len(pdev, 0);
regs = ioremap(bar0_start, bar0_len);

之后就可以用 readl(regs + offset) 和 writel(value, regs + offset) 来读写寄存器了。这里有个坑: 不是所有 BAR 都是内存类型,有些老设备用 I/O 端口类型 ,这时候要用 inb/outb 系列函数,而且 ioremap 不适用。判断方法是看 pci_resource_flags(pdev, bar) 返回的 IORESOURCE_IO 还是 IORESOURCE_MEM 。

还有一个常见问题是 64 位 BAR 。PCIe 设备很多用 64 位 BAR,这时候 BAR0 和 BAR1 合起来表示一个 64 位地址,驱动读取时要用 pci_resource_start 配合 pci_resource_len ,内核已经帮你处理好了合并逻辑,但你自己算偏移的时候要注意别把 BAR1 当成独立的 BAR 用。

2.3 中断与 DMA:数据通路的两个关键角色

中断和 DMA 是 PCI 驱动性能的关键。中断方式经历了从 INTx 传统中断 到 MSI 再到 MSI-X 的演进。INTx 是电平触发,多个设备可能共享一根中断线,驱动需要自己判断是不是自己的设备触发的。MSI 是消息信号中断,设备通过写特定地址来触发中断,每个设备有独立的中断向量,效率高很多。MSI-X 则支持更多向量,适合多队列网卡这类设备。

在驱动里,申请中断的接口是 pci_alloc_irq_vectors 和 request_irq 。我一般会优先尝试 MSI-X,失败再退到 MSI,最后才用 INTx:

int nvec = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSIX | PCI_IRQ_MSI | PCI_IRQ_LEGACY);
if (nvec < 0) {
    dev_err(&pdev->dev, "Failed to allocate IRQ vectors\n");
    return nvec;
}

DMA 则是设备直接访问内存的机制,不需要 CPU 参与搬运。驱动需要分配 DMA 缓冲区,把物理地址告诉设备,设备写完后通过中断通知驱动。这里的关键是 DMA 一致性映射 和 流式映射 的区别:一致性映射适合长期存在的缓冲区,流式映射适合一次性传输。 dma_alloc_coherent 和 dma_map_single 是最常用的两个接口。

注意:DMA 地址和 CPU 虚拟地址不是一回事,设备看到的是总线地址(在 x86 上通常等于物理地址,但在某些架构上需要经过 IOMMU 转换)。写驱动时一定要用 dma_map_* 系列函数来获取总线地址,不能直接把 virt_to_phys 的结果给设备。

3. 手把手写一个 PCI 驱动骨架

3.1 驱动注册与设备匹配

写 PCI 驱动第一步是定义 pci_driver 结构体,里面最关键的是 id_table 和 probe 函数。 id_table 告诉内核这个驱动支持哪些设备, probe 是匹配成功后调用的初始化函数。

static const struct pci_device_id my_pci_ids[] = {
    { PCI_DEVICE(0x1234, 0x5678) },
    { 0, }
};
MODULE_DEVICE_TABLE(pci, my_pci_ids);

static struct pci_driver my_pci_driver = {
    .name = "my_pci_drv",
    .id_table = my_pci_ids,
    .probe = my_pci_probe,
    .remove = my_pci_remove,
};

module_pci_driver(my_pci_driver);

PCI_DEVICE 宏展开后就是 vendor 和 device 两个字段。如果你要匹配某个厂商的所有设备,可以用 PCI_DEVICE_ID_ANY 作为 device ID。热词里那个 pci\ven_8086&dev_7aa4 对应的就是 PCI_DEVICE(0x8086, 0x7aa4) 。

probe 函数里要做的事情按顺序是: 使能设备、申请 BAR 资源、映射寄存器、申请中断、注册上层接口 。每一步失败都要回滚前面的操作,这是驱动健壮性的基本要求。我见过太多驱动在 probe 里出错后直接返回,结果资源泄漏,卸载模块时各种报错。

static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    int ret;
    void __iomem *regs;

    ret = pci_enable_device(pdev);
    if (ret)
        return ret;

    ret = pci_request_regions(pdev, "my_pci_drv");
    if (ret)
        goto err_disable;

    regs = pci_iomap(pdev, 0, 0);
    if (!regs) {
        ret = -ENOMEM;
        goto err_release;
    }

    /* 申请中断、注册字符设备等 */
    return 0;

err_release:
    pci_release_regions(pdev);
err_disable:
    pci_disable_device(pdev);
    return ret;
}

pci_iomap 是 ioremap 的封装,会自动处理 I/O 端口和内存两种 BAR 类型,推荐优先使用。

3.2 字符设备接口的挂接

PCI 驱动本身不直接暴露给用户空间,通常要挂到某个子系统上。如果是一块自定义采集卡,最常见的做法是注册一个字符设备。热词里“字符设备驱动框架”被搜了很多次,说明很多人卡在这一步。

字符设备的核心是 file_operations 结构体,里面定义 open 、 release 、 read 、 write 、 unlocked_ioctl 等操作。在 probe 里注册字符设备,在 remove 里注销:

static int major;
static struct class *my_class;
static struct cdev my_cdev;

static int my_open(struct inode *inode, struct file *filp)
{
    struct my_dev *dev = container_of(inode->i_cdev, struct my_dev, cdev);
    filp->private_data = dev;
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off)
{
    struct my_dev *dev = filp->private_data;
    /* 从设备读取数据到用户空间 */
    return 0;
}

static const struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .unlocked_ioctl = my_ioctl,
};

注册流程是 alloc_chrdev_region 分配设备号, cdev_init 和 cdev_add 添加字符设备, class_create 和 device_create 创建设备节点。这样用户空间就能通过 /dev/my_pci_dev 访问了。

实操心得: device_create 创建的设备节点默认权限是 root 可读写,普通用户访问不了。可以在 udev 规则里加权限,或者在驱动里用 device_create 之后调用 device_create_file 设置属性。我一般直接在 udev 规则里处理,驱动里不掺和权限的事。

3.3 中断处理与并发控制

中断处理函数运行在中断上下文,不能睡眠,不能调用可能阻塞的函数。如果中断处理逻辑复杂,应该用 上半部/下半部 机制:上半部只做最紧急的确认和清中断,下半部用 tasklet、工作队列或线程化中断来处理耗时操作。

static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_dev *dev = dev_id;
    u32 status = readl(dev->regs + REG_STATUS);

    if (!(status & MY_IRQ_MASK))
        return IRQ_NONE;

    writel(status, dev->regs + REG_STATUS); /* 清中断 */
    /* 唤醒等待队列或调度下半部 */
    return IRQ_HANDLED;
}

并发控制是另一个重点。PCI 设备的寄存器可能被多个进程同时访问,中断处理函数也可能和进程上下文竞争。常用的锁有 spinlock_t (中断上下文用)、 mutex (进程上下文用)。如果中断处理函数和进程上下文都要访问同一资源,进程上下文必须用 spin_lock_irqsave 来关中断。

我踩过的一个坑是:在 ioctl 里用 mutex 保护寄存器访问,结果中断处理函数里也去拿这个 mutex ,直接死锁。后来改成中断里用 spinlock ,进程上下文用 spin_lock_irqsave ,问题解决。 记住一条原则:中断上下文只能用自旋锁,不能用互斥锁。

4. 掉卡、降速、AER:PCIe 稳定性问题排查实录

4.1 掉卡问题的常见原因与定位方法

PCIe 掉卡是运维和嵌入式开发里最头疼的问题之一。表现是设备突然从 lspci 列表里消失,或者驱动报 Device not ready 。原因可能出在物理层、链路层、配置空间或驱动本身。

排查第一步是看内核日志 dmesg ,搜索 pcie 、 aer 、 link down 这些关键词。如果看到 Link is down 或 Link training failed ,说明物理链路出了问题,可能是金手指接触不良、线缆质量差、供电不稳。如果看到 AER: Corrected error 或 Uncorrected error ,说明链路有误码,需要检查信号完整性。

第二步是看 lspci -vvv 的输出,重点关注 LnkSta 字段。正常应该是 Speed 8GT/s, Width x4 这样的格式,如果显示 Speed 2.5GT/s, Width x1 ,说明链路降速降宽了。降速的原因可能是设备本身能力限制、插槽限制、或者信号质量不达标。

第三步是看 /sys/bus/pci/devices/ 下对应设备的 link_speed 和 link_width 文件,这些是实时值。如果发现链路速率和预期不符,可以尝试重新训练链路:向 link_control 写入 1 触发重训练。

注意:触发链路重训练需要 root 权限,而且不是所有平台都支持。有些平台的重训练会导致设备短暂消失,操作前最好确认业务能容忍。

4.2 AER 报错解读与处理策略

AER 是 PCIe 的高级错误报告机制,分为 可纠正错误 和 不可纠正错误 。可纠正错误包括接收端错误、坏 TLP、重放超时等,通常硬件会自动恢复,但频繁出现说明链路质量有问题。不可纠正错误包括 Completer Abort、Unsupported Request、ECRC 错误等,可能导致设备功能异常。

在 dmesg 里看到 AER 报错时,先看错误类型和错误源。 Corrected error 一般不用太紧张,但要看频率,如果每秒几十次,那肯定不正常。 Uncorrected error 要重视,尤其是 Fatal 级别的,可能导致设备直接掉线。

处理策略上,如果是可纠正错误,可以尝试降低链路速率来提升稳定性。比如把 Speed 8GT/s 降到 5GT/s 或 2.5GT/s ,误码率会明显下降。操作方法是通过 setpci 命令修改链路控制寄存器的 Target Link Speed 字段,然后触发重训练。

如果是不可纠正错误,先确认是不是特定操作触发的,比如大流量 DMA 传输。如果是,检查 DMA 缓冲区对齐、地址范围是否超出设备能力。有些老设备只支持 32 位 DMA 地址,如果驱动分配了 64 位地址,就会报 Completer Abort。这时候需要用 pci_set_dma_mask 限制 DMA 掩码。

4.3 常见问题速查表

现象 可能原因 排查方法 处理建议
设备不在 lspci 列表 枚举失败、链路未训练 dmesg 看 link training 检查供电、金手指、插槽
驱动 probe 失败 BAR 分配失败、中断申请失败 dmesg 看具体错误码 检查资源冲突、BIOS 设置
链路降速 信号质量差、插槽限制 lspci -vvv 看 LnkSta 触发重训练、换插槽
AER 可纠正错误频繁 链路误码 dmesg 看错误计数 降速、检查线缆
DMA 传输失败 地址超出设备能力 看 AER 错误类型 设置 DMA mask
中断不触发 MSI 未使能、向量分配失败 /proc/interrupts 看计数 检查 MSI 能力、回退 INTx

这张表是我在实际项目中慢慢积累的,基本上覆盖了八成以上的 PCIe 稳定性问题。遇到新问题的时候,先按表里的思路过一遍,能省不少时间。

5. 热插拔与国产平台适配的实战经验

5.1 PCIe 热插拔功能的驱动支持

PCIe 热插拔是个很实用的功能,服务器上换网卡、换加速卡不用关机。但热插拔对驱动有额外要求: 驱动必须正确处理设备移除和重新插入 。内核在设备移除时会调用驱动的 remove 函数,驱动要在这里释放所有资源,包括 BAR 映射、中断、DMA 缓冲区、字符设备等。

热插拔的触发方式有两种: 原生热插拔 和 基于电源控制的热插拔 。原生热插拔需要硬件支持,比如插槽上有存在检测引脚。软件上,内核通过 pciehp 驱动来管理热插拔事件,用户空间可以通过 /sys/bus/pci/slots/ 下的文件来控制插槽电源。

我实测下来,热插拔最容易出问题的地方是 驱动 remove 不干净 。比如中断没释放,重新插入时申请中断失败;或者 DMA 缓冲区没释放,内存泄漏。所以写驱动时, remove 函数要和 probe 严格对称, probe 里申请了什么, remove 里就释放什么,顺序反过来。

还有一个坑是 设备重新插入后 BAR 地址可能变化 。热插拔后内核会重新枚举,BAR 分配的地址可能和之前不一样。驱动不能缓存 BAR 的物理地址,每次 probe 都要重新读取。

5.2 国产 Linux 平台上的 PCIe 适配要点

现在国产 Linux 平台越来越多,很多项目要求从 x86 迁移到国产 CPU 平台。PCIe 驱动在这类平台上适配时,有几个点要特别注意。

首先是 DMA 地址映射 。x86 上总线地址通常等于物理地址,但国产平台上可能有 IOMMU 或者地址转换窗口, dma_map_single 返回的总线地址和物理地址不一样。驱动里所有给设备的地址都必须用 DMA API 获取,不能直接 virt_to_phys 。

其次是 中断控制器差异 。国产平台的中断控制器可能是 GIC 或者自定义的,MSI 中断的分配方式和 x86 不同。 pci_alloc_irq_vectors 在大多数平台上都能正常工作,但有些平台需要额外的固件配置。如果 MSI 申请失败,先检查设备树或 ACPI 表里的中断描述是否正确。

第三是 配置空间访问方式 。x86 用 0xCF8/0xCFC 端口访问配置空间,国产平台可能用 ECAM 或者自定义机制。内核的 PCI 子系统已经抽象了这些差异,驱动里用 pci_read_config_* 和 pci_write_config_* 就行,不要直接操作端口。

实操心得:在国产平台上调试 PCIe 驱动,我习惯先跑 lspci -vvv 确认设备枚举正常,再用 setpci 读几个关键寄存器确认配置空间可访问,最后才加载驱动。这样能把问题分层定位,避免驱动和平台问题混在一起。

5.3 从字符设备到子系统:驱动架构的演进思路

刚开始写 PCI 驱动时,很多人会直接写一个字符设备,所有功能都塞在 ioctl 里。这种做法在简单场景下能用,但设备一复杂就难维护。更好的做法是 把驱动挂到对应的子系统上 :网卡挂到网络子系统,存储卡挂到块层,采集卡如果符合 V4L2 就挂到 V4L2。

挂到子系统的好处是复用成熟框架,用户空间有标准接口,不用自己造轮子。比如网卡驱动注册 net_device ,用户空间用 ip 命令就能配置,不用自己写配置工具。代价是要理解子系统的框架和回调机制,学习曲线陡一些。

我的建议是: 先写字符设备版本跑通基本功能,再根据设备类型迁移到对应子系统 。这样既能快速验证硬件,又能逐步优化架构。热词里“嵌入式 Linux 项目”被搜了很多次,说明很多人是在做具体项目,这种渐进式思路比较实用。

6. 调试工具与性能优化的一些私房技巧

6.1 lspci、setpci、pcimem 的组合用法

lspci 是最常用的 PCI 设备查看工具,但很多人只用 lspci 看列表,其实 -vvv 和 -xxx 才是真正有用的。 -vvv 显示设备的详细能力,包括链路状态、MSI 能力、电源管理能力。 -xxx 显示配置空间的十六进制内容,排查 BAR 问题时特别有用。

setpci 可以直接读写配置空间,适合在驱动没加载时调试硬件。比如读 Vendor ID: setpci -s 00:1f.0 0x00.w 。写配置空间要小心,写错了可能导致设备异常,建议先读出来确认再写。

pcimem 是用户空间读写 MMIO 的工具,可以绕过驱动直接访问 BAR 空间。调试阶段用它可以快速确认硬件寄存器是否正常响应。用法是 pcimem /sys/bus/pci/devices/0000:01:00.0/resource0 0x0 w ,读取 BAR0 偏移 0 处的 32 位值。

这三个工具组合起来,基本能覆盖从枚举到寄存器访问的所有调试需求。我一般按 lspci 看状态、 setpci 读配置、 pcimem 读寄存器的顺序来排查。

6.2 性能优化:从中断合并到 DMA 对齐

PCIe 设备性能优化有几个方向。 中断合并 是减少中断次数、提升吞吐量的常用手段。很多设备支持中断合并寄存器,可以设置“收到 N 个包或超时 M 微秒后才触发中断”。驱动里配置这个寄存器,能显著降低 CPU 占用。

DMA 对齐 也很关键。PCIe 传输对地址对齐有要求,不对齐会导致额外的 TLP 拆分,降低有效带宽。分配 DMA 缓冲区时尽量按 4KB 或更大粒度对齐, dma_alloc_coherent 默认会做对齐,但自己用 dma_map_single 时要手动保证。

多队列 是高性能网卡和存储卡的标配。每个队列有独立的中断向量和 DMA 环,多个 CPU 核心并行处理,避免单点瓶颈。驱动里要实现多队列,需要申请多个 MSI-X 向量,每个队列一个中断处理函数。

注意:多队列不是越多越好,队列数超过 CPU 核心数反而会增加调度开销。一般设置为 CPU 核心数或核心数的一半比较合适。

6.3 内核日志与 ftrace 的配合使用

调试驱动时, printk 是最直接的手段,但生产环境不能随便加打印。这时候可以用 动态调试 : pr_debug 配合 dynamic_debug 机制,运行时通过 /sys/kernel/debug/dynamic_debug/control 开关打印,不用重新编译内核。

ftrace 适合跟踪函数调用和中断延迟。比如想看 probe 函数的执行时间,可以用 function_graph tracer。想看中断处理函数的延迟,可以用 irqsoff tracer。这些工具在排查性能问题和死锁时特别有用。

我个人的习惯是:开发阶段用 pr_debug 加动态调试,性能分析用 ftrace ,死锁排查用 lockdep 。这三板斧下来,大部分驱动问题都能定位。

7. 写在最后的一些个人体会

PCI 驱动这个领域,入门门槛确实比字符设备高一些,因为要理解配置空间、BAR、中断、DMA 这一整套机制。但一旦跑通一个完整的驱动,后面再遇到新设备,基本就是套模板加调试的节奏。我自己的经验是, 不要一上来就啃 PCI 规范 ,那玩意儿太厚太枯燥,先从 lspci 和 dmesg 入手,看真实设备的行为,再回头查规范里对应的章节,理解会快很多。

另外,调试 PCIe 问题时, 分层定位 特别重要。先确认物理链路正常,再确认枚举正常,再确认配置空间可读写,最后才怀疑驱动。很多问题其实出在硬件或固件层面,驱动背了锅。我见过不少案例,折腾半天驱动,最后发现是插槽供电不足或者 BIOS 里 PCIe 速率被限制。

最后分享一个小技巧:如果你手头没有真实 PCIe 设备,可以用 QEMU 模拟。QEMU 支持模拟多种 PCIe 设备,配合内核的 pci-test 驱动,可以在虚拟机里练习驱动开发。虽然和真实硬件有差异,但用来理解枚举、BAR 映射、中断这些基本概念足够了。等基本流程跑通,再上真实硬件调试,会顺利很多。

更多推荐