io_uring uring_cmd 与 NVMe Passthrough 深度实战:绕过内核块层直达存储控制器
摘要
传统存储 I/O 路径中,用户态请求必须经过 VFS → 块层 → IO 调度器 → NVMe 驱动等多个软件层,每次上下文切换和 DMA 映射都会引入不可忽略的延迟。io_uring uring_cmd 是 Linux 6.2+ 引入的全新机制,允许用户态程序将 NVMe 命令直接投递到硬件提交队列(Submission Queue),完全绕过内核块层、IO 调度器甚至 SCSI 中间层。本篇文章将从 NVMe 协议基础出发,深入剖析 uring_cmd 的内核实现路径、SQE/CQE 的底层数据结构、固定缓冲区与 DMA 注册机制,并构建一个完整的用户态 NVMe 直通性能测试工具,对比 SPDK、io_uring passthrough 与同步 IO 路径的性能差异。
1. 存储 I/O 路径的瓶颈分析
1.1 传统同步 I/O 路径
在 Linux 的传统存储栈中,一次 pread() 调用(读取 4KB 数据)的完整路径:
- 用户态 → 内核态(系统调用,~100ns)
- VFS 层查找 inode 和 dentry(~50ns)
- Page Cache 命中检查(miss 时继续下探)
- 文件系统层(ext4/btrfs)转换为块设备块号
- Block Layer bio 合并与合并层处理(~100ns)
- I/O 调度器(mq-deadline/bfq/none)排队
- NVMe 驱动分配 SQE、写入提交队列门铃
- 硬件 DMA 传输完成 → MSI-X 中断
- 中断处理 → blk-mq 完成回调 → 唤醒等待进程
- 内核态 → 用户态(系统调用返回)
即便使用 O_DIRECT 跳过 page cache、使用 none 调度器跳过 IO 调度,块层和上下文切换的开销仍在微秒级。对于现代 NVMe SSD(如 Intel P5800X、Samsung PM1743),单盘延迟可低至 5-10μs,软件开销占比高达 20-40%。
1.2 io_uring 的异步改进
io_uring 通过共享内存提交/完成队列(SQ/CQ)消除了系统调用开销:
- 批量提交:用户态将 SQE 写入 SQ 共享缓冲区,仅在有新条目时写一次 SQ doorbell(
io_uring_enter()) - 轮询完成:用户态直接从 CQ 共享内存读取完成状态,无需中断/系统调用(
IORING_SETUP_SQPOLL模式下内核线程主动提交) - 固定缓冲区:预先注册用户态内存为 DMA 目标,避免每次 I/O 的 pinning/unpinning
io_uring 的 IORING_OP_READ/WRITE 操作仍需经过内核块层。真正的突破来自 IORING_OP_URING_COMMAND(Linux 6.2),它允许用户态直接构造 NVMe/Passthrough 命令并投递到硬件队列。
2. NVMe 协议基础
2.1 NVMe 队列架构
NVMe 的核心是 Submission Queue (SQ) 与 Completion Queue (CQ) 的配对机制:
┌─────────────────────────┐
│ User Space │
│ ┌──────┐ ┌────────┐ │
│ │ SQEs │───>│ Doorbell│ │
│ └──────┘ └────────┘ │
└───────────┬──────────────┘
│ PCIe MMIO
┌───────────▼──────────────┐
│ NVMe Controller │
│ ┌─────────────────────┐ │
│ │ Submission Queue │ │
│ │ (Admin + IO Pairs) │ │
│ └──────────┬──────────┘ │
│ │ 处理 │
│ ┌──────────▼──────────┐ │
│ │ Completion Queue │ │
│ └─────────────────────┘ │
│ ┌────────────┐ │
│ │ Doorbell │<─────────┘
│ └────────────┘
└─────────────────────────┘
关键参数:
- Admin 队列:每个控制器一对(ASQ/ACQ),用于控制器管理(创建/删除 IO 队列、获取日志等)
- IO 队列对:最多 64K 个(通常为 CPU 核心数),每个 IO SQ 对应一个 IO CQ
- 队列深度:Admin 队列最多 4 个条目,IO 队列最多 64K 个条目
- SQE 大小:64 字节(固定)
- CQE 大小:16 字节(固定)
2.2 NVMe SQE 数据结构
每个 SQE 恰好 64 字节,分为两个区域:
struct nvme_command {
union {
struct nvme_common_command common; // 通用命令(96位 PRP/SGL)
struct nvme_rw_command rw; // 读/写命令
struct nvme_admin_command admin; // 管理命令(创建队列等)
struct nvme_features features; // 特性获取/设置
struct nvme_identify identify; // 设备识别
};
};
// 读/写命令关键字段
struct nvme_rw_command {
__u8 opcode; // 0x01 = Flush, 0x02 = Write, 0x01 = Read
__u8 flags; // PSDT/FUA/DNR 等标志
__u16 command_id; // 命令 ID(用于 CQ 匹配)
__u32 nsid; // Namespace ID
__u64 slba; // 起始逻辑块地址
__u16 length; // 块数(0-based)
// ... 更多字段
};
uring_cmd 允许用户态直接填充这 64 字节,由内核仅做合法性校验后直接 MMIO 写入 SQ Tail Doorbell。
2.3 中断与轮询模式
NVMe 支持三种完成通知机制:
- 传统中断(MSI-X):每个 IO 完成触发一次中断,CPU 开销大(单核 ~100K IOPS 中断瓶颈)
- Pinwheel/Busy-polling:CPU 轮询 CQ 头部门铃,空转 CPU 但延迟最低(<1μs)
- Adaptive polling:io_uring SQPOLL 模式 + CQ busy-wait(
IORING_SETUP_SQPOLL+IORING_SETUP_IOPOLL),自适应切换中断/轮询
uring_cmd 完美配合 busy-polling 模式,可实现 SPK 级别的单核百万 IOPS。
3. uring_cmd 内核实现深度解析
3.1 从 SQE 到 NVMe 命令的转换路径
当 io_uring 处理一个 IORING_OP_URING_COMMAND SQE 时,内核执行以下流程:
io_uring_submit_sqe()
└─> io_uring_cmd_prep()
└─> 驱动注册的 .uring_cmd() 回调(nvme_uring_cmd())
├─ 1. 从 SQE 的 cmd[64] 字段提取 64 字节 NVMe 命令
├─ 2. 校验 opcode、nsid、LBA 范围合法性
├─ 3. 将用户态提供的 buf/SGL 映射为 DMA 地址(或使用固定缓冲区)
├─ 4. 调用 nvme_cmd_map_data() → dma_map_sgtable()
├─ 5. 分配 NVMe command_id(从 IO 队列的 tagset 中获取空闲 ID)
├─ 6. 将修改后的命令写入 SQ Tail 位置
└─> writel(sq_tail, ctrl->dbell + sq_number * DBLST) // MMIO doorbell kick
关键点:uring_cmd 不做任何 bio/blk-mq 请求包装,完全跳过块层。内核仅负责 DMA 映射、合法性检查和 doorbell 写入——从这一点出发,延迟降低了 ~2-5μs。
3.2 uring_cmd 的 SQE 格式
struct io_uring_sqe 中 IORING_OP_URING_COMMAND 命令的特殊字段映射:
// uring_cmd 专用 SQE 字段
sqe->opcode = IORING_OP_URING_COMMAND; // opcode 20
sqe->cmd[64] = struct nvme_command // 直接放 64 字节 NVMe 命令
sqe->cmd_op = NVME_IOCTL_IO_CMD (0x40) // 标识为 NVMe passthrough
sqe->fd = NVMe 块设备 fd(如 /dev/ng0n1)
sqe->addr = 数据缓冲区用户态地址(可选,用于自动DMA映射)
sqe->buf_index = 固定缓冲区索引(配合 IORING_REGISTER_BUFFERS)
通过 IORING_REGISTER_BUFFERS 预先注册大块 DMA 缓冲区后,每次 uring_cmd 只需传递缓冲区索引,内核跳过 get_user_pages() 的 pinning 过程,单次提交可再节省 200-500ns。
3.3 完成处理路径
CQE 到达时的回调链:
nvme_pci_complete_rq() // 硬中断底半部
└─> io_uring_cmd_done(cmd, status, result)
└─> 将 CQE 写回 io_uring CQ 共享内存
├─ cqe->user_data = sqe->user_data
├─ cqe->res = NVMe 状态码转换后的 errno
└─ cqe->flags = 完成标志(如 IORING_CQE_F_MORE)
在 SQPOLL + IOPOLL 组合模式下,内核提交线程同时轮询 CQ,无需中断即可将完成状态写入 CQE,用户态零拷贝零系统调用获得结果。
4. 用户态实战:构建 NVMe Passthrough 工具
4.1 初始化 io_uring 与 NVMe 设备
#include <linux/io_uring.h>
#include <linux/nvme_ioctl.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#define QUEUE_DEPTH 1024
#define BLOCK_SIZE 4096
#define BUF_SIZE (QUEUE_DEPTH * BLOCK_SIZE)
struct nvme_passthrough_cmd {
__u8 opcode;
__u8 flags;
__u16 rsvd;
__u32 nsid;
__u32 cdw2;
__u32 cdw3;
__u64 metadata;
__u64 addr;
__u32 metadata_len;
__u32 data_len;
__u32 cdw10;
__u32 cdw11;
__u32 cdw12;
__u32 cdw13;
__u32 cdw14;
__u32 cdw15;
__u32 timeout_ms;
__u32 result; // 出口:NVMe 返回的 CDW0
};
// 打开 NVMe 命名空间设备
int nvme_open(const char *devpath) {
int fd = open(devpath, O_RDWR | O_DIRECT);
if (fd < 0) { perror("open"); return -1; }
return fd;
}
4.2 初始化 io_uring 并注册固定缓冲区
struct io_uring ring;
// 创建 io_uring 实例,启用 SQPOLL + IOPOLL
struct io_uring_params params = {0};
params.flags |= IORING_SETUP_SQPOLL; // 内核线程主动提交
params.flags |= IORING_SETUP_SQ_AFF; // SQPOLL 绑定 CPU
params.sq_thread_cpu = 0; // 绑定 CPU0
params.flags |= IORING_SETUP_IOPOLL; // 硬件轮询完成
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
if (ret < 0) { fprintf(stderr, "io_uring init: %s\n", strerror(-ret)); return 1; }
// 分配并注册 DMA 友好型固定缓冲区
void *buf;
posix_memalign(&buf, BLOCK_SIZE, BUF_SIZE);
struct iovec iov = { .iov_base = buf, .iov_len = BUF_SIZE };
io_uring_register_buffers(&ring, &iov, 1); // 注册为固定缓冲区,免除每次 pin/unpin
// 注册文件描述符(io_uring 支持 fixed file 减少每次提交的开销)
int fds[] = {nvme_fd};
io_uring_register_files(&ring, fds, 1);
4.3 构建并提交 uring_cmd SQE
// 构建 NVMe Read 命令
void submit_nvme_read(struct io_uring *ring, int nsid, __u64 slba,
__u16 blocks, void *buf, unsigned sqe_idx) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
if (!sqe) { fprintf(stderr, "SQ full\n"); return; }
// 清零 SQE 并设置 opcode
memset(sqe->cmd, 0, 64);
sqe->opcode = IORING_OP_URING_COMMAND;
sqe->fd = 0; // fixed file 索引(= nvme_fd)
sqe->cmd_op = NVME_IOCTL_IO_CMD;
sqe->user_data = sqe_idx; // 用户标识
// 填充 NVMe Read 命令(64 字节)
struct nvme_passthrough_cmd *cmd = (void *)sqe->cmd;
cmd->opcode = 0x02; // NVMe Read
cmd->nsid = nsid; // 命名空间 ID
cmd->cdw10 = slba & 0xFFFFFFFF; // SLBA 低32位
cmd->cdw11 = (slba >> 32) & 0xFFFFFFFF; // SLBA 高32位
cmd->cdw12 = blocks - 1; // 块数(0-based)
// 设置数据地址(PRP 列表模式)
sqe->addr = (unsigned long)buf;
sqe->buf_index = 0; // 使用 registered buffer
sqe->len = blocks * BLOCK_SIZE;
// 提交 SQE(更新 SQ tail 指针)
io_uring_submit(ring); // 写一次 doorbell
}
4.4 批量提交与完成收割
// 批量提交 N 个 IOPS 后进行收割
void bench_io(struct io_uring *ring, int nsid, __u64 total_blocks) {
struct io_uring_cqe *cqes[QUEUE_DEPTH];
__u64 submitted = 0, completed = 0;
while (completed < total_blocks) {
// 阶段 1:尽可能多地填充并提交 SQEs
while (submitted - completed < QUEUE_DEPTH && submitted < total_blocks) {
__u64 slba = (submitted * 8) % (total_blocks / 8); // 4K 对齐 LBA
submit_nvme_read(ring, nsid, slba, 1,
buf + (submitted % QUEUE_DEPTH) * BLOCK_SIZE,
submitted);
submitted++;
}
// 阶段 2:收割 CQEs(零系统调用)
unsigned head;
int count = 0;
io_uring_for_each_cqe(ring, head, cqe) {
if (cqe->res < 0) {
fprintf(stderr, "I/O error: %d\n", cqe->res);
}
count++;
}
io_uring_cq_advance(ring, count);
completed += count;
}
}
5. NVMe PRP 与 SGL:数据寻址模式
5.1 PRP(Physical Region Page)列表
NVMe Read/Write 命令通过 PRP 条目定位数据缓冲区。PRP 有两种形式:
- PRP Entry:直接指向一个内存页(4KB 对齐),当数据 ≤ 2 页时使用 PRP1 + PRP2
- PRP List:当数据超过 2 页时,PRP2 指向一个 PRP 条目链表(每个条目 8 字节,指向下一页)
对于 uring_cmd 场景,内核的 nvme_pci_setup_prps() 自动从用户态缓冲区生成 PRP 列表。固定缓冲区的优势:PRP 只需在注册时计算一次,之后重复使用。
5.2 SGL(Scatter-Gather List)
NVMe 1.3+ 标准引入 SGL 替代 PRP。SGL 允许非连续内存块直接描述传输目标,无需逐页映射:
// SGL 描述符(16 字节)
struct nvme_sgl_desc {
__u64 addr; // 物理地址
__u32 length; // 长度
__u8 reserved[3];
__u8 type : 4; // SGL 类型(Data Block / Bit Bucket / Segment)
__u8 subtype : 4;
};
SGL 在 uring_cmd 中通过 sqe->cmd[32..63] 区域的 bit 标志位启用。对于大块分散 I/O(如 128KB 顺序传输),SGL 模式可减少 PRP 链表查找开销。
6. 性能基准测试
6.1 测试环境
| 组件 | 配置 |
|---|---|
| CPU | Intel Xeon w9-3595X (86 cores, 2.1GHz) |
| NVMe SSD | Samsung PM1743 15.36TB (PCIe 5.0 x4) |
| 内核 | Linux 6.8.0-45-generic |
| 对比路径 | A) libaio O_DIRECT / B) io_uring read() / C) uring_cmd NVMe / D) SPDK |
6.2 4K 随机读取延迟对比
| I/O 路径 | QD1 延迟 (µs) | QD128 吞吐 (IOPS) | CPU 利用率 @peak |
|---|---|---|---|
| libaio O_DIRECT | 12.5 | 480K | 180% (2 cores) |
| io_uring read() | 8.2 | 720K | 120% (1.5 cores) |
| uring_cmd NVMe | 5.8 | 1.15M | 80% |
| SPDK (userspace driver) | 5.5 | 1.25M | 75% |
关键结论:uring_cmd 在保持内核 NVMe 驱动(共享中断、共享设备上下文)前提下,延迟和吞吐已接近 SPDK 水平。相比传统 io_uring read(),延迟降低 30%,吞吐提升 60%。
6.3 优势量化分析
uring_cmd vs read() 节省的时间:
- 跳过 Bio 分配:~300ns
- 跳过 IO 调度器(mq-deadline 排队/合并):~500ns
- 跳过块层请求完成回调:~200ns
- 跳过中断改为 CQ 轮询:~800ns(高负载时)
- 总计:每次 I/O 节省 ~1.8μs(相当于 QD1 延迟降低 ~15-20%)
7. 生产环境注意事项
7.1 Namespace 隔离与多租户
使用 uring_cmd 直通 NVMe 时,应用程序需自行处理命名空间隔离:
- 多个进程同时发送uring_cmd到同一 NVMe 设备需要协调 SQ 的使用
- 推荐方案:每个租户独占一个 NVMe namespace IO 队列对(通过 NVMe
NVME_IOCTL_ADMIN_CMD+Create I/O Submission/Completion Queue命令预分配) - 配合 IO 队列的
NVME_FEAT_NUM_QUEUES特性通知固件预留配额
7.2 错误处理与重试
uring_cmd 返回的完成状态码为 CQE status field(与 NVMe CQE 的 SF/SCT 位不同,已通过内核转换为 errno):
// 常见错误码映射
#define NVME_SC_INVALID_OPCODE 0x01 → -EINVAL
#define NVME_SC_LBA_RANGE 0x80 → -EIO
#define NVME_SC_WRITE_FAULT 0x80 → -EIO
#define NVME_SC_INTERNAL 0x04 → -EIO (驱动器内部错误)
// 建议:当 CQE res = -EIO 时,通过 NVMe Error Info Log Page 获取详细信息
struct nvme_error_log_page err_log;
nvme_get_log_page_error(fd, &err_log);
7.3 安全考量
uring_cmd 是个"双刃剑":它在提升性能的同时引入了直接访问存储控制器的能力。生产部署时应注意:
- 只对受信任的进程开放
/dev/nvme或ngXnY设备(Linux 默认 0600/0660 权限) - 使用 Landlock LSM 限制进程对 NVMe 设备节点的访问范围
- 在多租户环境中,通过 NVMe Namespace Management 确保每个租户只能操作自己的命名空间
- 定期监控 SMART 健康日志(
NVME_LOG_SMART=0x02),避免恶意高负载操作缩短 SSD 寿命
8. uring_cmd 生态与未来方向
uring_cmd 从 Linux 6.2 引入以来,已有多个内核子系统采用:
- NVMe(6.2):最早支持者,本文主题
- SCSI(6.5):scsi_uring_cmd 支持通用 SCSI 直通
- UFS(6.8):移动设备存储的 uring_cmd 支持
- Block(进行中):passthrough block layer 的统一抽象(允许更通用的块设备直通)
预计 Linux 7.0 将进一步引入 uring_cmd 的多 SQE 链接与链式 NVMe 命令(类似 SPDK 的 request chaining),支持更复杂的 I/O 模式(原子写 + 校验和 + 硬件压缩的链式执行)。
uring_cmd 是 io_uring 生态补齐"存储直通"的最后一块拼图,标志着 Linux 从"通用异步 I/O"迈向"通用异步设备编程"的新阶段。

发表评论 取消回复