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 | 管理完成队列的物理地址 |
初始化的顺序很重要。我一般这么干:
- 读CAP寄存器,确认设备支持的最大队列深度、支持的IO命令集等。
- 读VS寄存器,确认NVMe版本。如果是1.2以下的,有些特性就不能用。
- 配置CC寄存器,设置IO队列深度、仲裁机制等。注意,写CC之前,设备必须处于「未使能」状态。
- 写AQA、ASQ、ACQ,设置管理队列的属性和地址。
- 写CC的EN位为1,使能控制器。
- 轮询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控制器初始化的整个流程串起来了。你跟着这个顺序走,基本不会漏掉什么。
这张图里,我把「控制器寄存器初始化」拆成了6个子步骤。你跟着这个顺序走,基本不会漏掉什么。尤其是那个判断循环,很多新手会忽略超时处理,结果设备卡死了都不知道。
我的经验:调试NVMe初始化时,我习惯在每一步之后都打印一下关键寄存器的值。比如使能控制器后,立刻读CSTS,看看RDY位有没有变化。这样一旦出问题,能快速定位是哪个环节卡住了。
好了,NVMe控制器初始化的核心内容就这些。从PCIe设备探测,到BAR映射,再到中断配置,最后操作寄存器,每一步都有它的道理。你写代码的时候,多想想「硬件为什么要这么设计」,而不是死记硬背API。这样遇到问题,你才能举一反三。