☰
Zynq UltraScale+裸机驱动NVMe SSD实战:PCIe枚举与队列初始化
2026/10/28 7:21:37 网站建设 项目流程

简介:这份资源面向FPGA与嵌入式开发工程师、Zynq UltraScale+平台学习者,以及需要验证NVMe SSD读写链路的开发者,提供一套裸机环境下的SSD测试工程。项目基于Vitis平台组织,包含平台创建与工程构建脚本,便于快速复现和二次开发。压缩包共24个文件,以8个C源文件与8个头文件为核心,承载NVMe协议初始化、命令队列与读写测试逻辑;另有3个Tcl脚本负责平台与工程自动生成,配合XDC约束、BD框图与链接脚本,构成从硬件描述到软件编译的完整闭环,整体约693KB,结构紧凑。目前已有985人学习下载,说明该方向具备一定关注度。读者可借此掌握裸机NVMe驱动分层思路、寄存器配置与DMA传输流程,并参考脚本化构建方式搭建自己的验证环境,适合作为Zynq UltraScale+高速存储接口调试的起点。

1. 从一块 NVMe 盘说起:Zynq UltraScale+ 裸机跑 SSD 到底难在哪

如果你手上有 Zynq UltraScale+ 的板子,又恰好想让它直接读写一块 NVMe SSD,大概率已经踩过一层坑:Linux 下nvme-cli一行命令搞定的事,搬到裸机环境里,连设备枚举都过不去。这个项目就是冲着这个场景来的——在 Zynq UltraScale+ 上不依赖操作系统,用 C 语言直接驱动 NVMe SSD,完成识别、初始化、读写和性能测试。它适合三类人:做嵌入式存储验证的工程师、需要在裸机环境里跑高速缓存的开发者,以及想搞清楚 NVMe 协议在无 OS 环境下怎么落地的人。裸机 NVMe 的核心难点不在协议本身,而在于你要自己管队列、自己处理 PCIe 枚举、自己对齐内存,任何一环没对齐,盘就直接不响应。

2. 裸机 NVMe 的底层逻辑:PCIe 枚举、BAR 映射与队列初始化

2.1 为什么裸机 NVMe 比 Linux 下难一个量级

在 Linux 里,NVMe 驱动帮你做了三件事:PCIe 设备枚举与 BAR 空间映射、Admin 队列与 I/O 队列的创建、命令提交与完成中断处理。裸机环境下,这三件事全部要自己写。Zynq UltraScale+ 的 PS 端有 PCIe Root Complex,但裸机 SDK 只提供了最基础的配置接口,剩下的寄存器操作得对着 NVMe 规范一条条来。

常见做法是先用 Xilinx 的xilnvm或直接操作 PCIe 配置空间,扫描 Bus 0 上的设备,找到 Class Code 为0x010802的 NVMe 控制器。找到之后读 BAR0,拿到控制器寄存器基地址。这一步如果 BAR 没使能,后面所有寄存器读写都是读回全 F 或者全 0,现象就是“盘像死了一样”。

我一般会先确认三件事:PCIe 链路是否训练成功(看 Link Status 寄存器)、BAR0 是否被正确分配、控制器是否处于使能状态。这三步任何一步失败,后面都不用往下走。

2.2 从 PCIe 枚举到 NVMe 寄存器映射的实操步骤

下面这段代码是裸机环境下扫描 PCIe 设备并定位 NVMe 控制器的核心逻辑。实际项目中我会把它放在main.c的初始化阶段,配合串口打印每一步的状态。

#include "xparameters.h" #include "xil_printf.h" #include "xil_io.h" #define PCIE_ECAM_BASE 0xA0000000U // ZynqMP PCIe ECAM 基地址 #define BUS_SHIFT 20 #define DEV_SHIFT 15 #define FUN_SHIFT 12 // 读取 PCIe 配置空间的一个 32 位字 static u32 pcie_read_cfg(u8 bus, u8 dev, u8 fun, u16 offset) { u32 addr = PCIE_ECAM_BASE | ((u32)bus << BUS_SHIFT) | ((u32)dev << DEV_SHIFT) | ((u32)fun << FUN_SHIFT) | (offset & 0xFFC); return Xil_In32(addr); } // 扫描 Bus 0 上的 NVMe 控制器 int nvme_scan_controller(u8 *out_bus, u8 *out_dev, u8 *out_fun) { for (u8 dev = 0; dev < 32; dev++) { u32 vendor = pcie_read_cfg(0, dev, 0, 0x00); if (vendor == 0xFFFFFFFF || vendor == 0x00000000) continue; u32 class_code = pcie_read_cfg(0, dev, 0, 0x08); // NVMe 控制器的 Class Code 是 0x010802 if ((class_code >> 8) == 0x010802) { *out_bus = 0; *out_dev = dev; *out_fun = 0; xil_printf("NVMe found at dev %d\r\n", dev); return 0; } } return -1; }

