4. Admin Submission Queue 创建:SQ 的物理内存分配、Doorbell 机制、SQ Tail Doorbell 更新

好,咱们接着聊。上一节我们把 Admin Submission Queue 和 Completion Queue 的基址寄存器(ASQ、ACQ)配置好了,但说实话,那只是把“门牌号”告诉了 NVMe 控制器。真正要让它跑起来,你得把物理内存给它准备好,并且教会它怎么通过 Doorbell 来通知你“我准备好了”。

这一节,我带你手把手走一遍 Admin SQ 的创建流程。重点就三个:物理内存怎么分配Doorbell 到底是什么鬼、以及Tail Doorbell 怎么更新。嗯,这三个点搞明白了,你基本就掌握了 NVMe 队列交互的核心。

4.1 物理内存分配:别让控制器“迷路”

NVMe 控制器访问的是物理地址,不是虚拟地址。所以你在驱动里用 kmalloc 或者 dma_alloc_coherent 分配的内存,必须保证物理连续。为什么?因为控制器没有 MMU,它拿到的就是一个裸的物理地址,你给它一个不连续的地址,它直接就懵了。

我个人习惯用 dma_alloc_coherent,它一次性帮你搞定两件事:分配物理连续的内存,并且返回给 CPU 的虚拟地址和给控制器的物理地址(也叫 DMA 地址)。

核心要点:Admin SQ 的大小由 CAP.MQES 字段决定。MQES 表示最大队列深度,单位是“条目数”。比如 MQES = 63,意味着队列深度最大为 64(因为从 0 开始计数)。

举个例子,假设 MQES = 63,每个 SQ 条目(Submission Queue Entry)是 64 字节。那么你需要分配的内存大小就是:

SQ 大小 = (MQES + 1) * 64 字节 = 64 * 64 = 4096 字节

刚好 4KB,一个页面大小。是不是很巧?NVMe 的设计者显然考虑到了页面对齐的问题。

代码实现大致是这样:

/* 假设 dev 是 pci_dev 结构体指针 */
struct nvme_queue *admin_q;
dma_addr_t sq_dma_addr;
void *sq_cpu_addr;

admin_q = kzalloc(sizeof(*admin_q), GFP_KERNEL);
if (!admin_q)
    return -ENOMEM;

/* 分配物理连续内存,4KB 对齐 */
sq_cpu_addr = dma_alloc_coherent(&dev->dev, 
                                 4096, 
                                 &sq_dma_addr, 
                                 GFP_KERNEL);
if (!sq_cpu_addr) {
    kfree(admin_q);
    return -ENOMEM;
}

admin_q->sq_cpu = sq_cpu_addr;
admin_q->sq_dma = sq_dma_addr;
admin_q->q_depth = 64;  /* MQES + 1 */

这里有个坑,我曾经踩过:分配的内存必须保证 4KB 对齐。虽然 dma_alloc_coherent 默认会返回页对齐的地址,但如果你自己用 __get_free_pages 或者 kmallocDMA_API 的方式,一定要手动检查对齐。否则控制器读到的地址是错的,命令根本发不出去。

注意:Admin SQ 和 Admin CQ 的物理地址必须写入 ASQ 和 ACQ 寄存器。写入前,记得把地址右移 12 位(因为寄存器只存页号,低 12 位固定为 0)。

4.2 Doorbell 机制:你敲铃,我干活

Doorbell,直译就是“门铃”。这个名字很形象。你想想看,你给控制器提交了一个命令,它怎么知道你去提交了?难道要它一直轮询你的内存?那太浪费了。

NVMe 的做法是:你写一个寄存器,告诉控制器“我有新命令了,快来取”。这个寄存器就是 Doorbell。

每个队列(包括 Admin SQ 和 Admin CQ)都有自己独立的 Doorbell 寄存器。它们位于 NVMe 控制器的 BAR 空间中,偏移地址是固定的。具体来说:

  • SQ Tail Doorbell:偏移地址 = 0x1000 + (2 * queue_id) * 4
  • CQ Head Doorbell:偏移地址 = 0x1000 + (2 * queue_id + 1) * 4

对于 Admin 队列,queue_id = 0。所以:

  • Admin SQ Tail Doorbell 偏移 = 0x1000
  • Admin CQ Head Doorbell 偏移 = 0x1004

你写的是 Tail 指针的值,而不是 Head 指针。为什么?因为控制器内部维护了一个 Head 指针,它知道自己已经处理到哪了。你告诉它 Tail 在哪,它就知道从 Head 到 Tail 之间的所有命令都是新的,需要处理。

