简介:这份PDF文档聚焦基于FPGA的NVMe协议逻辑实现,面向存储系统开发的研究人员与工程师,尤其是希望把NVMe真正实现到FPGA与SSD控制器上的开发者。内容围绕admin与nvme命令集解析、随机与顺序读写性能优化、DMA快速数据搬移、文件管理系统硬件加速展开,并讨论Master/Slave统一接口下Slave模式在SSD控制器中作为高效执行单元的角色,配有Verilog代码示例与调试建议。压缩包仅1个PDF文件,约119KB,轻量易读,适合作为实现思路与关键模块的快速参考。目前已有432人学习,可帮助读者理清FPGA侧协议逻辑与读写通路的搭建方法,少走调试弯路。
1. 主机驱动跑得飞快,板卡却成了瓶颈:这套 FPGA NVMe 实现解决什么问题
做过存储加速卡的人大概都遇过这种场景:主机侧 NVMe 驱动已经压满,中间接一块自研板卡,板卡上既要处理 PCIe 事务层,又要把 4KB 读写拆成 NAND 页操作,多绕一层软件就多出几十微秒延迟。这份基于 FPGA 的 NVMe 协议逻辑实现,思路是把命令集解析、DMA 搬移、FTL 映射尽可能压进 RTL:Master 模式下它自己就是一块 SSD 控制器,Slave 模式下挂到主控 SoC 旁边当协处理器,接口做成统一的标准寄存器视图。它适合手上已有 PCIe 硬核和 DDR 控制器、想把存储协议栈从固件搬进硬件的团队,也适合刚啃完 NVMe 协议手册、需要一份可综合代码对照着看队列机制的开发者。
2. 架构总览:Master/Slave 双模怎么切模块、怎么分时钟域
拿到这种"协议 + 控制器"合体的工程,最容易犯的错是从命令解析代码开始读。我的习惯是先画两条线:一条是数据从 PCIe 进来以后经过哪些模块,另一条是每个模块挂在哪个时钟域上。NVMe 控制器出问题,十有八九不是协议理解错了,而是跨时钟域握手少了一级寄存器,或者队列指针在错误的域里被采样。
2.1 模块边界表:先定谁在哪个时钟域
这套实现把整条链路拆成六个模块,边界划得比较干净:PCIe 侧只认 AXI4-Stream 和 AXI4-Lite,NAND 侧只认自己的页操作接口,中间用异步 FIFO 隔开。下表是我按源码整理出来的模块职责,实际工程里你可以按资源情况把 prp_cache 和 ftl_core 合并,但命令引擎和 DMA 引擎建议保持独立,否则调试时 ILA 抓出来的波形根本没法看。
| 模块 | 时钟域 | 主要职责 | 典型资源占用 |
|---|---|---|---|
| nvme_regs | core_clk | BAR0 寄存器、CC/AQA/ASQ/ACQ、Doorbell 写译码 | 少量 LUT/FF |
| nvme_cmd_engine | core_clk | 取 SQ 命令、解析 opcode、组装 CQE | BRAM 存 SQ/CQ |
| nvme_dma_engine | core_clk + axi_clk | PRP 遍历、AXI4 突发读写、描述符调度 | 数据 FIFO + 控制逻辑 |
| prp_cache | core_clk | PRP 列表项缓存,减少重复回读 | 片内 URAM |
| ftl_core | nand_clk | LBA→PPA 映射、磨损均衡、坏块替换 | BRAM + 软核 |
| ecc_pipe | nand_clk | BCH/LDPC 编解码流水 | DSP + BRAM |
值得说的是 prp_cache。NVMe 的一次大块传输,PRP 列表可能指向几十个分散的 4KB 页,如果每搬一页都回主机内存读一次列表项,光地址读取就能吃掉 20% 带宽。用片内 URAM 做一级缓存以后,列表项命中率在顺序读场景下接近 100%,这也是后面能跑到 3.2GB/s 的前提之一。
2.2 顶层例化:PCIe 硬核、命令引擎、DMA 与 FTL 怎么串
顶层不做逻辑,只做连线和一个模式选择的多路器。下面这段是节选后的顶层例化,端口只留了关键几组,AXI 读写通道的其余信号按常规接法补全即可。
module nvme_ctrl_top #( parameter integer NUM_IO_SQ = 8, // I/O 提交队列数量,对应 QID 1~8 parameter integer AXI_DATA_W = 256 // 与 DDR 控制器之间的 AXI 位宽 )( input wire core_clk, // 250MHz,PCIe 用户时钟 input wire core_rst_n, input wire nand_clk, // NAND 侧时钟,与 core_clk 异步 // ---- BAR0 配置访问,来自 PCIe 硬核 ---- input wire [11:0] cfg_addr, input wire [31:0] cfg_wdata, input wire cfg_we, output wire [31:0] cfg_rdata, // ---- 主机命令 TLP 解包后的流式接口 ---- input wire [63:0] cmd_tdata, input wire cmd_tvalid, output wire cmd_tready, input wire cmd_tlast, // ---- 数据搬移 AXI4 主口,接 DDR 控制器 ---- output wire [63:0] m_axi_awaddr, output wire [7:0] m_axi_awlen, output wire m_axi_awvalid, /* 省略 W/B/AR/R 通道,接法与常规 AXI4 主设备一致 */ // ---- 对外模式指示,供主控 SoC 采样 ---- output wire slave_mode_sel ); wire mode_slave; // 1 = Slave,0 = Master wire [31:0] reg_rdata_bus; // 寄存器组:Master 模式下由主机 BAR0 访问,Slave 模式下由主控 MMIO 访问 nvme_regs u_regs ( .clk (core_clk), .rst_n (core_rst_n), .cfg_addr (cfg_addr), .cfg_wdata (cfg_wdata), .cfg_we (cfg_we), .cfg_rdata (reg_rdata_bus), .mode_slave (mode_slave) ); // 命令引擎:只跑在 core_clk 域,内部维护 SQ/CQ 指针 nvme_cmd_engine #(.NUM_IO_SQ(NUM_IO_SQ)) u_cmd_engine ( .clk (core_clk), .rst_n (core_rst_n), .cmd_tdata (cmd_tdata), .cmd_tvalid (cmd_tvalid), .cmd_tready (cmd_tready), .cmd_tlast (cmd_tlast), .dma_req (dma_req), .dma_desc (dma_desc) ); // DMA 引擎:描述符从命令引擎来,AXI 主口对外 nvme_dma_engine #(.AXI_DATA_W(AXI_DATA_W)) u_dma ( .clk (core_clk), .rst_n (core_rst_n), .desc_valid (dma_req), .desc_in (dma_desc), .m_axi_awaddr (m_axi_awaddr), .m_axi_awlen (m_axi_awlen), .m_axi_awvalid(m_axi_awvalid) ); // FTL 挂在 NAND 时钟域,跨域交给异步 FIFO,不在顶层直接连 ftl_core u_ftl ( .lba_clk (core_clk), .nand_clk (nand_clk), .lba_req (ftl_req), .lba_resp (ftl_resp) ); assign slave_mode_sel = mode_slave; endmodule这段例化里有两个参数值得改:NUM_IO_SQ决定你能开多少条 I/O 队列,主机侧多核并发靠的就是这个,队列少了在 fio 多线程下会看到明显的锁竞争;AXI_DATA_W要和 DDR 控制器的数据位宽对齐,256bit 配 BL8 是常见组合,改成 512bit 的话突发长度要跟着重算,否则一拍搬的字节数和描述符里的 length 对不上。
模式选择的实现很朴素,就是寄存器里一个 bit,但这个 bit 会影响三处行为:命令来源是 BAR0 还是外部 MMIO、CQ 中断是走 MSI-X 还是拉一根电平线、Namespace 容量是从 Identify 结构体读还是从映射表读。所以我在顶层单独引出slave_mode_sel,方便主控 SoC 上电时先探测再决定要不要接管。
2.3 Slave 模式的寄存器映射:主控眼里的那块"黑匣子"
Slave 模式的本质是把 FPGA 变成一个"能被 MMIO 操纵的存储外设"。主控 SoC 不关心你内部有几个状态机,它只认一张寄存器表。这套实现把表压到了 8 个寄存器,够用且不容易出错:
| 偏移 | 名称 | 位域 | 说明 |
|---|---|---|---|
| 0x00 | MAGIC | [31:0] | 固定 0x4E564D45,主控探测设备是否存在 |
| 0x04 | CTRL | [0] EN, [1] SLAVE_EN, [3:2] MODE | 使能与主从模式选择 |
| 0x08 | AQA | [11:0] ASQS, [27:16] ACQS | Admin 队列深度减一 |
| 0x10 | ASQ_LO/HI | [63:0] | Admin SQ 基地址,4KB 对齐 |
| 0x18 | ACQ_LO/HI | [63:0] | Admin CQ 基地址,4KB 对齐 |
| 0x20 | NS_LBA_NUM | [63:0] | Namespace 容量,单位 LBA |
| 0x28 | LBA_SHIFT | [7:0] | LBA 位宽,9 表示 512B,12 表示 4KB |
| 0x2C | IRQ_STATUS | [31:0] | 完成中断聚合状态,写 1 清除 |
主控侧的初始化代码大概长这样,用 MMIO 直接怼:
/* 主控 SoC 侧:把 FPGA 当协处理器拉起来 */ #define NVME_ACC_BASE 0x40000000u static inline void acc_write(uint32_t off, uint32_t val) { *(volatile uint32_t *)(NVME_ACC_BASE + off) = val; } int acc_slave_init(uint64_t asq_pa, uint64_t acq_pa, uint32_t q_depth) { if (*(volatile uint32_t *)(NVME_ACC_BASE + 0x00) != 0x4E564D45u) return -1; /* 探测失败,可能没烧录比特流 */ acc_write(0x08, ((q_depth - 1) << 16) | (q_depth - 1)); /* AQA */ acc_write(0x10, (uint32_t)(asq_pa & 0xFFFFFFFFu)); acc_write(0x14, (uint32_t)(asq_pa >> 32)); acc_write(0x18, (uint32_t)(acq_pa & 0xFFFFFFFFu)); acc_write(0x1C, (uint32_t)(acq_pa >> 32)); acc_write(0x04, 0x3); /* EN=1, SLAVE_EN=1 */ return 0; }写这段代码时要注意两点:基地址必须 4KB 对齐,否则命令引擎取指会跨页读到脏数据;AQA里存的是"深度减一",写 0x1F 表示 32 项,这个减一在协议里是硬规定,我第一次接的时候直接写了 32,结果主机只发 31 条命令就卡住。
2.4 模式切换不是改个寄存器那么简单
很多人以为 Master 到 Slave 就是一个 mux,实际动起来会牵出三个连锁反应。第一是中断路径:Master 模式下 CQ 完成走 MSI-X,需要构造中断向量表;Slave 模式下主机可能只给你一根 GPIO 中断线,得把多个队列的完成事件聚合成一个电平信号,清中断靠写 IRQ_STATUS。第二是容量上报:Master 模式靠 Identify Namespace 返回 NSZE,Slave 模式主控可能直接读 NS_LBA_NUM,两个地方的值必须同步,否则会出现"系统识别 512GB、实际只能写 400GB"的怪事。第三是复位域:Slave 模式下主控可能会单独复位你,这时候 PCIe 硬核不能跟着复位,否则链路重训要几百毫秒,所以复位要拆成软复位和硬复位两路。
3. 命令处理链路:64 字节命令项怎么被拆成状态机动作
NVMe 的命令项固定 64 字节,完成项固定 16 字节,这个设计对硬件非常友好——队列可以做成定长环形缓冲,指针只需要加法和比较。但真正写 RTL 时会发现,64 字节里有一半字段在你的场景里用不上,先想清楚哪些字段必须解析,代码量能少三分之一。
3.1 命令项字段:opcode、CID、PSDT 与 PRP 的位置
命令项的前 16 字节是通用头,后面 48 字节跟具体 opcode 相关。我一般先定义一个 packed struct 把头部固定下来,综合工具会自动按位展开,不会浪费资源。
// NVMe 命令项 DWORD0 的字段布局,总长 64 字节,这里只解头部 typedef struct packed { logic [15:0] cid; // Command Identifier,完成项里原样带回,用来配对 logic [3:0] rsvd; logic [1:0] psdt; // 0=PRP,1=SGL,本项目只支持 PRP logic [1:0] fuse; // 原子操作标志,普通读写填 0 logic [7:0] opc; // Opcode,决定走 Admin 还是 I/O 分支 } nvme_cmd_dw0_t; // 命令项 DWORD6~DWORD9 是 PRP1/PRP2,64 位物理地址 typedef struct packed { logic [63:0] prp2; // 第二页地址,或 PRP List 指针(bit0=1 时) logic [63:0] prp1; // 第一页地址,承载数据起始位置 logic [31:0] dword5; // 命令相关,读写命令里是 SLBA 高位 logic [31:0] dword4; // 读写命令里是 SLBA 低位 } nvme_cmd_prp_t;cid这个字段最容易忽视。它是主机分配的命令标识,设备完成时必须原样写回 CQE,主机靠它把完成事件和发起时的请求对上。我见过一个实现为了省 16 位寄存器,把 cid 固定填 0,结果主机侧跑单线程没问题,一上多线程就出现"写成功但数据错位",查了两天才定位到。
psdt字段决定后续地址怎么解释:填 0 走 PRP,填 1 走 SGL。这套实现只支持 PRP,所以解析时如果读到 psdt=1,直接返回 Invalid Field 状态码,不要让命令往下走——不支持的路径早点拒绝,比走到 DMA 阶段再出错好排查得多。
3.2 Admin 命令解析:Identify 与 Set Features 的落地写法
Admin 命令数量不多,但每条都关系到控制器能不能被主机正常识别。最核心的是 Identify(opcode 0x06),主机靠它拿到控制器能力、Namespace 列表和 LBA 格式。实现上不需要真的去构造一个复杂结构体,常见做法是在片内 ROM 里预置一份模板,按 NSID 选段后直接 DMA 到主机缓冲。
always_comb begin admin_illegal = 1'b0; dma_req = 1'b0; dma_len = 32'd0; unique case (cmd_dw0.opc) 8'h06: begin // Identify // NSID=0 取控制器结构体,非 0 取 Namespace 结构体 dma_src = (cmd_nsid == 32'd0) ? IDENT_CTRL_BASE : (IDENT_NS_BASE + {cmd_nsid[7:0], 12'h000}); dma_len = 32'h1000; // 固定 4KB,协议规定 dma_req = 1'b1; end 8'h09: begin // Set Features // 只处理 Arbitration(0x01) 和 Number of Queues(0x07) if (cmd_cdw10[7:0] == 8'h01 || cmd_cdw10[7:0] == 8'h07) begin feature_wr_en = 1'b1; feature_id = cmd_cdw10[7:0]; feature_val = cmd_cdw11; end else begin admin_illegal = 1'b1; // 其余特性返回无效字段 end end 8'h02: begin // Get Log Page,暂只支持 SMART 日志 dma_src = SMART_LOG_BASE; dma_len = 32'h200; dma_req = 1'b1; end default: admin_illegal = 1'b1; endcase end这段组合逻辑的关键在dma_src的选择:Identify 的返回内容按 NSID 分段,模板在 ROM 里按 4KB 边界排列,所以地址拼接用{nsid[7:0], 12'h000},最多支持 256 个 Namespace。如果工程里只需要一个 Namespace,直接把 nsid 判断去掉,省下的比较器在时序紧张的时候很管用。
Set Features里我只放行了两个特性:Arbitration 用来配队列仲裁权重,Number of Queues 用来告诉主机"我支持几条 I/O 队列"。后者特别重要——主机在初始化阶段会先读这个特性,如果返回值小于它想要的队列数,它会自己降级。把feature_val里的 NCQR/NSQR 字段按实际例化的NUM_IO_SQ填,别写死成 0xFFFF,否则主机开了 64 条队列你只有 8 条,多出来的命令直接丢。
3.3 SQ/CQ 双缓冲:Doorbell 那根线为什么不能省
NVMe 的队列机制是"主机写 tail、设备写 head",靠 Doorbell 寄存器通知对方。硬件实现时每个队列要维护两组指针:一组是设备自己推进的 head,一组是主机通过 Doorbell 写入的 tail。如果只维护一组,主机连续提交多条命令时就会丢。
// 每个 I/O 队列一组指针:head 由硬件推进,tail 由 Doorbell 更新 always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) begin sq_head[qid] <= '0; sq_tail[qid] <= '0; end else begin // 硬件侧:成功取走一条命令后 head 前进,回卷靠比较而非取模 if (cmd_fetch_done[qid]) begin sq_head[qid] <= (sq_head[qid] == q_depth[qid] - 1) ? '0 : sq_head[qid] + 1'b1; end // 主机侧:写 SQ Tail Doorbell,只更新 tail,绝不碰 head if (db_wr_en && db_is_sq && db_qid == qid) begin sq_tail[qid] <= db_value; end end end // 非空判断:head 和 tail 不等就说明有新命令 assign sq_not_empty[qid] = (sq_head[qid] != sq_tail[qid]);这里用的是比较回卷而不是取模运算,因为队列深度固定为 2 的幂,比较器比除法器省资源,时序也更好收敛。db_value是主机写进来的绝对值,不是增量,所以直接覆盖,不需要累加。
CQ 侧的逻辑稍微不同:设备写 tail、主机写 head,判断"队列满"的条件是cq_tail + 1 == cq_head。如果主机长时间不回收完成项,设备必须停下来等,不能覆盖未读的 CQE,否则主机会读到错乱的完成状态。这个等待条件我一般做成一个 backpressure 信号,直接反压命令引擎,让它暂停取新命令,比丢完成项安全得多。
3.4 完成项回写与中断聚合
完成项 16 字节,其中最关键的是最后一个 DWORD 里的相位标志 P。主机判断一个 CQE 是否有效,看的就是这个 bit 和自己维护的相位值是否一致。设备每绕队列一圈翻转一次相位,这个机制避免了每次复位都要清空整个队列内存。
// CQE 回写:dword3 的 bit16 是相位标志,bit31:17 是状态字段 always_ff @(posedge clk) begin if (cq_wr_en) begin cq_mem[cq_tail][31:0] <= cmd_result; // 命令相关结果 cq_mem[cq_tail][47:32] <= {sq_id, sq_head_snap}; // SQ 头指针快照 cq_mem[cq_tail][63:48] <= cmd_dw0.cid; // CID 原样带回 cq_mem[cq_tail][95:64] <= 32'h0; cq_mem[cq_tail][111:96] <= 16'h0; cq_mem[cq_tail][127:112] <= {status_field, cq_phase[qid]}; end end // 相位翻转:队列绕回 0 时翻转一次 always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) cq_phase[qid] <= 1'b0; else if (cq_wr_en && cq_tail[qid] == q_depth[qid] - 1) cq_phase[qid] <= ~cq_phase[qid]; end中断聚合是性能调优的抓手。如果每写一个 CQE 就拉一次中断,主机在 4KB 随机读场景下会被中断风暴淹掉。常见做法是设一个阈值(比如累积 8 个完成项)或者超时(比如 10 微秒没有新完成),二者先到先触发。这套实现里阈值做成了寄存器可配,压测时把它调到 32,IOPS 能涨一截,但延迟会从 80 微秒涨到 120 微秒,看你更在意哪头。
4. DMA 数据通路:描述符、PRP 列表与 AXI4 突发的对齐规则
DMA 是这类项目里最容易"看着能跑、压测就崩"的部分。原因不复杂:协议层要求 4KB 页对齐,总线层要求突发不跨 4KB 边界,两套规则叠在一起,地址算错一个字节就会在长时间运行后表现为数据错乱或者总线挂死。
4.1 描述符结构:24 位长度字段与 16MB 上限
描述符是命令引擎和 DMA 引擎之间的契约,字段设计要一次到位,后面改起来牵一发动全身。这套实现用的是 128 位定长描述符:
typedef struct packed { logic [35:0] src_addr; // 源物理地址,字节寻址 logic [35:0] dst_addr; // 目的物理地址,字节寻址 logic [23:0] length; // 本次传输字节数,最大 16MB logic last; // 一条 PRP 链的最后一个描述符 logic intr; // 完成后是否触发中断 logic [1:0] dir; // 00=主机到设备,01=设备到主机 logic [1:0] rsvd; } dma_desc_t;length字段给 24 位是有讲究的:NVMe 单条命令最大传输量受 MDTS 限制,而 2^24 = 16MB 正好覆盖常见的 MDTS 配置(比如 128 个 4KB 页 = 512KB,或者 4096 个页 = 16MB)。如果给 16 位,最大只能描述 64KB,大块顺序读写就要拆成很多条描述符,命令引擎的调度开销会明显上升。
last位的作用是让多段传输能自动衔接。一条命令可能对应几十个不连续的 PRP 页,DMA 引擎每搬完一页就取下一个描述符,直到last拉高才去写 CQE。这样命令引擎不需要关心具体搬了几页,只等一个完成信号。
4.2 DMA 引擎状态机:六个状态跑完一次搬移
状态机我坚持用显式枚举,不用计数器凑,因为出问题时 ILA 上看到的状态名能直接对应到代码行,省掉一轮猜谜。
typedef enum logic [2:0] { D_IDLE, // 等描述符 D_FETCH, // 锁存描述符字段 D_PRP_CHK, // 检查 PRP 对齐与页边界 D_READ, // AXI 读突发 D_WRITE, // AXI 写突发 D_DONE // 写完成项、发中断 } dma_state_t; always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) state <= D_IDLE; else unique case (state) D_IDLE: if (desc_valid) state <= D_FETCH; D_FETCH: state <= D_PRP_CHK; D_PRP_CHK: if (prp_addr_ok) state <= D_READ; else state <= D_DONE; // 地址非法,直接报错 D_READ: if (axi_rlast && rresp_ok) state <= D_WRITE; D_WRITE: if (axi_wlast && axi_bvalid) state <= D_DONE; D_DONE: state <= D_IDLE; endcase endD_PRP_CHK这个状态是血泪经验加进去的。最初版本没有它,地址直接从描述符送进 AXI 主机口,功能仿真一切正常,上板跑半小时后开始随机出错。后来在 ILA 上抓到一次awaddr低 12 位是 0x804,才意识到主机给的 PRP 地址没有页对齐,而 AXI 从机对跨页突发返回了 SLVERR。加上这个检查状态以后,非法地址在发总线之前就被拦下来,直接返回 Data Transfer Error 状态码给主机,问题从"偶发数据错乱"变成"稳定报错",反而好查了。
4.3 PRP 遍历与 4KB 页边界
PRP 的规则不复杂但容易记混:PRP1 是第一页地址,PRP2 有两种解释——如果数据不超过两页,它就是第二页地址;如果超过两页,它的 bit0 置 1,指向一个 PRP 列表,列表里每项指向后续的页。
// PRP 解析:先看数据量,再决定 PRP2 怎么解释 always_comb begin prp1_page = {cmd_prp.prp1[63:12], 12'h000}; // 强制 4KB 对齐 prp1_offset = cmd_prp.prp1[11:0]; total_bytes = cmd_nlb * lba_size; // NLB 是 0 基,注意加一 if (total_bytes <= (12'h1000 - prp1_offset)) begin page_cnt = 1; prp2_is_list = 1'b0; end else if (total_bytes <= (12'h2000 - prp1_offset)) begin page_cnt = 2; prp2_is_list = 1'b0; end else begin page_cnt = (total_bytes + prp1_offset + 12'hFFF) >> 12; prp2_is_list = cmd_prp.prp2[0]; // bit0=1 表示列表指针 end endprp1_offset这个量必须保留。主机完全可以把数据起始地址放在页中间,比如 0x1000_0800,前 2KB 属于其他用途。这时第一页只能搬 2KB,后面的页才按整页走。我见过有实现直接丢掉 offset,把地址向下对齐到 4KB 再搬整页,结果主机侧校验发现每次写都多写了前面 2KB 的旧数据。
prp2_is_list的判断条件是数据超过两页。列表指针本身的地址也要求 4KB 对齐,列表项的低 12 位保留,所以遍历时用{entry[63:12], 12'h000}取页地址,最多支持 512 项(一页列表 4KB / 8 字节),对应 2MB 数据,更大的传输要支持多级列表——这套实现没做多级,超过 2MB 的命令直接返回 Invalid Field,让主机自己拆小。
4.4 带宽账怎么算:3.2GB/s 的来路与瓶颈
PCIe Gen3 x4 的原始带宽是 8GT/s × 4 = 32GT/s,用 128b/130b 编码,有效数据率是 32 × 128/130 / 8 ≈ 3.94GB/s。再扣掉 TLP 头、DLLP、ACK/NAK 的开销,实际可用大概在 3.2~3.4GB/s,所以顺序读跑到 3.2GB/s 基本就是吃满了物理链路。
要让这个数字稳定,四个地方必须对齐:
- TLP 负载大小配到 256 字节。配 128 字节头部开销占比太高,配 512 字节很多主机侧 MPS 不支持,协商完还是掉回 256。
- AXI 突发长度配 256 beat,位宽 256bit,一拍 32 字节,一次突发正好 8KB。但 AXI4 规定单次突发不能跨 4KB 边界,所以实际会拆成两个 4KB 突发,这个拆包逻辑必须做对。
- 描述符里的
length按 4KB 整数倍下发,DDR 侧配合 BL8 模式,读写切换的 tWTR/tRTW 开销能降到最低。 - PRP 列表项缓存命中率要高,否则每页都回主机读地址,带宽会被打成两半。
压测时我的习惯是同时开三个计数器:DMA 搬移字节数、AXI 有效拍数、CQE 写入数量。三个数的比值能直接告诉你瓶颈在哪——字节数正常但有效拍数偏低,说明突发被打断;CQE 数量远小于命令数,说明完成项回写路径堵了。
5. 避坑与排查:Doorbell、PRP 对齐、相位翻转的翻车记录
这章是我从几个项目的调试记录里挑出来的,都是"功能仿真过、上板压测崩"的类型。如果你的实现在短时间测试下表现正常,长时间跑就出各种玄学问题,先对照下面几条看一遍。
5.1 现象:顺序读一开始很快,几秒后掉到 800MB/s
原因基本可以锁定在 Doorbell 处理上。主机提交命令的速度比设备取命令快,如果设备侧只维护一份 tail 指针,主机的连续写会互相覆盖,设备看到 tail 没变就认为没有新命令,于是空转;等主机发现队列积压再补写,又可能一次跳过好几条。表现出来就是带宽忽高忽低,平均下来只有峰值的四分之一。
解决办法是把 tail 做成两份:一份是"门铃写入值",一份是"已确认消费值",两者差值是待处理命令数。硬件只在取完一条命令后才推进消费值,并且每次取命令前重新比较一次。另外 Doorbell 寄存器本身要做写合并保护,主机连续写同一个地址时,只保留最后一次写入的值,中间值直接丢弃,不能进 FIFO 排队,否则队列指针会来回跳。
5.2 现象:主机日志报 Abort Command,数据校验全错
先查 PRP 地址对齐。协议要求 PRP1 和 PRP2 指向的物理地址低 12 位必须为 0,只有 PRP1 允许带页内偏移(在特定场景下)。如果主机给了一个 0x1001 这样的地址,设备必须返回 Invalid Field,而不是"尽力而为"地向下对齐。向下对齐的后果是把前一页的数据也搬进来,写命令就会覆盖掉不属于自己的内存,读命令则返回错位数据,主机侧校验自然全错。
排查手段很简单:在 DMA 引擎的地址检查状态里加一个断言,任何 PRP 地址低 12 位非 0 就拉高一个 error 脉冲,用 ILA 把这个脉冲设成触发条件。上板跑一次,能立刻看到出错的命令项和当时的地址值。我现在所有这类工程都会在D_PRP_CHK状态里放这个检查,宁可多花几个 LUT,也不要事后去猜。
5.3 现象:DMA 跑到一半 AXI 从机返回 SLVERR 并卡死
看awlen和awaddr的组合。AXI4 明确规定一次突发不能跨 4KB 地址边界,跨了从机行为未定义,有些 DDR 控制器直接返回 SLVERR。NVMe 的页大小通常就是 4KB,所以一条命令的传输很容易在页边界处踩线,尤其是prp1_offset不为 0 的时候,第一段传输长度不是 4KB,后面的突发起点就全歪了。
解决方式是在描述符调度层做拆包:每次发突发前算一下当前地址到下一个 4KB 边界的剩余字节数,取它和剩余传输量的较小值作为本次突发的长度。伪代码就三行,但要放在状态机进入D_READ之前:
assign page_remain = 12'h1000 - xfer_addr[11:0]; assign burst_bytes = (xfer_left < page_remain) ? xfer_left : page_remain; assign burst_len = (burst_bytes >> 5) - 1; // 256bit 位宽,一拍 32 字节注意burst_len要减一,AXI 的awlen是"拍数减一"。另外burst_bytes必须能被一拍字节数整除,除不尽的情况(比如最后一页只剩 100 字节)要么让主机侧保证传输量按 LBA 对齐,要么在设备侧补零,不能把半拍数据发上总线。
5.4 现象:压测两小时后完成队列不再更新
这类"跑一段时间才挂"的问题,九成出在相位标志上。主机判断 CQE 有效靠 P 位,设备每绕队列一圈翻转一次。如果翻转时机算错了——比如在写最后一个 CQE 之前就翻转,或者队列深度配错导致多翻转一次——主机会认为整个队列的完成项都无效,于是不再回收,队列满了设备就停下来等。
排查方法是同时抓三个信号:cq_tail、cq_phase、主机侧读到的cq_head。正常情况下cq_phase的翻转应该精确发生在cq_tail从 depth-1 回卷到 0 的那一拍。如果发现相位提前翻转,去检查队列深度寄存器:主机写的是"深度减一",你在硬件里又减了一次,实际深度就少了一半,回卷点自然对不上。
5.5 现象:接上主控 SoC 后,Namespace 容量识别成 0
Slave 模式下主控读到的容量来自两处:Identify Namespace 结构体的 NSZE 字段,和寄存器表里的 NS_LBA_NUM。如果两处不一致,或者字节序搞反,
本文还有配套的精品资源,点击获取