首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >漫谈PCIe之如何理解PCIe驱动

漫谈PCIe之如何理解PCIe驱动

作者头像
FPGA技术江湖
发布2026-04-17 13:22:19
发布2026-04-17 13:22:19
180
举报

前言

我们习惯了用 Verilog 去死磕 PCIe 的底层协议状态机。但一旦越过硬件边界来到操作系统层面,Linux 内核是如何接管并驱动这些 PCI/PCIe 设备的呢?由于不同的 CPU 架构实现了各异的芯片组,加上各种 PCI 设备自身独特的功能需求,Linux 内核中的 PCI 支持远比我们希望的要复杂得多。今天这篇文章,我们将从驱动开发的视角,梳理 Linux PCI 设备驱动的核心生命周期与关键 API。

驱动注册和发现

在 Linux 中,PCI 驱动程序通过 pci_register_driver() 在系统中发现设备。但实际上,这个过程是反向的:当 PCI 通用代码发现了一个新设备时,具有匹配描述的驱动程序才会被内核通知 。

系统上电或有 PCIe 设备热插拔时,底层的总线枚举其实已经完成了。Linux 的 PCI 通用代码会去扫描物理总线,读取每个设备的配置空间(拿到 Vendor ID, Device ID 等),把系统里所有的硬件设备都登记在自己的花名册上。这时候,你的驱动程序可能还没加载进系统。

当你的驱动程序跑起来,调用 pci_register_driver() 进行注册时,它并不是去物理总线上找设备。相反,它只是向内核提交了一份匹配描述(也就是包含它能支持的 Vendor ID 和 Device ID 的 id_table)。

内核会拿着你提交的匹配描述,去自己早就登记好的设备花名册里比对。一旦内核发现:总线上有个硬件设备的 ID,刚好和这个驱动要求的 ID 对上了;内核就会主动触发(通知)驱动程序里写好的 probe 探测函数,并把指向该硬件设备的指针(struct pci_dev *)塞给驱动 。

代码语言:javascript
复制
static struct pci_driver my_pci_driver = {
    .name = "my_pci_driver",
    .id_table = my_driver_id_table, 
    .probe = my_probe_function,
    .remove = my_remove_function,
};

匹配描述

在传给注册函数 pci_register_driver()struct pci_driver 结构体中,有一个名为 id_table 的字段,它就是一个指向驱动程序感兴趣的设备 ID 表的指针。

这个 ID 表是一个 struct pci_device_id 类型的数组,并且必须以一个全零的条目作为结束标志。通常建议将其定义为 static const

在实际写代码时,你不需要手动去填充上面所有的字段。大多数驱动程序只需要使用宏 PCI_DEVICE()PCI_DEVICE_CLASS() 就可以非常方便地设置 pci_device_id 表了 。

代码语言:javascript
复制
static const struct pci_device_id my_driver_id_table[] = {
    { PCI_DEVICE(0x10EC, 0x8168) }, /* 使用宏,只匹配具体的 Vendor ID 和 Device ID */
    {, } /* 必须以全 0 结尾,告诉内核数组到此为止 */
};
MODULE_DEVICE_TABLE(pci, my_driver_id_table); /* 导出表 */

static struct pci_driver my_pci_driver = {
    ...
    .id_table = my_driver_id_table, 
    ...
};

驱动名称(name)

这是驱动程序必不可少的身份标识。当你注册驱动后,内核会使用这个名字在 sysfs 文件系统中创建对应的目录(例如 /sys/bus/pci/drivers/AdriftCorePCIe/)。后续如果在运行时动态添加新的设备 ID 到驱动中,也会用到这个名字对应的路径。

代码语言:javascript
复制
#define DEVICE_NAME "AdriftCorePCIe"

static struct pci_driver my_pci_driver = {
    .name = DEVICE_NAME 
    ...
    ...
};

探测与初始化(probe)

这是驱动认领设备的入口。

当内核发现了一个与你的驱动程序 id_table 匹配的、且尚未被其他驱动占有的 PCI 设备时,就会调用这个探测函数。这可能发生在执行 pci_register_driver() 的过程中(如果设备已经存在),或者在稍后插入新设备时触发。

在这个阶段,probe 的核心动作包括:

接收设备指针:内核会为每一个 ID 表匹配的设备,将一个 struct pci_dev * 指针传递给该函数。

决定是否接管:如果驱动程序决定接受并获取该设备的所有权,probe 必须返回零;如果无法接管,则返回一个负数的错误码。

允许睡眠上下文probe 函数始终在进程上下文 (process context) 中被调用,因此它是允许睡眠的。这对于后续申请内存或长时间等待硬件就绪非常关键。

一旦 probe 决定接管设备并获得了所有权,驱动程序通常需要在这个函数内部执行以下标准的初始化步骤:

启用设备 (Enable the device)

请求 MMIO/IOP 资源 (Request MMIO/IOP resources)

