简介:这是一份用于在FPGA上实现LeNet-5手写数字识别系统的硬件加速工程包,面向FPGA开发者和深度学习硬件加速初学者。项目采用纯Verilog语言编写RTL代码,完整包含两个卷积层、两个池化层、两个全连接层和三个激活层,并结合MNIST数据集完成数据预处理、加载与后处理,可帮助读者从寄存器传输级理解CNN各模块的硬件实现与整体调度。压缩包共79个文件,以.v源码为主,涵盖卷积、池化、全连接、激活、UART通信和顶层控制等模块,另含Vivado工程文件(.xpr)、约束文件(.xdc)、仿真Testbench、运行报告以及README和代码说明文档,压缩包整体仅295KB。附赠的说明文档(docx)和代码说明txt进一步给出了架构细节、实验结果与部署指南,便于对照复现和二次修改。已有192人学习下载,适合希望从RTL层面掌握卷积神经网络硬件加速设计的开发者参考。
1. FPGA 上跑 LeNet-5 手写数字识别:不只是把网络搬进去
把一个训练好的 LeNet-5 手写数字识别网络,用纯 Verilog 的 RTL 代码固化到 FPGA 里,听起来像“把卷积神经网络跑起来”,真正动手才发现问题是另一套:权重怎么存、乘累加怎么做、图像怎么进、结果怎么出来。一个完整的 LeNet-5 硬件加速器包含两个卷积层、两个池化层、两个全连接层和三个激活层,规模不大,但正好覆盖了 CNN 硬件化的全部关键点。对 FPGA 入门者和想转硬件加速方向的工程师来说,这是一个性价比极高的练习项目:能真正在开发板上看到数码管或者串口回传识别结果,而不是只在仿真波形里自嗨。
2. LeNet-5 硬件化拆解:从网络结构到 RTL 数据流
2.1 先给每一层画一张硬件账本
我在动手写代码前,习惯先把网络结构列成一张表,每一层的输入输出尺寸、算子类型、数据位宽都写清楚。硬件实现和软件实现最大的区别是:软件里一个 for 循环可以套多层,硬件里每个算子都要对应具体的存储和计算电路。LeNet-5 的经典结构输入是 32×32 单通道灰度图,MNIST 原图是 28×28,通常先缩放到 32×32,或者直接把 28×28 放进网络然后忽略边界输出。为了省事,我一般保留 28×28 输入,卷积不补零,这样 C1 输出是 24×24,而不是原始论文里的 28×28。如果你坚持和 LeNet-5 原始尺寸一致,就在输入四周补一圈 0。
| 层 | 类型 | 输入尺寸 | 输出尺寸 | 参数 | 激活 |
|---|---|---|---|---|---|
| C1 | 卷积 | 28×28×1 | 24×24×6 | 6 个 5×5 卷积核,1 个偏置 | ReLU |
| S2 | 池化 | 24×24×6 | 12×12×6 | 2×2 最大池化 | 无 |
| C3 | 卷积 | 12×12×6 | 8×8×16 | 16 个 5×5×6 卷积核,16 个偏置 | ReLU |
| S4 | 池化 | 8×8×16 | 4×4×16 | 2×2 最大池化 | 无 |
| F5 | 全连接 | 4×4×16=256 | 120 | 256×120 权重矩阵 | 无 |
| F6 | 全连接 | 120 | 84 | 120×84 权重矩阵 | ReLU |
| OUT | 输出 | 84 | 10 | 84×10 权重矩阵 | argmax |
严格来说,原始 LeNet-5 的激活函数是 tanh 或 sigmoid,C1 和 C3 后面各有一个,F6 后面也有一个,正好对应标题里的“三个激活层”。但 FPGA 上做 tanh 查找表要占一块 ROM,而且 MNIST 这种简单任务用 ReLU 精度不会差多少,所以我这里三个激活层全部用 ReLU,输出层只做 argmax,不做 softmax。这样 C1、C3、F6 三处各放一个 ReLU 模块,结构上不缩水,硬件开销却小很多。
这张表还有一个作用:算存储。C1 权重只有 6×5×5=150 个,C3 权重是 16×6×5×5=2400 个,F5 权重是 256×120=30720 个,F6 权重是 120×84=10080 个,输出层权重是 84×10=840 个。全部权重加起来不到 5 万个 16-bit 数,用 BRAM 存绰绰有余。算力上,整个网络大约 20 万次乘累加,100 MHz 时钟下单通道串行 MAC 也只需要几毫秒。这也是为什么这个项目特别适合作为第一个 FPGA 神经网络工程:规模小,串行实现也能满足实时性,不需要一上来就做深度流水线。
2.2 权重先量化再写 RTL:PyTorch 模型转成 .mem 文件
FPGA 里做浮点运算很浪费,卷积和全连接用的权重必须转成定点数。我的做法是:在 PC 上用 PyTorch 训练或者拿到一个训练好的 LeNet-5 模型,把权重和偏置转成 16-bit 有符号整数,然后导出成 Verilog 能直接读取的 .mem 文件。量化精度我一般用 Q8.8,也就是小数部分占 8 bit,权重范围如果超过 [-128, 127],就考虑用 Q4.12 或者先做一下权重的归一化。
import torch import numpy as np from pathlib import Path def quant_and_dump(state_dict, key, scale=2**8, out_dir="weights"): t = state_dict[key].detach().cpu().numpy().astype(np.float64) # 转定点:四舍五入并截断到 int16 范围 q = np.clip(np.round(t * scale), -32768, 32767).astype(np.int16) # 按行展开写十六进制,Verilog 的 $readmemh 可以直接吃 Path(out_dir).mkdir(exist_ok=True) name = key.replace(".", "_") with open(f"{out_dir}/{name}.mem", "w") as f: for v in q.flatten(): f.write(f"{v & 0xFFFF:04X}\n") print(f"{key}: shape={t.shape}, absmax={np.abs(t).max():.4f}") model = torch.load("lenet_mnist.pt", map_location="cpu") state = model if isinstance(model, dict) else model.state_dict() for k, v in state.items(): if "weight" in k or "bias" in k: quant_and_dump(state, k)这段脚本的关键是scale=2**8,它决定了定点数的精度和范围。MNIST 这种简单任务,权重绝对值通常不超过 1,Q8.8 的精度足够,但偏置可能会稍大,所以统一用 int16 承接,累加器再用 20 bit。导出格式是每行一个 4 位十六进制数,Verilog 里用$readmemh("weights/conv1_weight.mem", rom)直接加载到寄存器数组或 RAM 里,省去手工初始化一大片 case 语句。
写完脚本后我一般会做一次软件侧的定点回灌:把量化后的权重和偏置放回 PyTorch 或 numpy 里跑一遍 MNIST 测试集,确认识别率没有明显下降。这一步不能省,因为如果 PC 上定点模型已经识别率很差,后面所有 FPGA 调试都是在浪费时间。量化后的 MNIST 识别率通常还能保持在 98% 以上,如果掉到 95% 以下,先检查是不是有层的输出被截断得太狠,而不是急着改 RTL。
2.3 数据存储与取数:一张图在 FPGA 里怎么流转
FPGA 里没有“图片”这个概念,只有地址和数据。我一般把处理流程分成三段:第一段是输入缓存,用一块 BRAM 存原始图像,地址从 0 到 783(28×28),每次读出一个 8-bit 像素;第二段是中间特征图缓存,C1 输出的 6 张 24×24 特征图按通道连续存放,每张 576 字节;第三段是权重 ROM,按层组织,每个卷积核的 25 个权重连续排列。
取数时的地址生成是最容易出错的地方。卷积层每算一个输出像素,需要按行扫描 5×5 窗口,也就是 25 个输入像素。如果直接用多重 for 循环的概念映射到硬件计数器,很容易在边界处地址越界。我习惯用一个二维地址映射函数来生成读地址:addr = row * stride + col,然后在状态机里用两个计数器分别表示行偏移和列偏移。对于 S2 池化层,输入是 24×24×6,输出是 12×12×6,本质就是每隔一个像素取 2×2 块,这个用计数器低 1 位做判断就能实现,不需要单独存一份中间图。
3. 用 Verilog 写卷积层:从图像存储到 MAC 累加器
3.1 一个可综合的乘累加单元:MAC 是整个加速器的心脏
卷积层的核心是一个乘累加器。虽然 FPGA 上可以铺很多乘法器并行计算,但我的建议是第一版先用单 MAC 串行复用的方案。原因很简单:MNIST 的 LeNet-5 总共才 20 万次乘累加,100 MHz 下串行跑也就 2 毫秒上下,完全够实时;单 MAC 的资源占用极小,逻辑简单,出了问题好查。后面如果想提速,再把单 MAC 复制成多个,或者引入流水线,都是很容易的增量修改。
module mac16 #( parameter DW = 8, // 输入像素位宽,实际扩展为 9-bit 有符号 parameter WW = 8, // 权重位宽,8-bit 有符号 parameter AW = 20 // 累加器位宽,20-bit 有符号 )( input wire clk, input wire rst_n, input wire clr, // 清零,开始新一次乘累加 input wire ce, // 每个时钟使能一次累加 input wire signed [DW:0] data_in, // 像素,零扩展到 9-bit input wire signed [WW-1:0] weight, // 权重 output reg signed [AW-1:0] acc ); wire signed [DW+WW:0] prod; assign prod = data_in * weight; always @(posedge clk or negedge rst_n) begin if (!rst_n) acc <= {AW{1'b0}}; else if (clr) acc <= {AW{1'b0}}; else if (ce) acc <= acc + prod; end endmodule这个 MAC 模块的参数有三个地方要注意。第一,data_in声明成 9-bit 有符号,是因为输入像素本身是无符号 0~255,直接和 8-bit 有符号权重相乘会被 Verilog 当成无符号数处理,符号位会出错,所以提前做一次零扩展。第二,乘法的结果位宽是DW+WW+1=17-bit,累加器用 20-bit 是为了防止 25 次累加后溢出。实际上 17-bit×25 大约需要 22-bit,20-bit 不够,我的做法是在 MAC 内部把累加器放宽到 24-bit,输出时再做饱和截断到需要的位宽。第三,clr和ce要严格错开,clr只在一次卷积窗口的最后一个乘积累加完成后拉高,不能在中间清零。
写 MAC 时有一个很容易踩的坑:在always块里直接写acc <= acc + data_in * weight,然后希望综合工具自动推断出 DSP 乘法器。这样写可以综合,但乘积路径上没有任何寄存器,时钟频率高一点时序就崩。我建议无论如何先让乘积结果prod单独打一拍,也就是先 wire 再在累加器里使用,这样一个时钟只做乘法或者只做加法,时序压力小很多。
3.2 卷积层的状态机:地址生成和 25 次累加怎么配合
有了 MAC,还需要一个状态机来调度。卷积一个输出像素需要 25 次乘累加,也就是要读 25 次输入像素和 25 个权重。我习惯把状态机分成两个主要状态:一个负责从 BRAM 里读数据,另一个负责控制 MAC 累加。两者不一定同一个时钟,但为了简单,第一版可以做成同步的:每个时钟读一对像素和权重,送给 MAC 累加,计数器计满 25 个后进入加偏置和 ReLU 状态。
module conv_fsm #( parameter PIXEL_ADDR_W = 10, // 28x28 图像寻址 parameter WEIGHT_ADDR_W = 8 )( input wire clk, input wire rst_n, input wire start, output reg done ); localparam IDLE = 2'd0; localparam ACC = 2'd1; localparam BIAS = 2'd2; reg [1:0] state; reg [4:0] tap_cnt; // 0~24,记录 25 个权重位置 reg signed [23:0] acc_r; // 内部宽累加器 // 这里省略 BRAM 读端口和 ROM 读端口的实际信号连接 // 实际工程中,读地址由 tap_cnt 映射到 row/col 地址 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; tap_cnt <= 5'd0; acc_r <= 24'd0; done <= 1'b0; end else begin case (state) IDLE: begin if (start) begin acc_r <= 24'd0; tap_cnt <= 5'd0; state <= ACC; done <= 1'b0; end end ACC: begin // pixel_bram_dout 和 weight_rom_dout 由外部组合逻辑产生 acc_r <= acc_r + $signed(pixel_bram_dout) * $signed(weight_rom_dout); if (tap_cnt == 5'd24) begin state <= BIAS; end else begin tap_cnt <= tap_cnt + 1'b1; end end BIAS: begin // 加偏置、ReLU、写回下一级 RAM // acc_final <= (acc_r + bias > 0) ? 饱和截断 : 0 state <= IDLE; done <= 1'b1; end endcase end end endmodule这里的tap_cnt有两个含义:它既是 25 个权重的位置计数,也是 5×5 窗口内像素的索引。真正工程里要把tap_cnt拆成row_off = tap_cnt / 5和col_off = tap_cnt % 5,然后跟当前输出像素的基准地址叠加,得到 BRAM 读地址。我用一个除法器和取模器来做映射,虽然浪费一点逻辑,但代码清晰,不容易在边界条件上翻车。如果嫌除法器太浪费,可以改成两个计数器,一个计 0~4,一个计 0~4,嵌套组合出tap_cnt,这样完全不需要除法。
状态机里最值得注意的地方是:ACC状态在tap_cnt == 24时下一个周期进入BIAS,但最后一次累加结果是在进入BIAS的同一个时钟沿写入acc_r的,所以BIAS状态里读到的acc_r刚好是 25 次累加的完整结果。这个时序要反复对照波形图确认,否则很容易出现最后少一次累加或者多累加一次的问题。我在第一次写的时候就在这里丢过结果,后来把所有中间信号都拉到 ModelSim 里看一遍,才明白是状态跳转和累加更新抢了同一个时钟沿。
3.3 池化层:最大池化两行代码,地址对齐才是重头戏
LeNet-5 原版用的是平均池化,但 FPGA 上最大池化几乎不花资源,就四个数两两比较。从识别率角度看,MNIST 上最大池化和平均池化差别很小,我最终选了最大池化,理由很简单:省一个除法器,哪怕只是右移两位。如果一定想要平均池化,把输出改成(a0 + a1 + b0 + b1) >> 2即可,代价是加法和移位逻辑,也不大。
module pool2x2 #( parameter DW = 8 )( input wire clk, input wire rst_n, input wire valid, // 四个输入对齐后同时有效 input wire [DW-1:0] a0, a1, // 第一行两个相邻像素 input wire [DW-1:0] b0, b1, // 第二行两个相邻像素 output reg [DW-1:0] dout, output reg dout_valid ); wire [DW-1:0] m0; wire [DW-1:0] m1; assign m0 = (a0 > a1) ? a0 : a1; assign m1 = (b0 > b1) ? b0 : b1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dout_valid <= 1'b0; dout <= {DW{1'b0}}; end else begin dout_valid <= valid; dout <= (m0 > m1) ? m0 : m1; end end endmodule池化层真正的难点不在计算,而在“四个像素怎么凑齐”。如果卷积层输出是一行一行写到 BRAM 里的,那么池化时要同时读第 2k 行和第 2k+1 行的第 2j 列和第 2j+1 列。我的做法是在卷积层写完特征图后,用一个专门的池化状态机重新按地址读 BRAM,每次读四个地址,而不是在卷积流水线里硬凑。这样多花一点 BRAM 读带宽,但控制逻辑清晰很多,对 100 MHz 下的 MNIST 来说完全够用。如果你希望在流水线里直接做池化,就需要额外加一行缓存,把同一行的两个像素先攒起来,等下一行对齐再出结果,代码会复杂不少,收益却不大。
4. 池化、全连接与激活:三个算子怎么合成一个可复用的数据通路
4.1 全连接层和卷积层的区别只在地址:共享同一套 MAC
全连接层本质上就是矩阵乘法,n 个输入乘以 n×m 权重矩阵,得到 m 个输出。跟卷积层比,它没有滑窗,没有 5×5 窗口,只是一个一维的乘累加。所以我在第二级全连接层根本没有写新的乘法器,直接把卷积用的 MAC 模块例化了一份,只是地址生成逻辑换了。
F5 层的输入是 S4 池化后的 4×4×16=256 个数,输出 120 个节点。每一项输出都是 256 次乘累加,120 个输出就是 30720 次乘累加。F6 层是 120 到 84,输出层是 84 到 10。这三层如果都用同一个 MAC 串行算,总次数约 4 万左右,比两个卷积层加起来还少,所以复用是完全可行的。
module fc_core #( parameter IN_N = 256, // 输入向量长度 parameter OUT_N = 120, // 输出节点数 parameter DW = 9, // 输入数据位宽(含符号位) parameter WW = 8, // 权重位宽 parameter AW = 20 // 累加器位宽 )( input wire clk, input wire rst_n, input wire start, input wire signed [DW-1:0] data_in, input wire signed [WW-1:0] weight_in, output reg signed [AW-1:0] result, output reg done ); reg [8:0] cnt; // 0~IN_N-1 reg active; reg signed [AW-1:0] acc_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 9'd0; active <= 1'b0; acc_r <= {AW{1'b0}}; done <= 1'b0; end else if (start) begin cnt <= 9'd0; acc_r <= {AW{1'b0}}; active <= 1'b1; done <= 1'b0; end else if (active) begin if (cnt == IN_N - 1) begin result <= acc_r; // 最后一个乘积已经累加进 acc_r active <= 1'b0; done <= 1'b1; end else begin acc_r <= acc_r + data_in * weight_in; cnt <= cnt + 1'b1; end end end endmodule这段代码里result <= acc_r的时机要特别说一句。我在cnt == IN_N-1的周期里没有再累加,是因为这个周期读进来的是第 IN_N-1 个数据,当计数器走到最后一个值时,累加其实已经完成,acc_r已经是最终结果。这是串行 MAC 最常见的两种写法之一,另一种是先判断cnt == IN_N-2再累加,两种都能用,但必须跟你的地址读时序严格匹配,否则就会出现全连接输出差一个权重的情况。我吃过这个亏,后来索性把这种“最后一个周期不计算”的风格固定下来,所有层都统一。
全连接层的权重排布决定了地址生成。F5 层我把权重按“输出节点优先”排列:先存第 0 个输出节点的 256 个权重,再存第 1 个输出节点的 256 个权重,依此类推。这样状态机只需要一个out_idx和一个in_idx,每算完一个输出节点,in_idx清零,out_idx加一,权重 ROM 地址就等于out_idx * IN_N + in_idx。如果你从 PyTorch 导出的权重顺序是(out_channels, in_channels),转 .mem 文件时不需要额外调整,直接按行展开就是这个顺序;如果你的模型是(in_channels, out_channels),就必须转置后再导出,不然识别率会直接崩成 10%。
4.2 激活层用 ReLU 加饱和截断:硬件里最便宜的“非线性”
三个激活层在 FPGA 里实现方式完全一样:判断符号位,负数归零,正数超出目标位宽就截到最大值。ReLU 本质上是一个比较器加多路选择器,综合出来几十个 LUT,跟查找表实现 tanh 动辄几百个 LUT 相比,简直是白送。
module relu_sat #( parameter DW = 16, // 输入位宽,有符号 parameter OUTW = 8 // 输出位宽,无符号 )( input wire signed [DW-1:0] din, output reg [OUTW-1:0] dout ); wire signed [DW-1:0] maxv; assign maxv = (1 << OUTW) - 1; // 255 always @(*) begin if (din < 0) dout = {OUTW{1'b0}}; else if (din > maxv) dout = {OUTW{1'b1}}; else dout = din[OUTW-1:0]; end endmodule这个模块有两个参数需要注意。DW=16是进入激活层前的累加结果位宽,OUTW=8是输出特征图的像素位宽,也就是下一层的输入位宽。饱和上限是 255,如果某一层的输出值经常顶到 255,说明前面累加器或权重量化的比例不对,特征图会大量“削顶”,识别率下降。C1 和 C3 的输入是 8-bit 像素或者 8-bit 特征图,所以OUTW保持 8;F6 层后面接输出层,我一般把OUTW设成 12 或 16,让输出层权重有更多动态范围。识别率如果异常,先查每一个激活层的分布,看是不是某层几乎全为 0 或者几乎全为 255,这个信号比看波形更直观。
4.3 输出层不需要 softmax:argmax 就是冠军
最后一个全连接层输出 10 个分数,对应数字 0~9。软件里通常要过一个 softmax 变成概率,但硬件里找最大值的 argmax 就够了。argmax 用一段很短的时序逻辑就能实现:每个时钟进来一个分数,跟当前最佳分数比较,如果更大就更新索引。
module argmax10 #( parameter DW = 16 )( input wire clk, input wire rst_n, input wire valid, // 每拍输入一个分数 input wire signed [DW-1:0] score, output reg [3:0] result, output reg done ); reg [3:0] idx; reg signed [DW-1:0] best; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin idx <= 4'd0; result <= 4'd0; best <= {DW{1'b0}}; done <= 1'b0; end else if (valid) begin if (idx == 4'd0 || score > best) begin best <= score; result <= idx; end if (idx == 4'd9) begin done <= 1'b1; idx <= 4'd0; end else begin idx <= idx + 1'b1; done <= 1'b0; end end end endmodulescore是有符号数,因为输出层的累加结果可能是负的;best初始化为 0 没问题,因为 MNIST 输出层权重和偏置训练后,分数基本都在正负几十的范围内,如果十个分数全为负数,argmax 会返回 0,也算合理。这个模块在valid拉高 10 拍后给出done,result就是最终的识别数字。到手写数字识别的最后一公里,我通常把这个result直接接到开发板的数码管译码器上,同时在 UART 模块里把 ASCII 码result + '0'发出去,这样板子和 PC 上看到的都是同一个结果。
5. 上板调试最常见的 5 个坑:现象、原因和排查顺序
5.1 卷积输出全是 0:先检查 ReLU 和偏置的先后顺序
现象:ModelSim 仿真里,C1 特征图几乎全黑,少数几个亮点的数值也很小,像是被“压扁”了。
原因:我在第一版代码里把 ReLU 放在了加偏置之前。卷积累加结果经过 25 次乘法后,很多中间值是负的,ReLU 把这些负数全部归零,然后才加偏置。偏置加在 0 上,等于整个输出都被砍掉了一大截,特征图自然报废。正确的顺序必须是:先加偏置,再做 ReLU,输出范围才能落在合理区间。
解决:把偏置加法放进 MAC 的累加结束状态,或者在全连接层最后处理,统一遵循“累加 → 加偏置 → 激活 → 截断”的顺序。我后来把relu_sat模块的输入直接接到偏置加法器的输出,而不是累加器输出,这个坑就再没出现过。
5.2 上板结果乱跳:复位脚释放导致 MAC 状态不确定
现象:仿真全对,第一次烧到板子上,数码管显示的数字每次都不一样,甚至按一次复位键结果就变一次。
原因:FPGA 的全局复位脚释放时机和时钟不是严格同步的。我最初用按键直接接异步复位,手指按下弹起的毛刺让 MAC、地址计数器和状态机各自复位到不同时刻,整个数据通路错位。这个坑在 FPGA 项目实战里非常典型,不是逻辑问题,而是复位同步问题。
解决:所有模块的复位统一用一个同步复位器,把外部按键复位先打两拍,生成rst_n_sync,再分发给所有子模块。我建议在任何 RTL 工程里都加上这个两拍同步复位,不用纠结“FPGA 有没有固定复位脚”这种问题,同步复位永远是更稳的做法。
5.3 权重顺序不对:识别率掉到 10%,跟随机猜一样
现象:软件定点模型识别率 98%,FPGA 计算完的结果只要一经过全连接层,识别率就变成 10% 左右,像是瞎猜。但仿真里每一层的数值又都能对上,直到全连接才爆发。
原因:这是最阴的一个坑。PyTorch 的 Conv2d 权重形状是(out_ch, in_ch, kh, kw),我第 2 章的导出脚本是按这个顺序展平的,RTL 里卷积读取也按这个顺序,没毛病。但全连接层的权重要看原始实现,有的模型文件里 FC 层权重是(in_features, out_features),有的训练脚本转置过一次,导出后自然对不上。全连接对权重顺序极度敏感,错一个位置,结果就是噪声。
解决:在导出脚本里把估计的 FC 权重形状打印出来,对照 RTL 里out_idx * IN_N + in_idx的地址映射,反推导出顺序。我这里的 fc_core 地址映射是(out, in),所以导出时权重必须是(out_ch, in_ch)。如果模型里是反的,直接对权重做一次转置再写文件。最靠谱的验证办法是在 PC 端用同一份 .mem 文件做一次前向计算,把每个层的输出和 RTL 仿真逐层对拍,全连接这种“静默错误”立刻就能定位。
5.4 在 ModelSim 里看不到“完整”的 RTL 电路图:不是软件问题,是写法问题
现象:很多人打开 ModelSim 的 RTL Viewer 或 Schematic 窗口,发现看到的只有一堆 D 触发器和 MUX,找不到卷积模块,怀疑工具坏了。
原因:RTL 电路图只是把可综合的 Verilog 结构还原成门级草图。如果代码用了大量for循环生成变量索引、数组端口例化,或者把状态机写得像行为级 C 语言,综合出视图自然是一堆乱线。ModelSim 的 RTL 视图本身也不是为“查看电路逻辑”设计的,它只是辅助。
解决:想看清晰的电路图,我用两招。第一,把模块端口拆细,比如卷积模块不要让顶层直接例化mac16,而是在卷积模块内部例化,这样 RTL Viewer 里就能看到“mac16 实例”这个层级。第二,如果实在想确认综合结果,直接跑 Vivado 或 Quartus 的综合,看综合后的 Schematic,那个图才是真实映射到 LUT/DSP 的结构,比 ModelSim 的 RTL Viewer 有意义得多。
5.5 时序违例集中爆发:累加链路太长,DSP 没吃上
现象:综合后时序报告一堆红色,关键路径全在 MAC 的乘法累加链上,时钟从 100 MHz 降到 30 MHz 才能收敛。
原因:我在 3.1 节里说“乘积先单独打一拍”,但如果累加器本身又一直组合地接在乘法器后面,比如写成acc_r <= acc_r + prod,这条环路会让综合工具无从优化。另一个原因是权重 ROM 和像素 BRAM 的读数据到乘法器之间没有寄存器,BRAM 输出延迟再叠加乘法器和加法器,路径必然超长。
解决:在 MAC 环路里至少插入两级流水:第一级锁存 BRAM/ROM 读出的数据,第二级做乘法并锁存乘积,第三级做累加。这样每个时钟只做一件事,100 MHz 基本不会违例。资源多一点没关系,这个规模的 LeNet-5 用不到多少 LUT。先功能正确,再优化性能,这是 FPGA 开发的铁律。
6. 上板验证与优化:串口回传、资源估算和流水线提速
6.1 用串口把结果打出来:不要只靠数码管
上板验证时,我通常先做两件事:把一张固定图片存进 BRAM,跑一遍,然后把十个输出分数通过 UART 发到 PC 串口助手。光看数码管上的数字只能知道“对不对”,看不到分数分布,也就不知道模型是不是在“勉强对”。UART 模块本身几十行 Verilog 就能写完,波特率 115200,8-N-1,把argmax10的结果转换成 ASCII 字符发送,PC 端看到Predicted: 7,就算闭环了。
UART 回传还有一个额外好处:可以把中间层特征图也导出来看。我在调试 C1 层时,会把 24×24×6 的特征图按顺序打包成字节流发到 PC,用 Python 脚本重新拼成图片,跟 PyTorch 里同一张图的特征图做对比。如果特征图形状对得上但数值偏大偏小,问题基本在量化比例;如果形状完全是乱的,问题在地址生成。这个“可视化中间层”的方法,比盯波形高效得多。
6.2 提速的起点:先测单 MAC 的实际吞吐,再决定并行度
第一个版本单 MAC 跑完整张 28×28 图大约需要 20 万个时钟周期,100 MHz 下约 2 毫秒,已经很快。如果你想把它压到微秒级,方向其实很明确:把卷积层的 6 个或 16 个输出通道拆成并行 MAC,每个通道一个独立的 MAC 和对应的权重 ROM,这样 C1 的 6 个输出通道可以同时算,吞吐量直接提升 6 倍。代价是 BRAM 读带宽要跟上,一个 BRAM 端口一个时钟只能读一个像素,并行 6 个通道意味着每个时钟要读同一个像素给 6 个 MAC,这个用单 BRAM 无法实现,需要把输入图像复制到 6 个 BRAM 副本里,也就是以存储换带宽。
我个人的建议是,先把串行版本完整跑通、识别率和仿真对齐,再考虑并行。原因很简单:并行 MAC 会让地址生成和时序约束的复杂度成倍上升,如果串行版本里还藏着符号位或权重顺序的 bug,并行化之后所有错误都会被放大,排查难度翻几倍。这个项目最大的价值不是“快”,而是让你把卷积、池化、全连接、激活这几个 CNN 基本算子在 RTL 里全部亲手实现一遍。我当年第一次做这个项目时,花在“全连接权重转置”上的时间比写整个状态机还长,那时候如果直接上并行架构,大概率连板子都点不亮。
最后说一个我自己一直保留的习惯:每完成一版上板验证,我会把 PC 端定点模型导出的每一层中间结果存成一份文本文件,跟 RTL 仿真的 $display 打印结果做逐层对拍。这个动作看起来笨,但它是所有“玄学”问题的后悔药。只要每一层的最大误差记录在案,后续改并行度、改量化位宽,都能立刻知道是哪一层引入了偏差。希望这个验证方法能帮到你。
本文还有配套的精品资源,点击获取