
1. 为什么说 NVMe 是存储驱动开发的“新手村”搞存储驱动开发最怕的就是一上来就啃硬骨头。SCSI、SAS、FC 这些传统协议栈光是协议文档就够你看半年再加上各种历史包袱和厂商私有扩展新手很容易从入门到放弃。而 NVMe 不一样它从诞生之初就是为闪存设计的协议简洁、队列模型清晰、寄存器定义规整最关键的是——它跑在 PCIe 上而 PCIe 的枚举和配置机制是标准化的你不需要跟各种私有总线打交道。我当初从 U-Boot 里第一次接触 NVMe 驱动的时候最大的感受就是“清爽”。整个初始化流程就是PCIe 枚举找到设备、映射 BAR 空间、配置 Admin 队列、发 Identify 命令拿命名空间信息、再建 IO 队列。就这么几步没有隐藏的中间层没有莫名其妙的兼容性分支。你完全可以在一个下午的时间里把从 PCIe 设备发现到读写第一个块的完整链路跑通。这篇文章适合谁看如果你已经写过简单的字符设备驱动对 Linux 内核的模块机制有基本了解想往块设备或者存储子系统方向深入那 NVMe 就是最好的切入点。如果你是在做嵌入式开发需要在 U-Boot 阶段就支持 NVMe 启动那这篇文章里的实操步骤可以直接参考。甚至如果你只是好奇“/dev/nvme0n1p5 到底代表什么”我也会从命名规则开始讲清楚。提示本文涉及的代码和操作基于 Linux 5.x 内核和 U-Boot 2020.04 以上版本不同版本之间 API 可能有细微差异但核心逻辑是通用的。2. NVMe 协议栈的整体设计与核心思路拆解2.1 从 PCIe 枚举到 NVMe 设备识别的完整链路NVMe 设备本质上是一个 PCIe 端点设备Endpoint所以你要做的第一件事就是让系统能够发现它。在 Linux 内核启动过程中PCIe 子系统会扫描总线读取每个设备的配置空间根据 Class Code 来匹配驱动。NVMe 设备的 Class Code 是 0x010802这个值在 PCIe 配置空间的偏移 0x0B 处。整个链路的层次关系是这样的最底层是 PCIe 物理层和链路层负责数据包的可靠传输往上是 PCIe 配置空间和 BAR 映射让 CPU 能够访问设备的寄存器再往上才是 NVMe 控制器寄存器CAP、VS、CC、CSTS 等这些寄存器通过 BAR0 映射到内存空间最上层是 NVMe 队列机制包括 Admin 队列和 IO 队列通过提交队列SQ和完成队列CQ来实现命令的下发和完成通知。为什么 NVMe 要用队列而不是传统的寄存器直接读写因为闪存的并行性太强了。一个 NVMe 设备可以支持最多 64K 个 IO 队列每个队列深度可以达到 64K。这种设计让多核 CPU 可以各自使用独立的队列完全避免锁竞争。相比之下传统的 SATA/AHCI 只有一个命令队列深度只有 32在高并发场景下就是瓶颈。2.2 队列机制Admin 队列与 IO 队列的分工NVMe 的队列分为两类Admin 队列和 IO 队列。Admin 队列在控制器初始化阶段就存在每个控制器只有一个 Admin 提交队列和一个 Admin 完成队列。它的作用是处理管理类命令比如 Identify、Get/Set Features、Create/Delete IO 队列等。IO 队列则是驱动在初始化完成后动态创建的用于处理实际的读写命令。队列的内存布局是环形缓冲区。每个队列由两部分组成提交队列SQ和完成队列CQ。SQ 里存放的是命令Command每个命令 64 字节CQ 里存放的是完成条目Completion Entry每个 16 字节。驱动写 SQ 的尾门铃Tail Doorbell通知设备有新命令设备处理完后写 CQ并触发中断或者更新相位位Phase Bit来通知驱动。这里有个关键细节CQ 的相位位机制。设备在写 CQ 条目时会翻转相位位驱动通过比较相位位来判断这个条目是新完成的还是上一轮遗留的。这个设计避免了额外的寄存器读写非常巧妙。我在调试的时候曾经因为相位位判断逻辑写错导致驱动一直认为没有新完成卡死在轮询循环里。后来用逻辑分析仪抓了 PCIe 事务才定位到问题。2.3 为什么选择在 U-Boot 阶段就打通 NVMe很多人觉得 U-Boot 里的驱动只要能读内核和设备树就行了没必要做完整的 NVMe 支持。但实际项目中尤其是嵌入式场景U-Boot 阶段的 NVMe 驱动质量直接影响启动可靠性和调试效率。你想想如果 U-Boot 里 NVMe 读写不稳定内核加载到一半失败你连内核日志都看不到只能靠串口打印猜问题。U-Boot 的 NVMe 驱动结构比 Linux 内核简单得多它只实现了最基础的功能初始化控制器、创建一对 Admin 队列、创建一对 IO 队列、实现块读写。没有复杂的电源管理、没有多队列调度、没有热插拔支持。但正是这种简单让它成为理解 NVMe 协议的最佳入口。你可以把 U-Boot 的 NVMe 驱动代码完整读一遍也就两三千行比 Linux 内核的 NVMe 驱动少了将近一个数量级。3. 核心细节解析与实操要点3.1 PCIe 配置空间的关键寄存器与 BAR 映射在写 NVMe 驱动之前你必须先搞清楚 PCIe 配置空间里哪些寄存器是必须关注的。对于 NVMe 设备来说最重要的几个字段是配置空间偏移字段名称作用0x00Vendor ID厂商 ID用于识别设备来源0x02Device ID设备 ID配合 Vendor ID 唯一标识0x0BClass Code0x010802 表示 NVMe 控制器0x10BAR0映射 NVMe 控制器寄存器0x3CInterrupt Line中断号分配0x3EInterrupt Pin中断引脚BAR0 是最关键的。NVMe 规范要求 BAR0 必须是一个 64 位内存 BAR大小至少 16KB。在驱动初始化时你需要读取 BAR0 的基地址然后通过 ioremap 或者类似的机制把它映射到内核虚拟地址空间。映射之后你就可以通过读写这些虚拟地址来访问 NVMe 控制器寄存器了。注意BAR 映射的时候一定要检查 BAR 的类型和大小。我曾经遇到过一块 NVMe 盘它的 BAR0 报告大小是 16KB但实际只实现了前 4KB 的寄存器后面的地址访问会返回全 F。如果你不检查就直接读写可能会拿到错误的数据。3.2 NVMe 控制器寄存器详解与初始化流程NVMe 控制器寄存器位于 BAR0 空间偏移从 0x00 开始。核心寄存器包括CAPController Capabilities偏移 0x0064 位报告控制器的能力比如最大队列深度、支持的命令集、超时时间等。VSVersion偏移 0x0832 位报告 NVMe 协议版本。CCController Configuration偏移 0x1432 位驱动写这个寄存器来配置控制器比如使能、设置队列深度、选择命令集。CSTSController Status偏移 0x1C32 位报告控制器状态比如是否就绪、是否有致命错误。AQAAdmin Queue Attributes偏移 0x2432 位设置 Admin 队列的深度。ASQAdmin Submission Queue Base Address偏移 0x2864 位Admin 提交队列的基地址。ACQAdmin Completion Queue Base Address偏移 0x3064 位Admin 完成队列的基地址。初始化流程按顺序来先读 CAP 确认控制器支持的特性然后等 CSTS.RDY 为 0表示控制器未就绪接着配置 AQA、ASQ、ACQ设置 CC 的 EN 位为 1最后轮询 CSTS.RDY 直到变为 1。这个过程看起来简单但每一步都有坑。比如 AQA 的设置Admin 队列深度是 2 的幂次方最小值是 2。你写 AQA 的时候要写深度减一。我见过有人直接写深度值结果设备解析出来的队列深度是预期值的两倍导致内存越界。还有 CC 寄存器的 IOSQES 和 IOCQES 字段分别表示 IO 提交队列条目大小和 IO 完成队列条目大小必须是 2 的幂次方通常 SQ 是 664 字节CQ 是 416 字节。3.3 命令下发与完成处理的完整流程NVMe 的命令格式是 64 字节分为通用命令和命令特定字段。以读命令为例你需要填充以下字段CDW0操作码Opcode放在低 8 位比如 0x02 表示读命令FUSE 和 CID 放在高位。NSID命名空间 ID指定你要读哪个命名空间。CDW10-CDW11起始逻辑块地址SLBA64 位。CDW12逻辑块数量NLB低 16 位有效表示要读多少个块。PRP1/PRP2物理区域页Physical Region Page描述数据缓冲区在物理内存中的位置。PRP 机制是 NVMe 的一个特色。它不像传统 DMA 那样要求缓冲区连续而是用 PRP 列表来描述分散的内存页。PRP1 直接指向第一个页PRP2 可以指向第二个页也可以指向一个 PRP 列表。对于大块数据传输PRP 列表可以链接多个页非常灵活。命令下发后设备会处理并写完成队列。完成条目里的 Status 字段表示命令执行结果Phase Bit 表示这是新完成还是旧完成。驱动需要轮询或者等待中断然后检查 Phase Bit读取 Status最后更新 CQ 的头指针Head Doorbell。提示在 U-Boot 阶段通常用轮询模式因为中断子系统还没初始化。轮询的时候要注意加超时不然设备出问题的时候会死循环。我一般设置超时时间为 1 秒超过就报错返回。4. 实操过程与核心环节实现4.1 在 U-Boot 中添加 NVMe 驱动支持U-Boot 的 NVMe 驱动代码位于 drivers/nvme/ 目录下。如果你用的是比较新的 U-Boot 版本NVMe 支持已经默认包含了你只需要在配置文件里打开 CONFIG_NVME 和 CONFIG_NVME_PCI 两个宏。然后确保 PCIe 控制器驱动已经就绪因为 NVMe 驱动依赖 PCIe 枚举来发现设备。编译烧录之后在 U-Boot 命令行里执行nvme scan如果一切正常你会看到类似这样的输出NVMe device found: 0 Vendor ID: 0x144d Device ID: 0xa808 Namespace 1: 256 GB然后就可以用nvme read和nvme write命令来读写数据了。比如nvme read 0x80000000 0 1表示从命名空间 0 的 LBA 0 读取 1 个块到内存地址 0x80000000。但实际项目中你往往需要在代码里直接调用 NVMe 驱动接口而不是通过命令行。U-Boot 提供了nvme_read()和nvme_write()两个函数你可以在 board 初始化代码或者启动脚本里调用它们。这里有个细节U-Boot 的 NVMe 驱动在第一次访问设备时会自动初始化控制器但如果你在 PCIe 枚举之前就调用会返回 -ENODEV。所以确保调用顺序正确。4.2 Linux 内核 NVMe 驱动的编译与加载Linux 内核的 NVMe 驱动分为两部分PCIe 部分nvme-core和块设备部分nvme。在 menuconfig 里你需要打开Device Drivers - NVMe Support - NVM Express block device Device Drivers - NVMe Support - NVMe hardware support编译成模块的话你会得到 nvme.ko 和 nvme-core.ko。加载顺序是先 insmod nvme-core.ko再 insmod nvme.ko。加载成功后dmesg 里会看到类似这样的日志nvme nvme0: pci function 0000:01:00.0 nvme nvme0: 8/0/0 default/read/poll queues nvme nvme0: 1 namespace, 256 GB这时候 /dev/nvme0n1 就出现了。如果你对 /dev/nvme0n1p5 这种命名感到困惑我解释一下nvme0 表示第一个 NVMe 控制器n1 表示该控制器下的第一个命名空间p5 表示该命名空间下的第 5 个分区。所以 /dev/nvme0n1p5 确实是第一个 NVMe 硬盘的第一个命名空间的第 5 个分区。注意这里“第一个 NVMe 硬盘”的说法其实不太准确因为一个 NVMe 控制器可以管理多个命名空间每个命名空间在逻辑上可以看作一个独立的“硬盘”。4.3 用 QEMU 搭建 NVMe 驱动调试环境如果你手头没有真实的 NVMe 硬件或者不想每次调试都重启物理机QEMU 是最好的选择。QEMU 从 2.6 版本开始就支持模拟 NVMe 设备你可以用下面的命令启动一个带 NVMe 盘的虚拟机qemu-system-x86_64 \ -m 2G \ -kernel bzImage \ -append root/dev/nvme0n1p1 consolettyS0 \ -drive filenvme.img,formatraw,ifnone,idnvmedrive \ -device nvme,drivenvmedrive,serialdeadbeef \ -nographic这里的 nvme.img 是你用 dd 命令创建的空文件大小随意。QEMU 会把它模拟成一个 NVMe 设备。你可以在 guest 系统里用 fdisk 分区、mkfs 格式化然后挂载读写。调试驱动的时候你可以在 QEMU 启动参数里加上-trace nvme*来打开 NVMe 相关的 trace观察命令的下发和完成过程。注意QEMU 模拟的 NVMe 设备在性能上和真实硬件差距很大尤其是队列深度和并发处理。如果你要测试性能相关的逻辑还是得上真机。但用来验证功能正确性QEMU 足够了。4.4 关键参数计算与配置实例NVMe 驱动初始化时有几个参数需要根据硬件能力来计算。以 Admin 队列深度为例CAP 寄存器里的 MQES 字段表示最大队列深度你需要取 MQES1 和你的实际需求之间的较小值。假设 MQES 是 1023你想要 64 的队列深度那 AQA 寄存器应该写AQA (64 - 1) 16 | (64 - 1)高 16 位是完成队列深度减一低 16 位是提交队列深度减一。为什么减一因为寄存器里存的是深度减一的值这样 0 就表示深度 1充分利用了寄存器空间。再比如 IO 队列的创建你需要发 Admin 命令 Create IO Submission Queue 和 Create IO Completion Queue。命令里的 QID 字段表示队列 ID从 1 开始分配0 被 Admin 队列占用。QSIZE 字段是队列深度减一。PC 位表示队列在内存里是否物理连续通常设为 1。QPRIO 表示队列优先级如果你不需要 QoS设 0 就行。5. 常见问题与排查技巧实录5.1 设备识别失败从 PCIe 枚举开始排查NVMe 设备识别失败是最常见的问题可能的原因从底层到上层依次是PCIe 链路没建立、配置空间读取失败、BAR 映射失败、控制器初始化超时。排查顺序也应该从底层开始。先看 PCIe 链路。在 Linux 下用lspci -vv查看设备是否出现在总线上。如果设备根本没出现那问题在 PCIe 物理层或者枚举阶段。检查 PERST 信号是否正常释放参考时钟是否稳定。我遇到过一块主板PCIe 时钟的对地电容焊错了导致时钟信号质量差设备时有时无。后来用示波器量了时钟眼图才确认。如果设备出现在 lspci 里但驱动没绑定检查 Class Code 是否正确。有些国产 NVMe 主控的 Class Code 不是标准的 0x010802而是 0x010801 或者其他值。这种情况下你需要手动添加设备 ID 到驱动里或者修改设备的配置空间。如果驱动绑定了但初始化超时重点看 CSTS.RDY 是否一直为 0。这通常是 CC.EN 写下去之后设备没有响应。检查 AQA、ASQ、ACQ 的地址是否对齐NVMe 规范要求这些地址必须 4KB 对齐。我见过有人用 kmalloc 分配队列内存结果地址没对齐设备直接罢工。5.2 读写超时与数据校验错误的处理读写超时通常和队列管理有关。先确认 CQ 的相位位判断逻辑是否正确。如果相位位判断反了驱动会认为没有新完成一直轮询直到超时。你可以加打印把每次读到的 CQ 条目的 Phase Bit 和预期值打出来对比。数据校验错误则可能是 PRP 配置有问题。检查 PRP1 和 PRP2 指向的物理地址是否正确数据缓冲区的大小是否和 NLB 匹配。NVMe 的块大小通常是 512 字节或者 4096 字节如果你按 512 字节算但设备实际是 4096 字节读出来的数据就会错位。用 Identify 命令读出来的 Namespace 信息里有 LBA Format 字段里面明确写了块大小。还有一个隐蔽的坑内存屏障。在写 SQ 尾门铃之前必须确保命令数据已经写入内存并且对设备可见。在 ARM 架构上你需要用wmb()或者dma_wmb()来保证写顺序。在 x86 上因为强内存模型通常不需要但为了可移植性还是加上为好。我曾经在 ARM 平台上调试因为少了内存屏障命令偶尔会丢失查了两天才定位到。5.3 常见问题速查表现象可能原因排查方法lspci 看不到设备PCIe 链路未建立检查 PERST、参考时钟、供电驱动不绑定Class Code 不匹配读配置空间 0x0B 偏移确认初始化超时队列地址未对齐确认 ASQ/ACQ 4KB 对齐读写超时相位位判断错误打印 CQ 条目的 Phase Bit数据错误PRP 配置错误检查 PRP 地址和块大小命令丢失缺少内存屏障在门铃写入前加 wmb()性能低下队列深度太小增大 AQA 和 IO 队列深度5.4 独家避坑经验分享第一个坑不要假设所有 NVMe 设备都支持相同的特性。CAP 寄存器里有很多能力位比如是否支持命令集、是否支持电源管理、是否支持热插拔。你的驱动应该根据 CAP 的值来动态配置而不是硬编码。我见过一个驱动在某个国产主控上跑得好好的换到另一块盘上就挂了就是因为硬编码了队列深度。第二个坑Admin 队列和 IO 队列的内存最好用 DMA 一致性内存分配。在 Linux 下用dma_alloc_coherent()在 U-Boot 下用memalign()加flush_dcache()。如果你用普通内存记得在每次命令下发前刷 cache完成后 invalidate cache。不然设备读到的命令可能是旧的或者驱动读到的完成条目是 cache 里的旧数据。第三个坑热插拔场景下设备移除时驱动要能正确处理。PCIe 热插拔会触发设备移除通知你的驱动需要停止所有队列、释放中断、清理资源。如果处理不当轻则内核报错重则系统崩溃。测试热插拔的时候建议在 QEMU 里先验证因为 QEMU 可以精确控制设备移除的时机。6. 从驱动开发延伸到系统集成6.1 在银河麒麟等国产系统上适配 NVMe 驱动国产操作系统如银河麒麟 V10 默认搭载的 Linux 内核版本可能是 4.19 或 5.4这些版本的内核 NVMe 驱动已经相当成熟通常不需要你从头写驱动。但如果你需要更换内核版本比如从 4.19 升级到 5.10NVMe 驱动的 API 可能有变化。主要关注点是块设备层的接口变化比如blk-mq的 API 在 5.x 里有调整。适配的时候先确认内核配置里 NVMe 相关选项是否打开。然后检查设备树或者 ACPI 表里 PCIe 控制器的配置是否正确。有些国产平台的 PCIe 控制器在 ACPI 表里没有正确描述 BAR 空间导致内核枚举失败。这种情况下你可能需要写一个 quirk 来修正。6.2 NVMe 启动盘在 Windows 系统下的驱动注入虽然这篇文章主要讲 Linux 和 U-Boot但很多读者也会遇到在 Windows 下安装系统时 NVMe 盘不被识别的问题。这是因为 Windows 安装镜像默认不包含 NVMe 驱动。解决方法是用 NTLite 或者 DISM 工具把 NVMe 驱动注入到安装镜像里。具体步骤是挂载 install.wim添加驱动包然后提交更改。注入的驱动可以从主板厂商官网下载或者从已经装好系统的机器上提取。提示注入驱动的时候要注意驱动版本和系统版本的匹配。32 位和 64 位的驱动不能混用不同 Windows 版本的驱动签名要求也不同。6.3 PCIe 拓扑对 NVMe 性能的影响NVMe 的性能不仅取决于盘本身还和 PCIe 拓扑密切相关。如果你把 NVMe 盘插在 PCIe Switch 后面而不是直接连在 CPU 的 PCIe 控制器上延迟会增加带宽也可能受限。用lspci -t可以查看 PCIe 拓扑树确认你的 NVMe 盘挂在哪个桥下面。另外PCIe 的链路宽度和速率也很关键。一块 PCIe 4.0 x4 的盘插在 PCIe 3.0 x2 的插槽上性能会打对折。用lspci -vv查看 LnkCap 和 LnkSta 字段确认协商出来的速率和宽度是否符合预期。如果协商结果低于预期检查插槽的物理连接和金手指是否干净。我在实际项目中遇到过一块盘标称 PCIe 4.0 x4但实际只协商到 x2。后来发现是主板 BIOS 里有个 PCIe 拆分选项设错了把 x4 的插槽拆成了两个 x2。改回 Auto 之后恢复正常。这种问题不看拓扑和链路状态是很难发现的。6.4 从 NVMe 驱动到其他存储协议的迁移思路掌握了 NVMe 驱动开发之后你再去看其他存储协议会轻松很多。比如 SCSI 的队列模型和 NVMe 有相似之处只是命令集和传输层不同。SATA/AHCI 的寄存器操作和 NVMe 的寄存器操作也有对应关系。你可以把 NVMe 驱动里的队列管理、命令下发、完成处理这些通用逻辑抽象出来迁移到其他驱动里。甚至你可以尝试写一个简单的块设备驱动把 NVMe 的命令封装成块设备的读写请求。Linux 的 blk-mq 框架提供了多队列支持和 NVMe 的多队列天然契合。你只需要实现queue_rq回调把块设备的请求转换成 NVMe 命令然后下发到对应的 IO 队列。这个过程中你会更深入地理解 Linux 块设备子系统的运作机制。最后再分享一个小技巧调试 NVMe 驱动的时候善用 ftrace 和 tracepoint。Linux 内核的 NVMe 驱动里埋了很多 tracepoint比如nvme_sq和nvme_cq可以记录每个命令的下发和完成。打开这些 tracepoint你就能看到命令的完整生命周期定位问题比加 printk 高效得多。在 QEMU 里还可以配合-trace参数从虚拟化层面观察 PCIe 事务双管齐下几乎没有查不出来的 bug。