设置 DMA 掩码大小 (Set the DMA mask size) :包含一致性 (coherent) 和流式 (streaming) DMA

分配并初始化共享控制数据 (Allocate and initialize shared control data) :通常使用 pci_allocate_coherent()

访问设备配置空间 (Access device configuration space) :在需要的情况下执行

注册 IRQ 处理程序 (Register IRQ handler) :通过 request_irq() 完成

初始化非 PCI 部分 (Initialize non-PCI) :例如初始化芯片中的 LAN、SCSI 等特定功能模块

启用 DMA/处理引擎 (Enable DMA/processing engines)

probe 就是你的驱动程序正式登台亮相的地方。内核把匹配的硬件交到它手上,它负责把这块硬件通电、申请资源、配中断、设 DMA,最终完成点亮,让系统能够真正使用这块硬件。

代码语言:javascript
复制
/* * 2. Probe 函数:设备的接管与点亮
 * 该函数在进程上下文中调用,允许睡眠。
 */
static int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    int err;

    /* 2.1 启用 PCI 设备 (唤醒设备、分配资源、分配 IRQ 等) */
    err = pci_enable_device(pdev);
    if (err) {
        dev_err(&pdev->dev, "Failed to enable PCI device\n");
        return err; /* 接管失败,返回负数错误码 */
    }

    /* 2.2 请求 MMIO/IOP 资源,防止与其他设备发生地址冲突 */
    /* 注意:现代内核通常使用 pci_request_regions 包装函数 */
    err = pci_request_regions(pdev, "my_pci_driver");
    if (err) {
        dev_err(&pdev->dev, "Failed to request PCI regions\n");
        goto err_disable_device;
    }

    /* 2.3 设置 DMA 掩码:声明设备的 DMA 寻址能力 (例如支持 64 位 DMA) */
    err = pci_set_dma_mask(pdev, DMA_BIT_MASK());
    if (err) {
        dev_err(&pdev->dev, "No suitable DMA available\n");
        goto err_release_regions;
    }
    pci_set_consistent_dma_mask(pdev, DMA_BIT_MASK());

    /* 2.4 启用 DMA 总线主控模式 (设置 PCI_COMMAND 寄存器中的 Bus Master 位) */
    pci_set_master(pdev);

    /* * 后续初始化步骤(伪代码):
     * - 映射 MMIO 寄存器空间 (pci_iomap)
     * - 配置 MSI/MSI-X 中断 (pci_alloc_irq_vectors)
     * - 注册中断处理程序 (request_irq)
     * - 初始化你的 DPU/硬件引擎状态机
     */

    dev_info(&pdev->dev, "PCI device probed successfully!\n");
    return; /* 成功接管设备,返回 0 */

err_release_regions:
    pci_release_regions(pdev);
err_disable_device:
    pci_disable_device(pdev);
    return err;
}

设备的正确关闭与清理

如果说 probe 是驱动接管设备的入场仪式,那么 remove 就是它的优雅退场与资源回收大管家。

在内核的生命周期中,remove 的主要职责就是完全反向执行 probe 中所做的一切,确保设备被安全关闭,并且所有占用的系统资源都被彻底释放,不留任何内存泄漏或导致系统崩溃的隐患。

remove() 函数会在由该驱动程序处理的设备被移除时调用。这通常发生在两种场景:

驱动被注销时:比如当驱动程序退出(执行 pci_unregister_driver()),或者你通过命令行(如 rmmod)卸载驱动模块时,PCI 层会自动为该驱动处理的所有设备调用 remove 钩子。

设备被物理拔出时:当设备从支持热插拔 (hot-pluggable) 的插槽中被手动拔出时。

probe 一样,remove 函数始终在进程上下文 (process context) 中被调用,因此它是允许睡眠 (sleep) 的。

当模块需要被卸载或者设备不再使用时,remove 函数通常需要严格按照以下步骤进行清理:

禁用设备生成中断 (Disable the device from generating IRQs) :这是第一步,必须阻止芯片产生新的中断。如果不做这一步,且中断号是与其他设备共享的,可能会引发致命的尖叫中断 (screaming interrupt)问题 。

释放 IRQ (Release the IRQ) :调用 free_irq() 来注销中断处理程序。

停止所有 DMA 活动 (Stop all DMA activity) :在尝试释放 DMA 控制数据之前,停止所有 DMA 操作极其重要。如果未能停止 DMA 就直接释放内存,可能会导致内存损坏、系统挂起,甚至在某些芯片组上发生硬崩溃 ^^。

释放 DMA 缓冲区 (Release DMA buffers) :包括流式 (streaming) 和一致性 (coherent) DMA 缓冲区的清理与解除映射。

从其他子系统注销 (Unregister from other subsystems) :比如解绑相关的 SCSI 或网络设备 (netdev)。

禁用设备及释放区域

  • • 禁用设备对 MMIO/IO 端口地址的响应。
  • • 释放 MMIO/IOP 资源。
