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 提交 任意设备的命令。核心机制是:
- 用户态通过
io_uring准备一个uring_cmdSQE - 内核注册的
uring_cmd回调函数接收命令 - 回调函数直接将命令转发给底层设备(这里是 NVMe)
- 完成后通过
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 设备时:
io_uring层验证 SQE 参数合法性- 调用
nvme_uring_cmd_io()回调函数 - 回调函数将
struct nvme_passthrough_cmd翻译成标准 NVMe 命令 - 直接使用
nvme_submit_user_cmd()提交到硬件队列 - 完成时触发
io_uring的 CQE 写回
整个过程中,没有 page cache 查找,没有 elevator 调度,没有 Block 层的请求合并——只有纯粹的 NVMe 命令提交与完成。
四、从零实现 NVMe Passthrough
4.1 打开 NVMe 设备
#include

发表评论 取消回复