资讯动态

NVMe驱动开发入门:从PCIe枚举到队列初始化实战

发布时间:2026/9/30 1:03:11 来源:尧图企业网站定制
1. 为什么说 NVMe 是存储驱动入门的“最优解”搞存储驱动开发最怕的就是一上来就啃那些几十年前的老古董协议。你去看 IDE、SATA 甚至早期的 SCSI光是兼容性处理和历史包袱就能把人劝退。但 NVMe 不一样它从诞生之初就是为闪存设计的协议栈干净、队列模型优雅、寄存器定义清晰最关键的是——它站在 PCIe 的肩膀上你不需要去折腾那些复杂的物理层协议只要把 PCIe 枚举和配置空间搞明白剩下的就是对着 NVMe 规范填命令了。我当初从零开始摸 NVMe 驱动的时候最大的感受就是“路径短”。从 Linux 内核的nvme_probe函数进去到真正发出第一条 Identify 命令中间涉及的代码路径比 SATA 的 AHCI 驱动短了将近一半。这对于刚接触存储驱动的同学来说意味着你可以在更短的时间内看到“自己写的代码真的从硬盘里读出了数据”这个正反馈而不是在层层抽象里迷失方向。这篇文章主要面向三类人一是刚入行做嵌入式存储的工程师二是想从应用层下沉到内核驱动的开发者三是需要在自己的板子上适配 NVMe 固态的 BSP 工程师。我会从 PCIe 枚举讲起一路走到 NVMe 队列的初始化和命令提交中间穿插 U-Boot 阶段的准备工作和 Linux 内核里的实操细节。你不需要有很深的存储背景但至少得能看懂 C 语言和基本的寄存器操作。提示本文涉及的代码和操作均基于公开的 PCIe 与 NVMe 规范不涉及任何特定厂商的私有协议。所有实验建议在自研开发板或虚拟机环境中进行避免影响生产设备。2. 从 PCIe 枚举到 NVMe 设备识别底层链路是怎么打通的2.1 PCIe 枚举过程驱动加载前的“点名”环节在 NVMe 驱动真正开始工作之前PCIe 总线必须先完成枚举。这个过程说白了就是主机RCRoot Complex挨个问“有没有设备挂在总线上你是什么类型需要多少资源”对于 NVMe 固态来说它通常是一个 PCIe EndpointEP挂在某个 Root Port 下面。枚举的核心是配置空间的读取。每个 PCIe 设备都有 4KB 的配置空间前 256 字节是标准 PCI 兼容区域后面的 4KB 是 PCIe 扩展能力区域。NVMe 设备在配置空间里会声明自己的 Class Code 为0x010802这个值就是告诉系统“我是个 NVMe 存储控制器。”我在调试基于 LS1028A 的板子时遇到过枚举失败的情况。现象是lspci能看到设备但驱动加载后报probe timeout。后来查下来是 RC 侧的PERST#信号释放时序不对。PERST#是 PCIe 的复位信号规范要求它在电源和时钟稳定后至少保持 100ms 的低电平然后才能拉高。如果拉高太早EP 侧还没完成内部初始化枚举就会失败。注意PERST#的时序是硬件工程师容易忽略的点。如果你在 bring-up 阶段发现设备时有时无先拿示波器看这个信号别急着怀疑驱动。2.2 配置空间里的关键寄存器BAR 与命令寄存器枚举完成后系统需要给 NVMe 设备分配地址空间。NVMe 控制器通常需要两个 BARBase Address Register一个用于映射控制器寄存器通常是 BAR0另一个用于映射 Doorbell 寄存器通常是 BAR1。BAR0 里放的是 NVMe 的 CAP、VS、CC、CSTS 等核心寄存器BAR1 里放的是 SQSubmission Queue和 CQCompletion Queue的 Doorbell。这里有个细节NVMe 规范要求 BAR0 和 BAR1 必须是内存空间不能是 I/O 空间。如果你在 U-Boot 里手动配置 BAR记得把最低位设为 0表示内存映射。另外BAR 的大小需要通过写入全 1 再读回来来确定这个操作叫“BAR sizing”是 PCIe 枚举的标准流程。命令寄存器Command Register的 bit 0 是 I/O Space Enablebit 1 是 Memory Space Enablebit 2 是 Bus Master Enable。对于 NVMe 设备你必须把 bit 1 和 bit 2 都置 1否则设备无法响应内存访问也无法发起 DMA。我见过有同事只置了 bit 1结果驱动能读到寄存器但发不出命令查了半天才发现是 Bus Master 没开。2.3 U-Boot 阶段的准备工作让内核“看见”硬盘如果你的系统跑的是 U-Boot Linux 的组合那 U-Boot 阶段的 PCIe 初始化就至关重要。U-Boot 需要完成以下几件事配置 RC 的地址窗口、扫描总线、给 EP 分配 BAR、使能 Memory Space 和 Bus Master。这些做完之后Linux 内核启动时才能通过设备树或 ACPI 拿到正确的 PCIe 拓扑信息。我在银河麒麟 V10 上换内核 4.19 的时候就遇到过 U-Boot 没把 BAR 配好导致内核枚举不到 NVMe 的情况。当时的现象是dmesg里只有pcieport的日志完全没有nvme相关的输出。后来在 U-Boot 里加了pci enum命令手动触发枚举才发现是 RC 的MEMORY_BASE和MEMORY_SIZE设错了导致 BAR 分配到了无效地址。提示U-Boot 的pci命令是个好东西。pci list可以看设备列表pci display可以看配置空间pci bar可以看 BAR 分配情况。Bring-up 阶段多用这些命令比直接改代码猜要快得多。3. NVMe 驱动核心结构队列、命令与中断处理3.1 队列模型为什么 NVMe 比 AHCI 快那么多AHCI 只有一个命令队列深度是 32。NVMe 呢最多支持 64K 个队列每个队列深度也是 64K。这个差距不是量变是质变。多队列意味着多核 CPU 可以各自用自己的队列不需要抢锁不需要等一个队列空出来。这就是 NVMe 在高并发场景下碾压 SATA 的根本原因。NVMe 的队列分为 SQSubmission Queue和 CQCompletion Queue。SQ 是主机写给设备的命令CQ 是设备写给主机的完成状态。每个队列都是一段连续的内存里面放的是固定大小的条目SQ Entry 64 字节CQ Entry 16 字节。主机通过写 Doorbell 寄存器来通知设备“SQ 里有新命令了”设备通过写 Doorbell 来通知主机“CQ 里有新完成了”。这里有个容易搞混的点SQ 的 Doorbell 在 BAR1 里的偏移是0x1000 (2 * qid) * 4CQ 的 Doorbell 偏移是0x1000 (2 * qid 1) * 4。qid 是队列 ID0 是 Admin 队列1 开始是 I/O 队列。我当初写驱动的时候把 SQ 和 CQ 的 Doorbell 偏移搞反了结果命令发出去设备没反应查了一晚上才定位到。3.2 Admin 队列与 I/O 队列的初始化流程NVMe 控制器上电后默认只有一个 Admin 队列。驱动需要先通过 Admin 队列发送 Identify 命令获取控制器的能力集比如支持多少队列、队列深度是多少然后通过 Set Features 命令创建 I/O 队列。初始化流程大致是这样的等待 CSTS.RDY 变为 0确保控制器处于禁用状态。配置 Admin 队列的基地址AQA、ASQ、ACQ 寄存器。设置 CC.EN 为 1使能控制器。等待 CSTS.RDY 变为 1确认控制器就绪。通过 Admin 队列发送 Identify Controller 命令获取队列能力。通过 Admin 队列发送 Set FeaturesNumber of Queues协商 I/O 队列数量。通过 Admin 队列发送 Create I/O Completion Queue 和 Create I/O Submission Queue 命令。初始化完成可以开始发 I/O 命令了。每一步都有坑。比如第 3 步CC.EN 置 1 之前必须先设置 CC.CSS 为 0表示使用 NVM 命令集否则控制器可能不认。再比如第 6 步Set Features 的返回值是实际分配的队列数量可能比你请求的少驱动必须用返回值来初始化不能想当然。3.3 中断处理MSI-X 与轮询的取舍NVMe 支持 MSI-X 中断每个 CQ 可以绑定一个中断向量。这样当命令完成时设备直接给对应的 CPU 发中断不需要驱动去轮询所有队列。但在某些嵌入式场景下MSI-X 可能不可用或者中断延迟太高这时候就需要退回到轮询模式。我在一块 FPGA 板子上调试时发现 MSI-X 中断偶尔会丢。后来查下来是 PCIe 的Mask寄存器没配对导致中断被屏蔽了。解决方法是在使能 MSI-X 之前先清除所有向量的 Mask 位然后再设置Enable位。这个顺序不能反否则可能丢中断。轮询模式虽然简单但 CPU 占用率会很高。我的经验是如果队列深度不大比如 32且 I/O 频率不高轮询可以接受但如果队列深度上百还是老老实实把 MSI-X 调通。4. 实操在 Linux 内核里写一个最小 NVMe 驱动4.1 环境准备与代码框架我假设你已经有一个能跑 Linux 的开发板PCIe 链路已经调通lspci能看到 NVMe 设备。内核版本建议 4.19 以上因为 4.19 对 NVMe 的支持已经比较完善但又不至于太新导致 API 变动太大。代码框架很简单一个pci_driver结构体注册probe和remove回调。在probe里做 BAR 映射、队列初始化、中断注册在remove里做资源释放。static struct pci_driver nvme_driver { .name my_nvme, .id_table nvme_id_table, .probe my_nvme_probe, .remove my_nvme_remove, };nvme_id_table里填PCI_DEVICE_CLASS(PCI_CLASS_STORAGE_EXPRESS, 0xffffff)这样就能匹配到所有 NVMe 设备。4.2 BAR 映射与寄存器访问在probe函数里第一步是pci_enable_device然后是pci_request_mem_regions接着用pci_iomap把 BAR0 和 BAR1 映射到内核虚拟地址。bar0 pci_iomap(pdev, 0, 0); bar1 pci_iomap(pdev, 1, 0);映射完成后就可以用readl和writel访问寄存器了。比如读 CAP 寄存器u64 cap readl(bar0 NVME_REG_CAP); cap | (u64)readl(bar0 NVME_REG_CAP 4) 32;注意 CAP 是 64 位的要分两次读。我第一次写的时候只读了低 32 位结果算出来的队列深度是错的调了半天。4.3 队列内存分配与 Doorbell 配置队列内存必须是物理连续的所以不能用kmalloc要用dma_alloc_coherent。Admin 队列的 SQ 和 CQ 各需要 4KB假设深度是 64每个条目 64 字节64 * 64 4096。I/O 队列根据协商的数量来分配。admin_sq dma_alloc_coherent(pdev-dev, 4096, admin_sq_dma, GFP_KERNEL); admin_cq dma_alloc_coherent(pdev-dev, 4096, admin_cq_dma, GFP_KERNEL);然后把物理地址写到 ASQ 和 ACQ 寄存器里writel(admin_sq_dma 0xFFFFFFFF, bar0 NVME_REG_ASQ); writel(admin_sq_dma 32, bar0 NVME_REG_ASQ 4);Doorbell 的配置前面说过SQ 和 CQ 的偏移不一样写的时候要小心。4.4 发送第一条 Identify 命令Identify 命令是 NVMe 驱动必须发的第一条命令。它的作用是把控制器的详细信息型号、序列号、队列能力等读到主机内存里。命令的构造很简单在 SQ 里填一个 64 字节的条目opcode设为0x06Identifycid设为一个唯一的值nsid设为 0表示控制器级别prp1设为数据缓冲区的物理地址。struct nvme_command cmd {0}; cmd.identify.opcode 0x06; cmd.identify.cid 1; cmd.identify.prp1 cpu_to_le64(buf_dma);填完之后写 SQ 的 Doorbell然后等 CQ 的相位位Phase Bit翻转。相位位是 CQ 条目里的一个标志用来判断条目是否有效。初始时相位位是 0设备写完一个条目后会翻转一次。我当初等相位位的时候忘了先读一遍 CQ 的初始状态结果第一次判断就错了。正确的做法是初始化时记录当前的相位位然后每次等它翻转。5. 常见问题与排查技巧实录5.1 设备识别不到从 PCIe 链路查起如果你在lspci里看不到 NVMe 设备问题大概率在 PCIe 物理层或枚举阶段。排查顺序如下现象可能原因排查方法lspci无设备PERST# 时序不对示波器看 PERST# 和参考时钟lspci有设备但驱动不 probeClass Code 不匹配lspci -vv看 Class 字段驱动 probe 但读寄存器全 FBAR 未映射或地址错误cat /proc/iomem看 BAR 分配命令超时Bus Master 未使能读 Command Register 的 bit 25.2 命令超时队列配置的常见坑命令超时是最常见的问题。我总结了几种情况第一种是 Doorbell 写错了。SQ 和 CQ 的 Doorbell 偏移不一样写反了设备就收不到通知。第二种是队列深度设错了。AQA 寄存器里的深度是“实际深度减一”比如深度 64 要写 63。第三种是 PRP 列表没配对。如果数据超过一个页需要 PRP List而不是简单的 PRP1。提示调试命令超时时可以先在 QEMU 里跑一遍。QEMU 的 NVMe 模拟器有详细的日志能看到命令有没有被正确解析。5.3 中断丢失MSI-X 配置的注意事项MSI-X 中断丢失通常有三个原因一是 Mask 位没清二是向量表地址没对齐必须是 4KB 对齐三是中断亲和性设错了导致中断发到了没有处理程序的 CPU 上。我的做法是在注册 MSI-X 之前先用pci_alloc_irq_vectors申请向量然后用pci_irq_vector获取具体的 IRQ 号最后用request_irq注册处理函数。每一步都检查返回值出错就打印日志。5.4 性能不达标队列深度与中断合并的调优如果你发现读写速度远低于预期先看队列深度。默认的 Admin 队列深度通常够用但 I/O 队列深度需要根据负载调整。深度太小命令排队等待深度太大内存占用高且延迟增加。另一个调优点是中断合并。NVMe 支持中断合并Interrupt Coalescing可以设置“多少个命令完成后发一次中断”或“多长时间发一次中断”。在高 IOPS 场景下适当合并可以减少 CPU 中断开销。我在一块 E5 的板子上测 NVMe 启动 Win10BIOS 里没开 Above 4G Decoding结果 NVMe 只能跑在 32 位地址空间带宽直接砍半。后来在 BIOS 里打开 Above 4G速度才恢复正常。这个坑在服务器主板上很常见尤其是老平台。6. 从驱动到应用NVMe 在系统启动与虚拟化中的角色6.1 U-Boot 里的 NVMe 支持让内核从硬盘启动很多嵌入式板子现在都支持从 NVMe 启动。U-Boot 需要先初始化 PCIe然后扫描 NVMe 设备读取分区表最后把内核镜像从 NVMe 加载到内存。这个过程涉及nvme scan、nvme part、ext4load等命令。我在 LS1028A 上配 U-Boot 的 NVMe 启动时发现nvme scan能识别到设备但nvme part读不出分区表。后来查下来是 U-Boot 的 NVMe 驱动没使能CONFIG_NVME_PARTITIONS加上这个配置后就好了。6.2 内核虚拟化场景下的 NVMe 直通在虚拟化场景下NVMe 设备可以通过 VFIO 直通给虚拟机。这样做的好处是虚拟机里的驱动可以直接操作硬件性能接近原生。但配置起来有几个关键点一是 IOMMU 必须使能否则 DMA 会出错二是 MSI-X 中断需要重映射三是 BAR 空间需要映射到虚拟机的地址空间。我试过在 QEMU/KVM 里直通一块 NVMe 固态虚拟机的启动速度比用 virtio-blk 快了将近 30%。但直通之后宿主机就不能再用这块盘了所以适合那种“一块盘专门给一个虚拟机”的场景。6.3 银河麒麟 V10 换内核后的 NVMe 兼容性银河麒麟 V10 默认的内核版本可能比较老有些新的 NVMe 特性不支持。换到 4.19 内核后NVMe 驱动会更完善但也要注意内核配置里的CONFIG_BLK_DEV_NVME和CONFIG_PCI_MSI必须打开。我换内核的时候忘了把旧内核的.config拷过来结果新内核没编 NVMe 驱动启动后找不到硬盘。后来用make oldconfig把旧配置迁移过来再手动打开 NVMe 相关选项才解决问题。7. 一些踩坑之后的个人体会NVMe 驱动开发最吸引我的地方是它的“确定性”。PCIe 枚举有明确的规范NVMe 命令有固定的格式队列操作有清晰的流程。你只要把每一步做对它就一定能工作。不像有些老协议明明代码没问题但就是时好时坏查到最后发现是某个时序的 margin 不够。如果你刚开始接触存储驱动我建议先从 QEMU 的 NVMe 模拟器入手。QEMU 的 NVMe 设备完全符合规范而且有详细的 trace 日志。你可以在 QEMU 里把驱动调通再移植到真实硬件上。这样能省去很多硬件调试的时间。最后分享一个小技巧在写 NVMe 驱动的时候把每个命令的cid和完成状态打印出来。这样一旦出错你能立刻知道是哪个命令超时了而不是面对一堆寄存器值发呆。这个习惯帮我省了至少几十个小时的调试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