1. 第二篇动手前先把后半段计算量盘一遍写这篇之前我先把上一篇的进度给你对齐一下C1 层那个 6 通道、5x5 卷积的数据通路我们已经跑通了输入像素是 8bit权重量化成 8bit 有符号定点累加器用 32bit输出特征图统一打成 16bit 有符号数。当时我在文末留了个问题卷积做完之后后面那几层到底怎么接才能不浪费前面搭好的行缓冲和 MAC 阵列这篇接着往后写目标很明确让 LeNet-5 的 RTL 实现从像素输入一路算到 10 类输出中间池化、全连接、顶层调度全部打通。适合正在写 CNN Verilog、或者写完卷积层卡在数据流控制上的人参考。我会把池化层的取数方式、卷积和全连接的统一计算阵列、状态机握手、定点数量化、上板验证这几个部分挨个说透。动手写 RTL 之前我习惯先把剩余计算量量化清楚因为计算量决定了你要用几个乘法器、BRAM 位宽开多大、状态机需要多少个周期。LeNet-5 后半段不是“全连接层”三个字就能概括的C3 有部分连接表C5 名义上是卷积、实际上等价于全连接这些都会影响地址生成和权重排列。我整理了一张表直接按我的实现版本算层/操作输出尺寸乘加次数约说明C1 卷积6×28×28117,6001 输入通道、6 输出通道、5x5 卷积S2 平均池化6×14×141,176每个输出一次 scale 乘法4 个像素相加不算 MACC3 卷积16×10×10165,000按 LeNet-5 连接表有效连接对 66 组S4 平均池化16×5×5400同上C5 卷积120×1×148,00016 输入通道、120 输出通道、5x5 卷积核F6 全连接8410,080120×84输出层1084084×10总 MAC 大约 34 万次。这个数字放到今天的加速器里连零头都不算但在 FPGA 上依然需要认真设计因为真正限制吞吐的往往不是乘法器数量而是数据搬移和状态切换。我最后只用了 4 个硬件乘法器所有卷积、池化、全连接共用这一组 MAC。按 100MHz 跑纯运算周期约 0.86ms加上层间握手、地址切换、停顿单帧推理做到 1.2ms 左右足够用。值得多说一句的是 C3 的连接表。LeNet-5 的 C3 不是把 S2 的 6 张特征图全接进来而是每个输出特征图只连接其中 3 到 6 个输入通道原始连接规则是前 6 个输出各连 3 个中间 6 个各连 4 个最后 4 个各连 6 个有效连接总数为 66 组。如果 RTL 里图省事按全连接写每个输出像素要计算 6×25150 次 MAC总共 240,000 次按部分连接实现则是 165,000 次省下约 31% 的运算量。这个差异对 100MHz 来说不算致命但会给权重存储和地址生成带来连锁反应。我建议从一开始就把连接表当成一张“通道配置表”做进软件预处理而不是在 RTL 里用大量判断逻辑去模拟。2. 池化层 RTL 设计平均池化并不比卷积省事很多教程把池化层一笔带过好像就是“四选一取平均”但在 RTL 里实现 2x2 平均池化难的不是除法而是怎么在时间上把四个像素凑齐以及怎么处理和上一层的握手。LeNet-5 里 S2 和 S4 都是 2x2 不重叠平均池化输入尺寸分别是 28×28 和 10×10输出分别是 14×14 和 5×5尺寸都能被 2 整除所以不需要补边。这一点很关键如果遇到奇数尺寸池化边界处理会复杂很多但 LeNet-5 恰好没有我们就不用额外做 valid 信号修补。我的做法是给池化模块配一个行缓冲只缓存上一行数据。以 S2 为例C1 输出是逐像素、逐行写入的每来一个像素池化模块先把它写进 line_buf等第二行数据到达时同一个列位置上上一行的像素已经从 line_buf 里读出来了。再加上当前行的当前像素和当前行的前一列像素就能凑齐 2x2 窗口。这里需要两个周期形成稳定的窗口信号第一个周期把当前像素和前一列像素对齐第二个周期把上一行两个像素对齐然后用组合逻辑相加。核心计算可以写成这样wire signed [DATA_W3:0] sum_4p; wire signed [DATA_W-1:0] avg_val; assign sum_4p $signed(prev_row_q) $signed(prev_row_nq) $signed(cur_row_q) $signed(cur_row_nq); assign avg_val sum_4p 2;这里的必须是有符号右移不能写成否则负数池化结果会出错。很多新手在这里被坑过如果特征图经过 ReLU 都是非负数无符号右移没问题但 LeNet-5 原始激活是双曲正切一类特征图有正有负一旦用了无符号右移负数部分全部变成大正数后续全连接直接崩掉。关于除以 4 的舍入方式我在实际工程里没有做四舍五入直接算术右移 2 位。原因有两个一是池化输出后面还要经过一层可训练 scale 和偏置这些参数在量化时可以把舍入误差吸收掉一部分二是 RTL 里做四舍五入需要判断符号位并加偏移组合逻辑路径会变长在 100MHz 下不划算。如果你坚持要 round-half-up可以写成avg_val (sum_4p 2sd2) 2但要仔细验证负数的行为不要想当然。原版 LeNet-5 的池化层还有一个可训练系数和偏置公式是y avg * scale bias。在 RTL 里我建议把这个操作拆成两种选择如果资源和时序紧张直接在软件里把 scale 和 bias 合并到下一层卷积权重中前提是池化后没有激活函数如果必须保留就单独加一个 MAC把池化输出当成普通数据流和卷积层一样做一次乘加。我实测下来去掉这个可训练 scale/bias 对最终分类准确率影响很小所以最终版本没在 RTL 里实现它只保留了平均池化本身。池化模块的握手信号也值得注意上一层的dout_valid可以直接作为池化的din_valid但池化不是每个输入周期都产生输出而是每四个输入像素产生一个输出。我用了两个计数器 col_cnt 和 row_cnt 来跟踪当前像素在原始特征图中的位置当col_cnt[0]1 row_cnt[0]1时拉高dout_valid一个周期。这个写法比在每个输入像素上不停判断“是否是 2x2 块右下角”更清晰也方便后面状态机确认完成时刻。S4 和 S2 的逻辑完全相同只是输入输出尺寸、通道数不同。所以我把池化模块参数化IN_W、OUT_W、FEAT_NUM做成参数S2 和 S4 各例化一份。例化两份比复用一份省去不少地址切换烦恼因为池化模块本身很小资源占用可忽略。3. 卷积和全连接共用一个计算阵列一个被低估的复用思路LeNet-5 后半段包含三种形态卷积C3、C5、全连接F6、输出层以及名义上是卷积但输出是 1×1 的 C5。如果为每一层单独写一套 MAC 逻辑代码量很大而且顶层连线的仲裁逻辑会非常绕。更合理的做法是做一个参数化 MAC 阵列通过配置输入数据来源和权重地址生成方式在同一套硬件上跑完所有乘加密集层。我选的方案是 4 个乘法器并行每个乘法器对应一个独立的输出累加器。每个周期从权重 RAM 中读 4 个权重从输入 RAM 中读一个像素或一个输入向量元素4 个乘法器分别把输入广播值乘以各自的权重再累加到各自的累加器中。这样做的核心收益是输入带宽只需要 16bit×4? 不是一个输入值 16bit权重位宽 8bit×4BRAM 权重端口开 32bit 即可。对应到卷积模式4 个累加器分别累加同一个输入像素对 4 个不同输出通道的贡献。遍历顺序是行坐标、列坐标、输入通道、权重核位置、输出通道组。每次外层循环切换输出通道组时把累加器结果写回输出特征图然后清零。对应到全连接模式我把矩阵向量乘看成“输出神经元并行”的循环输入向量长度 M120或 84输出神经元数量 N84或 10每个周期读一个输入 x[m]同时读 4 个权重 w[n0][m]、w[n1][m]、w[n2][m]、w[n3][m]分别累加到 4 个输出神经元上。这跟卷积模式是完全一样的计算结构只是地址生成从“图像坐标”换成“矩阵行列坐标”。F6 层的 84 个输出、120 个输入用 4 个 MAC 跑一轮需要 (84/4)×1202520 个周期输出层 10 个输出需要 (10/4)×84向上取整到 3 组×84252 个周期开销都不大。C5 虽然叫卷积但它的输出是 1×1所以本质上是一个 120×48000 的矩阵乘直接走全连接地址生成逻辑即可。也就是说在 LeNet-5 里C5 之后的所有层都可以用同一套矩阵乘逻辑处理不需要单独的全连接模块。权重 RAM 的排列方式是整个复用设计的关键。我建议在软件端预处理权重时按“输出通道、输入通道、卷积核坐标”的顺序展平。这样 RTL 地址生成器只需根据层配置输入通道数、输出通道数、核大小产生线性地址适合 BRAM 连续读。对于 C3 的部分连接表软件端在展平权重时直接跳过没有连接的输入通道并把每个输出特征图实际需要的输入通道数存成一张配置表RTL 根据配置表决定当前输出图需要扫描多少个输入通道。这比把连接表完整存进 RTL、再在每个周期判断“该不该乘”要高效得多也避免浪费 MAC 周期。这里的每周期读 4 个权重意味着权重 RAM 的数据位宽是 32bit4×8bit深度大约为总权重数除以 4。LeNet-5 所有权重加偏置大约六万多个8bit 量化后约 480Kbit放到中等容量 FPGA 的 BRAM 里完全没压力。如果后续想增加并行度到 8 路 MAC只需要把权重 RAM 位宽提到 64bit地址生成按 8 个输出一组其他逻辑不用大改。共享计算阵列带来的麻烦是控制信号变多。卷积和全连接两种模式需要不同的地址生成器我用一个mode_sel信号在主状态机里切换同时在地址生成模块里用case选择地址计算逻辑。实践下来最省心的做法不是把所有地址逻辑塞进一个模块而是拆成conv_addr_gen和fc_addr_gen两个子模块分别产生读地址再用mode_sel做 MUX。虽然多了一点 LUT但调试时可以直接看每个子模块的输出不用在一大段 if-else 里找 bug。4. 顶层状态机与两套握手信号让每一层像积木一样拼起来LeNet-5 的 RTL 实现做到这里模块边界已经很清楚C1 卷积、S2 池化、C3 卷积、S4 池化、C5 卷积、F6 全连接、输出全连接每一层都是“输入特征图进来输出特征图出去”的独立模块。把这些模块串起来需要解决两个问题数据存在哪、什么时候告诉下一层可以开始。我先说数据存储。卷积层需要反复读取同一张特征图的 5x5 邻域用 FIFO 做流式缓存并不合适因为卷积窗口会跨行而且同一行数据会被多次读取。标准做法是给每层配一块双端口 RAM前一层计算结果写入当前层从同一块 RAM 读出。为了不让读写地址冲突我采用乒乓缓冲两块 RAM奇数帧/层用 A 写 B 读偶数层用 B 写 A 读。在 LeNet-5 这种小规模网络里中间特征图很小乒乓缓冲多占的资源完全可以接受。LeNet-5 各层特征图占用情况我大概估算过C1 输出 6×28×28×16bit约 9.4KBS2 输出 6×14×14约 2.3KBC3 输出 16×10×10约 3.2KBS4 输出 16×5×5约 0.8KB。C5 输出 120×1×1约 240B。全部中间结果合起来不到 16KB所以即使用乒乓缓冲翻倍也没压力。我最终只给 C1 输出和 C3 输出各配了一块大 RAM后面几层直接复用 S2/S4 的存储地址映射在顶层完成。主状态机我写成这样IDLE、C1_CONV、S2_POOL、C3_CONV、S4_POOL、C5_CONV、F6_FC、OUT_FC、FINISH。每个状态对应一个“启动信号”和“完成信号”。启动信号是当前状态进入时拉高一个周期的 pulse完成信号是下一层模块返回的 done。所有层模块在内部维护一个busy状态防止 start 信号被重复触发。主状态机只负责跳转不关心层内部到底要跑多少周期。握手信号的设计上我用的是 start/done 脉冲不是 AXI-Stream 那种 valid/ready 握手。原因很简单层间数据是块状传递不是流式连续传递一整张特征图算完后下一层才需要读取完整数据。如果每层内部是流式卷积可以在层间用 valid/ready 做流水衔接但那样控制器复杂度会高不少。LeNet-5 整体计算量小串行执行完全够快不需要做层间流水冒险。代码结构大致是这样reg [3:0] state; wire c1_done, s2_done, c3_done, s4_done, c5_done, f6_done, out_done; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; end else begin case (state) IDLE: if (global_start) state C1_CONV; C1_CONV: if (c1_done) state S2_POOL; S2_POOL: if (s2_done) state C3_CONV; C3_CONV: if (c3_done) state S4_POOL; S4_POOL: if (s4_done) state C5_CONV; C5_CONV: if (c5_done) state F6_FC; F6_FC: if (f6_done) state OUT_FC; OUT_FC: if (out_done) state FINISH; FINISH: if (soft_rst) state IDLE; default: state IDLE; endcase end end因为每一层模块都封装了完整的内部控制和坐标计数顶层不需要知道具体地址这让后续替换某一层变得非常容易。比如把平均池化改成最大池化只要改 S2/S4 模块内部逻辑顶层状态机一行都不用动。5. 定点数量化RTL 里不需要浮点但必须提前想好位宽卷积和全连接在 RTL 里全是定点乘加浮点运算既占资源又拖时序。LeNet-5 训练出来的权重通常都是浮点部署到 FPGA 之前必须先量化。我选用的是最通用的方案输入图像 8bit 无符号扩展成 16bit 有符号权重 8bit 有符号中间特征图 16bit 有符号累加器 32bit 有符号。16bit 有符号特征图能表示 -32768 到 32767。C1 层输入最大 255权重最大约 ±127一个输出像素最多 25 次乘累加理论最大绝对值 25×255×127≈809,62532bit 累加器足够。C3 和 C5 的输入特征图经过激活后范围通常远小于 32767即使按最极端情况算C5 一个输出要累加 400 个乘项16 通道×5x5400×32767×127≈1.66e9仍然小于 2^31-1。所以 32bit 累加器在理论上够用这也是我敢把饱和逻辑只放在输出端的底气。但“理论上够用”不等于“实际能用”。每层累加完必须右移并截断回 16bit否则下一层的输入位宽会爆炸。右移位数不是拍脑袋定的要在软件里统计每一层输入输出动态范围后计算。比如 C1 卷积后如果浮点输出范围约 ±20量化到 16bit 需要缩放因子假设累加器里实际放大 128 倍那输出时右移 7 位C3 层的动态范围可能不同右移位数可能是 8 或 6。我建议不要全网络只用一个缩放因子因为不同层激活范围差很多统一缩放会让某一层精度严重受损。RTL 里实现带符号饱和截断的常用写法是wire signed [ACC_W-1:0] acc_s; reg signed [DATA_W-1:0] out_s; always (*) begin if (acc_s made_threshold_high) begin out_s 16sh7FFF; end else if (acc_s made_threshold_low) begin out_s 16sh8000; end else begin out_s acc_s[ACC_W-1 -: DATA_W]; end end这里的made_threshold_high和made_threshold_low要根据右移后的边界计算不能直接拿 32bit 极限比较。更省事的做法是先对acc_s做算术右移shift位得到一个 32bit 结果再判断是否需要饱和右移后的结果截取低 16bit。但注意截取前一定要用有符号比较否则负数会被当成大正数饱和到正最大值。激活函数的选择上我没有把原版 LeNet-5 的激活函数完整搬到 RTL 里而是用 ReLU 替代。原版用到双曲正切如果非要在 FPGA 上精确实现需要做查找表或分段线性逼近消耗不少 BRAM。工程上 ReLU 替换后的准确率损失通常很小而且在 RTL 里实现只需要判断累加结果的符号位最高位为 1 就输出 0否则输出右移截断后的值。这一判断可以和饱和逻辑合并消耗几个 LUT。量化误差要提前用仿真验证。我一般先写一个 Python 或 C 的参考模型把浮点权重量化成定点后跑一遍前向记录每层输出然后 RTL 仿真加载同一组权重和输入每层打印输出。两者对比时特征图最大绝对值误差能控制在 2 个 LSB 以内最终分类结果一致。如果误差偏大优先怀疑两点右移位数选错或者饱和阈值设得太早。不要一上来就改位宽位宽加大只是浪费资源不一定解决问题。6. 上板验证遇到的三个坑和最终实测结论RTL 仿真全通之后上板验证是另一道坎。我遇到的第一个坑是权重 RAM 初始化。当时我把所有权重放到一个.mem文件里通过$readmemh加载到 BRAM仿真没问题但上板后输出全是 0。查了半天发现是综合工具对$readmemh路径的处理和仿真器不一样文件路径写成了相对路径综合时没找到文件BRAM 里全被初始化为 0。解决方法是把权重文件路径改成绝对路径或者在综合属性里显式指定(* ram_init_file weights.mem *)。另一个更稳的办法是上电后通过串口把权重从外部存储加载进来这样不用依赖综合工具的初始化行为。第二个坑是复位时序。顶层状态机在复位释放后的第一个周期就把global_start拉高了但此时子模块可能还停留在复位状态start信号被当成无效脉冲吞掉之后整条流水线就卡死在 IDLE。我在顶层加了复位同步释放并且在上电后延迟 100 个周期再给global_start确保所有子模块内部状态机都已经退出复位。这个延迟只影响首次启动不影响后续吞吐。如果你用的外部输入信号存在异步抖动最好再采一拍避免global_start出现毛刺。第三个坑和输出层有关。输出层没有做 softmax因为 softmax 是单调增函数取最大值对应的索引和取 softmax 后最大值索引是同一个。但输出层 10 个结果是带符号数RTL 里比较最大值时如果用了无符号比较器负数会被当成大正数导致选出来的索引完全不对。我单独写了一个有符号比较器树用$signed包裹所有输入比较结果只输出索引。最终输出端的class_id就是分类结果不需要额外做归一化。上板验证时我准备了几张手写数字图片每张 32×32 像素以十六进制格式存进输入 ROM。跑完后通过串口把class_id发出来对比正确标签。没有直接对比全部输出值因为 10 个 logit 数值通过串口传太麻烦看最大值的索引就足够了。如果你要精确对比软硬件误差建议在每层输出端加一个调试读口仿真时用$display打印上板时再用串口回读不要只想着靠波形抓。最终在我手头这块中低端 FPGA 开发板上4 个硬件乘法器、100MHz 时钟整帧 LeNet-5 推理约 1.2ms分类准确率和浮点参考模型相差不到 0.5 个百分点。这个结果对大多数演示场景足够。整个工程里最占用时间的不是卷积计算而是层与层之间的 RAM 拷贝和状态切换。如果你想继续压时间可以尝试把 C1 和 S2 做层间流水让卷积输出还没写完时池化就开始处理已完成的行但这么做的控制逻辑复杂度会明显上升需要评估收益再动手。最后分享一个我在调试过程中养成的小习惯每写一层 RTL先把该层单独包成一个 testbench喂一组随机输入和软件参考模型对拍层模块全部对上之后再连顶层。这样最终顶层出问题时只需要怀疑握手和地址映射不用在组合逻辑和位宽截断里反复翻找。LeNet-5 虽小但该走的验证流程一步都不能省。