3. Linux内核NVMe驱动框架

大家好,我是这次课程的主讲。今天咱们来聊聊NVMe驱动的核心框架。说实话,我做了十几年存储驱动,NVMe是我见过最优雅的协议之一。它把现代存储的性能潜力发挥到了极致。

这一章,我会带你从驱动初始化开始,一步步深入到核心数据结构,最后看看IO请求是怎么走完整个路径的。嗯,内容不少,但都是干货。

1.1 驱动初始化流程:从module_init到设备就绪

先看入口。NVMe驱动的起点是module_init(nvme_init)。这个函数做了三件关键的事:

  • 注册PCI驱动(nvme_driver)
  • 分配管理队列(Admin Queue)
  • 探测命名空间(Namespace)

我个人习惯把初始化分成两个阶段:

阶段一:PCI探测

当系统发现NVMe设备时,nvme_probe被调用。这里有个细节——我记得第一次看代码时,被nvme_reset_work绕晕了。其实它就是个状态机:

// 伪代码,展示核心流程
nvme_probe() {
    pci_enable_device()      // 使能PCI设备
    pci_set_master()         // 设置总线主控
    nvme_reset_work()        // 启动重置工作队列
}

nvme_reset_work() {
    nvme_configure_admin_queue()  // 配置管理队列
    nvme_init_identify()          // 获取设备信息
    nvme_scan_namespaces()        // 扫描命名空间
}

关键点:管理队列是所有IO的基础。它没配好,后面什么都干不了。

阶段二:命名空间扫描

设备识别完成后,驱动会遍历所有可能的命名空间ID(从1到NN)。每个有效的命名空间都会创建一个nvme_ns结构体,并注册为块设备。

我曾经遇到过一个坑:某些厂商的设备返回的NN(Number of Namespaces)特别大,导致扫描时间过长。后来内核加了超时机制,但你自己写驱动时要注意这个。

1.2 核心数据结构:三驾马车

NVMe驱动有三个核心结构体,我管它们叫「三驾马车」:

结构体 作用 关键字段
nvme_ctrl 控制器抽象,代表一个NVMe设备 admin_q, io_queues, cap, ctrl_config
nvme_ns 命名空间,对应一个块设备(如/dev/nvme0n1) ns_id, lba_shift, disk, ctrl
nvme_queue 提交/完成队列对(SQ/CQ) sq_cmds, cqes, qid, q_depth

咱们一个一个说。

nvme_ctrl:大脑中枢

每个NVMe控制器对应一个nvme_ctrl。它管理着所有IO队列、中断、电源状态等。说白了,它就是设备的「大脑」。

struct nvme_ctrl {
    struct nvme_queue *admin_q;    // 管理队列
    struct nvme_queue **queues;    // IO队列数组
    u32 cap;                       // 控制器能力寄存器
    u32 ctrl_config;               // 控制器配置
    struct list_head namespaces;   // 命名空间链表
    // ... 还有几十个字段
};

注意:ctrl结构体是全局唯一的。所有命名空间和队列都通过指针引用它。千万别搞出野指针。

nvme_ns:存储单元

命名空间就是用户看到的块设备。每个nvme_ns包含一个gendisk结构,这就是Linux块设备层的接口。

我刚开始做NVMe驱动时,总搞不清ns_id和lba_shift的关系。其实很简单:

  • ns_id:命名空间编号,从1开始
  • lba_shift:逻辑块大小,通常是9(512字节)或12(4096字节)

nvme_queue:IO通道

队列是NVMe性能的关键。每个队列对包含一个提交队列(SQ)和一个完成队列(CQ)。驱动通过nvme_queue结构管理它们。

struct nvme_queue {
    struct nvme_command *sq_cmds;  // 提交队列命令槽
    struct nvme_completion *cqes;  // 完成队列条目
    u16 qid;                       // 队列ID(0是管理队列)
    u16 q_depth;                   // 队列深度
    // ...
};

1.3 IO路径分析:从submit到complete

好了,现在咱们看看一个IO请求是怎么走完的。我画了一张流程图,帮你理清思路。

NVMe IO请求路径 1. 块设备层提交IO 2. nvme_submit_bio() 3. 构造NVMe命令 4. 写入SQ门铃 5. 中断处理 & 完成回调 关键说明 • 步骤1-2:块层到驱动层 • 步骤3:命令构造是关键 • 步骤4:门铃触发硬件 • 步骤5:中断返回结果 避坑提醒: 门铃写入顺序不能乱 多队列要绑定正确CPU 命令超时需单独处理

咱们一步步拆解:

步骤1:块设备层提交

用户发起读写请求,经过VFS、块设备层,最终调用到NVMe驱动的nvme_submit_bio。这个函数是入口点。

步骤2:构造NVMe命令

驱动把bio请求转换成NVMe协议的命令格式。这里要注意:

  • 读命令:opcode = 0x02
  • 写命令:opcode = 0x01
  • 需要设置PRP(物理区域页)或SGL(散列聚集列表)

经验之谈:PRP和SGL的选择直接影响性能。小IO用PRP,大IO用SGL。我见过有人不分青红皂白全用PRP,结果4K以上IO性能惨不忍睹。

步骤3:写入门铃

命令构造完成后,驱动把命令写入SQ的槽位,然后更新门铃寄存器。硬件看到门铃变化,就知道有新命令要处理了。

// 门铃写入示例
void nvme_queue_submit(struct nvme_queue *nvmeq) {
    // 1. 复制命令到SQ
    memcpy(&nvmeq->sq_cmds[nvmeq->sq_tail], cmd, sizeof(*cmd));
    
    // 2. 更新tail指针
    nvmeq->sq_tail = (nvmeq->sq_tail + 1) % nvmeq->q_depth;
    
    // 3. 写门铃寄存器(关键步骤!)
    writel(nvmeq->sq_tail, nvmeq->q_db);
}

注意:门铃写入必须使用writel或writeq,不能用普通内存赋值。否则硬件可能收不到通知。

步骤4:中断处理

设备处理完命令后,把完成结果写入CQ,然后触发中断。中断处理函数nvme_irq会遍历CQ,取出完成条目,回调上层。

这里有个优化点:中断合并(Interrupt Coalescing)。我曾经在一个项目中,把中断合并阈值从1调到8,吞吐量提升了30%。但延迟也增加了,所以要根据场景权衡。

1.4 多队列并发:性能的关键

NVMe支持最多65535个IO队列。每个队列可以绑定到不同的CPU核心,实现真正的并行处理。

驱动初始化时,会根据CPU数量创建队列:

// 队列创建逻辑
nr_io_queues = min(num_online_cpus(), dev->max_io_queues);
for (i = 0; i < nr_io_queues; i++) {
    nvme_create_io_queue(dev, i + 1);  // qid从1开始
}

每个队列有自己的锁和中断。这样多个CPU可以同时提交命令,互不干扰。嗯,这就是NVMe能跑满PCIe带宽的秘诀。

我建议你在调试时,先用cat /proc/interrupts看看中断分布。如果所有中断都落在同一个CPU上,那多队列就没起作用。

1.5 本章小结

咱们这一章覆盖了三个核心内容:

  • 初始化流程:从PCI探测到命名空间扫描
  • 数据结构:ctrl、ns、queue三者的关系
  • IO路径:从bio提交到中断完成

说实话,NVMe驱动看起来复杂,但核心逻辑就这些。你只要把队列模型搞懂了,剩下的都是细节。

下一章咱们会深入队列管理,包括队列创建、销毁、以及中断亲和性设置。到时候我会分享一些实际项目中的调优经验。


可可精品资料库