FIO测试命令下发到NVMe执行命令

7 阅读7分钟

研究背景

本人不是专业计算机出生的,对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个步骤

  1. 获取核心数据结构
  2. 检查队列是否启用
  3. 检查控制器是否就绪
  4. 预备请求
  • 将request转化NVMe命令
  • 初始化I/O描述符
  • DMA数据映射
  1. 提交命令到提交队列
  • 将命令拷贝到提交队列的SQE槽位中
  • 写门铃寄存器通知控制器

关于这一点,我们先看下核心步骤”写门铃寄存器通知控制器”

这里首先会有批量写入的一个优化,判断当前是不是最后一个命令,这个传参的一部分。

门铃机制:
在这个模块当中设计到一个概念:控制器的处理进度(event_idx)是否落后于驱动期望的进度(new_idx

  1. 这个内存地址 dbbuf_ei 是驱动和控制器提前约定好的

    • 驱动在初始化时分配内存,把物理地址告诉控制器

    • 控制器就知道“有事情往这里写”

  2. 控制器会在完成一批命令后,自动更新这个值

    • 比如它处理完 SQ 里第 16 个命令,就会把 event_idx 更新成 16

    • 驱动下一次读,就能看到这个新值

  3. event_idx 的具体含义

    • 通常是 SQ 的头指针已处理位置

    • 驱动用这个来推控制器:你已经落后了,请加速

一旦控制器落后,那么需要写 MMIO 门铃进行唤醒,这个本质是在修改控制器内部的提交队列尾指针。

控制器是什么、以及具体的工作原理

  • 谁做:控制器硬件自己。它的固件里有一个逻辑,会以一定频率去读取主机内存中特定的地址(也就是 dbbuf_db)。

  • 何时触发:不由驱动触发,是控制器自发的行为。你可以把它理解为控制器内部有一个一直在运行的“监控程序”,会不断检查你内存中的变量。

  • 驱动的角色:驱动的任务是把数据(新的sq_tail值)写到那个约定的内存地址。至于控制器什么时候读到,驱动不知道,也不直接控制。

通过一种精心设计的“生产者-消费者”模型和“阶段位”机制来协调,避免了读写冲突。

可以把驱动和控制器看作两个通过共享内存通信的独立处理器:

  • 驱动(生产者):负责往共享内存里命令(SQ,提交队列)和门铃值(DBBUF)。

  • 控制器(消费者):负责从共享内存里命令,并在完成后回状态(CQ,完成队列)和进度(DBBUFdbbuf_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_dbdbbuf_ei。它们不是数组,是独立的槽位。

  • dbbuf_db (Shadow Doorbell):驱动的“影子门铃”槽位。驱动把 sq_tail 的值拷贝到这里。

  • dbbuf_ei (Event Index):控制器的“进度报告”槽位。控制器把自己处理到的位置 (sq_head) 更新到这里。

  • 目的:让驱动和控制器通过内存通信,避免频繁的 MMIO 操作。

5. 完整的“槽位”操作流程

我们以你分析过的代码为例,走一遍完整的流程,把所有“槽位”串起来。

  1. 驱动准备命令:驱动在内存中构造好 struct nvme_command

  2. 写入 SQ 槽位nvme_sq_copy_cmd() 把这个命令复制到 nvmeq->sqes[tail] SQ 槽位中。

  3. 更新 DBBUF 槽位nvme_dbbuf_update_and_check_event() 将新的 tail 值写入 dbbuf_db 槽位。

  4. 检查是否需要 MMIO:读取控制器更新在 dbbuf_ei 槽位里的值,判断是否还需要去写 MMIO 门铃。

  5. (可选)写 MMIO 门铃:如果判断需要,就执行 writel(nvmeq->sq_tail, nvmeq->q_db),这是真正的硬件门铃槽位

  6. 控制器处理:控制器感知到新命令(通过轮询 DBBUF 或 MMIO 中断),从 sq_head 指向的 SQ 槽位读取命令执行。

  7. 写入 CQ 槽位:控制器执行完毕,将完成状态写入 nvmeq->cqes[head] CQ 槽位

  8. 触发中断:控制器发送 MSI-X 中断。

  9. 驱动读取 CQ 槽位nvme_handle_cqe()cq_head 指向的 CQ 槽位读取 CQE。

  10. 更新 DBBUF 进度:控制器在处理命令的过程中,会更新 dbbuf_ei 槽位,驱动则在下次提交时读取。