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 |
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 创建流程步骤
- 分配物理连续或PRP描述的内存区域。
- 填写Admin SQ命令入口,写入Admin SQ。
- 写门铃寄存器(Doorbell),通知控制器取命令。
- 轮询或等待中断,从Admin CQ读取完成状态。
- 确认创建成功后,将该SQ/CQ加入驱动内部管理链表。
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%。
4.5 实战经验与优化建议
队列分配策略:我建议将不同I/O类型的SQ分开——比如读队列、写队列、管理队列。这样可以让中断处理函数只处理特定类型的完成项,避免在大流量下遍历所有CQ项。
门铃写入顺序:先写SQ的门铃,再读CQ的状态?不对,应该是先写SQ的Tail,再更新控制器门铃。我曾经把写顺序搞反,导致控制器一次性看到大量无效项,直接报Completion Queue Overflow。
中断向量亲和性:用/proc/irq/xxx/smp_affinity把MSI-X中断固定到NUMA节点相同的CPU核心。我实测过跨NUMA访问时延会增加30%,所以绑定务必与内存分配一致。
CAP.CQR、CAP.MQES和CAP.DSTRD。