4. NVMe控制器初始化:PCIe设备探测、BAR空间映射、MSI/MSI-X中断配置、控制器寄存器初始化

NVMe控制器的初始化,说白了就是让操作系统「认识」这块SSD,并且准备好和它通信。这个过程分四步走:先找到设备,再映射它的内存空间,接着配好中断,最后把控制器「唤醒」。每一步都有坑,我一个个说。

4.1 PCIe设备探测:怎么找到NVMe设备?

Linux内核里,NVMe驱动注册了一个pci_driver结构体。内核在启动时,会扫描PCIe总线,发现设备ID匹配的,就调用驱动的probe函数。

static struct pci_driver nvme_driver = {
    .name       = "nvme",
    .id_table   = nvme_id_table,
    .probe      = nvme_probe,
    .remove     = nvme_remove,
    .shutdown   = nvme_shutdown,
    .driver     = {
        .pm = &nvme_pm_ops,
    },
};

这里有个细节:nvme_id_table里不光有NVMe设备的Vendor ID和Device ID,还可能有Subsystem ID。我遇到过一块国产SSD,它的Device ID和标准NVMe设备一样,但Subsystem ID不同。结果驱动没认出来,折腾了半天才发现是ID表没配全。

核心要点:probe函数是驱动和设备的「第一次握手」。内核会传给你一个struct pci_dev *指针,里面包含了设备的全部PCIe配置空间信息。

4.2 BAR空间映射:把硬件寄存器映射到虚拟地址

NVMe控制器的寄存器,是通过PCIe的BAR(Base Address Register)暴露给软件的。说白了,BAR就是硬件给软件留的一扇门。你得把这扇门映射到内核的虚拟地址空间,才能读写寄存器。

我个人习惯用pcim_iomap_regions这个函数,它把映射和资源管理打包在一起,省心。

static int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
    int err;

    err = pcim_enable_device(pdev);
    if (err)
        return err;

    err = pcim_iomap_regions(pdev, 1 << 0, "nvme");
    if (err)
        return err;

    dev->bar = pcim_iomap_table(pdev)[0];
    if (!dev->bar)
        return -ENOMEM;

    /* 现在可以通过 dev->bar 访问寄存器了 */
}

嗯,这里要注意:NVMe规范要求BAR0至少是8KB大小。但有些老设备只实现了4KB,你映射的时候如果按8KB去映射,可能会出问题。我建议你在映射前,先读一下BAR的原始值,确认一下实际大小。

避坑指南:我曾经遇到一块设备,它的BAR0地址是64位的,但我的代码只处理了32位的情况。结果映射出来的地址全是错的,读写寄存器直接导致系统崩溃。后来我加上了pci_resource_flags的判断,才搞定。

4.3 MSI/MSI-X中断配置:告别轮询,拥抱中断

NVMe设备支持三种中断方式:INTx、MSI、MSI-X。INTx是传统的中断线方式,性能差,现在基本不用。MSI和MSI-X都是消息信号中断,但MSI-X支持更多的中断向量,而且每个向量可以独立配置。

我个人强烈推荐用MSI-X。为什么?因为NVMe的IO队列可以每个队列配一个中断向量,这样多核CPU可以并行处理中断,性能直接拉满。

static int nvme_setup_interrupts(struct nvme_dev *dev, struct pci_dev *pdev)
{
    int nr_io_queues = num_online_cpus();
    int result;

    /* 先尝试MSI-X,最多分配 nr_io_queues 个向量 */
    result = pci_alloc_irq_vectors(pdev, 1, nr_io_queues, PCI_IRQ_MSIX);
    if (result > 0) {
        dev->num_vecs = result;
        return 0;
    }

    /* MSI-X失败,回退到MSI */
    result = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_MSI);
    if (result > 0) {
        dev->num_vecs = 1;
        return 0;
    }

    /* 实在不行,用INTx */
    result = pci_alloc_irq_vectors(pdev, 1, 1, PCI_IRQ_INTX);
    if (result > 0) {
        dev->num_vecs = 1;
        return 0;
    }

    return -ENOSPC;
}

你想想看,如果只用MSI,所有队列的中断都挤在一个向量上,CPU0忙死,其他核心闲着。这多浪费?

小技巧:分配中断向量时,别贪心。我一般先问设备最多支持多少个,然后根据CPU核心数取最小值。分配多了,设备可能不支持,分配少了,性能又上不去。

