4、Linux内核NVMe驱动:驱动架构概览、核心数据结构与初始化流程
好,咱们今天来聊聊Linux内核里NVMe驱动的骨架。说实话,我当年第一次看NVMe驱动代码时,感觉像进了迷宫——结构体套结构体,回调函数满天飞。但后来摸清了门道,发现其实就那么几个核心模块在转。
NVMe驱动说白了,就是内核和NVMe固态硬盘之间的翻译官。它得把上层的块设备请求,翻译成NVMe协议里的命令,再通过PCIe总线发出去。反过来,也得把硬件的中断和完成状态,翻译回上层能理解的回调。
4.1 驱动架构概览
Linux内核的NVMe驱动,整体上分三层:
- PCIe驱动层:负责设备枚举、BAR空间映射、中断注册。这部分是标准的PCIe驱动框架。
- 核心管理层:管理控制器、命名空间、队列资源。这是驱动的大脑。
- 块设备层:把NVMe命名空间注册成块设备,处理上层的读写请求。
我个人习惯把这三层想象成「房东-管家-租客」的关系。PCIe层是房东,负责把房子(硬件资源)租给你;核心层是管家,管着钥匙(队列)和房客(命名空间);块设备层就是租客,只管住进来用。
关键点:NVMe驱动最巧妙的地方,在于它用多队列机制把这三层串了起来。每个CPU核心可以有自己的提交队列和完成队列,避免了锁竞争。
4.2 核心数据结构:struct nvme_dev
这个结构体是整个驱动的「总控台」。每个NVMe控制器对应一个nvme_dev实例。我曾在一次线上问题排查中,就是通过打印这个结构体的字段,定位到队列分配失败的根因。
struct nvme_dev {
struct nvme_queue **queues; // 队列数组,索引0是Admin队列
struct request_queue *admin_q; // 管理队列的请求队列
struct device *dev; // PCIe设备指针
struct pci_dev *pci_dev; // PCI设备结构体
int q_depth; // 队列深度,默认1024
int num_queues; // 当前分配的队列数
int max_qid; // 最大队列ID
u32 ctrl_config; // 控制器配置寄存器缓存
struct work_struct reset_work; // 重置工作项
struct mutex shutdown_lock; // 关机锁
...
};
这里我重点说几个字段:
queues:这是个指针数组,每个元素指向一个nvme_queue。索引0永远是Admin队列,索引1开始是I/O队列。q_depth:队列深度。我记得有次调优,把深度从128改成1024,随机读性能直接翻倍。但别盲目改大,太深了延迟会变高。num_queues:实际分配的队列数。不一定等于CPU核数,因为硬件可能不支持那么多。
避坑指南:我曾经遇到过一个问题,num_queues被错误地设成了0,导致驱动加载时直接崩溃。后来加了个校验:如果硬件返回的队列数小于1,就强制设为1(至少保留Admin队列)。
4.3 核心数据结构:struct nvme_queue
每个nvme_queue代表一个硬件队列对(Submission Queue + Completion Queue)。这是数据流动的管道。
struct nvme_queue {
struct nvme_dev *dev; // 所属控制器
spinlock_t sq_lock; // 提交队列自旋锁
spinlock_t cq_lock; // 完成队列自旋锁
void *sq_cmds; // 提交队列的内存映射
void *cq_cmds; // 完成队列的内存映射
dma_addr_t sq_dma_addr; // 提交队列的DMA地址
dma_addr_t cq_dma_addr; // 完成队列的DMA地址
u32 __iomem *q_db; // 门铃寄存器地址
u16 q_depth; // 队列深度
u16 cq_head; // 完成队列头指针
u16 sq_tail; // 提交队列尾指针
u16 cq_phase; // 完成队列相位位
u8 cq_vector; // 中断向量号
...
};
你想想看,为什么要有两个锁?因为提交和完成是异步的。提交队列由CPU写,完成队列由硬件写。用两个锁可以避免互相阻塞。嗯,这里要注意:sq_lock是自旋锁,因为提交操作必须在原子上下文中完成。
门铃寄存器q_db是个很有意思的东西。每次往提交队列里放了一个命令,就得写一次门铃,告诉硬件「有新活儿了」。我刚开始调试时,经常忘记写门铃,结果命令发出去硬件根本不执行,排查了半天。
注意事项:cq_phase这个字段很容易被忽略。硬件用相位位来标记完成条目是否有效。每次处理完一轮完成队列,相位位会翻转。如果处理逻辑写错了,可能会漏掉完成事件或者重复处理。
4.4 驱动初始化流程
驱动初始化,说白了就是三步走:发现设备、分配资源、启动运行。咱们一步步拆开看。
4.4.1 PCIe探测阶段
内核PCIe子系统发现NVMe设备后,会调用驱动的probe函数。这个函数叫nvme_probe。
static int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
struct nvme_dev *dev;
int result;
// 1. 分配nvme_dev结构体
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
return -ENOMEM;
// 2. 启用PCIe设备
result = pcim_enable_device(pdev);
if (result)
goto out_free_dev;
// 3. 设置DMA掩码(64位优先)
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));
// 4. 映射BAR空间
result = pcim_iomap_regions(pdev, 1 << 0, "nvme");
if (result)
goto out_free_dev;
// 5. 读取控制器能力寄存器
dev->ctrl_config = readl(dev->bar + NVME_REG_CAP);
...
}
这段代码里,我个人觉得最坑的是DMA掩码设置。有些老设备只支持32位DMA,如果你强行设64位,设备会静默失败。我建议先尝试64位,失败再回退到32位。
4.4.2 队列初始化
探测完硬件后,接下来就是创建队列。这一步叫nvme_create_io_queues。
static int nvme_create_io_queues(struct nvme_dev *dev)
{
unsigned int nr_io_queues;
int result;
// 1. 计算需要的队列数(通常等于CPU核数)
nr_io_queues = num_online_cpus();
result = nvme_set_queue_count(dev, &nr_io_queues);
if (result < 0)
return result;
// 2. 实际分配的队列数可能少于请求数
dev->num_queues = nr_io_queues + 1; // +1 是Admin队列
// 3. 为每个队列分配内存和DMA缓冲区
for (i = 0; i < dev->num_queues; i++) {
dev->queues[i] = nvme_alloc_queue(dev, i, q_depth);
if (!dev->queues[i])
goto out_free_queues;
}
// 4. 配置中断
result = nvme_configure_interrupt(dev, nr_io_queues);
...
}
这里有个细节:nvme_set_queue_count会发一个Admin命令给硬件,询问它支持多少队列。硬件返回的数字可能比请求的少。比如你请求64个,硬件说只能给32个,那就用32个。
经验之谈:我曾经在虚拟化环境下遇到过,硬件返回的队列数是0。原因是虚拟机没正确透传NVMe设备的中断。后来在nvme_set_queue_count后面加了个判断:如果返回0,就强制设为1(只保留Admin队列),至少能让设备识别出来。
4.4.3 命名空间扫描与块设备注册
队列准备好了,就该看看硬盘里有哪些命名空间了。这一步叫nvme_scan_namespaces。
static void nvme_scan_namespaces(struct nvme_dev *dev)
{
struct nvme_id_ns *id;
unsigned int nn; // Number of Namespaces
// 1. 发送Identify命令,获取命名空间列表
id = nvme_identify_ns(dev, 0, NVME_NSID_ALL);
nn = le32_to_cpu(id->nns);
// 2. 遍历每个命名空间
for (i = 1; i <= nn; i++) {
if (nvme_validate_ns(dev, i))
continue; // 跳过无效命名空间
// 3. 注册为块设备
nvme_alloc_ns(dev, i);
}
}
每个命名空间注册后,会在/dev/下出现一个nvmeXnY设备文件。比如nvme0n1就是第一个控制器的第一个命名空间。
嗯,这里要注意:命名空间ID不是连续的。有些设备会跳过一些ID。所以遍历时不能假设ID从1到nn都是有效的。
4.4.4 初始化完成后的状态
整个初始化流程走完后,驱动就处于就绪状态了。这时候:
- Admin队列可以处理管理命令(比如创建/删除I/O队列、获取日志)
- I/O队列可以处理读写命令
- 每个队列都绑定了中断,CPU可以并行处理完成事件
| 阶段 | 关键动作 | 常见问题 |
|---|---|---|
| PCIe探测 | 使能设备、映射BAR、设置DMA | DMA掩码设置失败 |
| 队列初始化 | 分配队列、配置中断 | 硬件返回队列数不足 |
| 命名空间扫描 | Identify命令、注册块设备 | 命名空间ID不连续 |
调试技巧:如果你在初始化阶段遇到问题,可以在/sys/kernel/debug/nvme/下查看调试信息。或者用dmesg看内核日志,NVMe驱动会打印详细的初始化过程。
好了,这一章的内容就到这里。驱动架构和初始化流程是理解NVMe多队列调度的基础。下一章咱们会深入队列调度算法,看看内核是怎么在多个队列之间分配IO请求的。