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 设备来说,最重要的几个字段是:
| 配置空间偏移 | 字段名称 | 作用 |
|---|---|---|
| 0x00 | Vendor ID | 厂商 ID,用于识别设备来源 |
| 0x02 | Device ID | 设备 ID,配合 Vendor ID 唯一标识 |
| 0x0B | Class Code | 0x010802 表示 NVMe 控制器 |
| 0x10 | BAR0 | 映射 NVMe 控制器寄存器 |
| 0x3C | Interrupt Line | 中断号分配 |
| 0x3E | Interrupt Pin | 中断引脚 |
BAR0 是最关键的。NVMe 规范要求 BAR0 必须是一个 64 位内存 BAR,大小至少 16KB。在驱动初始化时,你需要读取 BAR0 的基地址,然后通过 ioremap 或者类似的机制把它映射到内核虚拟地址空间。映射之后,你就可以通过读写这些虚拟地址来访问 NVMe 控制器寄存器了。
注意:BAR 映射的时候一定要检查 BAR 的类型和大小。我曾经遇到过一块 NVMe 盘,它的 BAR0 报告大小是 16KB,但实际只实现了前 4KB 的寄存器,后面的地址访问会返回全 F。如果你不检查就直接读写,可能会拿到错误的数据。
3.2 NVMe 控制器寄存器详解与初始化流程
NVMe 控制器寄存器位于 BAR0 空间,偏移从 0x00 开始。核心寄存器包括:
- CAP(Controller Capabilities,偏移 0x00):64 位,报告控制器的能力,比如最大队列深度、支持的命令集、超时时间等。
- VS(Version,偏移 0x08):32 位,报告 NVMe 协议版本。
- CC(Controller Configuration,偏移 0x14):32 位,驱动写这个寄存器来配置控制器,比如使能、设置队列深度、选择命令集。
- CSTS(Controller Status,偏移 0x1C):32 位,报告控制器状态,比如是否就绪、是否有致命错误。
- AQA(Admin Queue Attributes,偏移 0x24):32 位,设置 Admin 队列的深度。
- ASQ(Admin Submission Queue Base Address,偏移 0x28):64 位,Admin 提交队列的基地址。
- ACQ(Admin Completion Queue Base Address,偏移 0x30):64 位,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 是 6(64 字节),CQ 是 4(16 字节)。
3.3 命令下发与完成处理的完整流程
NVMe 的命令格式是 64 字节,分为通用命令和命令特定字段。以读命令为例,你需要填充以下字段:
- CDW0:操作码(Opcode)放在低 8 位,比如 0x02 表示读命令;FUSE 和 CID 放在高位。
- NSID:命名空间 ID,指定你要读哪个命名空间。
- CDW10-CDW11:起始逻辑块地址(SLBA),64 位。
- 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 console=ttyS0" \ -drive file=nvme.img,format=raw,if=none,id=nvmedrive \ -device nvme,drive=nvmedrive,serial=deadbeef \ -nographic这里的 nvme.img 是你用 dd 命令创建的空文件,大小随意。QEMU 会把它模拟成一个 NVMe 设备。你可以在 guest 系统里用 fdisk 分区、mkfs 格式化,然后挂载读写。调试驱动的时候,你可以在 QEMU 启动参数里加上-trace nvme*来打开 NVMe 相关的 trace,观察命令的下发和完成过程。
注意:QEMU 模拟的 NVMe 设备在性能上和真实硬件差距很大,尤其是队列深度和并发处理。如果你要测试性能相关的逻辑,还是得上真机。但用来验证功能正确性,QEMU 足够了。
4.4 关键参数计算与配置实例
NVMe 驱动初始化时,有几个参数需要根据硬件能力来计算。以 Admin 队列深度为例,CAP 寄存器里的 MQES 字段表示最大队列深度,你需要取 MQES+1 和你的实际需求之间的较小值。假设 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.10,NVMe 驱动的 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。