代码语言:javascript
复制
/* * 3. Remove 函数:设备的优雅退场与资源回收
 * 必须严格反向执行 probe 中的分配步骤。
 */
static void my_pci_remove(struct pci_dev *pdev)
{
    /* * 卸载前期的关键清理(伪代码):
     * - 停止设备侧的数据收发与引擎运转
     * - 停止设备产生中断,并释放 IRQ (free_irq)
     * - 停止所有 DMA 活动,释放 DMA 缓冲区
     * - 解除 MMIO 空间映射 (pci_iounmap)
     */

    /* 3.1 释放 MMIO/IOP 资源区域 */
    pci_release_regions(pdev);

    /* 3.2 禁用 PCI 设备响应,与 pci_enable_device 对称相反 */
    pci_disable_device(pdev);

    dev_info(&pdev->dev, "PCI device removed successfully.\n");
}

remove 就是负责擦屁股的。它必须严丝合缝地把 probe 里申请的内存还给系统,把注册的中断注销掉,把开启的 DMA 停下来,最后让硬件安安静静地进入关闭状态。

整个驱动模块的执行

在 Linux 内核驱动的架构中,如果把 probe 和 remove 比作针对单个具体 PCIe 硬件的上岗和下岗,那么module_initmodule_exit就是整个驱动程序模块本身的出生和消亡。

在早期的内核代码中,你通常需要手动编写这两个函数,看起来像这样:

代码语言:javascript
复制
static int __init my_pci_init(void)
{
    /* 向内核的 PCI 核心注册你的驱动结构体 */
    return pci_register_driver(&my_pci_driver);
}

static void __exit my_pci_exit(void)
{
    /* 注销驱动,这会自动触发所有已接管设备的 remove 函数 */
    pci_unregister_driver(&my_pci_driver);
}

module_init(my_pci_init);
module_exit(my_pci_exit);

驱动的出生与注册

当你通过 insmod 或 modprobe 命令将编译好的 .ko 驱动模块加载到内核时,系统第一个调用的就是 module_init 宏指定的初始化函数。

在 PCIe 驱动中,它主要干一件事:向内核注册自己。

PCI 设备驱动程序会在其初始化期间调用 pci_register_driver(),并传入指向描述该驱动程序的结构体(struct pci_driver)的指针 。这就好比去内核那里挂号,把自己的 id_table、probe 和 remove 提交给内核。

注:module_init() 函数(以及仅由它调用的所有初始化函数)应该被标记为 __init 属性 。这个属性非常巧妙,它告诉内核:这段初始化代码在驱动完成初始化后就可以被直接丢弃,从而节省宝贵的内核内存空间 。

驱动的消亡与注销

当你通过 rmmod 命令卸载驱动模块时,内核会调用 module_exit 宏指定的退出函数。

在 PCIe 驱动中,它的核心任务是:向内核注销自己,并引发连锁清理

调用核心 API:当驱动程序退出时,它只需调用 pci_unregister_driver()

连锁触发 remove:这是非常省心的一点。当你调用注销函数后,PCI 层会自动去寻找所有目前正由该驱动程序处理的设备,并自动为它们逐一调用 remove 钩子函数。这意味着你不需要在 module_exit 里手动写循环去清理设备,内核全帮你包办了。

属性标记:与初始化对应,退出函数应该被标记为 __exit 属性。对于那些并非以模块形式动态加载,而是直接编译进内核镜像(非模块化)的驱动来说,带有 __exit 标记的代码会被直接忽略,因为它永远不会被卸载。

写在最后

习惯了使用 Verilog 雕琢 PCIe 的底层状态机,再回过头来看看 Linux 内核是如何以软件的视角接管这些硬件的,是一件非常有趣的事情。

正如我们在文中所看到的,Linux 并没有让驱动程序去干满大街找设备的脏活累活。相反,它构建了一个极其优雅的总线-设备-驱动模型:底层总线负责枚举和登记(发现硬件),驱动模块负责提交自己的匹配描述并注册(module_initid_table),而内核的 PCI Core 则扮演了红娘的角色,精准地将匹配的设备交到驱动的 probe 函数手中进行点亮,最后在卸载时通过 removemodule_exit 实现系统资源的完美回收。

从 RTL 侧的代码逻辑,跨越物理层与链路层,最终到达操作系统的驱动框架,这种从底至顶的完整视角,能让我们在遇到复杂的系统级 Bug 时拥有更清晰的排查思路。硬件不只是冷冰冰的寄存器,软件也不只是虚无缥缈的指针,它们的完美交汇,才是系统稳定运行的基石。

END

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-04-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 FPGA技术江湖 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
    • 驱动注册和发现
      • 匹配描述
      • 驱动名称(name)
      • 探测与初始化(probe)
      • 设备的正确关闭与清理
    • 整个驱动模块的执行
      • 驱动的出生与注册
      • 驱动的消亡与注销
    • 写在最后
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档