3. NVMe驱动核心数据结构:struct nvme_dev、struct nvme_queue、struct nvme_ns、struct nvme_cmd

说实话,NVMe驱动里最绕不开的就是这四个结构体。我刚开始啃NVMe源码时,光是把它们之间的关系理清楚就花了两天。你想想看,一个PCIe设备、一堆队列、一堆命名空间、还有无数命令在飞,没有这几个核心结构体撑着,整个驱动根本跑不起来。

今天我就带你把这四个“骨架”拆开看看。嗯,咱们一个一个来。

3.1 struct nvme_dev:整个驱动的“大脑”

这个结构体代表一个NVMe控制器实例。说白了,就是你的SSD插到PCIe槽上后,内核为它创建的那个“总管家”。

我个人习惯把 nvme_dev 想象成一座工厂的中央控制室。它里面存着控制器的寄存器映射地址、中断向量、各个队列的指针、还有一堆配置信息。

核心字段一览:

  • pci_dev *pdev —— 指向PCIe设备的指针,所有硬件交互的起点
  • dma_addr_t bar —— 控制器的寄存器基地址,MMIO操作全靠它
  • struct nvme_queue **queues —— 队列数组指针,管理所有提交/完成队列
  • struct nvme_ns *ns —— 命名空间数组,每个namespace对应一个逻辑盘
  • int q_depth —— 队列深度,我遇到过因为设太小导致IOPS上不去的情况
  • u32 db_stride —— doorbell步长,不同厂商的SSD这个值可能不一样
struct nvme_dev {
    struct pci_dev *pdev;
    void __iomem *bar;
    struct nvme_queue **queues;
    struct nvme_ns *ns;
    int q_depth;
    u32 db_stride;
    // ... 还有不少字段,但上面这几个是核心
};

我记得有一次调试一个国产SSD,死活读不到命名空间信息。查了两天,最后发现是 bar 地址映射错了。嗯,这种低级错误最坑人。

3.2 struct nvme_queue:命令的“流水线”

队列是NVMe的灵魂。没有队列,命令就没法提交,也没法完成。nvme_queue 就是管理这条流水线的结构体。

每个队列包含两部分:提交队列(SQ)和完成队列(CQ)。驱动往SQ里塞命令,硬件处理完后往CQ里写完成状态。

我的经验: 队列深度不是越大越好。我曾经把q_depth设成4096,结果内存占用暴涨,性能反而下降了。一般128或256就够用。

struct nvme_queue {
    struct nvme_dev *dev;       // 所属的控制器
    struct nvme_command *sq_cmds; // 提交队列的命令缓冲区
    struct nvme_completion *cqes; // 完成队列的完成项缓冲区
    dma_addr_t sq_dma_addr;     // 提交队列的DMA地址
    dma_addr_t cq_dma_addr;     // 完成队列的DMA地址
    u16 sq_tail;                // 提交队列的尾指针
    u16 cq_head;                // 完成队列的头指针
    u16 qid;                    // 队列ID,0号队列是管理队列
    // ...
};

这里有个关键点:sq_tailcq_head 是驱动和硬件通信的“信使”。驱动往SQ里写命令后,更新 sq_tail 并写doorbell通知硬件。硬件处理完,更新 cq_head,驱动轮询或中断来消费完成项。

我曾经犯过一个错:更新了 sq_tail 但忘了写doorbell。结果命令一直卡在队列里,系统直接hang住。这种问题排查起来特别痛苦,因为没有任何错误日志。

3.3 struct nvme_ns:你看到的“盘”

用户看到的 /dev/nvme0n1,背后就是 nvme_ns。每个命名空间对应一个逻辑块设备。

这个结构体相对简单,但很重要。它把NVMe协议层的命名空间概念,映射到了Linux块设备层。

struct nvme_ns {
    struct nvme_dev *dev;       // 所属控制器
    struct gendisk *disk;       // 通用块设备,用户看到的就是它
    u32 ns_id;                  // 命名空间ID,从1开始
    u64 nsze;                   // 总逻辑块数
    u32 ncap;                   // 容量
    u16 nlbaf;                  // LBA格式数量
    u8 flbas;                   // 当前选中的LBA格式
    // ...
};

注意: 命名空间ID不是连续的。有些SSD会跳过某些ID,或者动态创建/删除命名空间。千万别假设ID从1开始递增。

我记得有一次,用户报告说系统里只看到一个nvme设备,但BIOS里明明有两个。查了半天,发现第二个命名空间的 ns_id 是5,驱动在遍历时只检查了1到4,直接跳过了。嗯,这种边界情况最容易出bug。

3.4 struct nvme_cmd:最底层的“指令”

所有IO操作,最终都要变成一条NVMe命令。这个结构体就是命令的“模板”。

NVMe命令分为管理命令和IO命令两大类。管理命令用来操作控制器本身(比如创建队列、获取日志),IO命令用来读写数据。

struct nvme_command {
    union {
        struct nvme_common_command common;
        struct nvme_rw_command rw;
        struct nvme_admin_command admin;
        // 各种命令类型
    };
};

以读命令为例,核心字段包括:

字段 说明
opcode 操作码,0x02表示读,0x01表示写
nsid 目标命名空间ID
slba 起始逻辑块地址
nlb 要传输的逻辑块数(注意是0-based,nlb=0表示1个块)
prp1/prp2 物理区域页指针,指向数据缓冲区

避坑指南: 我曾经把 nlb 直接设成块数,结果读出来的数据全是错的。后来翻spec才发现,nlb=0表示1个块,nlb=1表示2个块。这种“0-based”的坑,NVMe协议里还有不少。

3.5 四个结构体的关系

这四个结构体不是孤立的。它们的关系可以用一张图说清楚:

struct nvme_dev 控制器实例 struct nvme_queue 命令流水线 struct nvme_ns 逻辑块设备 struct nvme_cmd 命令模板 queues[] ns[] sq_cmds[] nsid 核心数据结构关系图:dev管理queue和ns,queue承载cmd,ns标识cmd的目标

从图上可以看得很清楚:

  • nvme_dev 是顶层容器,持有所有队列和命名空间的指针
  • nvme_queue 是命令的载体,每个队列里有一串 nvme_cmd
  • nvme_ns 是命令的目标,每条命令都要指定操作哪个命名空间
  • nvme_cmd 是最小执行单元,驱动最终就是往队列里塞命令

嗯,这四个结构体搞明白了,NVMe驱动的一半就算拿下了。剩下的就是怎么初始化它们、怎么让它们协同工作。那些内容,咱们后面慢慢聊。

一句话总结: dev是大脑,queue是血管,ns是器官,cmd是血液。没有哪个都活不了。


公众号:蓝海资料掘金营,微信deep3321