
我们习惯了用 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 *)塞给驱动 。
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 表了 。
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,
...
};这是驱动程序必不可少的身份标识。当你注册驱动后,内核会使用这个名字在 sysfs 文件系统中创建对应的目录(例如 /sys/bus/pci/drivers/AdriftCorePCIe/)。后续如果在运行时动态添加新的设备 ID 到驱动中,也会用到这个名字对应的路径。
#define DEVICE_NAME "AdriftCorePCIe"
static struct pci_driver my_pci_driver = {
.name = DEVICE_NAME
...
...
};这是驱动认领设备的入口。
当内核发现了一个与你的驱动程序 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,最终完成点亮,让系统能够真正使用这块硬件。
/* * 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)。
禁用设备及释放区域:
/* * 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_init和module_exit就是整个驱动程序模块本身的出生和消亡。
在早期的内核代码中,你通常需要手动编写这两个函数,看起来像这样:
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_init 与 id_table),而内核的 PCI Core 则扮演了红娘的角色,精准地将匹配的设备交到驱动的 probe 函数手中进行点亮,最后在卸载时通过 remove 和 module_exit 实现系统资源的完美回收。
从 RTL 侧的代码逻辑,跨越物理层与链路层,最终到达操作系统的驱动框架,这种从底至顶的完整视角,能让我们在遇到复杂的系统级 Bug 时拥有更清晰的排查思路。硬件不只是冷冰冰的寄存器,软件也不只是虚无缥缈的指针,它们的完美交汇,才是系统稳定运行的基石。
END