恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
NVMe驱动源码深度解析:从PCIe初始化到IO队列与请求处理全流程
首页
资讯中心
/
NVMe驱动源码深度解析:从PCIe初始化到IO队列与请求处理全流程
NVMe驱动源码深度解析:从PCIe初始化到IO队列与请求处理全流程
发布时间:2026/10/22 5:30:17
1. 为什么值得花时间啃 NVMe 源码存储栈这东西平时写业务代码基本碰不到可一旦线上出现 IO 抖动、盘掉速、队列打满你往上翻日志翻到blk_mq那一层就懵了。我最早接触 NVMe 是因为一块企业级 SSD 在压测时出现间歇性超时iostat看着 util 不高但await飙到几十毫秒厂商说盘没问题内核社区说驱动没问题最后只能自己下源码一行行看。那次之后我才意识到NVMe 驱动是块硬骨头但啃下来之后整个块层的任督二脉就通了。这篇是系列的第一篇不打算一上来就扎进nvme_queue_rq的细节里而是先把整个 NVMe 驱动的骨架搭起来它怎么初始化、怎么和 PCIe 总线挂钩、怎么把块层的请求接过来、命令队列是怎么组织的。这些搞清楚了后面看 IO 路径、超时处理、多队列调度才有落脚点。适合谁看写过驱动、懂点 PCIe 基础、能看懂probe和remove那套流程的人。如果你连struct device_driver都没见过建议先去补一下 Linux 设备模型不然会看得很痛苦。我用的内核版本是 6.x 系列不同版本之间 NVMe 驱动的目录结构有调整比如早期nvme-core.c是个大杂烩后来拆成了core.c、pci.c、rdma.c、fabrics.c等。你手上的源码如果对不上先确认版本别硬套。下面所有分析都基于drivers/nvme/host/这个目录这是 NVMe over PCIe 的主战场。2. NVMe 驱动的整体架构与模块拆分2.1 从目录结构看设计意图打开drivers/nvme/host/你会看到这么一堆文件core.c、pci.c、rdma.c、fc.c、tcp.c、fabrics.c、multipath.c、zns.c等等。这个拆分不是随便拍的它背后有一条清晰的分层逻辑。core.c是核心中的核心它不关心底层是 PCIe 还是 RDMA只负责 NVMe 协议本身的实现命令的构造、队列的创建与销毁、请求的提交与完成、超时处理、错误恢复。你可以把它理解成“NVMe 协议引擎”。而pci.c是传输层专门处理 PCIe 相关的寄存器映射、中断申请、DMA 地址设置。rdma.c、tcp.c、fc.c则是另外几种传输方式对应 NVMe over Fabrics 的场景。这种分层的好处是协议逻辑只写一遍换传输方式只需要换底层实现。我当年看的时候就在想如果让我来设计大概率会把所有东西塞一个文件里然后被 review 的人骂死。内核这套拆分方式值得每个写驱动的人学。还有一个文件叫nvme.h这是整个驱动的头文件里面定义了struct nvme_dev、struct nvme_queue、struct nvme_command这些关键数据结构。看源码先看头文件这是我一直以来的习惯数据结构搞清楚了函数在干什么基本就猜到了。2.2 核心数据结构速览在深入流程之前有几个结构体必须先混个脸熟不然看代码就是看天书。struct nvme_dev是整个设备的抽象一个 NVMe 盘对应一个。它里面挂着struct nvme_queue数组也就是 IO 队列集合还有struct nvme_ctrl这是控制器层面的抽象负责和硬件打交道另外还有bar指针指向映射后的 PCIe BAR 空间读写寄存器全靠它。struct nvme_queue是队列的抽象每个队列有自己的提交队列SQ和完成队列CQ。SQ 是主机写给盘的命令CQ 是盘写完返回的结果。这两个队列都在主机内存里通过 DMA 地址告诉盘。队列的深度、门铃寄存器偏移、中断向量都挂在这个结构里。struct nvme_command是命令的载体64 字节NVMe 协议规定死了。里面有个common子结构包含操作码、命令 ID、命名空间 ID 等。不同的命令读、写、管理会复用这 64 字节通过操作码区分。struct nvme_request是块层请求和 NVMe 命令之间的桥梁。块层扔过来一个struct requestNVMe 驱动把它包装成nvme_request再填充nvme_command最后写入 SQ。这几个结构体之间的关系我画不出图也不让画但你可以在脑子里这么串块层请求 -nvme_request-nvme_command- 写入 SQ - 按门铃 - 盘处理 - 写 CQ - 中断 - 完成处理 - 结束请求。整条链路就通了。2.3 模块初始化入口NVMe over PCIe 的入口在pci.c里通过module_init注册。核心动作是调用pci_register_driver把nvme_pci_driver这个结构体注册到 PCIe 总线上。这个结构体里最关键的是id_table和probe回调。id_table是一张匹配表里面列了所有支持的厂商 ID 和设备 ID。当内核枚举到一个 PCIe 设备发现它的 ID 在表里就会调用对应的probe函数。这个机制是 PCIe 设备驱动的标准玩法NVMe 也不例外。probe函数叫nvme_probe它做的事情大致是使能设备、映射 BAR、初始化控制器、创建队列、注册块设备。每一步都可能失败失败就要回滚所以你会看到一堆goto和错误处理标签。这种写法在内核里很常见虽然看起来乱但比一层层嵌套 if 要清晰。我踩过的一个坑是有些主板 BIOS 里 NVMe 控制器默认是关闭的或者被配置成了 RAID 模式这时候probe根本不会被调用你在系统里看不到盘。遇到这种情况先去 BIOS 里把存储模式改成 AHCI 或 NVMe 直通别一上来就怀疑驱动。3. 控制器初始化从 PCIe 枚举到寄存器映射3.1 probe 函数的执行脉络nvme_probe一进来先调用pci_enable_device_mem这是让 PCIe 设备响应内存空间访问。然后pci_request_mem_regions申请 BAR 资源防止别的驱动来抢。接着dma_set_mask设置 DMA 掩码告诉内核这块设备能访问多大的物理地址范围。NVMe 盘一般支持 64 位 DMA所以会设成DMA_BIT_MASK(64)。再往下是nvme_dev_map这个函数负责把 PCIe BAR 映射到内核虚拟地址空间。映射完之后dev-bar就指向了 NVMe 控制器的寄存器区域。这块区域里有一堆寄存器比如 CAP、VS、CC、CSTS、AQA、ASQ、ACQ还有每个队列的门铃寄存器。门铃寄存器的偏移不是固定的要根据 CAP 寄存器里的 DSTRD 字段算出来。我见过有人直接硬编码门铃偏移结果换了个盘就挂了。正确做法是读 CAP 寄存器取 DSTRD 字段然后按(1 DSTRD) * 4的步长去算每个队列的门铃地址。这个细节在nvme_dev_map后面的代码里有体现看的时候别跳过。3.2 控制器复位与使能映射完 BAR 之后驱动会调用nvme_reset_ctrl或类似的流程来复位控制器。复位的动作是写 CC 寄存器的 EN 位为 0然后等 CSTS 寄存器的 RDY 位变成 0。这一步是告诉盘“我要重新配置你了先停下来。”复位完成后驱动开始配置管理队列。管理队列是一对特殊的 SQ/CQ用来发管理命令比如识别控制器、创建 IO 队列、获取日志页。管理队列的深度一般是 32 或 64通过 AQA 寄存器设置。ASQ 和 ACQ 寄存器分别写入管理 SQ 和 CQ 的物理地址。配置完管理队列驱动把 CC 寄存器的 EN 位设为 1等 CSTS 的 RDY 位变成 1。这时候控制器就算正式上线了可以开始发管理命令。这里有个细节等待 RDY 位的时候驱动用的是轮询加超时不是睡眠等待。因为这时候中断还没申请没法用中断唤醒。超时时间一般是几秒如果超了就认为控制器挂了probe 失败。我在一块杂牌盘上见过 RDY 一直不置位的情况最后查出来是盘的固件有 bug换盘解决。3.3 识别控制器与命名空间控制器上线后第一件事是发 Identify 命令获取控制器的能力信息。这个命令返回的数据结构叫struct nvme_id_ctrl里面包含了队列数量、命令集支持情况、命名空间数量等关键信息。驱动会根据返回的队列数量决定创建多少个 IO 队列。NVMe 协议规定队列数量是 0-based 的也就是说如果返回 3实际有 4 个队列。驱动一般会结合 CPU 数量和io_queue_depth参数来决定最终创建多少个。我一般会把队列数设成和 CPU 核数一样这样每个核有自己的队列减少锁竞争。接下来是 Identify Namespace 命令获取命名空间的信息比如容量、扇区大小、支持的读写命令等。一个控制器可以有多个命名空间每个命名空间对应一个块设备。驱动会遍历所有命名空间为每个活跃的命名空间创建一个gendisk。这里有个坑有些盘的命名空间是动态的比如 NVMe ZNS 或者某些企业盘支持命名空间管理。如果你在系统运行过程中创建或删除命名空间驱动需要处理nvme_ns_add和nvme_ns_remove事件。这部分逻辑在core.c里看的时候注意nvme_scan_ns_list和nvme_validate_ns这两个函数。4. 队列创建与 IO 路径的衔接4.1 管理队列与 IO 队列的区别管理队列只有一对全局共享用来发管理命令。IO 队列可以有多对每个队列绑定一个 CPU 或者一组 CPU用来发读写命令。管理队列的深度一般比较小IO 队列的深度可以很大常见的是 1024 或 2048。创建 IO 队列用的是Create IO Completion Queue和Create IO Submission Queue这两个管理命令。驱动先创建 CQ再创建 SQ因为 SQ 需要关联到某个 CQ。每个 CQ 可以关联多个 SQ但一个 SQ 只能关联一个 CQ。这种设计是为了灵活分配中断资源。中断这块NVMe 支持 MSI-X每个 CQ 可以有自己的中断向量。驱动在创建 CQ 的时候会申请一个中断向量并把中断处理函数注册上去。中断处理函数叫nvme_irq它做的事情很简单读 CQ 的头部处理完成项然后写 CQ 的门铃告诉盘“我处理完了”。我见过有人把中断处理写得很重在里面做大量计算结果中断延迟飙升。NVMe 的中断处理应该尽量轻把耗时的活儿丢到软中断或者工作队列里。内核的blk_mq框架已经帮你做了这件事你只需要调用blk_mq_complete_request就行。4.2 请求的提交过程块层把请求扔给 NVMe 驱动入口是nvme_queue_rq。这个函数先检查队列是否活跃然后调用nvme_setup_cmd来填充nvme_command。填充的内容包括操作码、命名空间 ID、LBA、数据长度、DMA 地址等。数据长度这块要注意NVMe 命令里的长度字段是 0-based 的也就是说如果你要传 1 个块字段填 0传 2 个块填 1。这个坑我踩过当时传了个 0结果盘认为我要传 1 个块数据对不上读出来的全是垃圾。填充完命令驱动把命令写入 SQ 的下一个空闲槽位然后写 SQ 的门铃寄存器通知盘“有新命令了”。门铃寄存器的值就是 SQ 的尾部指针。盘收到通知后会从 SQ 里取命令执行然后把结果写到 CQ。这里有个内存屏障的问题。写命令数据和写门铃之间必须有一个写屏障保证盘看到门铃的时候命令数据已经写入内存了。内核里用的是wmb()或者dma_wmb()。如果你自己写驱动忘了这个屏障可能会出现盘读到旧数据的情况而且这种 bug 极难复现。4.3 请求的完成过程盘写完 CQ 后会触发中断。中断处理函数nvme_irq读取 CQ 的当前头部然后逐个处理完成项。每个完成项里有命令 ID、状态码、SQ 头部等信息。驱动根据命令 ID 找到对应的nvme_request检查状态码如果成功就调用blk_mq_complete_request结束请求如果失败就进入错误处理流程。错误处理这块比较复杂简单的错误比如NVME_SC_ABORT_REQ会触发重试复杂的错误比如NVME_SC_NS_NOT_READY会触发控制器复位。我在实际项目中遇到过盘返回NVME_SC_INTERNAL的情况驱动会走nvme_reset_ctrl流程把整个控制器复位一遍。这个过程会导致所有未完成的 IO 失败上层应用如果没做好重试就会报错。处理完所有完成项后驱动写 CQ 的门铃寄存器告诉盘“我处理到这儿了”。这个门铃的值是 CQ 的头部指针。盘会根据这个指针来判断 CQ 是否满了如果满了盘会暂停写完成项直到驱动消费掉一些。5. 常见问题与排查技巧实录5.1 盘识别不到怎么办这是最常见的问题。先看lspci能不能看到设备如果看不到那是 PCIe 链路的问题跟驱动无关。检查 BIOS 设置、插槽、供电。如果lspci能看到但lsblk看不到盘那就看dmesg里有没有 NVMe 相关的报错。常见的报错有nvme: probe failed、nvme: controller is not ready、nvme: failed to allocate queues。probe failed一般是 BAR 映射失败或者寄存器读写异常controller is not ready是 RDY 位没置起来可能是盘固件问题failed to allocate queues是内存不够或者队列数量超了。我遇到过一次probe failed查了半天发现是 BIOS 里给 PCIe 插槽分配的 MMIO 空间太小BAR 映射不进去。后来在 BIOS 里把 Above 4G Decoding 打开就好了。这种问题没有通用解法只能一个个排查。5.2 IO 性能不达预期如果盘能识别但性能很差先看队列数量。cat /sys/block/nvme0n1/queue/nr_requests看队列深度ls /sys/block/nvme0n1/mq/看队列数量。如果队列数量是 1那性能肯定上不去。检查内核启动参数里有没有nvme.io_queues的限制或者 CPU 数量是不是太少。再看中断亲和性。cat /proc/interrupts | grep nvme看每个队列的中断落在哪个 CPU 上。如果所有中断都落在 CPU0那 CPU0 会成为瓶颈。可以用irqbalance或者手动设置/proc/irq/*/smp_affinity来分散中断。还有一个容易被忽略的点是 IO 调度器。NVMe 盘建议用none调度器因为盘本身有很强的并发处理能力调度器反而会增加延迟。echo none /sys/block/nvme0n1/queue/scheduler可以切换。5.3 超时与复位问题NVMe 驱动有超时机制默认是 30 秒。如果命令在 30 秒内没完成驱动会触发超时处理尝试 abort 命令如果 abort 失败就复位控制器。复位会导致所有 IO 失败上层应用会看到大量错误。如果你经常遇到超时先看盘的 SMART 信息有没有介质错误或者温度过高。nvme smart-log /dev/nvme0可以看。如果盘没问题那可能是驱动或者内核的 bug升级内核试试。我遇到过一次超时是因为队列深度设得太大盘的处理能力跟不上导致命令堆积。后来把队列深度从 2048 降到 1024问题就消失了。所以队列深度不是越大越好要根据盘的实际能力来调。5.4 常见问题速查表问题现象可能原因排查方法解决思路lspci 看不到设备PCIe 链路故障检查 BIOS、插槽、供电换插槽、开 Above 4G能看到设备但无块设备probe 失败dmesg 看报错根据报错定位可能是 BAR 或固件队列数量为 1内核参数限制看 nr_requests 和 mq 目录调整启动参数或 CPU 数量中断集中在 CPU0亲和性未设置/proc/interrupts设置 smp_affinityIO 延迟高调度器不合适看 scheduler切换为 none频繁超时复位队列深度过大或盘故障smart-log降低队列深度或换盘6. 几个容易被忽略的细节6.1 门铃寄存器的写顺序写门铃的时候一定要保证命令数据已经写入内存。内核里用的是writel加dma_wmb顺序不能反。我见过有人先写门铃再写数据结果盘读到空命令直接报错。这种 bug 在 x86 上可能因为强内存模型而不出现但换到 ARM 上就必现。6.2 队列内存的对齐要求NVMe 协议要求 SQ 和 CQ 的基地址必须按页对齐队列深度也必须是 2 的幂。驱动在分配队列内存的时候用的是dma_alloc_coherent这个函数返回的地址本身就是对齐的。但如果你自己写分配逻辑忘了对齐盘会拒绝创建队列。6.3 命令 ID 的分配每个命令都有一个唯一的 ID驱动用这个 ID 来匹配完成项。ID 的分配用的是ida_alloc或者类似的机制。如果 ID 用完了新的请求就会被阻塞。所以队列深度和 ID 数量要匹配不能队列很深但 ID 很少。6.4 控制器的多路径支持企业级场景下一个 NVMe 盘可能有多条 PCIe 路径。驱动通过multipath.c来管理多路径把同一个命名空间的多个路径聚合成一个块设备。这部分逻辑比较复杂涉及路径切换和故障转移。如果你只是用消费级盘可以跳过这块。7. 我个人在实际操作中的体会看 NVMe 源码这件事最大的收获不是记住了多少函数名而是理解了块层和驱动之间的契约。块层负责调度和合并请求驱动负责把请求翻译成硬件能懂的命令。这个边界一旦搞清楚你看其他块设备驱动也会快很多。我建议看源码的时候别一上来就追nvme_queue_rq的每一行。先把probe流程走一遍把设备怎么起来的搞清楚。然后看管理命令怎么发队列怎么建。最后再看 IO 路径。这个顺序符合硬件初始化的自然流程也符合人的认知规律。还有一点源码里的注释很少但Documentation/nvme/目录下有协议文档的摘要值得一读。另外 NVMe 规范的公开版本也可以找来对照看虽然很长但关键章节比如队列机制、命令格式看一遍比看代码猜要快。最后分享一个小技巧如果你有虚拟机环境可以用 QEMU 模拟 NVMe 盘然后在 guest 里加载驱动加printk调试。这样比在物理机上折腾安全得多也不怕把盘搞挂。QEMU 的 NVMe 模拟支持比较完整队列、中断、DMA 都能模拟是学习驱动的好工具。