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请求是怎么走完的。我画了一张流程图,帮你理清思路。
咱们一步步拆解:
步骤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驱动看起来复杂,但核心逻辑就这些。你只要把队列模型搞懂了,剩下的都是细节。
下一章咱们会深入队列管理,包括队列创建、销毁、以及中断亲和性设置。到时候我会分享一些实际项目中的调优经验。
可可精品资料库