4. 提交队列与完成队列:SQ和CQ的创建、管理、中断机制

SQ和CQ是整个NVMe协议的交通枢纽。说白了,主机发出去的每个命令都得走SQ,控制器干完活后结果放在CQ。我刚开始接触NVMe驱动时,以为这俩队列就是个环形的FIFO,结果调通第一条命令就撞了墙——门铃顺序搞反,控制器直接罢工。所以这一章,我带你彻底吃透SQ和CQ的创建、管理以及中断机制。

4.1 SQ和CQ的基本关系

一个SQ只能关联到一个CQ,但一个CQ可以被多个SQ共享。为什么这么设计?你想想看,同类型I/O的完成通知可以集中在一个CQ上处理,减少中断服务程序的触发次数。我碰到过一个厂商的固件限制——总共只支持32个CQ,但SQ可以到1024,这时候共享CQ就成了绕不开的方案。

组件含义核心属性
SQ(Submission Queue)主机提交命令的环状缓冲区SQT(队列类型)、QID、QSIZE、物理连续性标志PC
CQ(Completion Queue)控制器写入完成项的环状缓冲区QID、QSIZE、中断向量、中断启用标志IEN
要点:SQ和CQ大小必须为2的幂次方,并且队列容量不包含队列头部位置(即0号项可用)。我记得有一次在嵌入式系统上设了QSIZE=7,结果控制器校验不通过,报Invalid Queue Size,浪费了三天排查。

4.2 队列的创建

4.2.1 Admin队列的自动创建

系统启动后,控制器会自动创建Admin SQ和Admin CQ。你只需要通过PCIe配置空间设置它们的基址和大小。Intel的一块NVMe SSD就要求Admin队列深度至少16,否则会直接Abort初始化。

4.2.2 I/O队列的创建命令

创建I/O SQ和I/O CQ必须用Admin命令。我习惯用Create I/O Submission Queue(Opcode 01h)和Create I/O Completion Queue(Opcode 05h)。命令DWord布局如下:

/* Create I/O SQ Admin Command DWord 10:11 */
DW10[31:16] = QSIZE      // 队列深度-1(实际容量=QSIZE+1)
DW10[15:0]  = SQID       // 队列ID,0x01~0xFFFF
DW11[31:16] = CQID       // 关联的完成队列ID
DW11[3]     = PC         // 物理连续性:1=物理连续;0=PRP链表
DW11[0]     = QPRIO      // 队列优先级

同样,Create I/O CQ命令的DW10包含CQID、QSIZE;DW11包含中断使能位IEN和中断向量IV。

4.2.3 创建流程步骤

  1. 分配物理连续或PRP描述的内存区域。
  2. 填写Admin SQ命令入口,写入Admin SQ。
  3. 写门铃寄存器(Doorbell),通知控制器取命令。
  4. 轮询或等待中断,从Admin CQ读取完成状态。
  5. 确认创建成功后,将该SQ/CQ加入驱动内部管理链表。
调试技巧:创建队列后,可以用PCIe配置空间中的寄存器CAP.CQR(Contiguous Queues Required)判断驱动是否需要强制物理连续。如果硬件不要求,用PRP方式可以避免大块连续内存分配失败。

4.3 队列的管理

4.3.1 队列深度与数量上限

每个队列的深度受芯片内部SRAM大小限制。我见过最抠门的控制器只支持4个I/O队列、深度64;高端的则可以扩到1024个队列、深度4096。驱动初始化时要读取CAP.MQES(最大队列深度)和CAP.DSTRD(门铃步进)来索取资源。

4.3.2 多SQ共享CQ的绑定规则

Create I/O SQ命令中必须指定CQID。多个SQ可以指向同一个CQ,但必须在同一个中断向量下——这就意味着所有共享CQ的SQ的完成中断会合并到同一根线上。我曾经在一款FPGA原型上因为误将不同中断向量的SQ指向同一个CQ,结果中断永远不触发,查寄存器发现Phase Tag永远不翻转。

4.4 中断机制

NVMe支持三种中断方式,但实际场景中MSI-X是标配。我把它们的特点列出来:

中断方式向量数可编程性适用场景
Pin-based(传统INTx)1(共享)极低老旧平台、兼容测试
MSI最多32部分(可修改向量号)主流桌面、服务器
MSI-X最多2048每个向量可独立配置高性能、多队列场景

个人强烈建议新设计直接上MSI-X,因为你可以将不同队列组的CQ绑定到不同的CPU核心,实现无锁处理。我记得在一个NVMe虚拟化项目中,把16个I/O队列的CQ分别绑定到16个vCPU上,IOPS直接翻倍。

4.4.1 中断向量与CQ的关联

Create I/O CQ时设置的IV字段就是MSI-X表项索引。中断发生后,从CQ的Phase Tag(P)位判断是否有新完成项。我见过驱动直接用Phase Tag取反判断,省掉比较寄存器,既快又简单。

4.4.2 中断聚合

通过Set Features命令设置Interrupt Coalescing(聚合时间)和Interrupt Vector Coalescing(聚合阈值)。说白了就是攒够N个完成项,或者等T微秒,再统一发一次中断。这能减少CPU被频繁打断,但会增加延迟。我调优数据库负载时,把聚合阈值设为4、时间设为10μs,吞吐量提升15%。

避坑指南:我曾经在实时音频处理场景里开了中断聚合,结果缓冲区下溢导致爆音。后来把聚合彻底关掉,只保留MSI-X单向量,延迟降到2μs以下。记住——低延迟场景别开聚合。
NVMe 提交/完成队列中断交互流程 Host 侧内存 Controller 硬件 Host 应用 SQ (内存环) CQ (内存环) 门铃寄存器(DB) 控制器引擎 中断控制器 写SQ项 写DB (SQ Tail) 门铃通知 写CQ项 触发中断 中断信号 中断处理函数读CQ 虚线表示事件驱动路径,实线表示数据/内存访问路径

4.5 实战经验与优化建议

队列分配策略:我建议将不同I/O类型的SQ分开——比如读队列、写队列、管理队列。这样可以让中断处理函数只处理特定类型的完成项,避免在大流量下遍历所有CQ项。

门铃写入顺序:先写SQ的门铃,再读CQ的状态?不对,应该是先写SQ的Tail,再更新控制器门铃。我曾经把写顺序搞反,导致控制器一次性看到大量无效项,直接报Completion Queue Overflow。

中断向量亲和性:用/proc/irq/xxx/smp_affinity把MSI-X中断固定到NUMA节点相同的CPU核心。我实测过跨NUMA访问时延会增加30%,所以绑定务必与内存分配一致。

核心原则:SQ和CQ的生命周期由Host驱动管理,但物理内存的连续性、门铃步进(DSTRD)、中断向量数量这些硬件约束必须一一满足。创建任何队列前,先用Identify Controller获取CAP.CQR、CAP.MQES和CAP.DSTRD。