NVMe 概述及其在 Maestro 上的支持

Lobsters Hottest 工具

摘要

在 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 支线任务已完结,接下来继续搞桌面环境支持,进展顺利!

相似文章

NUMA:核心、内存及它们之间的距离

Hacker News Top

本文解释了非统一内存访问(NUMA)的概念、历史背景以及它在多插槽服务器上对性能的影响,同时介绍了Edera在使基于Xen的虚拟化实现端到端NUMA感知方面所做的工作。

即将推出的N1X可能成为赢家

Reddit r/LocalLLaMA

一则泄露消息透露了英伟达即将推出的N1X和N1处理器的细节,包括支持16通道DDR5内存,带宽超过500 GB/s。