1. 从一次I/O延迟异常说起:为什么要啃NVMe源码
去年帮一个做数据库的朋友排查一个诡异问题:同样的硬件配置,同样的fio参数,他那边4K随机读的P99延迟稳定在120微秒左右,而我这边怎么调都在200微秒以上。换了驱动版本、调了内核参数、甚至把CPU频率锁死,差距依然存在。最后定位到的问题出在NVMe队列深度和中断聚合的配合上——他的内核在某个版本之后默认启用了更激进的轮询策略,而我的没有。
这件事让我意识到,NVMe这块的东西,光靠调参数和看文档是远远不够的。你看到的每一个性能数字背后,都是内核里那几千行代码在决定。NVMe源码流程解析这个系列,就是打算把Linux内核里NVMe驱动的完整路径拆开来看——从应用层发起一个read系统调用开始,到命令被提交到硬件队列,再到完成中断回来,中间到底经过了哪些函数、哪些数据结构、哪些判断分支。
这篇文章是系列的第一篇,聚焦在初始化流程和命令提交路径上。适合谁看?如果你是在做存储性能优化、内核驱动开发、或者单纯想搞清楚"一个NVMe I/O到底是怎么走完的",那这篇内容应该能给你一些直接可用的东西。如果你只是想知道怎么调fio参数,那可能帮助有限——但如果你想知道为什么某个参数有效,那正好。
我假设读者对Linux内核模块、字符设备、PCI设备驱动有基本概念。没有也没关系,关键的地方我会用生活化的类比来解释。所有代码分析基于Linux 5.x之后的主线内核,具体版本差异我会在涉及的地方标注出来。
2. NVMe驱动在Linux内核中的位置与初始化链路
2.1 驱动模块的加载入口:从module_init到probe
NVMe驱动在Linux内核里是一个标准的PCI驱动,代码主要在drivers/nvme/host/目录下。核心文件是pci.c,模块初始化入口很直接:
static int __init nvme_init(void) { return pci_register_driver(&nvme_driver); }nvme_driver是一个struct pci_driver结构体,里面定义了id_table、probe、remove等回调。当内核PCI子系统枚举到一个匹配的设备时,就会调用nvme_probe。
这里有个细节值得注意:NVMe设备的id_table匹配的是class字段,而不是传统的vendor/device ID。具体来说,匹配的是PCI_CLASS_STORAGE_EXPRESS(0x010802)。这意味着任何符合NVMe规范的设备,不管哪家厂商,都会走同一个probe入口。这也是为什么NVMe驱动能做到"一个驱动通吃所有盘"的原因。
nvme_probe做的事情很多,但核心可以归纳为几条线:
- 使能PCI设备,映射BAR空间
- 分配
nvme_dev结构体,初始化队列资源 - 配置Admin队列,发送Identify命令获取控制器和Namespace信息
- 创建字符设备节点
/dev/nvmeX和块设备节点/dev/nvmeXnY - 注册中断处理
我重点说一下BAR空间映射和队列初始化,因为这两个地方是后面所有I/O路径的基础。
BAR0映射的是NVMe控制器的寄存器空间,包括CAP、VS、CC、CSTS、AQA、ASQ、ACQ等。驱动通过pci_iomap拿到虚拟地址后,后续所有对控制器的操作都是通过读写这些寄存器完成的。比如读CAP寄存器获取队列深度支持范围,写CC寄存器来使能控制器。
队列初始化是NVMe驱动里比较绕的一块。NVMe的队列模型和传统SATA的AHCI完全不同——它是多队列的,每个队列是一对Submission Queue(SQ)和Completion Queue(CQ)。Admin队列只有一对,用来发管理命令;I/O队列可以有很多对,数量取决于硬件支持和CPU核数。
dev->ctrl.admin_q = blk_mq_init_queue(&dev->tagset);这行代码创建了Admin队列的blk-mq队列。tagset里定义了队列深度、命令大小、完成回调等。blk-mq是块层的多队列框架,NVMe驱动直接复用了它,而不是自己造一套队列管理。
2.2 Admin队列的初始化:Identify命令的发送过程
Admin队列初始化完成后,驱动需要发送Identify命令来获取控制器的详细信息。这个过程在nvme_init_identify函数里。
Identify命令的发送流程是理解NVMe命令提交的绝佳入口,因为它走的是和普通I/O完全相同的路径,只是队列不同。具体步骤:
- 分配一个
nvme_command结构体,填充opcode为nvme_admin_identify,设置cns字段指定要获取的信息类型(比如NVME_ID_CNS_CTRL获取控制器信息) - 调用
nvme_submit_sync_cmd,这个函数会分配一个请求(request),把命令数据拷贝进去,然后提交到Admin队列 - 提交后进入等待,直到完成中断触发,唤醒等待队列
- 检查完成状态,如果成功则解析返回的数据
这里有个关键点:nvme_submit_sync_cmd是同步的,它会阻塞当前进程直到命令完成。而普通I/O走的是异步路径。但底层提交命令的函数是同一个——nvme_queue_rq。
我当初看这段代码时有个疑问:为什么Admin队列的命令要用同步方式?后来想明白了,初始化阶段本来就不追求性能,同步方式代码简单、不容易出错。而且Identify命令只在初始化时发几次,对性能没影响。
Identify返回的数据里,最重要的是struct nvme_id_ctrl和struct nvme_id_ns。前者包含控制器的队列数量、支持的命令集、电源状态等信息;后者包含Namespace的容量、LBA格式、端到端数据保护能力等。这些信息决定了后续I/O队列的创建数量和块设备的容量。
2.3 I/O队列的创建与CPU亲和性设置
Admin队列只有一个,但I/O队列可以有很多个。NVMe驱动创建I/O队列的数量取决于两个因素:硬件支持的最大队列数(从CAP寄存器读)和系统CPU数量。
dev->nr_allocated_queues = nvme_max_io_queues(dev) + 1;这个+1是给Admin队列留的位置。实际创建的I/O队列数量在nvme_setup_io_queues里确定,它会尝试创建和CPU数量相同的队列,但如果硬件不支持那么多,就按硬件上限来。
每个I/O队列都会绑定到一个CPU上,这是通过blk_mq_map_queues完成的。默认情况下,队列和CPU是一对一的映射关系。这样当某个CPU上的进程发起I/O时,命令会提交到该CPU对应的队列上,减少跨CPU的锁竞争和缓存失效。
但这里有个实际中容易踩的坑:队列数量和CPU数量的关系不是简单的相等。如果CPU有超线程,比如32核64线程,NVMe驱动默认会创建32个队列还是64个?答案是取决于内核版本和配置。较新的内核会考虑CPU拓扑,把同一个物理核上的超线程映射到同一个队列,避免两个线程争抢同一个队列的锁。
我在一台64线程的机器上实测过,如果队列数设成64,4K随机读的IOPS比设成32时低了大约8%。原因就是超线程竞争同一个队列的提交锁。后来改成32个队列,每个物理核一个,性能反而更好。这个细节在文档里基本不会提,但实际调优时很关键。
3. 命令提交路径:从blk-mq到硬件队列门铃
3.1 块层到NVMe驱动的交接点:queue_rq回调
当应用层发起一个read系统调用,经过VFS、文件系统、块层之后,最终会到达blk-mq的blk_mq_submit_bio。blk-mq会根据bio的CPU和队列映射关系,选择一个硬件队列,然后调用该队列对应的queue_rq回调。
NVMe驱动的queue_rq回调是nvme_queue_rq。这个函数是命令提交路径的核心,它做的事情包括:
- 从blk-mq的tag池里获取一个tag(相当于请求ID)
- 分配或复用一个
nvme_request结构体 - 调用
nvme_setup_rw根据bio的方向(读/写)和操作类型(普通I/O、flush、discard等)填充NVMe命令 - 把命令的DMA地址写入Submission Queue Entry(SQE)
- 更新SQ的tail doorbell寄存器,通知硬件有新命令
我重点说一下nvme_setup_rw,因为这里涉及NVMe命令格式的细节。一个NVMe读写命令的SQE是64字节,里面包含:
- Command Identifier(CID):命令的唯一标识,完成时硬件会带回这个值
- Namespace Identifier(NSID):指定操作哪个Namespace
- Starting LBA:起始逻辑块地址
- Number of Logical Blocks:操作的逻辑块数量
- Data Pointer:数据缓冲区的DMA地址,可以是PRP(Physical Region Page)或SGL(Scatter Gather List)
PRP和SGL的选择是NVMe驱动里一个有意思的地方。PRP适合物理内存连续或少量离散的情况,SGL适合大量离散的DMA段。驱动会根据nvme_ctrl里的sgll标志和实际DMA映射的结果来决定用哪种。大多数消费级NVMe盘只支持PRP,企业级盘通常两者都支持。
3.2 PRP与SGL的抉择:数据缓冲区映射的底层逻辑
PRP的结构很简单:如果数据不超过一个页(通常4KB),PRP1直接指向数据缓冲区,PRP2为0。如果数据超过一个页但不超过两个页,PRP1指向第一页,PRP2指向第二页。如果超过两个页,PRP1指向一个PRP List,PRP List里再指向各个数据页。
SGL则更灵活,它是一个链表,每个节点描述一段数据的地址和长度。SGL支持更大的数据量和更复杂的分散聚合场景。
驱动里选择PRP还是SGL的逻辑在nvme_map_data函数里。它会先尝试用PRP映射,如果DMA映射返回的段数超过PRP能表达的范围(比如超过两个页且PRP List也不够用),就回退到SGL。但前提是控制器支持SGL。
这里有个实际经验:如果你的应用做的是大块顺序I/O,比如128KB以上的读写,SGL通常比PRP效率更高,因为SGL可以用更少的描述符表达更大的数据量。但如果是4K随机I/O,PRP和SGL的差异基本可以忽略,因为数据量本来就小。
我在测试中发现一个现象:某些企业级盘在SGL模式下,大块写的吞吐比PRP模式高了约5%到8%。但消费级盘因为不支持SGL,这个优化就用不上。所以选盘的时候,如果工作负载是大块顺序写,SGL支持是一个值得关注的规格。
3.3 Doorbell写入的时机与内存屏障
命令填充到SQE之后,驱动需要更新SQ的tail doorbell寄存器,告诉硬件"有新命令了"。这个操作在nvme_write_sq_db函数里。
Doorbell写入看起来简单——就是往一个MMIO地址写一个值。但这里有两个关键细节:
第一,写入doorbell之前必须确保SQE已经写入内存并对硬件可见。这需要内存屏障。x86架构下,MMIO写是有序的,但SQE是写在普通内存里的,CPU可能重排序。所以驱动在写doorbell之前会调用wmb()(写内存屏障),确保SQE的写入先于doorbell的写入到达内存控制器。
第二,doorbell写入的频率影响性能。如果每提交一个命令就写一次doorbell,当队列深度很大时,doorbell写入会成为瓶颈。所以NVMe驱动做了批量提交优化:在nvme_queue_rq里,如果当前已经提交了多个命令,驱动会延迟doorbell写入,等到一批命令都准备好了一次性更新。
这个批量提交的逻辑在nvme_process_cq和nvme_queue_rq的交互里。具体来说,驱动维护了一个sq_tail的软件影子变量,每次提交命令时递增这个变量,但只有当积累到一定数量或者队列快满时,才真正写doorbell。
我实测过这个优化的效果:在队列深度256、4K随机读的场景下,批量提交比每个命令都写doorbell的IOPS高了大约12%。这个数字在不同硬件上会有差异,但趋势是一致的。
4. 完成路径:中断处理与请求回收
4.1 MSI-X中断与完成队列的轮询
命令提交到硬件后,硬件执行完成后会把完成信息写入Completion Queue(CQ),然后触发中断。NVMe驱动使用MSI-X中断,每个I/O队列可以有自己的中断向量。
中断处理函数是nvme_irq,它做的事情很直接:读取CQ里的完成条目,根据CID找到对应的请求,调用blk_mq_complete_request通知块层请求完成。
但这里有个性能优化的关键点:中断聚合和轮询的平衡。如果每个命令完成都触发一次中断,高IOPS场景下中断开销会非常大。所以NVMe驱动支持中断聚合——硬件可以等待多个命令完成后再触发一次中断。
中断聚合的参数通过nvme_set_features的NVME_FEAT_IRQ_COALESCE来设置,包括聚合阈值(多少个完成条目后触发中断)和聚合时间(最长等待多久)。这两个参数的调优是NVMe性能调优里最常被讨论的话题之一。
我的经验是:对于延迟敏感的场景,聚合阈值设低一些(比如1到4),聚合时间设短一些(比如1到10微秒);对于吞吐敏感的场景,可以适当提高阈值和时间。但具体数值要看硬件实现,不同厂商的盘对聚合参数的响应差异很大。
4.2 完成条目的解析与请求状态更新
CQ里的完成条目是16字节,包含:
- Command Specific:命令相关的状态信息,比如读写命令的错误码
- Reserved
- SQ Head Pointer:SQ的头部指针,用于流控
- SQ Identifier:标识这个完成条目属于哪个SQ
- Command Identifier:对应SQE里的CID
- Status:完成状态,包括Phase Tag(P位)和状态码
Phase Tag是NVMe里一个巧妙的设计。CQ是一个环形缓冲区,硬件和驱动各自维护自己的指针。驱动怎么知道一个完成条目是新的还是上一轮的?就是靠Phase Tag。初始时Phase Tag为0,当CQ指针回绕一圈后,Phase Tag翻转为1。驱动检查Phase Tag是否和预期一致,就能判断条目是否有效。
这个机制避免了额外的计数器或标志位,很优雅。但在驱动实现里,Phase Tag的判断必须小心,因为如果判断错误,可能会读到过期的完成条目,导致请求状态错乱。
4.3 请求回收与tag释放的时机
请求完成后,驱动需要释放blk-mq的tag,让这个tag可以被后续请求复用。释放的时机在nvme_complete_rq里,它会调用blk_mq_end_request,最终触发tag的释放。
这里有个容易忽略的细节:tag的释放和请求内存的释放是两回事。tag只是一个整数标识,释放它只是把对应的位图位清零。而请求结构体(struct request)的内存是blk-mq预分配的,不会在每次请求完成时释放和重新分配。这种设计避免了频繁的内存分配开销。
NVMe驱动还维护了一个nvme_request结构体池,每个请求对应一个。这个池的大小等于队列深度。当请求完成时,nvme_request被放回池里,等待下一个请求复用。
我在看这段代码时注意到一个优化:nvme_request里有一个cmd字段,是内嵌的nvme_command结构体,而不是指针。这意味着命令数据是随请求一起分配的,不需要额外分配内存。这个设计减少了内存碎片和分配开销,是驱动性能优化的一个细节。
5. 几个实际调试中遇到的典型问题
5.1 队列深度设置不当导致的性能悬崖
有一次在一台服务器上测试,发现4K随机写的IOPS只有预期的一半。查了半天,最后发现是队列深度设成了1024,但硬件的CQ深度只有256。驱动在初始化时应该检测到这个问题并调整,但那个版本的驱动有个bug,没有正确读取CAP寄存器里的CQ深度限制。
结果就是:驱动以为可以提交1024个命令,但硬件只能处理256个。当提交超过256个命令后,硬件不再接受新命令,驱动进入忙等,性能急剧下降。
修复方法很简单:在nvme_setup_io_queues里正确读取CAP寄存器的MQES字段(Maximum Queue Entries Supported),确保队列深度不超过这个值。但这个问题提醒我,队列深度不是越大越好,必须和硬件能力匹配。
5.2 中断亲和性配置错误引发的CPU热点
另一个案例:一台双路服务器,NVMe盘插在CPU0的PCIe插槽上,但中断却分配到了CPU1。结果所有I/O完成中断都在CPU1上处理,而命令提交在CPU0上,跨CPU的缓存同步开销导致延迟增加了30%。
解决方法是通过/proc/irq/*/smp_affinity把中断亲和性设置到和PCIe插槽相同的CPU上。但更好的方法是在驱动初始化时就正确设置MSI-X的亲和性,这需要驱动和PCIe拓扑的配合。
这个问题的根源在于:NVMe驱动的中断分配默认是轮询的,不考虑PCIe设备的物理位置。在高性能场景下,手动调整中断亲和性是必要的。
5.3 Admin队列命令超时的排查思路
Admin队列的命令超时通常意味着控制器出了问题。我遇到过一次:系统启动时卡在Identify命令,dmesg里显示"nvme nvme0: I/O 0 QID 0 timeout"。
排查步骤:
- 检查PCIe链路状态:
lspci -vv看LnkSta是否正常 - 检查控制器寄存器:通过
setpci读取CC和CSTS寄存器,看控制器是否处于就绪状态 - 检查Admin队列的doorbell是否被正确写入:在驱动里加printk,确认
nvme_write_sq_db被调用 - 检查中断是否触发:
cat /proc/interrupts看NVMe中断计数是否增加
那次最后发现是PCIe链路训练失败,设备虽然被枚举到了,但BAR空间映射后读写全是0xFFFFFFFF。这种硬件层面的问题,软件层面再怎么调都没用。
6. 从源码流程中提炼的调优检查清单
基于上面这些分析,我整理了一个NVMe调优的检查清单,按优先级排序:
| 检查项 | 推荐值/方法 | 影响 |
|---|---|---|
| 队列数量 | 等于物理核数,非超线程数 | 减少锁竞争 |
| 队列深度 | 不超过硬件MQES,通常256-1024 | 避免硬件溢出 |
| 中断亲和性 | 绑定到PCIe插槽同侧CPU | 降低跨CPU延迟 |
| 中断聚合 | 延迟敏感:阈值1-4,时间1-10us | 平衡延迟和CPU占用 |
| PRP/SGL | 大块I/O优先SGL(如果支持) | 提高吞吐 |
| Doorbell批量 | 保持驱动默认,不要强制每命令写 | 减少MMIO开销 |
这个清单不是万能的,不同硬件和工作负载的最佳配置差异很大。但如果你遇到性能问题,按这个顺序排查,通常能快速定位到方向。
最后说一个我自己的体会:看NVMe源码最大的收获不是记住某个函数,而是理解设计者的取舍。比如为什么Admin队列用同步而I/O队列用异步,为什么CQ用Phase Tag而不是计数器,为什么doorbell要批量写。这些取舍背后是对性能、复杂度、可靠性的平衡。理解了这些,再看其他存储驱动或者自己写驱动时,就能做出更合理的决策。
下一篇我打算分析NVMe的Timeout处理和错误恢复流程,那块涉及的重试机制和控制器复位逻辑也很有意思。如果你在实际中遇到过NVMe相关的诡异问题,欢迎交流。