4. Linux 内核 NVMe 驱动架构:核心数据结构、模块初始化、探测与移除

说实话,很多人拿到 NVMe 驱动源码,第一反应就是找入口函数。但我觉得,先搞清楚内核怎么组织这块代码,后面才不会抓瞎。这一章我们就从三个角度切入:内核里 NVMe 驱动用的那些核心数据结构,驱动怎么注册进系统,以及设备热插拔时它怎么响应。

4.1 核心数据结构一览

NVMe 驱动的实现依赖几个关键结构体,它们串联了整个 I/O 路径。我习惯先把它们打在脑子里,再去看代码,效率会高很多。

结构体名称作用域核心字段(列举)备注
nvme_dev一个 NVMe 控制器实例bar, irq, ctrl, queues对应 PCI 设备 probe 时创建
nvme_ctrl控制器抽象层admin_q, fabrics_q, ctrl_config分离了 PCIe 与 RDMA/FC 共享部分
nvme_queue单个提交/完成队列对sq_cmds, cqes, q_depth实际存放命令和完成槽位
nvme_ns命名空间实例ns_id, disk, lba_shift每个 namespace 对应一个块设备
request_queue块层 I/O 请求队列make_request_fn, q_used内核通用块层结构,由驱动填充

这些结构体之间的引用关系,我画了一张图帮你快速建立印象:

NVMe 驱动核心数据结构关系图 nvme_dev bar, irq, queues nvme_ctrl admin_q, fabrics_q nvme_queue sq_cmds, cqes, q_depth nvme_ns ns_id, disk, lba_shift request_queue make_request_fn 关键依赖关系 • nvme_dev 包含 nvme_ctrl 和 nvme_queue 数组 • nvme_ctrl 通过 admin_q 管理 nvme_ns 列表 • 每个 nvme_ns 关联一个 request_queue 和磁盘 gendisk

我的个人习惯:看驱动源码时,先 grep 这些结构体的定义和引用,再配合 pahole 查看布局,能快速理解内存分布。

4.2 模块初始化流程

驱动以内核模块的形式存在。入口是 module_init(nvme_pci_driver_init),它做的事情其实很固定——注册一个 PCI 驱动。

static int __init nvme_pci_driver_init(void)
{
    return pci_register_driver(&nvme_pci_driver);
}
module_init(nvme_pci_driver_init);

嗯,就这么几行。但背后发生的事情不少:

  • PCI 驱动注册:填充 pci_driver 结构体,指定 id_table(支持哪些 VID/DID),以及 probe、remove、suspend、resume 回调。
  • 动态分配主设备号:内核自动分配,驱动无需自己申请。
  • 初始化互斥锁和工作队列:比如 nvme_workq 用来调度异步 admin 命令。

我曾经踩过一个坑:自己写的 NVMe 驱动没有注册 id_table,导致设备插上后 probe 根本没被调用。排查了半天才发现是 VID/DID 不匹配。所以,id_table 必须跟你的硬件完全对应,尤其是 OEM 定制型号。

注意: 新版本内核(5.x+)还增加了 nvme_reset_work 作为延迟重置的工作项,如果你定制的是早期版本,记得小心这部分差异。

4.3 探测与移除细节

4.3.1 probe:设备发现时发生了什么?

nvme_pci_probe 是整个驱动的核心入口。当 PCI 子系统发现一个匹配的设备时,就会调用它。流程可以概括为:

  1. 启用 PCI 设备:调用 pci_enable_device,随后 pci_request_mem_regions 获取 BAR 空间。
  2. 映射 MMIO:将 BAR0 映射为虚拟地址,存放在 dev->bar 里。这里我建议你用 ioremap_wc 开启写合并,对 NVMe 的提交队列写入性能有明显提升。
  3. 分配中断:先尝试 MSI-X,不行就回退到 MSI。内核提供了 pci_enable_msix 和 pci_enable_msi。
  4. 创建管理队列(admin queue):这是 NVMe 规范要求的第一个队列对,用于执行 Admin 命令(如创建 I/O 队列、获取 log page)。
  5. 初始化控制器:通过 Admin 命令读取控制器能力(CC/ CAP/ VS),设置队列深度和仲裁机制。
  6. 创建 I/O 队列:根据硬件能力创建若干个 I/O 队列对,每个队列绑定到一个 CPU 核心以获得局部性。
  7. 扫描命名空间:发送 Identify 命令,获取每个 namespace 信息,并注册块设备(nvme_ns_alloc 后调用 add_disk)。

为什么我说要留意中断分配的顺序?因为我在某次适配国产 NVMe 卡时,发现它虽然宣称支持 MSI-X,但实际只实现了 2 个向量。当时我调用 pci_enable_msix 时分配了 8 个,结果内核直接返回错误。后来改成先读取实际可用向量数,再分配。

4.3.2 remove:优雅的清理

设备移除时,nvme_pci_remove 被调用。具体步骤:

  • 设置 dev->ctrl.state 为 DELETING,阻止新的 I/O 提交。
  • 等待所有正在处理的命令完成(nvme_cancel_request + 超时回收)。
  • 删除所有命名空间对应的块设备(del_gendisk)。
  • 释放中断、取消注册。
  • 释放 MMIO 映射、释放队列内存、释放 nvme_dev 结构体。

这里有个容易忽视的点:先清理 I/O 队列,再清理 Admin 队列。如果你反过来,Admin 队列先被销毁,那么 I/O 队列的完成处理函数中如果还想发送 Admin 命令(比如写 log),就会触发非法访问。我在某个内核 4.19 版本上调试时就因为顺序问题导致 oops,后来严格按“I/O → Admin → 资源”的顺序重构才稳定。

总结一下:NVMe 驱动的 probe/remove 遵循典型的 PCI 驱动模式,但多了 NVMe 特有的队列创建和命名空间发现步骤。理解 nvme_dev → nvme_ctrl → nvme_queue → nvme_ns 的层次关系,是掌握整章的基础。