贯穿项目设定:我们要设计一个小型 NPU —— LeNet-5 INT8 推理加速器,目标平台为 FPGA(仿真优先)。它支持卷积(3×3 / 5×5, stride=1)、2×2 最大池化、全连接与 ReLU;权重与特征图由主机预先写入片上 RAM,主机写寄存器启动计算,计算完成后以中断通知。08-07 已经回答了「LeNet 每一层放在硬件哪里算」,本页从这一页的结论出发,回答「这块电路的边界、接口和骨架怎么定」。
真实芯片公司的项目流程是:市场/系统团队提出需求 → 架构师写 Spec(规格说明书) → 团队评审 → 才允许写第一行 RTL。初学者最常犯的错误就是跳过 Spec 直接写代码,结果写到一半发现 RAM 深度不够、接口对不上、控制时序自相矛盾。所以本页前两节只做一件事:把一句话需求变成一份能指导写代码的 Spec。
是什么:需求解读就是把「做一个 LeNet-5 加速器」这类自然语言目标,拆成四类可检验的工程语言 —— 算子清单、性能指标、资源约束、精度要求,外加一条「怎么用」的使用流程。为什么:这四类分别决定了你的数据通路(要造哪些运算模块)、微架构选型(串行还是并行)、存储规模(RAM 深度位宽)和量化方案(requant 怎么做);使用流程决定接口与工作模式。「可检验」是关键词 —— 每条需求都要能变成一个测试用例或一个计算公式。
怎么做:逐条写下并给出本项目的定版结论:
| 需求类别 | 本项目定版结论 | 它决定了什么 |
|---|---|---|
| 算子清单 | ① 卷积 3×3 / 5×5,stride=1,无 padding,可选 bias;② 池化 2×2 max,stride=2;③ 全连接(按 1×1 卷积实现);④ ReLU 逐元素(每层可配使能) | 需要卷积引擎、池化单元、ReLU 单元、全连接复用卷积引擎 —— 数据通路的模块清单 |
| 性能指标 | 一次 MNIST 推理(约 42 万次 MAC)周期数 ≤ 1M 周期;工作频率 50MHz(即 ≤ 20ms) | 微架构:先做 1 MAC/周期串行设计即可达标,优化(多 MAC 阵列)留给 08-11 |
| 资源约束 | 片上 BRAM:权重 RAM 64KB + 特征图乒乓双缓冲 2×16KB(合计约 24 个 36Kb BRAM);运算单元 1 个 INT8 乘法器 + 1 个 INT32 加法器起步 | RAM 的深度/位宽(见知识点 2),以及「先跑通再加速」的迭代策略 |
| 精度 | 权重/激活 INT8,累加 INT32,requant 回 INT8;MNIST 测试精度相对 FP32(≥98%)损失 ≤ 1 个百分点 | 位宽规划与 requant 参数(见本页第 4 节),量化原理见 08-08 |
| 使用流程 | 主机经 APB 写权重/特征图 → 写层参数寄存器 → 写 CTRL 启动 → 轮询 STATUS 或等中断 → 读回结果 | 工作模式、寄存器映射、总线接口(本页第 2 节) |
是什么:Spec 分析就是把需求按「功能 Spec / 接口 Spec / 性能 Spec / 物理实现 Spec」四层拆开,每层回答一个问题:电路做什么、跟谁相连、跑多快、做成多大。为什么:四分法强制你把「行为」和「实现」分开 —— 功能 Spec 是验证 golden model 的依据,接口 Spec 是别人集成你的依据,性能 Spec 决定微架构,物理 Spec 决定 RAM/布线资源。任何一层缺失,都会在联调期变成返工。
| Spec 层 | 回答的问题 | 本项目示例条目 | 评审时会被问 |
|---|---|---|---|
| 功能 Spec | 输入什么、输出什么、算法定义 | out = ReLU(Σ W·in + bias);INT8 语义与 requant 公式;各算子的逐位精确行为 | 「溢出怎么办?负数怎么处理?」 |
| 接口 Spec | 引脚/总线/寄存器/握手信号 | APB 从机接口 + 寄存器映射(第 2 节)+ pe_start/pe_done 握手 | 「寄存器读写属性?复位值?」 |
| 性能 Spec | 周期数、吞吐、频率 | 单层卷积周期数公式 + 整网 ≤ 1M 周期 @ 50MHz | 「瓶颈在算力还是访存?」 |
| 物理实现 Spec | 面积/资源、RAM 规格、时钟域 | 权重 RAM 64KB、特征图 RAM 2×16KB、单时钟域、FPGA BRAM 数上限 | 「RAM 深度怎么推出来的?」 |
怎么做 —— 参数范围表推出 RAM 规格:这是 Spec 阶段最值钱的一步:列出每一层的参数上界,最大值直接决定 RAM 深度与地址位宽。推导过程(LeNet-5,输入按 32×32 计):
| 层 | 输出特征图 | 字节数(INT8) | 权重数(INT8) |
|---|---|---|---|
| 输入图 | 1×32×32 | 1 024 | — |
| C1 卷积 5×5 | 6×28×28 | 4 704 | 150(+bias 6×4B) |
| S2 池化 2×2 | 6×14×14 | 1 176 | — |
| C3 卷积 5×5 | 16×10×10 | 1 600 | 2 400(+bias 16×4B) |
| S4 池化 2×2 | 16×5×5 | 400 | — |
| C5 卷积 5×5(=全连接) | 120×1×1 | 120 | 48 000(+bias 120×4B) |
| F6 全连接 | 84 | 84 | 10 080(+bias 84×4B) |
| OUTPUT 全连接 | 10 | 10 | 840(+bias 10×4B) |
| 合计上界 | 最大单层 4 704 B | 特征图缓冲取 16 KB/块 | 约 61.5 KB → 权重 RAM 取 64 KB |
由此得到两个存储器的「物理 Spec」:权重 RAM = 64K 深度 × 8bit(地址 16 位);特征图 RAM = 乒乓两块,每块 16K 深度 × 8bit(地址 14 位)。再顺手推出性能账:全网 MAC 总数 ≈ 11.8 万 + 24 万 + 4.8 万 + 1 万 + 0.08 万 ≈ 42 万次,串行 1 MAC/周期 + 池化/写回开销后约 45~50 万周期,占 1M 周期预算的一半 —— 指标合理,还有优化余量。这就是「性能 Spec 驱动微架构」:先算账,再决定要不要并行。
一页纸 Spec 模板(可直接套用到你自己的项目,评审/面试/开源仓库 README 通用):
================================================================
项目一页纸 Spec: LeNet-5 INT8 推理加速器 (npu_top) v1.0
================================================================
1. 概述 本模块为 LeNet-5 INT8 推理加速器,APB 从机形态,
主机预写权重/特征图,寄存器启动,中断/轮询报完成。
2. 功能 支持 conv3x3/conv5x5(stride1,无pad,带bias)、
maxpool2x2(stride2)、fc(按1x1 conv 实现)、relu(可配)。
精度:INT8 乘、INT32 累加、requant 回 INT8(saturate)。
不支持:padding、stride>1 卷积、分组卷积、训练/反向。
3. 接口 APB(PADDR[7:0]/PWDATA/PRDATA);寄存器映射见寄存器表;
输出 irq(电平,写 STATUS 清除);层内握手 pe_start/pe_done。
4. 性能 50MHz;单层周期数 = 输出像素数×CH_OUT×K×K + 固定开销;
整网 ≤ 1M 周期(≈42 万 MAC,串行 MAC 即可达标)。
5. 物理 权重 RAM 64K×8;特征图 RAM 2×16K×8(乒乓);
单时钟域 clk,同步复位 rst_n;FPGA 目标:Xilinx 7 系列。
6. 验证 模块级 TB(逐模块)→ 系统级 TB;Python/NumPy 生成
黄金数据逐层比对(见第 5 节验证计划)。
7. 未决 多 MAC 并行度、DMA 直连、层参数表硬件化 —— 列入 v2。
================================================================
Spec 里最"硬"的两页是工作模式定义和寄存器映射: 前者回答「硬件有哪几种状态、谁在什么时候被允许做什么」,后者回答「软件怎么一步步驱动它」。这两页写清楚了,RTL、驱动、验证三拨人才能并行开工 —— 这就是接口先行的意义。
是什么:把加速器的生命周期划成三个模式。为什么:许多资源(RAM 写口、运算单元)只有一个,必须用模式来仲裁「同一时刻谁拥有它」,否则就会出现「主机在计算中途改写权重」这类灾难。怎么做:按下表定义,并把每条切换规则落实到寄存器位与 FSM 条件上。
| 模式 | 进入条件 | 谁主导 | 允许的动作 | 硬件职责 |
|---|---|---|---|---|
| 配置模式 | 复位后 / STATUS.idle=1 | 主机(APB) | 写层参数寄存器;经 RAM 写口写权重/特征图 | APB 译码,写寄存器组与 RAM;start 仅在 idle 时被采样 |
| 计算模式 | 写 CTRL.start=1 | 控制 FSM | 仅允许读 STATUS(轮询) | 锁存参数快照 → 驱动数据通路完成一层 → 置 done |
| 完成/回读 | STATUS.done=1 或 irq 上升 | 主机 | 读 STATUS(顺带清 done)→ 读输出特征图 → 配下一层 | 撤下 irq 电平,回到配置模式 |
完成通知: 轮询 vs 中断 —— 两种都实现,由软件选择:
| 对比项 | 轮询(Polling) | 中断(Interrupt) |
|---|---|---|
| 硬件代价 | 只要 STATUS.done 一位 | 再加 ie 使能位 + irq 输出引脚 + 电平保持逻辑 |
| CPU 行为 | 死循环读 STATUS,占着 CPU | 去干别的,irq 触发 ISR 再回来处理 |
| 适用场景 | 调试期单步观察、裸机无中断控制器 | SoC 集成后、多层流水调度 |
| 本项目实现 | 读 STATUS[1](done),读自动清除 | CTRL[1](ie)=1 时,done 拉高 irq 电平;清 done 即撤 irq(电平中断,对 GIC 友好) |
模式切换的时序约束(每一条都会写进 RTL 和断言):
是什么:接口 = 总线端口 + 中断 + 模块间握手;npu_top 对外只有一组 APB 端口(clk、rst_n、psel、penable、pwrite、paddr[7:0]、pwdata[31:0]、prdata[31:0]、pready、pslverr)、一根 irq 输出 —— 权重/特征图 RAM 都在片内,主机经 APB 的 RAM 写通路间接访问(所以地址寄存器里存的是片上 RAM 的偏移,不是系统总线地址)。为什么:寄存器是软硬件之间的合同,每一位都必须有:偏移、位域、读写属性、复位值。怎么做:下面就是本项目的完整寄存器表(11 个寄存器,可以直接当作你的第一份寄存器手册)。
| 偏移 | 名称 | 位域 | 读写 | 复位值 | 说明 |
|---|---|---|---|---|---|
| 0x00 | CTRL | [0] start | RW / HW 清零 | 1'b0 | 写 1 启动一层;idle=1 才采样,被采样后硬件自动清零 |
| — | [1] ie | RW | 1'b0 | 完成中断使能(1:done 时拉高 irq) | |
| 0x04 | STATUS | [0] idle | RO | 1'b1 | 1=FSM 处于 IDLE,可接受 start |
| — | [1] done | RO,读清除 | 1'b0 | 一层计算完成标志;读本寄存器即清除并撤 irq | |
| — | — | [4:2] layer_type | RO | 3'h0 | 回显当前/最近一层类型(000=conv,001=pool,010=fc) |
| 0x08 | INPUT_ADDR | [15:0] | RW | 16'h0 | 输入特征图在特征图 RAM 中的字节偏移 |
| 0x0C | WEIGHT_ADDR | [15:0] | RW | 16'h0 | 本层权重在权重 RAM 中的字节偏移 |
| 0x10 | OUTPUT_ADDR | [15:0] | RW | 16'h0 | 输出特征图写入的特征图 RAM 字节偏移 |
| 0x14 | BIAS_ADDR | [15:0] | RW | 16'h0 | 本层 bias 偏移(按 INT32 存,4 字节对齐) |
| 0x18 | LAYER_CFG | [2:0] layer_type | RW | 3'h0 | 000=conv / 001=maxpool / 010=fc |
| — | [3] relu_en / [4] k5 | RW | 2'b0 | relu_en: 本层输出过 ReLU;k5: 0=3×3 核,1=5×5 核 | |
| 0x1C | CH_CFG | [7:0] ch_in | RW | 8'h0 | 输入通道数 − 1(存 N−1:0 表示 1 通道) |
| — | [15:8] ch_out | RW | 8'h0 | 输出通道数 − 1 | |
| 0x20 | FM_CFG | [7:0] fm_in | RW | 8'h0 | 输入特征图边长 − 1(按正方形处理) |
| — | [15:8] fm_out | RW | 8'h0 | 输出特征图边长 − 1(conv: fm_in−K+1;pool: fm_in/2) | |
| 0x24 | REQUANT | [15:0] mult | RW | 16'h0 | requant 乘子 M₀(acc×M₀ 再右移) |
| — | [20:16] shift | RW | 5'h0 | 右移位数 0~31(量化参数由 08-08 离线求得) | |
| 0x28 | VERSION | [31:0] | RO | 32'h0001_0000 | 版本号 v1.0,驱动探测用(顺手养成的好习惯) |
怎么做 —— 一次卷积层的完整主机操作序列(驱动代码骨架,与寄存器表一一对应):
/* C1 层示例: 输入 1×32×32,权重 5×5,输出 6×28×28,带 ReLU */
apb_write(0x0C, 0x0000); /* WEIGHT_ADDR = 权重偏移 0 */
apb_write(0x14, 0x0096); /* BIAS_ADDR = 权重后第 150 字节 */
apb_write(0x08, 0x0000); /* INPUT_ADDR = 特征图 RAM A 块 */
apb_write(0x10, 0x4000); /* OUTPUT_ADDR = 特征图 RAM B 块 */
apb_write(0x1C, (6-1)<<8 | (1-1)); /* CH_CFG : ch_out=6, ch_in=1 */
apb_write(0x20, (28-1)<<8 | (32-1));/* FM_CFG : 32×32 → 28×28 */
apb_write(0x24, (8<<16) | 0x51AB); /* REQUANT : shift=8, mult 举例 */
apb_write(0x18, 0x11); /* LAYER_CFG: conv + relu_en + k5 */
apb_write(0x00, 0x00000001); /* CTRL.start = 1 → 硬件接管 */
do { s = apb_read(0x04); } /* 轮询 STATUS ...(或等 irq) */
while (!(s & 0x2)); /* done=1 退出;这次读已清 done */
有了模式与寄存器这两页"合同",就可以画顶层架构了。架构划分的原则只有一句话:控制通路和数据通路分开,存储子系统居中服务,三者之间用最少的握手信号对话。控制通路决定"什么时候做什么",数据通路决定"一个数怎么被算出来",存储子系统决定"数放在哪、谁下一拍能用"。
是什么:把 npu_top 切成三大块 —— 控制通路(APB 总线接口 + 寄存器组 + 控制 FSM)、数据通路(卷积模块、ReLU/requant、全连接)、存储子系统(权重 RAM + 特征图 RAM 乒乓双缓冲)。为什么:这样切,每块的验证可以独立做(寄存器模块用 APB TB 测,卷积模块用 golden data 测),接口一旦冻结,三块可以并行开发 —— 这正是真实项目里架构师画第一张框图的意义。怎么做:看图,蓝线是数据流、橙线是控制流、红线是中断:
逐块职责与握手约定:模块之间的信号只有三种语义 —— 请求(valid/start)、接受(ready)、完成(done/pulse),先在 Spec 里冻结它们,再各自写 RTL:
| 模块 | 职责 | 对外信号(约定) | 对应页面 |
|---|---|---|---|
| 总线接口 | APB 译码、等待态、错误响应 | 标准 APB(psel/penable/pwrite/paddr/pwdata/prdata/pready/pslverr) | 08-05 |
| 寄存器组 | 11 个寄存器的读写、start/done 语义、irq 生成 | start(脉冲)、层参数总线、done_set/读清接口 | 本页第 5 节代码 |
| 控制 FSM | 五状态调度、参数快照、乒乓选择 | pe_start(脉冲)/pe_ready(电平)/pe_done(脉冲) | 知识点 6 |
| 卷积模块 | K×K 窗口乘累加,行缓冲复用数据,可选池化 | pe_start/pe_done, RAM 读地址请求, 累加结果流(valid+data) | 08-11 |
| ReLU/requant | acc×M₀>>shift → 饱和 → max(0,x) | valid+INT32 入,valid+INT8 出(纯流水,无反压) | 08-08 |
| 全连接 | 展平向量×权重矩阵,复用 MAC 与 requant | 同卷积接口(控制 FSM 由此可统一调度) | 08-11 |
| 特征图 RAM | 乒乓 A/B 双块,各带独立读写口 | 读口: addr+en→1 拍后数据;写口: addr+data+en | 08-04(存储时序) |
是什么:控制 FSM 是整个 NPU 的"节拍器"。本项目采用两层结构:主 FSM 五状态(IDLE→CONFIG→LOAD→CALC→DONE)负责一次"一层"的调度;每个状态内部,运算引擎还有自己的子流程(取指→计算→写回),主 FSM 只看子流程的完成信号 pe_done,不看内部细节 —— 这就是分层控制:每层复杂度都被限制在局部。为什么:状态是"模式"在 RTL 里的落地形式;有了五状态,第 2 节定义的模式切换约束(start 只在 idle 采样、参数快照、done 电平)才有唯一的落点。怎么做:先画状态图,再列转移条件表,最后用三段式写 RTL。
状态转移条件表(写 RTL 前先填这张表,评审时逐行过一遍 —— 这就是"FSM 设计文档化"):
| 当前态 | 次态 | 转移条件 | 伴随动作 |
|---|---|---|---|
| IDLE | CONFIG | start=1(已与 idle 相与) | 寄存器组清 CTRL.start 位 |
| CONFIG | LOAD | 无条件(1 拍) | 锁存全部层参数快照(寄存器从此刻起"与我无关") |
| LOAD | LOAD | pe_ready=0 | 翻转 buf_sel(乒乓换块),配置引擎通道/核尺寸 |
| LOAD | CALC | pe_ready=1 | 发 pe_start 单周期脉冲 |
| CALC | CALC | pe_done=0 | STATUS.idle=0;引擎内部执行取指→计算→写回 |
| CALC | DONE | pe_done=1 | 整层输出已写回对侧特征图块 |
| DONE | IDLE | 无条件(1 拍) | done_set 脉冲 → 寄存器组置 STATUS.done → 视 ie 拉高 irq |
| 任意 | IDLE | rst_n=0(同步复位) | 全部输出归零,丢弃未完成计算 |
三段式 FSM 完整 Verilog(npu_ctrl.v,约 120 行) —— 所谓三段式:段 1 状态寄存器、段 2 次态组合逻辑、段 3 输出逻辑,各司其职:
//==============================================================
// npu_ctrl.v — 小型 NPU 顶层控制状态机(三段式, 同步复位)
// 职责: 响应 CTRL.start, 完成「锁存参数→装载通道→启动引擎→
// 等待完成→置位中断」, 一次 start 调度一层计算
//==============================================================
module npu_ctrl (
input wire clk, // 单时钟域 50MHz
input wire rst_n, // 同步复位, 低有效
// ---- 寄存器组 → 控制(配置模式接口) ----
input wire start, // 已与 idle 相与的有效启动请求
input wire [2:0] layer_type, // 0=conv 1=pool 2=fc
input wire relu_en, // 本层 ReLU 使能
input wire k5, // 0=3x3, 1=5x5
input wire [7:0] ch_in, // 输入通道-1
input wire [7:0] ch_out, // 输出通道-1
input wire [7:0] fm_in, // 输入特征图边长-1
input wire [7:0] fm_out, // 输出特征图边长-1
input wire [15:0] in_addr, // INPUT_ADDR 快照值
input wire [15:0] w_addr, // WEIGHT_ADDR 快照值
input wire [15:0] o_addr, // OUTPUT_ADDR 快照值
input wire [15:0] b_addr, // BIAS_ADDR 快照值
input wire [15:0] rq_mult, // requant 乘子 M0
input wire [4:0] rq_shift, // requant 右移位数
// ---- 运算引擎握手 ----
input wire pe_ready, // 引擎空闲可接收(电平)
input wire pe_done, // 引擎整层完成(脉冲)
output reg pe_start, // 启动引擎(单周期脉冲)
output reg buf_sel, // 乒乓选择: 0=读A写B, 1=读B写A
// ---- 状态回读 ----
output reg done_set, // 1 拍脉冲 → 寄存器组置 STATUS.done
output reg [2:0] st_type, // STATUS[4:2] 层类型回显
output reg [4:0] dbg_state // 调试口: 当前状态编码
);
// ---------- 状态编码(独热, 便于波形观察与综合优化) ----------
localparam [4:0] S_IDLE = 5'b00001,
S_CFG = 5'b00010,
S_LOAD = 5'b00100,
S_CALC = 5'b01000,
S_DONE = 5'b10000;
reg [4:0] state, nstate;
// 参数快照寄存器: CONFIG 态锁存后, 计算期间与寄存器组解耦
reg s_relu, s_k5;
reg [7:0] s_chin, s_chout, s_fmin, s_fmout;
reg [15:0] s_in, s_w, s_o, s_b, s_rqm;
reg [4:0] s_rqs;
//-------------------------------------------------------------
// 段 1: 时序逻辑 —— 状态寄存器(唯一给 state 赋值的地方)
//-------------------------------------------------------------
always @(posedge clk) begin
if (!rst_n)
state <= S_IDLE; // 同步复位: 任何态都回 IDLE
else
state <= nstate;
end
//-------------------------------------------------------------
// 段 2: 组合逻辑 —— 次态译码(纯组合, 不产生任何副作用)
//-------------------------------------------------------------
always @(*) begin
case (state)
S_IDLE: nstate = start ? S_CFG : S_IDLE; // start 只在 idle 采样
S_CFG : nstate = S_LOAD; // 快照锁存 1 拍完成
S_LOAD: nstate = pe_ready ? S_CALC : S_LOAD;
S_CALC: nstate = pe_done ? S_DONE : S_CALC;
S_DONE: nstate = S_IDLE; // 置位后立即回家
default: nstate = S_IDLE; // 未知态强制回家防锁死
endcase
end
//-------------------------------------------------------------
// 段 3: 时序逻辑 —— 数据通路控制输出与参数快照
//-------------------------------------------------------------
always @(posedge clk) begin
if (!rst_n) begin
pe_start <= 1'b0;
buf_sel <= 1'b0;
done_set <= 1'b0;
st_type <= 3'd0;
dbg_state <= S_IDLE;
s_relu <= 1'b0; s_k5 <= 1'b0;
s_chin <= 8'd0; s_chout <= 8'd0; s_fmin <= 8'd0; s_fmout <= 8'd0;
s_in <= 16'd0; s_w <= 16'd0; s_o <= 16'd0; s_b <= 16'd0;
s_rqm <= 16'd0; s_rqs <= 5'd0;
end else begin
pe_start <= 1'b0; // 脉冲类信号: 默认每拍清零
done_set <= 1'b0;
dbg_state <= state;
case (state)
S_CFG: begin // ① 锁存参数快照(一致性副本)
st_type <= layer_type;
s_relu <= relu_en; s_k5 <= k5;
s_chin <= ch_in; s_chout <= ch_out;
s_fmin <= fm_in; s_fmout <= fm_out;
s_in <= in_addr; s_w <= w_addr;
s_o <= o_addr; s_b <= b_addr;
s_rqm <= rq_mult; s_rqs <= rq_shift;
end
S_LOAD: begin // ② 装载: 换乒乓块 + 启动引擎
buf_sel <= ~buf_sel; // 读上一次的输出块, 写到闲块
if (pe_ready)
pe_start <= 1'b1; // 引擎就绪, 下一拍发启动脉冲
end
S_CALC: ; // ③ 等待: 子流程由引擎负责
S_DONE: done_set <= 1'b1; // ④ 完成脉冲 → 寄存器组置 done
default: ;
endcase
end
end
endmodule
逐段讲解:
if (!rst_n) 同步风格 —— 时序全在自己时钟域内,综合器能算出确定的恢复时间,这也是 FPGA 的首选复位风格(知识点 8 展开);default 分支强制回 IDLE,是防状态机锁死的标准防御;控制 FSM 解决"什么时候算",数据通路解决"数怎么被算对"。数据通路设计的两个核心问题是位宽规划(每一级数据多宽才既不溢出又不浪费)和速率匹配(生产者与消费者速度不一致时缓冲开多大);而把数据通路装配进芯片,还要回答时钟与复位怎么给。
是什么:位宽规划 = 给"乘→累加→量化回写"链条上每一级定宽度。业界(从 Google TPU v1 到 NVDLA)的标准答案是 INT8 乘 → INT32 累加 → requant → INT8:乘法天然变宽(8×8→16),累加随项数增长必须用 32 位兜底,最后用量化参数把 INT32 压回 INT8 存储。为什么:位宽不够会溢出错数,位宽太宽会白白多吃面积与功耗 —— 位宽表是数据通路的"物理 Spec"。怎么做:先算最坏情况:单个输出像素的累加项数 = K×K×CH_IN = 5×5×16 = 400 项,每项最大 |8bit×8bit| = 128×128 = 16384,再加 INT32 bias,总量级约 400×16384 ≈ 223 —— 远小于 231,INT32 累加器余量充足(这也是 INT8 网络通行 INT32 累加的根本原因):
数据从哪来到哪去 —— 一层数据流完整路径(对照图 1 的蓝线,建议能背出来):
FIFO 深度初估:当生产者与消费者速率不等时,中间要垫 FIFO(原理见 08-04)。初估公式:深度 ≥ (生产峰值速率 − 消费峰值速率) × 最大连续突发长度 × 裕量(≈2),再向上取 2 的幂。本项目示例:卷积引擎平均 2 周期出一个结果,而特征图写口被读口分时复用、平均 4 周期才收一个写 —— 速率差 1/4 结果/周期,最长连续突发约 64 个结果,需要 64×(1−4/2)… 取保守上界 16 深,取 2 的幂定为 16×8bit;第一版先用 8 深仿真,以 FIFO 的"水位最高点"实测修正。记住:初估允许粗,必须留仿真验证这一步。
是什么/为什么:时钟与复位是整个设计的"地基约束",Spec 阶段就要定死:本项目采用单时钟域 + 同步复位。怎么做:三件事 ——
| 决策 | 本项目选择 | 理由 / 备选方案 |
|---|---|---|
| 时钟域 | 单时钟域 clk=50MHz,APB 与计算同域 | 仿真优先的教学项目,先消灭 CDC 一整类 bug;性能吃紧再拆"总线 50M/计算 100M"双域,届时用 08-04 的异步 FIFO 与握手 |
| 复位风格 | 同步复位、低有效 rst_n | FPGA 的寄存器本就有同步复位资源,时序确定、免恢复时间检查;ASIC 高速场景常用"异步复位、同步释放"(两级触发器同步释放沿)——面试要能画出那个 2-FF 结构 |
| 复位范围 | 控制/状态寄存器全复位;数据通路大数据寄存器可不复位(靠 pe_start 前的清零) | 全复位会增加扇出与布线;哪些不复位要写进 Spec,避免"上电第一拍随机数"争议 |
| 低功耗初步 | 时钟门控概念:数据不动就不翻转 —— 用使能(ce)代替粗粒度门控,IDLE 态 MAC 输入冻结 | 一句话版:时钟门控 = 在时钟进模块前加锁存器/与门,模块空闲时不送时钟以省动态功耗;FPGA 上交给综合器自动推断,ASIC 上必须用工具插入标准 ICG 单元,严禁手写 gated clk 直接驱动触发器 |
clk & en 当门控时钟 —— 组合逻辑门控时钟会产生毛刺,必须用带锁存器的标准 ICG 单元;③ 复位只复了 FSM 忘了乒乓指针 buf_sel,复位后第一层写错块。架构冻结后,第一块该写的 RTL 不是卷积引擎,而是寄存器模块 —— 它是软硬件合同的法律文本,也是后面所有模块级 TB 的"遥控器"。本节给出完整可综合的 APB 寄存器模块(复用 08-05 的协议知识),并规划贯穿到 08-11 联调的验证计划。
是什么:寄存器模块 = APB 从机接口 + 第 2 节寄存器表的物理载体 + 与控制 FSM 的握手(start/done)。它还要负责两件容易漏掉的杂活:主机往片上 RAM 写数据的写通道,以及中断生成。为什么:把它单独成模块,卷积引擎再复杂也不用动总线接口;这正是"接口与功能分离"在 RTL 结构上的体现。怎么做:看代码,重点盯四个部位 —— 写译码里 start 的 idle 门控、STATUS.done 的读清除、读多路器、pslverr 的产生条件:
//==============================================================
// npu_regfile.v — APB 从机接口 + NPU 寄存器组(复用 08-05 协议)
// 地址空间: 0x00~0x28 寄存器(见表); paddr[7]==1 为片上 RAM
// 写窗口(特征图/权重各半区, 本页演示寄存器译码骨架)
//==============================================================
module npu_regfile (
input wire clk,
input wire rst_n, // 同步复位, 低有效
// ---- APB 从机接口 ----
input wire psel,
input wire penable,
input wire pwrite,
input wire [7:0] paddr, // 寄存器按 4 字节对齐
input wire [31:0] pwdata,
output reg [31:0] prdata,
output wire pready, // 零等待: 恒 1
output reg pslverr, // 非法地址/写被拒时置位
// ---- 中断输出 ----
output wire irq,
// ---- 片上 RAM 写通道(配置模式专用) ----
output reg ram_we,
output reg [15:0] ram_addr,
output reg [31:0] ram_wdata, // RAM 模块按字节 lane 拆分
input wire ram_err, // 计算模式中写 RAM 被拒
// ---- 控制 FSM 接口 ----
input wire busy, // FSM 非 IDLE
input wire done_set, // FSM 完成脉冲
input wire [2:0] lt_echo, // FSM 层类型回显 → STATUS[4:2]
output reg start, // 启动脉冲(仅 idle 时生成)
output wire ie, // 中断使能 → irq 门控
output reg [2:0] layer_type,
output reg relu_en,
output reg k5,
output reg [7:0] ch_in, ch_out, fm_in, fm_out,
output reg [15:0] in_addr,w_addr,o_addr,b_addr,rq_mult,
output reg [4:0] rq_shift
);
// ---- 偏移表: 与第 2 节寄存器表逐条对应 ----
localparam [7:0] A_CTRL=8'h00, A_STATUS=8'h04, A_IN=8'h08,
A_W =8'h0C, A_OUT =8'h10, A_B =8'h14,
A_LC =8'h18, A_CH =8'h1C, A_FM =8'h20,
A_RQ =8'h24, A_VER =8'h28;
reg ctrl_ie; // CTRL[1] 中断使能
reg st_done; // STATUS[1], 读清除
reg [15:0] r_rqm; // REQUANT[15:0]
assign pready = 1'b1; // 本设计无等待态
assign ie = ctrl_ie;
assign irq = ctrl_ie & st_done; // 电平中断: 清 done 即撤 irq
wire apb_wr = psel & penable & pwrite; // APB 写选通
wire apb_rd = psel & penable & ~pwrite; // APB 读选通
//------------------------------------------------------------
// 写译码: 寄存器 + RAM 窗口; start 仅在 idle 时接受
//------------------------------------------------------------
always @(posedge clk) begin
if (!rst_n) begin
ctrl_ie <= 1'b0; start <= 1'b0; ram_we <= 1'b0;
layer_type <= 3'd0; relu_en <= 1'b0; k5 <= 1'b0;
ch_in <= 8'd0; ch_out <= 8'd0; fm_in <= 8'd0; fm_out <= 8'd0;
in_addr <= 16'd0; w_addr <= 16'd0; o_addr <= 16'd0;
b_addr <= 16'd0; r_rqm <= 16'd0; rq_shift <= 5'd0;
ram_addr <= 16'd0; ram_wdata <= 32'd0;
end else begin
start <= 1'b0; // start 是单拍脉冲, 默认自清
ram_we <= 1'b0;
if (apb_wr) begin
if (paddr[7]) begin // RAM 写窗口(paddr[6]=0 特征图/1 权重)
ram_we <= ~ram_err; // 计算模式中 ram_err=1 → 拒绝
ram_addr <= {paddr[6], paddr[5:0], 8'd0}; // 窗口映射示例, 16 位全地址换算见配套仓库
ram_wdata <= pwdata;
end else begin
case (paddr)
A_CTRL: begin
ctrl_ie <= pwdata[1];
if (pwdata[0] & ~busy) // ★ idle 门控: busy 时写 start 被忽略
start <= 1'b1; // 下一拍给 FSM 一个单拍脉冲
end
A_IN: in_addr <= pwdata[15:0];
A_W: w_addr <= pwdata[15:0];
A_OUT: o_addr <= pwdata[15:0];
A_B: b_addr <= pwdata[15:0];
A_LC: begin
layer_type <= pwdata[2:0];
relu_en <= pwdata[3];
k5 <= pwdata[4];
end
A_CH: begin
ch_in <= pwdata[7:0]; // 存 N-1 契约
ch_out <= pwdata[15:8];
end
A_FM: begin
fm_in <= pwdata[7:0];
fm_out <= pwdata[15:8];
end
A_RQ: begin
r_rqm <= pwdata[15:0];
rq_shift <= pwdata[20:16];
end
default: ; // 只读/保留寄存器: 写被忽略
endcase
end
end
end
end
assign rq_mult = r_rqm;
//------------------------------------------------------------
// STATUS.done: FSM 置位, 读 STATUS 清除(读清除 RC 语义)
//------------------------------------------------------------
always @(posedge clk) begin
if (!rst_n) st_done <= 1'b0;
else if (done_set) st_done <= 1'b1;
else if (apb_rd && paddr==A_STATUS) st_done <= 1'b0;
end
//------------------------------------------------------------
// 读多路器(组合): 寄存器表右半边的镜像
//------------------------------------------------------------
always @(*) begin
case (paddr)
A_CTRL: prdata = {29'd0, ctrl_ie, 1'b0};
A_STATUS: prdata = {26'd0, lt_echo, st_done, ~busy};
A_IN: prdata = {16'd0, in_addr};
A_W: prdata = {16'd0, w_addr};
A_OUT: prdata = {16'd0, o_addr};
A_B: prdata = {16'd0, b_addr};
A_LC: prdata = {26'd0, k5, relu_en, layer_type};
A_CH: prdata = {16'd0, ch_out, ch_in};
A_FM: prdata = {16'd0, fm_out, fm_in};
A_RQ: prdata = {10'd0, rq_shift, r_rqm};
A_VER: prdata = 32'h0001_0000; // v1.0
default: prdata = 32'h0;
endcase
end
//------------------------------------------------------------
// 错误响应: 非法寄存器地址, 或 RAM 写通道被拒
//------------------------------------------------------------
always @(posedge clk) begin
if (!rst_n) pslverr <= 1'b0;
else if (apb_wr && (~paddr[7] && (paddr > A_VER) || ram_err))
pslverr <= 1'b1;
else
pslverr <= 1'b0;
end
endmodule
逐段讲解:① 写译码段的 ★ 行是本模块的灵魂 —— pwdata[0] & ~busy 把"start 只在 idle 采样"从一句 Spec 变成了一行电路,busy 时写 start 静默丢弃(也可改为置 pslverr,按 Spec 取舍);start 每拍默认清零,所以天然是单拍脉冲,FSM 侧无需再做边沿检测;② done 段实现读清除(RC)语义,irq 是纯组合的 ie & done 电平 —— CPU 进 ISR 读一次 STATUS,中断即撤,不用额外 ACK 寄存器;③ 读多路器用纯组合 case,APB 在 SETUP→ACCESS 后第一拍即可取数,与 pready=1 的零等待声明自洽;④ pslverr 段只报"真错误"(非法地址/RAM 写被拒),正常写不报 —— 错误响应的粒度也是 Spec 的一部分。
if(!psel) prdata=0),悬空数据会被总线抓走;② 读清除写成"psel 即清"而不是"ACCESS 相读才清",SETUP 相就把 done 清了;③ case 写译码漏 default,综合器推断出锁存器。是什么:验证计划 = 把"怎么证明设计符合 Spec"写成一张分阶段的作战表。为什么:NPU 这类数据密集型设计,手写定向用例永远追不上数据组合的空间 —— 必须靠黄金模型(golden model)对照法:用 Python/NumPy 按 Spec 的算法定义生成输入与期望输出,RTL 仿真逐比特比对(定点计算是确定的,容差必须为 0,这一点与 AI 模型验证"比个精度"完全不同)。怎么做:按下表推进,每个阶段有明确的进出口条件,不达标不进下一阶段:
| 阶段 | 对象 | 核心用例 | 出口条件(通关标准) |
|---|---|---|---|
| L0 静态检查 | 全部 RTL | lint/编码规范(位宽匹配、锁存器、组合环) | 0 error,warning 逐条归档 |
| L1 模块级 TB | npu_regfile | 复位值比对、每寄存器 RW/RO/RC 属性、start 忽略、pslverr | 寄存器表 11 项全过 |
| L1 模块级 TB | npu_ctrl | 定向: idle→done 全路径;随机: 提前 start、复位打入 | 状态转移表 8 行全覆盖 |
| L1 模块级 TB | 卷积/池化/ReLU 子模块 | 单层小图(6×6,1 通道)黄金数据比对 | 输出 100% 逐比特一致 |
| L2 子系统 TB | ctrl+引擎+RAM | 真实 LeNet 每层单独跑,乒乓切换 2 次以上 | 8 层各自逐比特一致 |
| L3 系统级 TB | npu_top 整网 | 整网推理 + 中断路径 + 计算中乱写寄存器/RAM 的负向用例 | MNIST 图集端到端一致 + 周期数 ≤ 1M |
黄金数据生成脚本骨架(Python 侧,几行就能搭起对照体系,完整版在 08-11 展开):
import numpy as np
rng = np.random.default_rng(0) # 固定种子: 结果可复现
x = rng.integers(-128, 128, (1, 32, 32), dtype=np.int16) # C1 输入
W = rng.integers(-128, 128, (6, 1, 5, 5), dtype=np.int16) # C1 权重
M0, SH = np.load('m0.npy'), int(np.load('sh.npy')) # 08-08 量化参数
acc = conv2d_int8(x, W) + bias # INT32 参考(NumPy 天生 int32)
out = np.clip(acc * int(M0) >> SH, -128, 127).astype(np.int8) # requant+饱和
out.tofile('golden_c1_out.bin') # TB 用 $readmemh/fread 读入比对
覆盖率的三个抓手:寄存器位域全覆盖(每个 bit 写 0/1 各一次)、配置空间抽样(层类型×核尺寸×relu_en×乒乓状态的正交组合)、场景覆盖(轮询与中断两条完成路径、计算中写 RAM 被拒、复位打入 CALC 态)。功能覆盖率数字(如"层类型×核尺寸组合 100%")是 L3 出口条件的一部分。
以下 12 条资源全部经过人工核实可达。使用建议:写本页 RTL 前看 7/8(别人怎么踩坑),定义寄存器/接口时翻 NVDLA 官网文档(工业级寄存器手册长什么样),想看别人怎么切顶层架构时读 9/10/11 的仓库 README 与顶层模块,想系统成长时报名一生一芯。
if (pwdata[0] & ~busy) 保证只有 idle 时 start 请求才生成脉冲(见知识点 9 代码 ★ 行);"计算中写 start 被忽略"是第 2 节模式切换约束的白纸黑字。C/D 都会让一次误操作演变成数据损坏 —— 防御性设计的原则是"非法请求不生效、可观测、可恢复"。