这段代码的逻辑很直接:遍历 Bus 0 上每个设备的配置空间,读 Vendor ID 判断设备是否存在,再读 Class Code 判断是不是 NVMe。参数上,PCIE_ECAM_BASE是 ZynqMP 的 ECAM 基地址,不同板子可能不同,需要根据 Vivado 地址编辑器里的分配来改。BUS_SHIFT、DEV_SHIFT、FUN_SHIFT是 PCIe 配置空间的标准位偏移,不能动。

找到控制器之后,下一步是读 BAR0。BAR0 的低 4 位是标志位,实际基地址要把低 4 位清零。拿到基地址后,NVMe 控制器的寄存器就映射在这个空间里。CAP 寄存器在偏移 0x00,VS 在 0x08,CC 在 0x14,CSTS 在 0x1C。先读 CAP 确认控制器支持的最大队列深度和是否需要连续内存,再写 CC 寄存器的 EN 位使能控制器,轮询 CSTS 的 RDY 位等待就绪。

注意:写 CC.EN 之前必须确保 CC 的其他位(如 IOSQES、IOCQES)已经配好,否则控制器可能进入未定义状态,表现为 RDY 永远不置位。

2.3 Admin 队列与 I/O 队列的创建细节

NVMe 的队列是一对一的关系:一个 Submission Queue 对应一个 Completion Queue。Admin 队列在控制器使能后自动存在,但你需要给它分配内存并告诉控制器基地址。I/O 队列则需要通过 Admin 命令来创建。

// 队列内存必须 4KB 对齐,且物理连续 static u8 admin_sq[4096] __attribute__((aligned(4096))); static u8 admin_cq[4096] __attribute__((aligned(4096))); typedef struct { u64 sq_base; u64 cq_base; u32 sq_size; // 队列深度,0 表示 1 个条目 u32 cq_size; } nvme_queue_cfg_t; int nvme_setup_admin_queue(nvme_queue_cfg_t *cfg) { // 写 AQA 寄存器:Admin Queue Attributes u32 aqa = ((cfg->sq_size - 1) << 16) | (cfg->cq_size - 1); Xil_Out32(NVME_BASE + 0x24, aqa); // 写 ASQ 和 ACQ 寄存器,告诉控制器队列基地址 Xil_Out32(NVME_BASE + 0x28, (u32)(cfg->sq_base & 0xFFFFFFFF)); Xil_Out32(NVME_BASE + 0x2C, (u32)(cfg->sq_base >> 32)); Xil_Out32(NVME_BASE + 0x30, (u32)(cfg->cq_base & 0xFFFFFFFF)); Xil_Out32(NVME_BASE + 0x34, (u32)(cfg->cq_base >> 32)); return 0; }

这里的关键参数是队列深度。AQA 寄存器里写的是“深度减一”,比如你要 64 个条目,就写 63。队列内存必须 4KB 对齐,因为 NVMe 规范要求队列基地址低 12 位为 0。如果用了 cache,还要在提交命令前 flush,否则控制器读到的可能是旧数据——这是裸机下最隐蔽的坑之一。

3. 读写命令的提交与完成:从 Identify 到实际数据搬运

3.1 Identify 命令:拿到盘的“身份证”

控制器就绪之后,第一件事是发 Identify 命令,拿到 Namespace 列表和每个 Namespace 的容量、LBA 格式。Identify 是 Admin 命令,通过 Admin SQ 提交,完成后从 Admin CQ 读结果。

