4. 队列创建:创建SQ和CQ的流程、Admin命令集介绍、Create I/O SQ/CQ命令详解

好,咱们进入正题。队列创建,说白了就是NVMe设备正式干活前的准备工作。你想想看,没有队列,数据怎么传?命令怎么发?所以这一步,是NVMe协议栈初始化的核心环节。

我个人习惯把队列创建分成两个阶段:Admin队列的创建和I/O队列的创建。Admin队列是控制器自己用的,I/O队列才是给上层应用用的。今天咱们重点讲I/O队列的创建流程,以及背后的命令机制。

4.1 队列创建的整体流程

先看一张流程图,帮你建立整体认知。这张图我画了很多遍,每次给团队新人讲NVMe时都会拿出来。

步骤1:识别控制器能力 读取Identify Controller数据 获取队列数量上限等参数 步骤2:设置Admin队列 通过ACQ/ASQ寄存器 设置Admin SQ和CQ基地址 步骤3:创建I/O CQ 发送Create I/O CQ命令 指定CQID、大小、中断等 步骤4:创建I/O SQ 发送Create I/O SQ命令 指定SQID、关联CQID等 关键说明 • 必须先创建CQ,再创建SQ • 一个CQ可以被多个SQ关联 • 一个SQ只能关联一个CQ • 队列ID从1开始(0是Admin) • 队列深度必须是2的幂 • 物理内存必须连续 我在项目中遇到过: 有人先创建SQ再创建CQ, 结果控制器直接报错。 顺序很重要,别搞反了。 ⚠ 队列创建失败后, 需要先删除再重试。

嗯,这张图基本把流程说清楚了。核心就四个步骤:查能力、配Admin、建CQ、建SQ。顺序不能乱,尤其是CQ必须在SQ之前创建。

4.2 Admin命令集概述

在讲具体的创建命令之前,咱们先聊聊Admin命令集。Admin命令集是NVMe协议的"控制面",所有管理操作都通过它来完成。

Admin命令集包含哪些命令?我列个表给你看:

OPC 命令名称 功能描述 我遇到的坑
00h Delete I/O SQ 删除I/O提交队列 删除前要确保队列空闲
01h Create I/O SQ 创建I/O提交队列 物理地址必须4K对齐
04h Delete I/O CQ 删除I/O完成队列 先删SQ再删CQ
05h Create I/O CQ 创建I/O完成队列 中断向量别配错
06h Identify 获取控制器/命名空间信息 数据结构很大,注意缓冲区
08h Abort 中止正在执行的命令 只能中止自己的命令
09h Set Features 设置控制器特性 有些特性只读
0Ah Get Features 获取控制器特性 返回值要解析对

你看,创建队列的命令是01h和05h。这两个命令的格式非常相似,但细节上有区别。我当年第一次看Spec时,差点把这两个命令搞混了。

4.3 Create I/O CQ 命令详解

先讲Create I/O CQ。为什么先讲它?因为CQ是SQ的基础,没有CQ,SQ就没法用。

Create I/O CQ命令的Submission Entry格式如下:

// Create I/O CQ 命令格式 (64字节)
// 字节偏移 | 字段名         | 描述
// --------+----------------+----------------------------------
// DW0     | CDW0           | OPC=05h, FUSE, PSDT, CID
// DW1     | NSID           | 命名空间ID (创建队列时设为0)
// DW2     | RSV            | 保留
// DW3     | MPTR/Metadata  | 元数据指针 (通常为0)
// DW4-DW5 | PRP1           | 物理区域页1 (CQ内存基地址)
// DW6-DW7 | PRP2           | 物理区域页2 (如果跨页)
// DW8     | CDW10          | QID (队列ID) | QSIZE (队列大小)
// DW9     | CDW11          | PC (物理连续) | IEN (中断使能) | IV (中断向量)
// DW10-DW15 | RSV          | 保留

// 实际代码中,我习惯这样构造:
struct nvme_create_cq_cmd {
    __u8    opcode;     // 0x05
    __u8    flags;      // FUSE相关
    __u16   cid;        // 命令ID
    __u32   nsid;       // 0
    __u64   prp1;       // CQ内存物理地址
    __u64   prp2;       // 跨页时的第二个地址
    __le16  qid;        // 队列ID (从1开始)
    __le16  qsize;      // 队列深度 (实际值-1)
    __le16  pc : 1;     // 1=物理连续, 0=不连续
    __le16  ien : 1;    // 1=使能中断
    __le16  iv : 14;    // 中断向量号
    __u32   rsv[5];     // 保留
};

这里有几个关键点,我得重点说说:

  • QID:队列ID从1开始,0是Admin队列的。我曾经见过有人把QID设为0,结果控制器直接返回无效参数。
  • QSIZE:队列深度,实际值是qsize + 1。比如你设qsize=127,实际深度就是128。必须是2的幂。
  • PC位:物理连续标志。我建议你尽量用物理连续的内存,这样只需要一个PRP1就够了,省事。
  • IEN和IV:中断使能和中断向量。如果你用MSI-X,IV就是中断向量号。别配错了,否则中断不来或者乱来。

