Linux NVMe Passthrough 与 io_uring uring_cmd:迈向零开销存储 I/O 的工程实践

引言:存储性能的最后一公里

现代 NVMe SSD 的延迟已经下探到微秒级别——单颗 Intel Optane 905P 的随机读延迟低至 10μs,PCIe 5.0 SSD 更是突破了 13GB/s 的顺序读取带宽。然而,传统的 Linux 存储 I/O 栈却如同一层又一层的"减速带":从 VFS 层到 Block 层,再到 NVMe 驱动层,每一层都引入了上下文切换、内存拷贝和锁竞争。

当我们试图在 eBPF、云原生数据库、AI 推理缓存等场景中榨取极致的存储性能时,一个核心问题浮出水面:能否让用户态程序直接向 NVMe 设备提交 I/O 请求,完全绕过内核 Block 层的调度与缓冲?

io_uring 的 uring_cmd(即 NVMe Passthrough 命令)正是这个问题的答案。自 Linux 5.19 引入以来,它提供了一套通过 io_uring 提交 NVMe 直接管理命令和数据命令的机制,让用户态程序能够以最小开销直接与 NVMe 设备对话。

本文将从 NVMe 协议基础出发,深入剖析 uring_cmd 的实现原理,并提供完整的代码示例和性能基准对比。


一、NVMe 协议基础:队列模型与命令生命周期

1.1 Submission Queue 与 Completion Queue

NVMe 的核心设计是基于提交队列(SQ)和完成队列(CQ)的异步通信模型。主机通过 SQ Doorbell 寄存器向设备写入新命令的 Tail 指针,设备完成处理后通过 CQ Doorbell 更新 Head 指针。

┌─────────────┐    SQ    ┌─────────────┐    CQ    ┌─────────────┐
│   Host      │ ───────► │  NVMe SSD   │ ───────► │   Host      │
│  App        │  Doorbell│  Controller │  Doorbell│  IRQ Handler│
└─────────────┘          └─────────────┘          └─────────────┘

在 Linux 内核中,每个 NVMe 设备拥有一组 Admin Queue(用于设备管理)和若干组 I/O Queue(用于数据读写)。Admin Queue ID 固定为 0,I/O Queue ID 从 1 开始递增。

1.2 NVMe 命令结构

每个 NVMe 命令是一个 64 字节的结构体,关键字段包括:

struct nvme_command {
    __u8  opcode;       // 操作码:读、写、flush 等
    __u8  flags;        // 标志位
    __u16 command_id;   // 命令唯一标识
    __u32 nsid;         // Namespace ID
    __u64 metadata;     // 元数据指针
    __u64 prp1;         // Physical Region Page 1(数据缓冲区)
    __u64 prp2;         // Physical Region Page 2(大数据传输的链表头)
    __u32 cdw10_cdw15;  // 命令特定参数
};

其中 opcode 定义了操作类型: - 0x01:NVMe Write - 0x02:NVMe Read
- 0x06:NVMe Flush - 0x09:NVMe Write Zeroes - 0xD8:NVMe Format(格式化)


二、内核 I/O 栈的开销分析

传统 read()/write() 系统调用涉及的路径极其复杂:

用户态 app
    │  syscall
    ▼
VFS 层 (generic_file_read_iter)
    │  page cache lookup
    ▼
文件系统层 (ext4_file_read_iter / xfs_file_read)
    ▼
Block 层 (submit_bio → blk_mq_submit_bio)
    │  电梯调度、请求合并
    ▼
NVMe 驱动层 (nvme_queue_rq)
    │  映射 PRP/SGL、提交 SQ
    ▼
NVMe 硬件

每一步的开销来源: - VFS 层:路径查找、权限检查、fd 到 file 的结构体转换 - Page Cache:未命中时的页面分配、radix tree 插入 - Block 层:blk-mq 的硬件队列映射、请求合并、cgroup throttling - 上下文切换:从用户态到内核态的寄存器保存、TLB 刷新

虽然 io_uring 的固定缓冲区和 SQPOLL 模式已经大幅降低了 syscall 开销,但对于延迟敏感的存储应用,我们仍希望完全跳过 Block 层。


三、io_uring uring_cmd 原理

3.1 uring_cmd 的设计目标

uring_cmd 的设计初衷是让用户态程序通过 io_uring 提交 任意设备的命令。核心机制是:

  1. 用户态通过 io_uring 准备一个 uring_cmd SQE
  2. 内核注册的 uring_cmd 回调函数接收命令
  3. 回调函数直接将命令转发给底层设备(这里是 NVMe)
  4. 完成后通过 io_uring 的完成队列返回结果

关键在于:整个流程完全绕过了 VFS、文件系统和 Block 层。

3.2 关键数据结构

// io_uring 中的 uring_cmd SQE
struct io_uring_sqe {
    __u8  opcode;       // IORING_OP_URING_CMD
    __u8  flags;
    __u16 ioprio;
    __s32 fd;           // 打开的设备 fd
    __u64 addr;         // 命令结构体地址(struct nvme_passthrough_cmd)
    __u32 len;          // 命令长度
    __u32 cmd_op;       // NVME_URING_CMD_IO / NVME_URING_CMD_IO_VEC
    // ...
};

// NVMe Passthrough 命令
struct nvme_passthrough_cmd {
    struct nvme_command cdw0;    // 标准 NVMe 命令(64字节)
    __u32  timeout_ms;           // 超时时间
    __u32  result;               // 命令执行结果(Doorbell 写入值)
};

3.3 内核处理流程

当 io_uring 提交 IORING_OP_URING_CMD 给 NVMe 设备时:

  1. io_uring 层验证 SQE 参数合法性
  2. 调用 nvme_uring_cmd_io() 回调函数
  3. 回调函数将 struct nvme_passthrough_cmd 翻译成标准 NVMe 命令
  4. 直接使用 nvme_submit_user_cmd() 提交到硬件队列
  5. 完成时触发 io_uring 的 CQE 写回

整个过程中,没有 page cache 查找,没有 elevator 调度,没有 Block 层的请求合并——只有纯粹的 NVMe 命令提交与完成。


四、从零实现 NVMe Passthrough

4.1 打开 NVMe 设备

#include                        
                    
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部