4、Linux NVMe驱动框架:驱动入口与模块初始化、PCIe probe流程、中断处理机制
各位好,今天我们聊聊NVMe驱动的骨架——驱动入口、PCIe probe流程,还有中断处理。这三个东西,说白了就是驱动从“被加载”到“开始干活”的完整路径。我刚开始接触NVMe驱动时,总觉得代码量太大,摸不着头脑。后来发现,只要把这三条主线理清楚,剩下的都是血肉填充。
4.1 驱动入口与模块初始化
每个内核驱动都有一个入口点。NVMe驱动也不例外。它的入口函数是 nvme_init(),在模块加载时被调用。
我个人习惯,看驱动先看module_init。这能告诉你驱动是怎么注册到内核的。NVMe驱动走的是PCIe子系统,所以它注册了一个 pci_driver 结构体。
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,
},
.sriov_configure = nvme_sriov_configure,
};
static int __init nvme_init(void)
{
int ret;
ret = nvme_core_init();
if (ret)
return ret;
ret = pci_register_driver(&nvme_driver);
if (ret)
nvme_core_exit();
return ret;
}
module_init(nvme_init);
这里有个细节:nvme_core_init() 先于 pci_register_driver() 调用。为什么?因为核心层初始化了字符设备、sysfs接口、还有内存分配器。这些是probe流程中要用到的基础设施。我在项目中遇到过有人把顺序搞反,结果probe时分配内存失败,查了半天才发现是初始化顺序的问题。
4.2 PCIe probe流程
当内核发现一个NVMe设备时,会调用 nvme_probe()。这个函数是驱动的心脏。它负责把PCI设备变成一个可用的块设备。
probe流程大致分几步:
- 启用PCI设备——调用
pci_enable_device(),让设备可以响应IO和内存访问。 - 设置DMA掩码——告诉设备能用多大的地址空间。64位设备设64位掩码,32位设备设32位掩码。
- 映射BAR空间——NVMe设备通常用BAR0作为门铃寄存器。通过
pci_ioremap_bar()映射到内核虚拟地址空间。 - 分配队列——创建管理队列(Admin Queue)和IO队列。管理队列用来发命令,IO队列用来传数据。
- 注册块设备——调用
nvme_alloc_admin_tags()和nvme_init_ctrl(),最终生成/dev/nvme0这样的设备节点。
static int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
struct nvme_dev *dev;
int result;
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
return -ENOMEM;
result = pci_enable_device_mem(pdev);
if (result)
goto out_free_dev;
result = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
if (result)
result = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(32));
result = nvme_setup_prp_pools(dev);
if (result)
goto out_disable_device;
result = nvme_init_ctrl(&dev->ctrl, &pdev->dev, &nvme_pci_ctrl_ops,
id->driver_data);
if (result)
goto out_release_prp_pools;
pci_set_drvdata(pdev, dev);
return 0;
out_release_prp_pools:
nvme_release_prp_pools(dev);
out_disable_device:
pci_disable_device(pdev);
out_free_dev:
kfree(dev);
return result;
}
嗯,这里要注意:nvme_setup_prp_pools() 是在 nvme_init_ctrl() 之前调用的。PRP(Physical Region Page)是NVMe用来描述数据缓冲区的结构。没有PRP池,后续的队列初始化就没法分配DMA缓冲区。我曾经在调试一个老版本内核时,发现PRP池分配失败导致probe卡死,最后定位到是DMA掩码设置不对——设备只支持32位,但代码默认设了64位。
4.3 中断处理机制
NVMe设备支持两种中断模式:传统MSI/MSI-X和中断聚合(Interrupt Coalescing)。我个人更推荐MSI-X,因为它支持每个队列独立中断,负载均衡效果更好。
中断处理的核心是 nvme_irq() 和 nvme_irq_check()。前者是标准中断处理函数,后者是轮询模式下的快速检查。
static irqreturn_t nvme_irq(int irq, void *data)
{
struct nvme_queue *nvmeq = data;
struct nvme_dev *dev = nvmeq->dev;
u16 start, end;
u16 completed;
if (nvmeq->cq_vector != irq)
return IRQ_NONE;
start = nvmeq->cq_head;
end = nvme_process_cq(nvmeq);
completed = end - start;
if (completed) {
nvmeq->cq_head = end;
if (nvmeq->cq_phase)
nvmeq->cq_phase = 0;
else
nvmeq->cq_phase = 1;
if (nvmeq->dev->ctrl.queue_count > 1)
nvme_ring_cq_doorbell(nvmeq);
}
return IRQ_HANDLED;
}
中断处理流程其实不复杂:
- 硬件把完成队列(Completion Queue)的条目写进内存。
- CPU收到中断,调用
nvme_irq()。 - 函数遍历完成队列,处理每个完成的命令。
- 处理完后,更新门铃寄存器,告诉硬件“我处理完了”。
这里有个性能关键点:nvme_process_cq() 会批量处理完成条目,而不是一个一个处理。这样可以减少门铃寄存器的写操作。门铃寄存器是MMIO空间,写一次开销不小。我在项目中遇到过,如果每个完成条目都写一次门铃,IOPS会下降30%以上。
4.4 整体框架图
下面这张图展示了NVMe驱动的整体框架。从模块初始化到中断处理,每个模块的职责一目了然。
这张图把三个模块串起来了。初始化注册PCI驱动,Probe创建设备和队列,中断处理响应硬件完成事件。三者缺一不可。
4.5 小结
NVMe驱动的入口、probe和中断处理,是理解整个驱动的钥匙。你想想看,驱动无非就是“初始化硬件 → 注册到内核 → 响应中断”这三件事。NVMe只是把这三件事做得更精细、更高效而已。
我个人建议,初学者先别急着看IO路径和命令提交。先把probe流程走一遍,把中断处理函数读懂。这两块搞明白了,剩下的都是锦上添花。
- module_init注册PCI驱动,nvme_core_init提供基础设施
- probe流程:使能设备 → 设置DMA → 映射BAR → 分配队列 → 注册块设备
- 中断处理:批量处理完成队列,减少门铃写操作
- MSI-X优于MSI,支持每个队列独立中断
好了,这一章就到这里。下一章我们深入IO路径,看看NVMe命令是怎么提交和完成的。