重要提醒:CQ的内存必须由主机分配,并且保证在队列生命周期内有效。控制器不会帮你管理内存,它只管用。

4.4 Create I/O SQ 命令详解

好,CQ创建完了,接下来创建SQ。SQ的格式和CQ很像,但有一个关键区别:SQ需要指定它关联的CQ。

// Create I/O SQ 命令格式 (64字节)
// DW0-DW7 与Create I/O CQ相同
// DW8     | CDW10          | QID | QSIZE
// DW9     | CDW11          | PC | QPRIO (队列优先级) | CQID (关联的CQ)

struct nvme_create_sq_cmd {
    __u8    opcode;     // 0x01
    __u8    flags;
    __u16   cid;
    __u32   nsid;       // 0
    __u64   prp1;       // SQ内存物理地址
    __u64   prp2;
    __le16  qid;        // SQ队列ID
    __le16  qsize;      // 队列深度
    __le16  pc : 1;     // 物理连续
    __le16  qprio : 2;  // 队列优先级 (1=URGENT, 2=HIGH, 3=MEDIUM)
    __le16  cqid : 13;  // 关联的CQ ID
    __u32   rsv[5];
};

这里有个容易踩的坑:QPRIO(队列优先级)。NVMe定义了三种优先级:

  • 1 (URGENT):紧急队列,控制器会优先处理
  • 2 (HIGH):高优先级
  • 3 (MEDIUM):中优先级

我当年在一个项目中,把所有SQ都设成了URGENT优先级。结果呢?控制器忙得团团转,反而影响了整体性能。后来我改成按业务类型分配优先级,效果好了很多。

我的建议:管理命令用HIGH优先级,数据读写用MEDIUM。除非你有特殊需求,否则别轻易用URGENT。

4.5 队列创建的完整流程示例

光说不练假把式。我给你一个完整的队列创建流程,从驱动角度看的伪代码:

// 队列创建流程 (伪代码)
int nvme_create_io_queues(struct nvme_ctrl *ctrl, int nr_queues)
{
    int i, ret;

    // 1. 先查控制器能力
    ret = nvme_identify_ctrl(ctrl);
    if (ret) return ret;

    // 2. 计算需要的队列数量
    int nr_cq = min(nr_queues, ctrl->max_qid);
    int nr_sq = nr_cq;  // 通常1:1映射

    // 3. 为每个队列分配内存 (4K对齐, 物理连续)
    for (i = 1; i <= nr_cq; i++) {
        ctrl->cq[i].mem = dma_alloc_coherent(..., 4K * qdepth);
        ctrl->sq[i].mem = dma_alloc_coherent(..., 4K * qdepth);
    }

    // 4. 先创建所有CQ
    for (i = 1; i <= nr_cq; i++) {
        ret = nvme_admin_cmd(ctrl, CREATE_CQ, 
            .qid = i,
            .qsize = qdepth - 1,
            .prp1 = ctrl->cq[i].dma_addr,
            .ien = 1,
            .iv = i  // 每个CQ用不同的中断向量
        );
        if (ret) goto err;
    }

    // 5. 再创建所有SQ
    for (i = 1; i <= nr_sq; i++) {
        ret = nvme_admin_cmd(ctrl, CREATE_SQ,
            .qid = i,
            .qsize = qdepth - 1,
            .prp1 = ctrl->sq[i].dma_addr,
            .cqid = i,  // SQ i 关联 CQ i
            .qprio = 2  // HIGH优先级
        );
        if (ret) goto err;
    }

    return 0;

err:
    // 清理:先删SQ,再删CQ
    for (; i >= 1; i--) {
        nvme_admin_cmd(ctrl, DELETE_SQ, .qid = i);
        nvme_admin_cmd(ctrl, DELETE_CQ, .qid = i);
    }
    return ret;
}

注意:创建失败后的清理顺序很重要。必须先删SQ再删CQ,因为CQ可能还被SQ引用着。我曾经在调试时搞反了顺序,结果控制器直接挂了,只能重启。

4.6 队列创建中的常见问题

最后,我总结几个我在项目中实际遇到过的坑:

  1. 内存对齐问题:队列内存必须4K对齐。有一次我用了kmalloc分配内存,没注意对齐,结果PRP地址传过去后控制器报错。
  2. 队列深度设置:深度必须是2的幂,且不能超过控制器支持的最大值。我建议你先读Identify数据,看看MQES字段。
  3. 中断向量冲突:多个CQ共用同一个中断向量时,要确保中断处理函数能区分是哪个CQ触发的。
  4. 队列ID重复:每个队列的ID必须唯一。如果你删除了一个队列,它的ID可以重用,但要确保之前的队列已经完全释放。

嗯,关于队列创建的内容,今天就讲这么多。记住核心思路:先CQ后SQ,顺序不能乱。下节课咱们聊聊队列的删除和重置,那也是容易出问题的地方。

一句话总结:队列创建是NVMe初始化的基石,CQ是SQ的前提,顺序和参数配置决定了系统能否稳定运行。

可可精品资料库