小技巧:Doorbell 寄存器是 32 位的,写入的值就是队列的 Tail 索引(从 0 开始)。比如你提交了 3 个命令,Tail 就是 2(如果从 0 开始计数)。

4.3 SQ Tail Doorbell 更新:时机很重要

好,现在内存分配好了,Doorbell 地址也知道了。那什么时候更新 Tail Doorbell?

答案是:在你把所有命令都写入 SQ 内存之后,最后一次更新 Tail Doorbell

为什么不能写一个命令就更新一次?因为每次写 Doorbell 都是一次 PCIe 事务,有延迟。如果你写一个命令敲一次门,性能会非常差。NVMe 的设计哲学是“批量提交”:你把一批命令都准备好,然后一次性告诉控制器“我准备好了”。

流程是这样的:

  1. 检查 SQ 中是否有空闲槽位(Tail 不能追上 Head)。
  2. 将命令写入 SQ 内存的 Tail 位置。
  3. 更新 Tail 指针(在驱动中维护的变量)。
  4. 重复步骤 1-3,直到所有命令都写入。
  5. 将最终的 Tail 值写入 Doorbell 寄存器。

代码实现:

/* 假设 admin_q 已经初始化好 */
u32 tail = admin_q->sq_tail;
u32 head = readl(admin_q->sq_head_reg); /* 从控制器读取 Head */

/* 检查队列是否满 */
if ((tail + 1) % admin_q->q_depth == head) {
    printk("Admin SQ is full!\n");
    return -EAGAIN;
}

/* 写入命令到 SQ 内存 */
struct nvme_command *cmd = &admin_q->sq_cpu[tail];
cmd->opcode = nvme_admin_create_cq;
/* ... 填充其他字段 ... */

/* 更新 Tail */
admin_q->sq_tail = (tail + 1) % admin_q->q_depth;

/* 写入 Doorbell,通知控制器 */
writel(admin_q->sq_tail, admin_q->sq_doorbell_reg);

这里有个细节:写入 Doorbell 之前,必须保证所有命令都已经写到了内存中。因为 CPU 的写操作可能被缓存,而 PCIe 设备看到的是最终结果。所以你需要加一个内存屏障(memory barrier),比如 wmb() 或者 mmiowb()

重要:在 x86 架构上,writel 通常已经隐含了写屏障。但在 ARM 或 RISC-V 上,你可能需要显式调用 wmb()。我建议你养成好习惯,每次写 Doorbell 前都加一个 wmb(),这样代码更安全。

4.4 一张图看懂整个流程

说了这么多,不如一张图来得直观。下面是我画的 Admin SQ 创建和 Doorbell 更新的流程图:

Admin SQ 创建与 Doorbell 更新流程 步骤1:分配物理内存 dma_alloc_coherent() 大小 = (MQES+1) * 64 字节 步骤2:写入ASQ寄存器 物理地址 >> 12 设置队列深度 步骤3:准备命令 填充SQ Entry到内存 更新驱动中的Tail指针 步骤4:内存屏障 wmb() 或 mmiowb() 确保命令已写入内存 步骤5:写SQ Tail Doorbell writel(tail, bar0_addr + 0x1000) 通知控制器有新命令 步骤6:控制器处理命令 从Head到Tail遍历SQ 执行命令并写CQ 注意:Doorbell 寄存器位于 BAR0 偏移 0x1000 处,每个队列占用 8 字节(SQ Tail + CQ Head)

这张图把整个流程串起来了。你从下往上看,就是控制器处理命令的路径;从上往下看,就是驱动提交命令的路径。

4.5 避坑指南

最后,分享几个我实际项目中踩过的坑:

  • Doorbell 写错了值:我曾经把 Head 值写到了 Tail Doorbell 里,结果控制器以为没有新命令,一直不处理。排查了半天才发现是寄存器偏移搞反了。
  • 忘记加内存屏障:在 ARM 平台上,没有 wmb() 导致控制器读到了旧数据,命令执行结果完全不对。后来我养成了习惯,每次写 Doorbell 前都加屏障。
  • 队列深度设置错误:如果 ASQ 寄存器中设置的队列深度和实际分配的内存大小不一致,控制器可能会访问越界。轻则命令失败,重则系统崩溃。

我的建议:在调试阶段,每次写 Doorbell 后,可以读一下控制器的 SQ Head 寄存器,确认它是否真的收到了你的通知。如果 Head 没有变化,说明 Doorbell 没写对。

好了,Admin SQ 的创建和 Doorbell 机制就讲到这里。下一节我们聊聊 Admin CQ 的创建,以及怎么处理完成中断。嗯,那个环节也有不少细节需要注意。


公众号:蓝海资料掘金营,微信deep3321