FPGA实战:用Verilog实现LeNet-5手写数字识别硬件加速器 简介这是一份用于在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×124×24×66 个 5×5 卷积核1 个偏置ReLUS2池化24×24×612×12×62×2 最大池化无C3卷积12×12×68×8×1616 个 5×5×6 卷积核16 个偏置ReLUS4池化8×8×164×4×162×2 最大池化无F5全连接4×4×16256120256×120 权重矩阵无F6全连接12084120×84 权重矩阵ReLUOUT输出841084×10 权重矩阵argmax严格来说原始 LeNet-5 的激活函数是 tanh 或 sigmoidC1 和 C3 后面各有一个F6 后面也有一个正好对应标题里的“三个激活层”。但 FPGA 上做 tanh 查找表要占一块 ROM而且 MNIST 这种简单任务用 ReLU 精度不会差多少所以我这里三个激活层全部用 ReLU输出层只做 argmax不做 softmax。这样 C1、C3、F6 三处各放一个 ReLU 模块结构上不缩水硬件开销却小很多。这张表还有一个作用算存储。C1 权重只有 6×5×5150 个C3 权重是 16×6×5×52400 个F5 权重是 256×12030720 个F6 权重是 120×8410080 个输出层权重是 84×10840 个。全部权重加起来不到 5 万个 16-bit 数用 BRAM 存绰绰有余。算力上整个网络大约 20 万次乘累加100 MHz 时钟下单通道串行 MAC 也只需要几毫秒。这也是为什么这个项目特别适合作为第一个 FPGA 神经网络工程规模小串行实现也能满足实时性不需要一上来就做深度流水线。2.2 权重先量化再写 RTLPyTorch 模型转成 .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, scale2**8, out_dirweights): 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_okTrue) 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_locationcpu) 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)这段脚本的关键是scale2**8它决定了定点数的精度和范围。MNIST 这种简单任务权重绝对值通常不超过 1Q8.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 到 78328×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 [DWWW:0] prod; assign prod data_in * weight; always (posedge clk or negedge rst_n) begin if (!rst_n) acc {AW{1b0}}; else if (clr) acc {AW{1b0}}; else if (ce) acc acc prod; end endmodule这个 MAC 模块的参数有三个地方要注意。第一data_in声明成 9-bit 有符号是因为输入像素本身是无符号 0~255直接和 8-bit 有符号权重相乘会被 Verilog 当成无符号数处理符号位会出错所以提前做一次零扩展。第二乘法的结果位宽是DWWW117-bit累加器用 20-bit 是为了防止 25 次累加后溢出。实际上 17-bit×25 大约需要 22-bit20-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 2d0; localparam ACC 2d1; localparam BIAS 2d2; 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 5d0; acc_r 24d0; done 1b0; end else begin case (state) IDLE: begin if (start) begin acc_r 24d0; tap_cnt 5d0; state ACC; done 1b0; 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 5d24) begin state BIAS; end else begin tap_cnt tap_cnt 1b1; end end BIAS: begin // 加偏置、ReLU、写回下一级 RAM // acc_final (acc_r bias 0) ? 饱和截断 : 0 state IDLE; done 1b1; 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 1b0; dout {DW{1b0}}; end else begin dout_valid valid; dout (m0 m1) ? m0 : m1; end end endmodule池化层真正的难点不在计算而在“四个像素怎么凑齐”。如果卷积层输出是一行一行写到 BRAM 里的那么池化时要同时读第 2k 行和第 2k1 行的第 2j 列和第 2j1 列。我的做法是在卷积层写完特征图后用一个专门的池化状态机重新按地址读 BRAM每次读四个地址而不是在卷积流水线里硬凑。这样多花一点 BRAM 读带宽但控制逻辑清晰很多对 100 MHz 下的 MNIST 来说完全够用。如果你希望在流水线里直接做池化就需要额外加一行缓存把同一行的两个像素先攒起来等下一行对齐再出结果代码会复杂不少收益却不大。4. 池化、全连接与激活三个算子怎么合成一个可复用的数据通路4.1 全连接层和卷积层的区别只在地址共享同一套 MAC全连接层本质上就是矩阵乘法n 个输入乘以 n×m 权重矩阵得到 m 个输出。跟卷积层比它没有滑窗没有 5×5 窗口只是一个一维的乘累加。所以我在第二级全连接层根本没有写新的乘法器直接把卷积用的 MAC 模块例化了一份只是地址生成逻辑换了。F5 层的输入是 S4 池化后的 4×4×16256 个数输出 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 9d0; active 1b0; acc_r {AW{1b0}}; done 1b0; end else if (start) begin cnt 9d0; acc_r {AW{1b0}}; active 1b1; done 1b0; end else if (active) begin if (cnt IN_N - 1) begin result acc_r; // 最后一个乘积已经累加进 acc_r active 1b0; done 1b1; end else begin acc_r acc_r data_in * weight_in; cnt cnt 1b1; 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{1b0}}; else if (din maxv) dout {OUTW{1b1}}; else dout din[OUTW-1:0]; end endmodule这个模块有两个参数需要注意。DW16是进入激活层前的累加结果位宽OUTW8是输出特征图的像素位宽也就是下一层的输入位宽。饱和上限是 255如果某一层的输出值经常顶到 255说明前面累加器或权重量化的比例不对特征图会大量“削顶”识别率下降。C1 和 C3 的输入是 8-bit 像素或者 8-bit 特征图所以OUTW保持 8F6 层后面接输出层我一般把OUTW设成 12 或 16让输出层权重有更多动态范围。识别率如果异常先查每一个激活层的分布看是不是某层几乎全为 0 或者几乎全为 255这个信号比看波形更直观。4.3 输出层不需要 softmaxargmax 就是冠军最后一个全连接层输出 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 4d0; result 4d0; best {DW{1b0}}; done 1b0; end else if (valid) begin if (idx 4d0 || score best) begin best score; result idx; end if (idx 4d9) begin done 1b1; idx 4d0; end else begin idx idx 1b1; done 1b0; end end end endmodulescore是有符号数因为输出层的累加结果可能是负的best初始化为 0 没问题因为 MNIST 输出层权重和偏置训练后分数基本都在正负几十的范围内如果十个分数全为负数argmax 会返回 0也算合理。这个模块在valid拉高 10 拍后给出doneresult就是最终的识别数字。到手写数字识别的最后一公里我通常把这个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 就能写完波特率 1152008-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 打印结果做逐层对拍。这个动作看起来笨但它是所有“玄学”问题的后悔药。只要每一层的最大误差记录在案后续改并行度、改量化位宽都能立刻知道是哪一层引入了偏差。希望这个验证方法能帮到你。本文还有配套的精品资源点击获取