4. Admin Submission Queue创建:Admin SQ的物理内存分配、Doorbell寄存器机制、SQ Tail更新策略
好,咱们接着聊Admin SQ的创建。这部分内容,说白了就是NVMe驱动初始化的第一道坎。你想想看,一个NVMe设备要正常工作,首先得有个地方放命令吧?这个放命令的地方,就是Submission Queue,简称SQ。
Admin SQ比较特殊,它是系统里第一个创建的队列。我习惯把它比作「通信的起点」——没有它,你跟设备连话都说不上。
4.1 物理内存分配:这事儿不能马虎
Admin SQ的内存分配,核心要求就一条:物理连续。为什么?因为NVMe设备通过PCIe总线访问内存,它用的是物理地址,不认虚拟地址那一套。你给个不连续的内存,设备读着读着就跳到别处去了,那还得了?
我当年第一次写NVMe驱动时,就踩过这个坑。当时图省事用了kmalloc,结果在内存碎片严重的系统上,分配大块连续内存直接失败。嗯,从那以后我再也不敢小看内存分配策略了。
具体怎么分配?Linux内核里常用的方法有这么几种:
dma_alloc_coherent:最推荐的方式。它直接给你分配物理连续且DMA可访问的内存,还帮你处理好一致性映射。我项目里90%的情况都用它。__get_free_pages:适合分配2的幂次大小的内存。但要注意,它返回的是内核虚拟地址,你得自己转成物理地址。kmalloc:只适合小内存分配。Admin SQ的队列大小通常不大(默认64个条目),勉强能用,但不推荐。
来看一段实际代码,这是我之前项目里用过的:
/* 分配Admin SQ的内存 */
struct nvme_queue *nvme_alloc_admin_sq(struct pci_dev *pdev)
{
struct nvme_queue *nvmeq;
int ret;
nvmeq = kzalloc(sizeof(*nvmeq), GFP_KERNEL);
if (!nvmeq)
return ERR_PTR(-ENOMEM);
/* 分配物理连续内存,大小 = 队列深度 × 每个条目大小(64字节) */
nvmeq->sq_cmds = dma_alloc_coherent(&pdev->dev,
NVME_ADMIN_SQ_DEPTH * sizeof(struct nvme_command),
&nvmeq->sq_dma_addr, GFP_KERNEL);
if (!nvmeq->sq_cmds) {
kfree(nvmeq);
return ERR_PTR(-ENOMEM);
}
/* 记录队列深度和物理地址 */
nvmeq->q_depth = NVME_ADMIN_SQ_DEPTH;
nvmeq->qid = 0; /* Admin Queue的ID固定为0 */
return nvmeq;
}
关键点:sq_dma_addr就是设备能直接用的物理地址。你把这个地址写到设备的寄存器里,设备就知道去哪儿取命令了。
4.2 Doorbell寄存器机制:敲门砖的艺术
内存分配好了,命令也放进去了,设备怎么知道有新命令来了?这就轮到Doorbell寄存器登场了。
Doorbell,直译就是「门铃」。你想想,你去朋友家敲门,朋友听到铃声才知道你来了。NVMe里也一样——你把命令写入SQ后,必须写一下Doorbell寄存器,告诉设备:「嘿,我有新命令,快来取!」
Doorbell寄存器是PCIe BAR空间里映射的一段内存区域。每个队列都有自己独立的Doorbell。Admin SQ的Doorbell偏移量通常是固定的,在NVMe规范里有明确定义。
我见过不少新手犯一个错误:命令写进去了,但忘了敲Doorbell。结果设备傻等着,驱动也傻等着,两边都以为对方有问题。其实说白了,就是差那一下「敲门」。
Doorbell寄存器的写入值,就是新的SQ Tail指针。设备看到这个值,就知道队列里有多少新命令需要处理。
4.3 SQ Tail更新策略:时机很重要
SQ Tail指针,说白了就是告诉设备「我写到第几个命令了」。设备从Head位置读到Tail位置,中间这些就是待处理的命令。
更新Tail的策略,我总结下来就三种场景:
| 策略 | 描述 | 适用场景 |
|---|---|---|
| 每提交一个命令就更新 | 写入一个命令,立即更新Tail并敲Doorbell | 延迟敏感型,需要设备尽快处理 |
| 批量提交后更新 | 写入多个命令后,一次性更新Tail | 吞吐量优先,减少PCIe事务次数 |
| 合并更新 | 结合前两种,根据当前负载动态选择 | 复杂场景,需要精细调优 |
我个人习惯用批量提交策略。为什么?因为每次写Doorbell都是一次PCIe事务,有开销的。你想想,如果每提交一个命令就敲一次门,那门铃响个不停,设备也烦,总线也忙。批量提交,一次敲完,大家都省事。
来看实际更新Tail的代码:
/* 更新SQ Tail并敲Doorbell */
static inline void nvme_update_sq_tail(struct nvme_queue *nvmeq)
{
/* 确保之前的命令写入已经完成 */
wmb();
/* 计算新的Tail值,写入Doorbell寄存器 */
u32 new_tail = nvmeq->sq_tail;
writel(new_tail, nvmeq->dbs + nvmeq->db_offset);
/* 更新驱动侧记录的Tail */
nvmeq->sq_tail = new_tail;
}
小技巧:wmb()这个写屏障不能省。它保证前面的命令数据确实写到了内存里,然后才去敲Doorbell。否则设备可能读到脏数据。我曾经因为漏了这个屏障,排查了整整两天...
4.4 避坑指南:我踩过的那些坑
做Admin SQ创建,有几个地方特别容易出问题:
- 内存对齐:NVMe要求SQ的物理地址必须按页对齐(通常是4KB)。如果不对齐,设备直接报错。我建议分配时用
dma_alloc_coherent,它默认就对齐好了。 - 队列深度:Admin SQ的深度不能超过设备支持的最大值。创建前一定要读设备的Capabilities寄存器,看看
MQES字段的值。 - Doorbell地址计算:不同NVMe版本,Doorbell的偏移量计算方式可能不同。别想当然,老老实实读规范。
特别注意:Admin SQ创建完成后,一定要验证设备是否真的识别了这个队列。我见过一个案例,驱动创建完SQ后直接开始提交命令,结果设备根本没准备好,所有命令都返回错误。正确的做法是:创建完SQ后,等待设备返回「创建成功」的确认,再开始正常使用。
好了,Admin SQ的创建就聊到这儿。说白了,就是三件事:分好内存、记住敲Doorbell、选对Tail更新策略。这三件事做好了,Admin SQ就能稳稳当当地跑起来。下一节咱们聊聊Admin CQ的创建,那是收命令完成通知的地方,同样重要。