首页 / 资讯中心 / 文章详情

深入Linux内核NVMe驱动源码:架构、初始化与IO路径解析

深入Linux内核NVMe驱动源码:架构、初始化与IO路径解析 ★ FEATURED ARTICLE
1. 从一块盘说起为什么要啃NVMe源码搞存储的圈子里有个说法你写的每一行文件读写代码最后都会变成NVMe协议里的一串命令。这话不夸张。现在但凡是个像样的服务器系统盘、数据盘、缓存盘十有八九挂的都是NVMe SSD。SATA那套AHCI的玩法在延迟和队列深度上早就被NVMe按在地上摩擦了。但问题也来了——用起来爽出问题就抓瞎。盘突然掉线、IO卡死、性能跑不满、超时重置这些场景你光靠dmesg里那几行日志根本定位不到根因必须往内核源码里钻。这篇东西就是我自己啃NVMe驱动源码的笔记整理。目标很明确把NVMe在Linux内核里的初始化流程、命令下发路径、队列机制、中断处理这几块硬骨头拆开揉碎讲清楚。适合谁看写过驱动、调过性能、被存储问题折磨过的后端开发和内核爱好者。如果你连probe函数是啥都不知道建议先去补补设备模型的基础不然看下去会比较痛苦。我用的内核版本是5.x系列的某个长期支持版不同版本之间函数名和结构体字段会有出入但主干逻辑大差不差。源码路径主要在drivers/nvme/host/下面核心文件就那几个pci.c管PCIe设备的探测和初始化core.c是通用层rdma.c、tcp.c、fc.c这些是不同传输层的实现。我们重点看PCIe这条线因为它是绝大多数本地NVMe盘的接入方式。提示读源码之前先把NVMe协议的spec过一遍至少把Admin命令集和IO命令集的提交队列、完成队列格式搞清楚。不然看代码里那些sq_head、cq_head、phase bit会一头雾水。2. NVMe驱动整体架构与设计思路拆解2.1 分层设计为什么不是一坨代码Linux的NVMe驱动没有写成一个大杂烩而是分了三层。最底下是传输层负责跟具体的硬件总线打交道PCIe、RDMA、TCP、FC各有一套实现。中间是核心层处理协议相关的通用逻辑比如命令的构造、队列的管理、超时处理、错误恢复。最上面是块设备层把NVMe盘抽象成标准的块设备对接文件系统和页缓存。这么分层的好处很明显。你换一种传输方式比如从PCIe换成RDMA核心层的代码几乎不用动只需要实现传输层那几个回调函数就行。反过来协议升级了比如从NVMe 1.4升到2.0核心层改一改传输层基本不受影响。这种设计思路在驱动开发里很常见但NVMe把它做得特别干净因为协议本身就是为多队列、高并发设计的接口抽象得很到位。我刚开始看的时候有个疑问为什么不把PCIe相关的代码直接塞进核心层后来想明白了NVMe over Fabrics是个很重要的场景如果核心层跟PCIe绑死那网络存储那块就没法复用了。内核维护者在这方面的洁癖是值得尊重的。2.2 核心数据结构三座大山整个驱动里最关键的数据结构就三个struct nvme_dev、struct nvme_queue、struct nvme_command。这三个东西理解了源码就通了一半。nvme_dev是PCIe设备的私有数据挂在pci_dev的driver_data上。它里面存了BAR空间的映射地址、所有的IO队列、Admin队列、设备能力寄存器读出来的值等等。你可以把它当成整个设备的控制中心。nvme_queue是队列的抽象。NVMe协议里队列分两种Admin队列和IO队列。Admin队列只有一个用来发管理命令比如创建IO队列、获取SMART信息、格式化盘。IO队列可以有很多个每个CPU核心可以分到一个这样就不需要锁竞争了。每个队列里有提交队列SQ和完成队列CQSQ里放的是要下发的命令CQ里放的是硬件写完的结果。nvme_command就是命令本身。NVMe的命令是64字节的固定格式里面包含了操作码、命名空间ID、LBA、数据长度、PRP/SGL列表等信息。这个结构体在代码里定义得很清楚每个字段对应协议里的哪一位都能查到。注意nvme_queue里的sq_cmds和cqes都是DMA一致性内存分配的时候要用dma_alloc_coherent不能用普通的kmalloc。因为设备要直接访问这块内存必须保证物理地址连续且缓存一致。2.3 队列映射多队列怎么分NVMe最大的卖点之一就是多队列。一个盘可以支持几千个队列每个队列深度可以到64K。Linux块层的多队列机制blk-mq跟NVMe简直是天作之合。驱动在初始化的时候会读控制器的能力寄存器看看它支持多少个队列然后根据CPU数量来决定实际创建多少个。具体怎么分默认情况下每个CPU核心对应一个IO队列。如果你的机器有64个逻辑核那就创建64个IO队列。这样每个核心往自己的队列里塞命令不需要跟别的核心抢锁。中断也是每个队列一个MSI-X向量哪个队列有完成事件就触发哪个中断然后由对应的CPU处理。这种设计在高并发场景下性能提升非常明显。但也不是队列越多越好。每个队列都要占内存SQ和CQ加起来再乘以队列深度内存开销不小。而且有些盘虽然标称支持很多队列实际跑起来队列太多反而会增加调度开销。所以驱动里有个io_queue_count的限制可以通过模块参数调整。3. 初始化流程从PCIe枚举到块设备注册3.1 probe函数的入口逻辑一切从nvme_probe开始。这个函数在pci.c里当PCIe总线枚举到一个匹配的设备时被调用。第一步是pci_enable_device把设备的BAR空间映射到内核地址空间。然后读PCI配置空间里的class code确认这确实是个NVMe设备。接下来是nvme_dev_map把BAR0映射进来。BAR0里放的是控制器的寄存器包括能力寄存器CAP、配置寄存器CC、状态寄存器CST、Admin队列属性寄存器AQA、Admin提交队列基址ASQ、Admin完成队列基址ACQ等等。这些寄存器是驱动跟硬件沟通的窗口。映射完之后驱动会读CAP寄存器看看控制器支持的最大队列深度、支持的命令集、是否有易失性写缓存、是否支持SQ/CQ的物理地址不连续等等。这些信息决定了后面怎么配置队列。// 简化后的probe流程 static int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id) { // 使能PCIe设备 pci_enable_device_mem(pdev); // 设置DMA掩码 dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(64)); // 映射BAR空间 dev nvme_pci_alloc_dev(pdev); // 读能力寄存器 nvme_read_cap(dev); // 配置Admin队列 nvme_configure_admin_queue(dev); // 识别控制器 nvme_init_ctrl(dev); // 创建IO队列 nvme_create_io_queues(dev); // 注册块设备 nvme_alloc_ns(dev); }3.2 Admin队列的建立Admin队列是驱动跟控制器沟通的第一条通道。在创建IO队列之前必须先有Admin队列。流程是这样的先分配一块DMA内存作为Admin提交队列ASQ再分配一块作为Admin完成队列ACQ。然后把这两块内存的物理地址写到ASQ和ACQ寄存器里。接着配置AQA寄存器设置队列深度。最后写CC寄存器把EN位置1启动控制器。启动之后驱动会等CST寄存器里的RDY位变成1表示控制器准备好了。这个过程可能需要几百毫秒所以驱动里用了msleep来轮询。如果超时了还没ready那就说明控制器有问题probe失败。Admin队列建好之后驱动会发一个Identify命令问控制器要基本信息型号、序列号、固件版本、支持的命名空间列表、支持的IO命令集等等。这个Identify命令的返回数据是一页4KB的结构体里面字段非常多驱动会挑需要的存到nvme_ctrl里。实操心得如果你在调试的时候发现probe卡在等RDY位先检查CC寄存器的值是不是写进去了。有些主板BIOS会锁住NVMe的配置空间导致写寄存器无效。另外BAR空间映射失败也会导致读出来的CAP全是0xFF这种情况一般是PCIe链路有问题。3.3 IO队列的创建与中断绑定Admin队列跑通之后就可以创建IO队列了。驱动会根据CPU数量计算需要多少个队列然后通过Admin命令Create IO Completion Queue和Create IO Submission Queue逐个创建。每个队列的中断向量通过MSI-X分配驱动会调用pci_alloc_irq_vectors来申请中断向量然后把每个队列的中断处理函数注册上去。中断处理函数nvme_irq要做的事情很简单读CQ里的完成条目根据phase bit判断是不是新的完成事件然后把对应的请求标记为完成最后触发块层的软中断去处理后续的IO完成逻辑。这里有个细节NVMe的CQ是环形缓冲区驱动维护一个cq_head指针硬件维护一个cq_tail指针。当cq_head追上cq_tail时队列就空了。中断绑定这块有个坑如果MSI-X向量不够用多个队列可能会共享一个中断向量。这时候中断处理函数就需要遍历所有共享该向量的队列效率会下降。所以生产环境里最好确保MSI-X向量数量足够覆盖所有IO队列。// 中断处理的核心逻辑 static irqreturn_t nvme_irq(int irq, void *data) { struct nvme_queue *nvmeq data; // 读CQ条目 while (nvmeq-cq_head ! nvmeq-cq_tail) { struct nvme_completion *cqe nvmeq-cqes[nvmeq-cq_head]; // 检查phase bit if (nvme_cqe_valid(nvmeq, nvmeq-cq_head)) { // 处理完成事件 nvme_handle_cqe(nvmeq, nvmeq-cq_head); // 移动head指针 nvmeq-cq_head (nvmeq-cq_head 1) % nvmeq-q_depth; } } // 更新CQ的doorbell寄存器 writel(nvmeq-cq_head, nvmeq-q_db); }3.4 命名空间的扫描与块设备注册IO队列建好之后驱动会发Identify命令获取命名空间列表。每个命名空间对应一个块设备比如/dev/nvme0n1、/dev/nvme0n2。驱动会为每个命名空间分配一个struct nvme_ns然后调用nvme_alloc_ns来注册块设备。注册块设备的时候要设置请求队列的操作函数包括queue_rq下发IO、timeout超时处理、poll轮询模式等等。queue_rq是整个IO路径的入口块层把请求交过来驱动把它转换成NVMe命令然后写到SQ里最后敲一下doorbell寄存器通知硬件。命名空间的容量、LBA格式、支持的读写命令集这些信息也是通过Identify命令获取的。驱动会根据这些信息设置块设备的逻辑块大小和物理块大小。如果你的盘支持4K原生扇区但文件系统格式化成了512字节性能会打折扣因为每次写都要做读-改-写。4. IO命令下发路径从块层到硬件4.1 请求到命令的转换块层把IO请求交给驱动的时候已经做了一层合并和排序。驱动拿到的是struct request里面包含了起始扇区、数据长度、读写方向、数据页的散列表等信息。nvme_queue_rq函数负责把这个请求转换成NVMe命令。转换过程不复杂但细节很多。首先根据请求的方向选择操作码读是nvme_cmd_read写是nvme_cmd_write。然后填命名空间ID、LBA、数据长度。数据长度字段在NVMe命令里是以逻辑块为单位不是字节数所以要用blk_rq_bytes除以块大小。接下来处理数据指针NVMe支持PRP和SGL两种方式。PRP适合物理地址连续或者只有两段的情况SGL更灵活但需要硬件支持。驱动默认用PRP如果数据页超过两页就构造PRP列表。PRP列表的构造是个技术活。第一个PRP指向数据缓冲区的起始物理地址第二个PRP如果数据不超过一页就直接指向数据如果超过一页就指向一个PRP列表页列表页里再放后续的物理页地址。这个逻辑在nvme_setup_prps函数里实现代码不长但很容易看晕。// PRP构造的核心逻辑 static int nvme_setup_prps(struct nvme_dev *dev, struct request *req) { struct nvme_iod *iod blk_mq_rq_to_pdu(req); struct bio_vec bv; // 获取第一个页的物理地址 bv req_bvec(req); iod-first_dma dma_map_page(dev-dev, bv.bv_page, bv.bv_offset, bv.bv_len, DMA_TO_DEVICE); // 如果数据超过一页构造PRP列表 if (blk_rq_bytes(req) bv.bv_len) { // 分配PRP列表页 // 把后续页的物理地址填进去 } }4.2 提交队列的写入与doorbell命令构造好之后驱动要把它写到SQ里。SQ是个环形缓冲区驱动维护一个sq_tail指针每次写一个命令就往后移一格。写完之后要把新的sq_tail值写到SQ的doorbell寄存器里通知硬件有新命令了。这里有个内存屏障的问题。写命令数据和写doorbell之间必须保证顺序否则硬件可能在命令还没写完的时候就看到了doorbell导致读到不完整的命令。所以驱动里用了wmb()或者dma_wmb()来保证写顺序。这个细节在x86上可能不明显因为x86的写操作本来就是有序的但在ARM或者PowerPC上不写屏障就会出问题。doorbell寄存器的地址是通过BAR空间映射的每个队列有自己的doorbell偏移。SQ的doorbell偏移是0x1000 (qid * 8)CQ的doorbell偏移是0x1000 (qid * 8) 4。写doorbell的时候用writel因为doorbell是32位的。注意doorbell写入的值是队列的tail指针不是head指针。SQ的doorbell告诉硬件“我已经写到这个位置了”CQ的doorbell告诉硬件“我已经处理到这个位置了”。搞反了会导致队列卡死。4.3 完成队列的处理与请求完成硬件处理完命令后会把结果写到CQ里然后触发中断。中断处理函数读CQ条目检查phase bit如果有效就说明这是一个新的完成事件。完成条目里包含了命令ID、状态码、SQ head位置等信息。驱动根据命令ID找到对应的请求检查状态码如果成功就调用blk_mq_complete_request通知块层如果失败就根据错误码做相应的处理。状态码分好几类成功、命令特定错误、数据保护错误、媒体错误等等。有些错误是可以重试的比如短暂的传输错误有些错误是致命的比如媒体损坏。驱动里有个nvme_decide_retry函数来判断是否需要重试。重试的时候会把请求重新放到队列里等下一次调度。完成队列的处理还有个批量优化的空间。如果一次中断里有多个完成事件驱动可以一次性处理完再更新doorbell减少寄存器写入次数。这个优化在高IOPS场景下效果很明显。4.4 超时处理与错误恢复NVMe驱动有个超时机制。每个请求下发的时候会记录一个时间戳如果超过nvme_io_timeout默认30秒还没完成超时处理函数就会被调用。超时处理分几步先尝试取消命令如果取消成功就重试如果取消失败就触发控制器重置。控制器重置是个大动作。驱动会把所有队列停掉重置控制器重新初始化Admin队列和IO队列然后重新扫描命名空间。这个过程会导致所有未完成的IO失败文件系统可能会报错。所以生产环境里要尽量避免超时可以通过调整nvme_io_timeout参数来适应不同的负载。错误恢复的代码在core.c的nvme_reset_work里。这个函数会在一个工作队列里执行因为重置过程不能在中断上下文里做。重置的时候会先nvme_kill_queues把所有队列标记为死亡然后nvme_disable_ctrl关掉控制器再重新nvme_enable_ctrl。整个过程跟probe的时候很像但多了清理旧资源的步骤。5. 常见问题与排查技巧实录5.1 盘识别不到怎么办这是最常见的问题。先看lspci能不能看到设备。如果看不到说明PCIe链路有问题检查插槽、供电、BIOS设置。如果能看到但/dev/nvme*没出现看dmesg里有没有nvme相关的报错。常见的错误有BAR空间映射失败、CAP寄存器读出来是0、Admin队列创建超时。BAR映射失败一般是PCIe资源分配问题可以尝试在BIOS里打开Above 4G Decoding。CAP读出来是0说明设备没上电或者链路没训练好。Admin队列超时可能是固件问题试试升级固件或者换一个内核版本。5.2 IO性能跑不满怎么调先确认队列数量够不够。cat /sys/block/nvme0n1/queue/nr_requests看看队列深度ls /sys/block/nvme0n1/mq/看看有多少个队列。如果队列数少于CPU数说明MSI-X向量不够检查BIOS里的MSI-X设置。然后看中断亲和性。cat /proc/interrupts | grep nvme看看每个队列的中断是不是均匀分布在不同CPU上。如果不均匀可以用irqbalance或者手动设置/proc/irq/*/smp_affinity来调整。还有可能是IO调度器的问题。NVMe盘建议用none调度器因为硬件本身就有很好的调度能力内核再调度反而增加开销。echo none /sys/block/nvme0n1/queue/scheduler。5.3 超时重置频繁发生超时重置说明有命令长时间没完成。先看dmesg里的超时日志里面会打印超时的命令ID和队列ID。如果是某个队列集中超时可能是那个队列的中断有问题。检查/proc/interrupts里对应中断的计数是不是在增长。如果是所有队列都超时可能是控制器固件挂了。试试nvme reset /dev/nvme0手动重置。如果重置也失败可能需要冷重启。固件问题只能靠升级固件解决驱动层面能做的不多。还有一种情况是电源管理导致的。NVMe盘支持APSTAutonomous Power State Transition在空闲的时候会自动进入低功耗状态。如果盘从低功耗状态唤醒太慢就会导致超时。可以在内核参数里加nvme_core.default_ps_max_latency_us0来禁用APST。5.4 常见问题速查表问题现象可能原因排查方法解决措施盘识别不到PCIe链路问题lspci查看设备检查插槽、BIOS设置BAR映射失败资源分配不足dmesg查看报错打开Above 4G DecodingAdmin队列超时固件问题查看CST寄存器升级固件或换内核IO性能低队列数不足查看mq目录调整MSI-X向量中断不均匀亲和性未设置cat /proc/interrupts手动设置smp_affinity频繁超时电源管理查看APST状态禁用APST重置失败控制器挂死查看重置日志冷重启或换盘实操心得调试NVMe问题的时候nvme-cli工具比dmesg好用得多。nvme list看设备列表nvme smart-log看健康状态nvme error-log看错误日志nvme get-feature看特性配置。这些信息比内核日志详细得多定位问题快很多。5.5 源码阅读的避坑指南读NVMe源码有几个坑要注意。第一不同内核版本的函数名和结构体字段变化很大网上搜到的代码片段可能跟你的版本对不上。建议直接看手头内核的源码用cscope或者ctags建索引跳转方便。第二NVMe协议里的很多概念在代码里的命名跟spec不完全一致。比如spec里叫Submission Queue Entry代码里叫nvme_commandspec里叫Completion Queue Entry代码里叫nvme_completion。看代码的时候要对着spec看不然容易混淆。第三中断处理和队列管理的代码有很多并发场景读的时候要时刻想着“这段代码在哪个上下文里执行”。有些函数是在中断上下文里跑的不能睡眠有些是在工作队列里跑的可以睡眠。搞错了上下文代码逻辑就看不懂了。第四不要一上来就啃core.c那个文件太大了。先从pci.c的probe流程看起把初始化路径理清楚然后再看IO路径。IO路径从nvme_queue_rq开始跟着请求的生命周期走一遍基本就通了。6. 写在最后NVMe驱动源码是个很好的学习材料它把多队列、DMA、中断处理、错误恢复这些内核驱动开发的核心技术都串起来了。啃完这一套再看其他块设备驱动会轻松很多。我自己的经验是第一遍看的时候不要纠结每个细节先把主干流程跑通知道每个函数大概在干什么。第二遍再深入看关键函数的实现比如PRP构造、doorbell写入、完成处理。第三遍可以带着问题去看比如“超时重置的时候队列是怎么清理的”、“中断共享的时候怎么区分是哪个队列的事件”。源码这东西看一遍有一遍的收获。我到现在也不敢说完全看懂了每次遇到新问题回去翻代码总能发现之前忽略的细节。如果你也在啃这块建议边看边做笔记把关键流程画成图比光看代码效率高得多。遇到卡住的地方去邮件列表里搜搜很多时候别人已经踩过坑了。
阅读完成 · 觉得有帮助?
咨询建站