起因

固态盘驱动是基础篇的倒数第二块。NVMe 名声吓人,裸机视角其实是最干净的存储协议:没有端口 IO、没有 ATAPI 历史包袱,一切都是 MMIO 寄存器和内存队列。干净不是偶然:SATA 时代的命令协议 AHCI 是 2004 年照着机械硬盘设计的,单命令队列、逐条排队,闪存根本吃不满;2011 年 Intel 牵头攒起 NVMe 联盟,直接按 SSD 的并行性重新设计——多队列、深队列、寄存器直达,机械时代的包袱一样没背。这篇从 BAR0 一直写到块设备抽象,寄存器表和命令格式全给。

一、控制器寄存器(BAR0 起)

PCIe 枚举找到 NVMe 控制器(类码 0x010802)后,BAR0 是一段寄存器窗口:

偏移 寄存器 关键字段
0x00 CAP bits0-15 MQES(队列最大深度);bits24-31 TO(就绪超时,单位 500ms);bits32-35 DSTRD(门铃步长)
0x08 VS 规范版本
0x14 CC bit0 EN 使能;CSS/MPS/IOSQES/IOCQES 位域见规范 §3.1.8
0x1C CSTS bit0 RDY 就绪;bit1 CFS 致命错误
0x24 AQA 管理队列深度(提交 bits0-11、完成 bits16-27,写"深度-1")
0x28 / 0x30 ASQ / ACQ 管理提交/完成队列基址(4KB 对齐、物理连续
0x1000+ 门铃 每队列一对:SQ tail、CQ head;步长 = 4 « DSTRD

二、初始化序列(顺序错了全是坑)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 0. 读 CAP:记下 DSTRD、TO、MQES
// 1. 从 DMA 池分配管理队列:SQ 64 项×64B、CQ 64 项×16B,各 4KB 对齐
// 2. AQA = (64-1) | (64-1)<<16;ASQ/ACQ 写基址
// 3. CC:MPS=0(4KB 页,PRP 按它算)、IOSQES=6(提交项 64B)、IOCQES=4(完成项 16B,都是 log2 尺寸),EN=1
// 4. 轮询 CSTS.RDY = 1,超时 = CAP.TO × 500ms(裸机永远带超时,不能死等)
// 5. Identify Controller(CNS=0):得 MPS 支持等
// 6. Identify Namespace(CNS=1):得 NSZE(容量)和 LBA 大小(LBAF)
// 7. 创建 IO 完成队列:Admin 命令 opcode 0x05
//    PRP1 = IO CQ 基址;cdw10 = (深度-1) | (中断向量 << 16);cdw11 = PC | IEN
// 8. 创建 IO 提交队列:opcode 0x01
//    PRP1 = IO SQ 基址;cdw10 = (深度-1) | (队列号 << 16);cdw11 = CQ 号 | PC

第 7、8 步的顺序不能反:先建完成队列,提交队列才能引用它。PC 位(physically contiguous)= 我们全用连续内存,走最简路径。

三、命令提交与完成

命令格式(64 字节,提交队列一项):cdw0 = opcode + 标志;cdw1 = NSID(IO 命令必填);cdw6-7 = PRP1/PRP2(数据指针);cdw10-15 = 命令专属字段。

常用 opcode:管理队列 Identify=0x06、建 IO CQ=0x05、建 IO SQ=0x01;IO 队列 Read=0x02、Write=0x01、Flush=0x00

IO 读写命令的专属字段:cdw10-11 = 起始 LBA(64 位);cdw12 低 16 位 = 块数-1。

飞行流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fn submit(sq: &mut Sq, cmd: &Cmd) {
    sq.buf[sq.tail] = cmd;                        // 填进提交队列
    sq.tail = next(sq.tail);
    write_doorbell_sq(sq.tail);                   // 敲门铃,控制器开始取
}
fn reap(cq: &mut Cq) -> Option<Cid> {
    let e = &cq.buf[cq.head];
    if phase(e) == cq.expected_phase {            // phase 位每绕一圈翻转,判新旧全靠它
        cq.expected_phase ^= 1;
        let cid = e.cid;
        cq.head = next(cq.head);
        write_doorbell_cq(cq.head);               // 告诉控制器已消费
        return Some(cid);
    }
    None
}

完成队列项的关键是 phase 位(dw0 bit0):控制器每写一圈翻转一次极性,软件靠它区分"新完成"和"上一圈的旧数据"——忘了判 phase 会被旧数据坑死。

四、PRP:数据指针的规则

MPS=0(4KB 页)下的规矩:PRP1 指向第一页(可以带页内偏移);数据跨两页以内,PRP2 直接给第二页地址;跨三页以上,PRP2 指向一张 PRP 列表(8 字节一項的页地址数组,除第一项外必须页对齐)。

工控负载的简化决策:单命令传输上限压到 8KB(两页),更大的读写由上层切块——PRP 列表这条分支直接不写,代码少一半,性能对 G 代码文件这种量级毫无影响。真要跑大吞吐(比如以后做整盘备份),再回头补列表分支,接口不用动。

五、中断模型与块设备抽象

中断走 MSI(PCIe 篇已接线),向量在设备区(0x20+)。ISR 里只做一件事:reap 完成队列、把完成事件塞环形缓冲给协作层。同步接口(加载配置、读程序文件时用)就在提交后带超时轮询完成——单任务同步模型简单可靠;真到需要异步流水线的那天再演进。

对上层暴露统一的块设备接口:

1
2
3
4
5
6
7
pub trait BlockDevice {
    fn read_blocks(&mut self, lba: u64, buf: &mut [u8]) -> Result;
    fn write_blocks(&mut self, lba: u64, buf: &[u8]) -> Result;
    fn flush(&mut self) -> Result;                // opcode 0x00,冲写缓存
    fn capacity_lbas(&self) -> u64;
    fn block_size(&self) -> u32;
}

这个 trait 是存储层的"协议":NVMe 实现它,后面的 U 盘(MSC+SCSI)也实现它,文件系统只认接口不认硬件——换介质不改上层。

运行期还要盯一个位:CSTS.CFS(bit1,控制器致命状态)。置位说明控制器内部出事了(过热、固件异常),IO 靠超时兜底发现,恢复路径是标准三步:CC.EN=0 → 轮询 CSTS.RDY=0 → 从管理队列开始整套重初始化。工控盘上这辈子里最好一次都别走到,但代码里必须有——走到的时候没有,就是数据静默丢失。

掉电策略提前立规矩:控制器写缓存默认开启(性能好但掉电丢数据),参数修改和加工完成节点必须 Flush;整机掉电保护(UPS/超级电容)是硬件侧设计,软件侧保证"flush 过的数据一定在盘上"。

总结

实战清单:CAP 读 DSTRD/TO、管理队列 4KB 对齐建好、CC 的 MPS/IOSQES/IOCQES 三个尺寸位设对再 EN、带超时等 RDY、Identify 拿容量和 LBA 大小、先建 IO CQ 再建 IO SQ、命令的 cdw 布局、门铃敲法、phase 位判完成、CFS 致命位的重初始化路径、单命令 8KB 上限绕开 PRP 列表、BlockDevice trait 收口、关键节点 Flush。跑通标志:QEMU -drive file=test.img,if=none,id=nvme -device nvme 下 Identify 出容量,读写 LBA0 回读一致。下一篇:U 盘驱动——MSC+SCSI 把 USB 存储也接进同一个 BlockDevice,然后是 exFAT 文件系统。

参考链接