1. NVMe协议基础回顾:协议栈架构、命令提交与完成队列机制、PRP与SGL数据传输方式

各位同学,咱们今天先不急着上手测兼容性。我得先带大家把NVMe协议的老底儿翻一翻。你想想看,如果连协议怎么工作的都不清楚,后面遇到兼容性问题,你连排查方向都没有。

我个人习惯,讲NVMe之前,先把它和传统硬盘做个对比。传统硬盘用的是AHCI协议,那是为机械硬盘设计的。NVMe呢,是专门为闪存介质打造的。说白了,一个是为了转盘子的,一个是为了闪存颗粒的。设计思路完全不同。

1.1 NVMe协议栈架构

NVMe的协议栈,其实没那么复杂。我把它分成三层来看:

  • 应用层:操作系统里的NVMe驱动,负责把IO请求打包成命令。
  • 传输层:这里用的是PCIe总线。NVMe命令通过PCIe的TLP(事务层包)来传输。
  • 设备层:SSD主控收到命令,去操作NAND闪存。

嗯,这里要注意一点。NVMe协议栈最大的特点就是并行化。传统AHCI只有一个命令队列,NVMe最多支持65535个队列。每个队列又可以深度达到65535。你想想看,这并发能力差了多少个数量级?

核心要点:NVMe协议栈的精髓,就是利用PCIe的高带宽和低延迟,配合多队列机制,把闪存的性能彻底释放出来。

我在项目中遇到过一件事。有个客户说他们的NVMe SSD在Linux下性能只有标称值的一半。我排查了半天,发现是驱动里只创建了一个IO队列。说白了,就是拿NVMe当AHCI在用。调整了队列数量后,性能直接翻倍。

1.2 命令提交与完成队列机制

这是NVMe协议最核心的部分。我建议大家一定要理解透彻。

NVMe使用两种队列:

  • 提交队列(SQ,Submission Queue):主机往这里写命令。
  • 完成队列(CQ,Completion Queue):设备往这里写完成状态。

一个SQ必须对应一个CQ。但多个SQ可以对应同一个CQ。这个设计很有意思,我后面会讲到它的好处。

命令提交的流程是这样的:

  1. 主机驱动把命令写入SQ的某个槽位。
  2. 驱动更新SQ的门铃寄存器(Doorbell),告诉设备:有新命令了。
  3. 设备从SQ中取出命令,开始执行。
  4. 执行完成后,设备把完成状态写入对应的CQ。
  5. 设备触发中断(或者主机轮询),告诉主机:命令完成了。
  6. 主机处理完成状态,更新CQ的门铃寄存器。

这个流程里,门铃寄存器是关键。我曾经调试过一个兼容性问题,就是门铃寄存器的写入顺序不对,导致设备漏掉了命令。排查了整整两天才找到原因。

避坑指南:我曾经在调试一个国产主控时发现,它对门铃寄存器的写入时序要求特别严格。主机必须保证在写门铃之前,命令已经在SQ中完全可见(也就是要加内存屏障)。否则设备可能读到不完整的命令。

为什么NVMe要用这种队列机制?说白了,就是为了减少CPU的参与。传统硬盘每个命令都要CPU去轮询状态,NVMe让设备自己管理队列,CPU只需要在开始和结束时介入一下。效率高多了。

1.3 PRP与SGL数据传输方式

NVMe的数据传输,有两种方式:PRP和SGL。这两个概念,很多初学者容易搞混。

PRP(物理区域页,Physical Region Page)

  • 这是NVMe最初定义的数据传输方式。
  • 它用一组物理地址来描述数据在内存中的位置。
  • 每个PRP条目指向一个4KB对齐的物理页。
  • 如果数据跨页,就需要多个PRP条目。

SGL(散聚列表,Scatter Gather List)

  • 这是NVMe 1.2引入的新方式。
  • 它更灵活,可以描述任意长度、任意对齐的数据块。
  • 每个SGL描述符可以指向一段连续的内存区域。
  • 支持链式结构,可以描述非常复杂的内存布局。

我给大家画个对比表格,一目了然:

特性 PRP SGL
引入版本 NVMe 1.0 NVMe 1.2
地址对齐要求 必须4KB对齐 无对齐要求
描述能力 只能描述整页 可描述任意长度
硬件复杂度
灵活性

在实际项目中,怎么选?我个人建议:

  • 如果你的数据都是4KB对齐的,用PRP就够了。简单可靠。
  • 如果数据大小不固定,或者有非对齐访问,用SGL更合适。
  • 有些老设备只支持PRP,这个在兼容性测试时要特别注意。

警告:我在做兼容性测试时发现,有些SSD声称支持SGL,但实际实现有bug。比如SGL描述符的地址计算有误,或者链式SGL的遍历逻辑有问题。所以,即使设备宣称支持SGL,也一定要做充分的数据完整性验证。

最后,我给大家看一个简单的PRP设置示例。这是在NVMe驱动中常见的操作:

// 假设我们要传输一个4KB的数据块
// 数据缓冲区的物理地址是 0x10000
// 这个地址已经是4KB对齐的

// 设置PRP1为数据缓冲区的物理地址
cmd->prp1 = 0x10000;

// 因为数据只有4KB,不需要PRP2
cmd->prp2 = 0;

如果数据跨页了呢?比如数据从0x10000开始,大小是8KB:

// PRP1指向第一个物理页
cmd->prp1 = 0x10000;

// PRP2指向第二个物理页
cmd->prp2 = 0x11000;

嗯,这里要注意。如果数据超过两个物理页,就需要用PRP列表了。PRP列表本身也是一个物理页,里面存放了一串PRP条目。这个机制,说白了就是用页表的方式来管理数据。

好了,NVMe协议的基础就回顾到这里。这些内容虽然基础,但非常重要。后面我们讲兼容性测试时,很多问题都出在这些基础机制上。比如队列深度不匹配、PRP地址错误、门铃寄存器时序问题等等。把这些基础打牢了,后面才能游刃有余。