研究背景
本人不是专业计算机出生的,对Linux代码只有粗浅的了解。
在部门负责NVMe、测试同事都不懂fio压测和NVMe通信,我作为一个常年排查linux系统问题,主要设计到pcie、acpi、时钟、CPU频率、内存相关的,但是从未排查过I/O通信相关的问题。这是我第一次对这个方向进行源代码的研究。
本人通过AI和源代码的观察,我发现从fio、用户态到内核态、块设备层、nvme驱动层,这样的方式去依次去理解,会牵扯到大量的分支,我们根本无法聚焦于一点,研究经历会分散。我在从头到尾梳理一遍之后,决定倒着去叙述这个事情。
NVMe驱动是如何将数据发送出去、确认数据发送成功的数量
在如下驱动结构体中,注意函数 nvme_queue_rq
/* Admin 队列操作表 - 用于管理命令 */
static const struct blk_mq_ops nvme_mq_admin_ops = { .queue_rq = nvme_queue_rq, // 提交请求到硬件 .complete = nvme_pci_complete_rq, // 请求完成回调 .init_hctx = nvme_admin_init_hctx, // 初始化 admin 硬件队列 .init_request = nvme_init_request, // 初始化 request .timeout = nvme_timeout, // 超时处理 // 注意:没有 .commit_rqs, .map_queues, .poll};/* I/O 队列操作表 - 用于数据读写 */static const struct blk_mq_ops nvme_mq_ops = { .queue_rq = nvme_queue_rq, // 提交请求到硬件 .queue_rqs = nvme_queue_rqs, // ⭐ 批量提交(高性能优化) .complete = nvme_pci_complete_rq, // 请求完成回调 .commit_rqs = nvme_commit_rqs, // ⭐ 批量提交完成通知 .init_hctx = nvme_init_hctx, // 初始化 I/O 硬件队列 .init_request = nvme_init_request, // 初始化 request .map_queues = nvme_pci_map_queues, // CPU 到硬件队列映射 .timeout = nvme_timeout, // 超时处理 .poll = nvme_poll, // 轮询模式支持};
在这个函数中一共有5个步骤
- 获取核心数据结构
- 检查队列是否启用
- 检查控制器是否就绪
- 预备请求
- 将request转化NVMe命令
- 初始化I/O描述符
- DMA数据映射
- 提交命令到提交队列
- 将命令拷贝到提交队列的SQE槽位中
- 写门铃寄存器通知控制器
关于这一点,我们先看下核心步骤”写门铃寄存器通知控制器”
这里首先会有批量写入的一个优化,判断当前是不是最后一个命令,这个传参的一部分。
门铃机制:
在这个模块当中设计到一个概念:控制器的处理进度(event_idx)是否落后于驱动期望的进度(new_idx)
-
这个内存地址
dbbuf_ei是驱动和控制器提前约定好的-
驱动在初始化时分配内存,把物理地址告诉控制器
-
控制器就知道“有事情往这里写”
-
-
控制器会在完成一批命令后,自动更新这个值
-
比如它处理完 SQ 里第 16 个命令,就会把
event_idx更新成 16 -
驱动下一次读,就能看到这个新值
-
-
event_idx的具体含义-
通常是 SQ 的头指针或已处理位置
-
驱动用这个来推控制器:你已经落后了,请加速
-
一旦控制器落后,那么需要写 MMIO 门铃进行唤醒,这个本质是在修改控制器内部的提交队列尾指针。
控制器是什么、以及具体的工作原理
-
谁做:控制器硬件自己。它的固件里有一个逻辑,会以一定频率去读取主机内存中特定的地址(也就是
dbbuf_db)。 -
何时触发:不由驱动触发,是控制器自发的行为。你可以把它理解为控制器内部有一个一直在运行的“监控程序”,会不断检查你内存中的变量。
-
驱动的角色:驱动的任务是把数据(新的
sq_tail值)写到那个约定的内存地址。至于控制器什么时候读到,驱动不知道,也不直接控制。
通过一种精心设计的“生产者-消费者”模型和“阶段位”机制来协调,避免了读写冲突。
可以把驱动和控制器看作两个通过共享内存通信的独立处理器:
-
驱动(生产者):负责往共享内存里写命令(
SQ,提交队列)和门铃值(DBBUF)。 -
控制器(消费者):负责从共享内存里读命令,并在完成后写回状态(
CQ,完成队列)和进度(DBBUF的dbbuf_ei)。
它们遵循严格的规则来避免同时读写同一块数据,主要通过以下两种方式:
1. 提交队列槽位 (Submission Queue Entry)
这是你最开始接触到的“槽位”。
-
是什么:一个固定大小的结构体
struct nvme_sqe(Submission Queue Entry),大小通常是 64 字节。每一个 SQE 就是一个“槽位”,存放着一条 NVMe 命令。 -
在哪:位于主机内存中,由驱动分配的一段连续内存(数组)。
-
怎么用:驱动填充
nvme_sqe数组,然后把数组的尾指针(sq_tail)更新并通知控制器。 -
你的代码对应:
nvme_sq_copy_cmd(nvmeq, &iod->cmd);就是把你准备好的命令,拷贝到nvmeq->sqes[tail]这个槽位里。
2. 完成队列槽位 (Completion Queue Entry)
这是“回执”槽位。
-
是什么:
struct nvme_completion,大小 16 字节。每个 CQE 对应之前提交的一个 SQE。 -
在哪:也是主机内存中的一段连续数组(
nvmeq->cqes)。 -
怎么用:控制器执行完命令后,会把包含状态码、命令ID等信息的 CQE,写入到 CQ 的
cq_head指针指向的槽位。然后驱动在中断处理函数里读取这些槽位。 -
你的代码对应:
struct nvme_completion *cqe = &nvmeq->cqes[idx];这就是读取第idx号槽位里的 CQE。
3. 环形队列索引:sq_tail, sq_head, cq_head, cq_phase
这些不是数据槽位,而是管理槽位的“指针”。它们用来标记队列的当前状态。
-
sq_tail(提交队列尾指针):指向驱动下一个要写入的 SQ 槽位。驱动每提交一个命令,sq_tail就增加 1。你代码里的nvmeq->sq_tail就是它。 -
sq_head(提交队列头指针):指向控制器下一个要读取的 SQ 槽位。控制器每处理一个命令,sq_head就增加 1。这个值会通过 CQE 的sq_head字段返回给驱动,帮助驱动释放已完成的槽位。 -
cq_head(完成队列头指针):指向驱动下一个要读取的 CQ 槽位。驱动每处理一个 CQE,cq_head就增加 1。 -
cq_phase(完成队列阶段位):这不是一个槽位,而是一个单比特的标志。它在 CQ 每循环一圈时翻转一次,驱动用它来区分新写入的 CQE 和上一轮的旧 CQE。这是实现无锁读写的关键设计。
4. 门铃缓冲区槽位 (Doorbell Buffer)
这是你刚刚深入分析的 DBBUF 优化里的“槽位”。
-
是什么:两个 32 位的整数变量,
dbbuf_db和dbbuf_ei。它们不是数组,是独立的槽位。 -
dbbuf_db(Shadow Doorbell):驱动的“影子门铃”槽位。驱动把sq_tail的值拷贝到这里。 -
dbbuf_ei(Event Index):控制器的“进度报告”槽位。控制器把自己处理到的位置 (sq_head) 更新到这里。 -
目的:让驱动和控制器通过内存通信,避免频繁的 MMIO 操作。
5. 完整的“槽位”操作流程
我们以你分析过的代码为例,走一遍完整的流程,把所有“槽位”串起来。
-
驱动准备命令:驱动在内存中构造好
struct nvme_command。 -
写入 SQ 槽位:
nvme_sq_copy_cmd()把这个命令复制到nvmeq->sqes[tail]SQ 槽位中。 -
更新 DBBUF 槽位:
nvme_dbbuf_update_and_check_event()将新的tail值写入dbbuf_db槽位。 -
检查是否需要 MMIO:读取控制器更新在
dbbuf_ei槽位里的值,判断是否还需要去写 MMIO 门铃。 -
(可选)写 MMIO 门铃:如果判断需要,就执行
writel(nvmeq->sq_tail, nvmeq->q_db),这是真正的硬件门铃槽位。 -
控制器处理:控制器感知到新命令(通过轮询 DBBUF 或 MMIO 中断),从
sq_head指向的 SQ 槽位读取命令执行。 -
写入 CQ 槽位:控制器执行完毕,将完成状态写入
nvmeq->cqes[head]CQ 槽位。 -
触发中断:控制器发送 MSI-X 中断。
-
驱动读取 CQ 槽位:
nvme_handle_cqe()从cq_head指向的 CQ 槽位读取 CQE。 -
更新 DBBUF 进度:控制器在处理命令的过程中,会更新
dbbuf_ei槽位,驱动则在下次提交时读取。