typedef struct { u32 cdw0; // 命令双字 0:操作码 + 命令 ID u32 nsid; // Namespace ID u32 rsvd[2]; u64 mptr; // 元数据指针 u64 prp1; // PRP 条目 1 u64 prp2; // PRP 条目 2 u32 cdw10; u32 cdw11; u32 cdw12; u32 cdw13; u32 cdw14; u32 cdw15; } nvme_cmd_t; int nvme_identify(u32 nsid, void *buf) { nvme_cmd_t cmd = {0}; cmd.cdw0 = (0x06 << 0) | (1 << 16); // 操作码 0x06 = Identify,命令 ID = 1 cmd.nsid = nsid; cmd.prp1 = (u64)buf; cmd.cdw10 = 0x01; // CNS = 1,表示 Identify Namespace // 把命令写入 Admin SQ 的尾部门铃 submit_admin_cmd(&cmd); return wait_admin_cq(1); }

cdw0的低 8 位是操作码,Identify 是 0x06。高 16 位是命令 ID,用来在完成队列里匹配。prp1指向数据缓冲区,必须是物理地址且 4KB 对齐。cdw10的 CNS 字段决定 Identify 的类型:0x01 是 Namespace,0x02 是控制器。发完命令后写 SQ Tail Doorbell,然后轮询 CQ 的 Phase Tag 和 Status 字段。

提示:PRP 列表在数据超过一个页面时才需要,Identify 的数据量固定 4KB,一个 PRP1 就够了。但如果你要读大块数据,PRP2 指向 PRP List,这个 List 本身也要物理连续。

3.2 I/O 队列的创建与读写命令下发

Admin 队列跑通之后,用Set Features命令创建 I/O 队列。每个 I/O 队列需要单独分配 SQ 和 CQ 内存,然后通过 Admin 命令把基地址告诉控制器。

int nvme_create_io_queue(u16 qid, u16 depth, u64 sq_base, u64 cq_base) { nvme_cmd_t cmd = {0}; cmd.cdw0 = (0x09 << 0) | (2 << 16); // 操作码 0x09 = Set Features cmd.cdw10 = 0x07; // FID = 0x07,Number of Queues cmd.cdw11 = ((u32)(qid) << 16) | (qid); // 提交队列和完成队列数量 submit_admin_cmd(&cmd); if (wait_admin_cq(2) != 0) return -1; // 再发一次 Set Features,配置队列深度 cmd.cdw0 = (0x09 << 0) | (3 << 16); cmd.cdw10 = 0x07; cmd.cdw11 = ((u32)(depth - 1) << 16) | (depth - 1); submit_admin_cmd(&cmd); return wait_admin_cq(3); }

创建完 I/O 队列后,读写命令就简单了:构造nvme_cmd_t,操作码 0x02 是 Read,0x01 是 Write,prp1指向数据缓冲区,cdw10和cdw11拼成起始 LBA,cdw12的低 16 位是传输的块数。写 SQ Tail Doorbell 之后,轮询 CQ 的 Phase Tag 翻转。

实际测试时,我一般会先写一个已知模式(比如递增的 32 位整数)到盘上,再读回来比对。如果读回的数据不对,先查 PRP 地址是否物理连续,再查 cache 是否 flush,最后查队列深度是否超过控制器 CAP 里报告的最大值。

3.3 性能测试:怎么在裸机下量出真实带宽

裸机下没有fio,性能测试得自己写。核心思路是:分配一块大缓冲区,循环下发读写命令,用 ARM 的PMCCNTR_EL0或者 AXI Timer 计时。

#include "xtime_l.h" #define TEST_LBA 0x1000 #define TEST_BLOCKS 2048 // 2048 * 512B = 1MB #define LOOP_COUNT 100 void nvme_perf_test(void) { XTime t_start, t_end; u32 *buf = (u32 *)memalign(4096, TEST_BLOCKS * 512); // 填充测试数据 for (u32 i = 0; i < TEST_BLOCKS * 128; i++) buf[i] = i; XTime_GetTime(&t_start); for (int loop = 0; loop < LOOP_COUNT; loop++) { nvme_write(TEST_LBA, TEST_BLOCKS, buf); nvme_read(TEST_LBA, TEST_BLOCKS, buf); } XTime_GetTime(&t_end); double elapsed = (double)(t_end - t_start) / COUNTS_PER_SECOND; double mb = (double)TEST_BLOCKS * 512 * LOOP_COUNT * 2 / (1024 * 1024); xil_printf("Bandwidth: %.2f MB/s\r\n", mb / elapsed); }

XTime_GetTime是 Xilinx 提供的计时接口,COUNTS_PER_SECOND通常是 33333333 或者 100000000,取决于板子的时钟配置。测试块数不要一次太大,否则容易触发 PRP List 的边界问题。我一般从 128 块开始,逐步加到 2048 块,观察带宽变化。如果带宽在某个块数之后骤降,大概率是 PRP List 没配对。

4. 避坑与排查:裸机 NVMe 最常见的五个翻车现场

4.1 控制器 RDY 位永远不置位

现象:写 CC.EN 之后轮询 CSTS.RDY,等了很久还是 0。

原因:最常见的是 CC 寄存器的 IOSQES 和 IOCQES 没配。这两个字段决定队列条目的大小,NVMe 规范要求都是 2 的幂次方,通常设成 4(表示 16 字节)。如果保持默认值 0,控制器会认为队列条目大小为 0,直接拒绝使能。

解决:在写 CC.EN 之前,先写CC = (4 << 16) | (4 << 20) | (1 << 0),确保 IOSQES 和 IOCQES 都是 4。

4.2 Identify 命令返回数据全 0

现象:Identify 命令的 CQ 显示完成,但缓冲区里全是 0。

原因:PRP1 指向的缓冲区没有 4KB 对齐,或者缓冲区在 cache 里没 flush。ZynqMP 的 A53 有 cache,控制器通过 PCIe 读内存时走的是 DDR,如果 cache 没写回,控制器读到的是旧数据。

解决:用__attribute__((aligned(4096)))声明缓冲区,提交命令前调用Xil_DCacheFlushRange((INTPTR)buf, 4096)。

4.3 读写大块数据时命令超时

现象:小块读写正常,超过 128KB 之后命令永远不完成。

原因:PRP2 没有正确指向 PRP List,或者 PRP List 本身没有物理连续。NVMe 规范要求 PRP List 的每个条目指向一个物理页,且 List 本身必须在一个页内。

解决:分配 PRP List 时用memalign(4096, ...),并且确保 List 里的每个地址都是 4KB 对齐的物理地址。如果数据超过 2MB,考虑用 SGL 而不是 PRP。

4.4 中断收不到,只能轮询

现象:配置了 MSI-X 中断,但中断服务函数从来没被调用。

原因:ZynqMP 的 GIC 配置和 PCIe 中断路由没配对。裸机下 MSI-X 需要手动配置 GIC 的 SPI 或者 LPI,还要在 PCIe 配置空间里使能 MSI-X Capability。

解决:先用轮询模式把功能跑通,再切中断。轮询模式下 CQ 的 Phase Tag 翻转就是完成标志,稳定可靠。中断作为优化项,等轮询版本验证无误后再加。

4.5 写盘之后读回数据不一致

现象:写进去的模式,读回来部分字节对不上。

原因:写命令的 CQ 完成只表示数据到了控制器的缓存,不表示已经落盘。如果紧接着读同一个 LBA,可能读到旧数据。另外,如果写命令用了 Write Uncorrectable 或者没有正确设置 FUA 位,行为也会异常。

解决:写命令的cdw12第 30 位是 FUA(Force Unit Access),置 1 可以强制落盘。测试时加上 FUA,或者写完等一段时间再读。生产环境里通常靠 Flush 命令来保证。

5. 进阶技巧:用队列深度和 PRP 复用把带宽再压榨 20%

裸机 NVMe 跑通之后,性能往往离盘的标称值还有距离。我实测下来,最有效的两个优化是:加大 I/O 队列深度、复用 PRP List 内存。

队列深度方面,CAP 寄存器里 MQES 字段报告了控制器支持的最大队列深度。很多盘支持 1024 甚至 4096。把 I/O 队列深度从 64 提到 256,顺序读带宽通常能涨 15% 到 20%。但要注意,队列深度越大,SQ 和 CQ 的内存占用也越大,ZynqMP 的 DDR 够用,但 cache 压力会上升。

// 从 CAP 寄存器读最大队列深度 u32 cap_low = Xil_In32(NVME_BASE + 0x00); u32 mqes = (cap_low & 0xFFFF) + 1; // MQES 是 0-based xil_printf("Max Queue Entries: %u\r\n", mqes); // 取实际可用深度,留一点余量 u32 depth = (mqes > 256) ? 256 : mqes;

PRP 复用方面,如果你反复读写同一块区域,PRP List 的内容不变,可以只分配一次,后续命令直接复用。省掉每次memalign和Xil_DCacheFlushRange的开销。我一般会预分配两个 PRP List 缓冲区,一个给读、一个给写,交替使用。

还有一个容易被忽略的点:CQ 的 Phase Tag。每次 CQ 条目被消费后,Phase Tag 会翻转。如果你在轮询时只检查 Status 字段而不检查 Phase Tag,可能会把旧条目当成新完成。正确做法是维护一个phase变量,初始为 1,每次 CQ 头指针回绕时翻转。

static u8 cq_phase = 1; int wait_cq(nvme_queue_t *cq, u16 expected_cid) { volatile nvme_cq_entry_t *entry = &cq->entries[cq->head]; while (1) { u16 status = entry->status; u8 phase = (status >> 0) & 0x1; if (phase == cq_phase) { u16 cid = status >> 1; // 命令 ID 在 status 的高位 if (cid == expected_cid) { cq->head++; if (cq->head >= cq->size) { cq->head = 0; cq_phase ^= 1; // 回绕时翻转 phase } return 0; } } } }

这段代码里,cq_phase初始为 1,每次头指针回绕到 0 时翻转。如果不做这个翻转,第二轮轮询会把第一轮的旧条目误判为新完成,现象就是“命令还没发就返回成功”,然后数据全错。这个坑我踩过两次,后来每次写 CQ 轮询都强制走一遍 phase 检查。

从那以后我每次在裸机下接新盘,都先跑一遍 Identify + 单块读写 + 1MB 顺序读写 + 队列深度扫描,四步全过才认为驱动稳定。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询