4.4 控制器寄存器初始化:让设备「活」起来

映射完BAR,配好中断,接下来就是操作控制器的寄存器了。NVMe控制器的核心寄存器都在BAR0里,包括:

偏移地址 寄存器名称 作用
0x00 CAP 控制器能力,告诉你支持什么特性
0x08 VS 版本号,NVMe 1.3还是1.4?
0x0C INTMS 中断掩码,控制哪些中断能触发
0x10 INTMC 中断清除,写1清除对应中断
0x14 CC 控制器配置,使能、IO队列深度等
0x1C CSTS 控制器状态,是否就绪、是否出错
0x24 AQA 管理队列属性,队列大小
0x28 ASQ 管理提交队列的物理地址
0x30 ACQ 管理完成队列的物理地址

初始化的顺序很重要。我一般这么干:

  1. 读CAP寄存器,确认设备支持的最大队列深度、支持的IO命令集等。
  2. 读VS寄存器,确认NVMe版本。如果是1.2以下的,有些特性就不能用。
  3. 配置CC寄存器,设置IO队列深度、仲裁机制等。注意,写CC之前,设备必须处于「未使能」状态。
  4. 写AQA、ASQ、ACQ,设置管理队列的属性和地址。
  5. 写CC的EN位为1,使能控制器。
  6. 轮询CSTS的RDY位,等待设备就绪。
static int nvme_enable_ctrl(struct nvme_dev *dev)
{
    u32 cc, csts;
    int ret;

    /* 读CAP,确认设备能力 */
    dev->cap = readl(dev->bar + NVME_REG_CAP);
    dev->max_q_depth = NVME_CAP_MQES(dev->cap);

    /* 配置CC寄存器 */
    cc = readl(dev->bar + NVME_REG_CC);
    cc &= ~NVME_CC_ENABLE;          /* 先确保使能位为0 */
    cc |= NVME_CC_CSS_NVM;          /* NVM命令集 */
    cc |= NVME_CC_MPS_4K;           /* 4KB内存页大小 */
    cc |= NVME_CC_AMS_RR;           /* 轮询仲裁 */
    cc |= NVME_CC_SHN_NONE;         /* 不关机 */
    cc |= NVME_CC_IOSQES_64;        /* 提交队列条目64字节 */
    cc |= NVME_CC_IOCQES_16;        /* 完成队列条目16字节 */
    writel(cc, dev->bar + NVME_REG_CC);

    /* 使能控制器 */
    cc |= NVME_CC_ENABLE;
    writel(cc, dev->bar + NVME_REG_CC);

    /* 等待就绪,超时2秒 */
    ret = readl_poll_timeout(dev->bar + NVME_REG_CSTS, csts,
                             csts & NVME_CSTS_RDY, 10, 2000000);
    if (ret)
        return -ETIMEDOUT;

    return 0;
}

注意:写CC寄存器时,EN位从0变1,设备会开始初始化内部状态。这个过程可能耗时几百毫秒。有些设备比较慢,轮询超时时间设短了,就会误判为设备故障。我建议至少设2秒。

4.5 知识体系总览

下面这张图,把NVMe控制器初始化的整个流程串起来了。你跟着这个顺序走,基本不会漏掉什么。

NVMe控制器初始化流程 PCIe设备探测 pci_driver->probe BAR空间映射 pcim_iomap_regions 中断配置 MSI-X优先 寄存器 初始化 控制器寄存器初始化详细步骤 读CAP寄存器 读VS寄存器 配置CC寄存器 写AQA/ASQ/ACQ 使能控制器 轮询CSTS.RDY 就绪? 初始化完成 超时返回

这张图里,我把「控制器寄存器初始化」拆成了6个子步骤。你跟着这个顺序走,基本不会漏掉什么。尤其是那个判断循环,很多新手会忽略超时处理,结果设备卡死了都不知道。

我的经验:调试NVMe初始化时,我习惯在每一步之后都打印一下关键寄存器的值。比如使能控制器后,立刻读CSTS,看看RDY位有没有变化。这样一旦出问题,能快速定位是哪个环节卡住了。

好了,NVMe控制器初始化的核心内容就这些。从PCIe设备探测,到BAR映射,再到中断配置,最后操作寄存器,每一步都有它的道理。你写代码的时候,多想想「硬件为什么要这么设计」,而不是死记硬背API。这样遇到问题,你才能举一反三。

专注资料整理