NVMe 概述及其在 Maestro 上的支持
摘要
在 Maestro 操作系统中实现 NVMe 驱动的技术概览,涵盖 PCIe 接口、队列与内存映射 I/O 细节。
<p><a href="https://lobste.rs/s/u16jsm/overview_nvme_its_support_on_maestro">评论</a></p>
查看缓存全文
缓存时间: 2026/04/23 11:25
# 非易失性内存高速接口(NVMe)
来源:https://blog.lenot.re/a/nvme
正如我在上一篇博文(https://blog.lenot.re/a/2025-retrospective)里提到的,我动手写了一个 **NVMe 驱动**,现在已经能跑起来了,本文就来整体介绍一下!
NVMe(Non-Volatile Memory Express)是固态硬盘(SSD)的现代标准,其规范由 [NVM Express 联盟](https://nvmexpress.org/) 制定。
你可能会问:Maestro 已经支持 [PATA](https://en.wikipedia.org/wiki/Parallel_ATA) 和 NVMe,为什么不先实现 SATA?
答案很简单:**NVMe 太好实现了!**
不过你也可能注意到,这篇文章拖了几个月才发。虽然驱动本身写起来不难,但为了让驱动正常工作,我对内核的一些设计缺陷做了重构,后面会聊到。
## NVMe 接口
> 驱动实现参考的是 [1.3d 版规范](https://nvmexpress.org/wp-content/uploads/NVM-Express-1_3d-2019.03.20-Ratified.pdf)(主要是读起来轻松)。
在内核眼里,**NVMe 控制器**就是挂在 **PCIe** 总线上的一个设备。PCIe 提供若干 **BAR**(Base Address Register),即内存地址,NVMe 控制器的寄存器就映射在这些地址上。
读写这些 BAR 地址就是在跟 NVMe 控制器做 I/O。
注意:某些情况下需要**关闭 BAR 内存的缓存**,或者启用 **Write-Through** 或 **Write-Combining**。
> **Write-Through** 标志告诉 CPU:写操作直接落到下游设备(RAM 或其他)。**Write-Combining** 同理,但 CPU 会等整个缓存行写满再一次性提交,通常比 Write-Through 更快。
NVMe 在 BAR 内存里暴露了一组寄存器(非完整列表):
偏移 | 名称 | 描述
---|---|---
0x00-0x07 | CAP | 控制器能力
0x08-0x0B | VS | 版本
0x0C-0x0F | INTMS | 中断屏蔽置位
0x10-0x13 | INTMC | 中断屏蔽清除
0x14-0x17 | CC | 控制器配置
0x1C-0x1F | CSTS | 控制器状态
0x24-0x27 | AQA | 管理队列属性
0x28-0x2F | ASQ | 管理提交队列
0x30-0x37 | ACQ | 管理完成队列
0x1000+(2X)*Y | SQxTDBL | 提交队列 X 尾部门铃
0x1000+(2X+1)*Y | CQxHDBL | 完成队列 X 头部门铃
> 表格“无耻地”抄自 [osdev.org](https://wiki.osdev.org/NVMe),`Y` 的值需读 `CAP` 寄存器获得。
在 NVMe 术语里,一块盘叫做一个 **namespace**。namespace 挂靠在控制器上,OS 通过控制器与其通信。
> 在 Linux 的 `/dev` 下,你会看到 `nvmeX`、`nvmeXnY` 或 `nvmeXnYpZ` 这类设备:`X` 是控制器 ID,`Y` 是该控制器上的 namespace ID,`Z` 是 namespace 内的分区 ID。
控制器初始化的细节比较枯燥,本文仅做概览,不讲教程。
### 提交队列与完成队列
NVMe 通过**提交队列**和**完成队列**实现异步 I/O。OS 把命令写进提交队列,NVMe 控制器处理完后把结果写回完成队列。
> 如果你熟悉 Linux 的底层接口,这会让你想起 [io_uring](https://en.wikipedia.org/wiki/Io_uring)。
这种做法的一大好处是:控制器可以自行优化命令执行顺序。
提交队列里的条目结构如下:
字节 | 字段 | 说明
---|---|---
3:0 | CDW0 | 操作码 [7:0]、融合操作 [9:8]、PSDT [15:14]、命令 ID [31:16]
7:4 | NSID | namespace ID
15:8 | - | 保留
23:16 | MPTR | 元数据指针
31:24 | PRP1 | 物理区页条目 1(物理内存源/目的)
39:32 | PRP2 | 物理区页条目 2,或 PRP 列表指针
43:40 | CDW10 | 命令特定
47:44 | CDW11 | 命令特定
51:48 | CDW12 | 命令特定
55:52 | CDW13 | 命令特定
59:56 | CDW14 | 命令特定
63:60 | CDW15 | 命令特定
命令完成后,NVMe 控制器往完成队列写一条记录:
字节 | 字段 | 说明
---|---|---
3:0 | DW0 | 命令特定结果
7:4 | DW1 | 保留
9:8 | SQHD | 提交队列头指针(控制器处理完后更新)
11:10 | SQID | 提交队列标识
13:12 | CID | 命令标识(与提交条目的 CDW0[31:16] 匹配)
15:14 | - | 相位标志 [0]、状态字段 [14:1]
写完这条记录后,控制器发中断通知 OS。
### 命令提交与完成流程
OS 提交命令时:
- 拿队列信号量(见下文)
- 把提交条目写进提交队列
- 在旁路表里记录当前进程,把提交与进程关联
- 写提交队列门铃寄存器,告诉控制器有新条目
- 让当前进程睡眠
> 注:Maestro 用信号量,许可数等于队列条目数。提交前拿许可,完成后再释放,防止队列溢出。
NVMe 控制器完成命令后:
- 写完成队列条目
- 发中断(见**消息信号中断**章节)
- OS 中断处理程序读完成条目
- 用完成条目里的 ID 找到对应提交条目,再通过旁路表找到进程
- 把完成条目拷到旁路表,供进程读取
- 唤醒进程
- 写完成队列门铃,告诉控制器条目已处理
- 进程恢复后从旁路表取结果,判断命令是否成功
### 管理队列与识别
启动时 NVMe 只有一对提交/完成队列,即**管理队列**,用来**识别**控制器和 namespace,再创建 **I/O 队列**。
**Identify** 命令返回一个大结构,包含控制器、namespace 列表或单个 namespace 的详细信息。
先用它拿控制器信息,再拿 namespace 列表,再逐个 namespace 取信息。
有了这些信息,就可以用 **Create_IO_Completion_Queue** 和 **Create_IO_Submission_Queue** 命令创建 I/O 队列对。
### 读与写
读写磁盘各有对应命令。
OS 只需给出**大小**(以块为单位)、**LBA**(逻辑块地址)和 **PRP**(Physical Region Page)或 PRP 列表。
PRP 是物理内存地址,数据从此处读或写。传 PRP 列表即可实现聚散 I/O(类似 `readv(2)`/`writev(2)`)。
## 消息信号中断(MSI)
PCI/PCIe 支持消息信号中断,无需专用引脚,设备(这里是 NVMe 控制器)在总线上发一条消息即可中断 CPU。
对于 PCIe,这条消息体现为向特定内存地址写一个 DWORD(4 字节)。
这个功能叫 **MSI-X**(旧版叫 **MSI**,但 NVMe 强制要求 MSI-X,故不展开)。
x86 CPU 有专用内存地址,往那里写 DWORD 即可触发中断。
OS 扫描 PCIe 设备时,可拿到指向中断映射表的 BAR,表结构如下:
位 127-96 | 位 95-64 | 位 63-32 | 位 31-0
---|---|---
Vector Control(0) | Message Data(0) | Message Address High(0) | Message Address Low(0)
Vector Control(1) | Message Data(1) | Message Address High(1) | Message Address Low(1)
... | ... | ... | ...
Vector Control(N-1) | Message Data(N-1) | Message Address High(N-1) | Message Address Low(N-1)
> 表格再次“无耻地”抄自 [osdev.org](https://wiki.osdev.org/PCI)
**Message Address** 是写消息的内存地址,**Message Data** 是写入的 DWORD 值,**Vector Control** 放标志位。
表的一行对应设备侧的一个中断 ID,Message Data 的格式与 CPU 架构相关,内含 CPU 侧的中断号。
## Maestro 的设计缺陷
内核几处设计问题让 NVMe 实现比预期费劲,本章做个盘点。
### 在 `execve` 的缺页处理里睡觉
用 `execve(2)` 执行程序时,内核会新建虚拟地址空间并构建新程序映像。
填充新地址空间时,内核会临时切到该空间,同时进入**临界区**,因为不允许在“当前进程绑定的地址空间”之外切换上下文,否则进程恢复时会拿错地址空间,导致内存踩坏。
> 临界区详情见 SMP 博文(https://blog.lenot.re/a/smp)。
填充地址空间时,内核用 `mmap(2)` 把 ELF 文件映射进来,页面按需加载。
然后内核有时需要清零 ELF 段尾部,这会触发缺页(页面尚不存在),缺页处理又会读盘填充内存。
NVMe 驱动发读命令后让当前进程睡眠,进而立即调度别的进程。
**然而**我们之前进了临界区,而临界区的目的就是禁止上下文切换。
在 NVMe 出现前这没问题,因为我的 PATA 实现是纯轮询(死等数据,不睡眠)。
修复方法:给 `Process` 结构加字段 `active_mem_space`,指向当前绑定的地址空间(可能与进程自己的地址空间不同)。临时切换时改这个字段,进程恢复时用 `active_mem_space` 而非进程自己的地址空间,于是不再需要临界区。
### NVMe 睡眠期间的 CPU 任务重均衡
进入睡眠时,NVMe 驱动代码如下:
```c
// 把进程设为睡眠态
process::set_state(State::Sleeping);
// 重新调度
schedule();
```
两行之间有空隙,别的 CPU 可能在此期间做跨核任务重均衡。
在进程状态被设为 `Sleeping` 但 `schedule` 还没保存上下文之前,另一个 CPU 如果先收到完成中断,就可能把任务唤醒。
结果进程同时在两个核上跑,其中一个寄存器状态还无效。
修复方法:进程状态新增标志,在上下文真正切换完成前加锁,其他 CPU 在 `schedule` 调用前无法捡走该任务。
### 在临界区外关写保护
x86 的 `CR0` 寄存器有**写保护**标志,关掉后内核就能写只读页。Maestro 上下文切换**不**保存 `CR0`。
`execve(2)` 里,内核曾临时关写保护,以便写 ELF 的只读段。
这又不能用临界区包起来,因为写内存可能触发读盘,读盘又会睡眠,进而上下文切换。
调度时进程可能被迁到别的核,而新核的写保护是默认开启的(`CR0` 不保存),进程恢复后写只读页直接内核恐慌。
修复方法:不再在这里关写保护,而是实现 `mprotect(2)`,在 `execve(2)` 内部用同样逻辑,初始化完再把页面设回只读。
## 未来优化
当前实现只有一对 I/O 队列,CPU 核多时会[竞争](https://en.wikipedia.org/wiki/Resource_contention)。
优化思路:按实际数量创建最多“CPU 核心数”对 I/O 队列,并平均绑核。若系统有 `N` 核、控制器支持 `M` 对 I/O 队列,每对队列服务 `N / M` 个核。
部分 NVMe 命令可进一步提速(不完全列表):
- **Write Zeros**:把块标记为全零(减少写放大,延长寿命)
- **Dataset Management**:向控制器提示某些块的访问频率
- **Copy**:在同一或不同 namespace 间拷数据,无需 CPU 参与
## 下一步?
NVMe 支线任务已完结,接下来继续搞桌面环境支持,进展顺利!
相似文章
maestro: 轻量级、兼容Linux的内核,使用Rust编写
Maestro 是一个用 Rust 编写的轻量级类 Unix 内核,旨在实现安全性和 Linux 兼容性。目前支持 x86 和 x86_64 架构,功能丰富,但仍处于早期开发阶段。
NUMA:核心、内存及它们之间的距离
本文解释了非统一内存访问(NUMA)的概念、历史背景以及它在多插槽服务器上对性能的影响,同时介绍了Edera在使基于Xen的虚拟化实现端到端NUMA感知方面所做的工作。
在 2x3090 NVLINK 上对 Qwen 3.6 27B MTP 进行基准测试
对 Qwen 3.6 27B MTP 在 4 张 RTX 3090 GPU 上的基准分析表明,基于 NVLink 的张量并行相较于 PCIe 配置可实现显著的吞吐量提升(最高达 +53%)。
@damnang2:本文完全向所有人开放。在这篇文章中,我介绍了两件事。第一,面向没有技术背景的读者…
一篇文章向非技术读者解释NVIDIA机架名称的结构,然后分析SemiAnalysis的最新帖子,以澄清关于中平面良率的含义。
即将推出的N1X可能成为赢家
一则泄露消息透露了英伟达即将推出的N1X和N1处理器的细节,包括支持16通道DDR5内存,带宽超过500 GB/s。