
卷积器这个东西做图像处理、CNN加速或者通信基带的朋友应该都不陌生。但真正落到RTL层面尤其是要求深度可配置、还要挂到APB总线上让软件随时改参数坑就一下子多起来了。我最近刚把一个2^N深度的卷积器从算法模型一路推到综合网表中间踩了不少雷也攒了一些在教科书上找不到的实操经验。这篇文章就把整个设计过程拆开揉碎讲一遍——从为什么选2^N深度、VerilogHDL怎么写才能既省资源又跑得动到APB总线挂载时strobe和data到底怎么配合再到DFT插扫描链时复位逻辑怎么改才不破坏功能。适合已经写过基本Verilog、想往可配置IP方向进阶的朋友也适合正在做CNN加速器或者数字信号处理模块的同行参考。1. 为什么是2^N深度而不是随便一个数1.1 卷积器深度的本质含义先把这个“深度”说清楚。在卷积器里深度通常指的是并行乘累加的数量也就是一个时钟周期内能同时完成多少次乘加运算。比如深度为8就是8个乘法器并行工作每个周期吞8个数据、吐1个部分和。深度越大吞吐率越高但面积也线性增长。那为什么偏偏要2^N直接说结论2^N深度让地址生成、数据对齐和流水线控制全部退化成位操作。你想想如果深度是6那计数器到6要归零地址偏移要算模6数据选择器要6选1——这些逻辑在RTL里全是比较器和减法器时序紧、面积大。而深度是8的时候计数器就是3位自然翻转地址偏移就是取低3位数据选择就是3-8译码器。综合工具看到这种结构优化起来毫不费力。我做过一个对比实验同样功能深度6和深度8在同一个工艺库下综合指标深度6深度8乘法器数量68计数器逻辑模6比较器减法3位自然翻转地址生成LUT4712关键路径延迟4.2ns3.1ns综合面积1.3倍基准1.0倍基准深度6虽然乘法器少两个但控制逻辑多出来的面积和延迟反而把优势吃掉了。所以只要你的算法允许深度取2的幂是性价比最高的选择。1.2 可拓展设计的接口约定“可拓展”这三个字意味着深度不能写死。我的做法是用一个parameter来控制但内部所有位宽和循环边界都从它推导出来。关键是要让综合工具在例化时就能确定所有位宽而不是运行时动态计算。module conv_core #( parameter DEPTH_LOG2 3, // 深度 2^DEPTH_LOG2 parameter DATA_WIDTH 16, parameter COEF_WIDTH 16 )( input wire clk, input wire rst_n, input wire valid_in, input wire [DATA_WIDTH-1:0] data_in, input wire [COEF_WIDTH-1:0] coef_in, output reg valid_out, output reg [DATA_WIDTHCOEF_WIDTH DEPTH_LOG2 -1:0] acc_out ); localparam DEPTH 1 DEPTH_LOG2; // 后续逻辑全部用DEPTH和DEPTH_LOG2推导 endmodule这里有个细节累加器的位宽要留够。DEPTH个DATA_WIDTH×COEF_WIDTH的数相加最坏情况下位宽是DATA_WIDTHCOEF_WIDTHDEPTH_LOG2。很多人忘了加这个DEPTH_LOG2结果深度一大就溢出仿真时波形看着对实际数据全错了。注意parameter的名字用DEPTH_LOG2而不是DEPTH是为了强制调用者传2的幂次。如果直接传DEPTH有人传个6进来你内部还得判断是不是2的幂反而麻烦。2. VerilogHDL实现中的三个关键结构2.1 移位寄存器链与数据对齐卷积器的输入数据需要按顺序进入乘法阵列。最直接的做法是用一个深度为DEPTH的移位寄存器链每个周期所有数据右移一位新数据从头部进入。这样每个乘法器固定接某一级寄存器的输出系数也固定接对应的coef不需要任何选择逻辑。reg [DATA_WIDTH-1:0] data_sr [0:DEPTH-1]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i0; iDEPTH; ii1) data_sr[i] {DATA_WIDTH{1b0}}; end else if (valid_in) begin data_sr[0] data_in; for (i1; iDEPTH; ii1) data_sr[i] data_sr[i-1]; end end这个结构的好处是每个乘法器的输入路径完全对称布局布线时工具能自动做寄存器平衡不会出现某一路特别长的情况。坏处是移位链本身有扇出深度大的时候第一级寄存器的负载会很重。我的经验是深度超过64就要考虑插缓冲或者改用双端口RAM做滑动窗口。系数那边我一般用一组独立的寄存器通过APB写入。系数不需要移位每个乘法器固定接一个系数寄存器就行。这样系数更新和数据流是解耦的软件可以先改系数再灌数据不会出现中间态。2.2 乘累加树的流水线切割DEPTH个乘法结果要加起来。如果直接写一个大的加法表达式综合工具会给你搭一棵组合逻辑加法树深度大的时候关键路径直接爆炸。我的做法是手动插入流水线寄存器把加法树切成若干级。以DEPTH8为例第一级做4个两两相加第二级做2个两两相加第三级做1个最终相加。每级之间打一拍// 第一级4个加法器 reg [ACC_WIDTH-1:0] sum_l1 [0:3]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i0; i4; ii1) sum_l1[i] 0; end else if (valid_pipe1) begin for (i0; i4; ii1) sum_l1[i] mult_result[2*i] mult_result[2*i1]; end end这里有个容易忽略的点valid信号也要跟着流水线走。每一级加法器打一拍valid就要延迟一拍。我见过有人数据流水了但valid没流水结果输出有效信号比数据早了好几个周期后级模块直接抓错数据。正确的做法是做一个和流水线级数等长的移位寄存器专门跟踪valid。流水线级数怎么定我的经验公式是每级加法器的输入位宽不超过32位时一级流水能跑到200MHz以上。如果ACC_WIDTH是40位那可能两级加法就要切一次。具体要看工艺库最好综合一遍看时序报告再调。2.3 输出寄存与反压处理卷积器的输出通常要送给后级FIFO或者总线。如果后级没准备好数据就丢了。所以输出端要有一个skid buffer或者简单的valid/ready握手。我的做法是在最后一级加法器后面加一个输出寄存器同时用一个2深度的FIFO做缓冲。当FIFO满的时候拉低内部的ready信号让流水线暂停。这里要注意暂停的时候移位寄存器链不能继续移位否则数据就错位了。所以valid_in实际上要跟内部ready做与运算。wire internal_ready; assign internal_ready !fifo_full; wire real_valid_in valid_in internal_ready;这个逻辑看着简单但如果你忘了把real_valid_in接到移位寄存器的使能上仿真时数据少的时候看不出来一旦后级反压就全乱了。我建议在testbench里专门造一个后级随机反压的case跑个几万周期确保数据不错位。3. APB总线挂载strobe和data的配合逻辑3.1 APB协议回顾与卷积器寄存器映射APB是个很简单的总线但简单不代表没坑。先快速过一下时序PSEL拉高表示选中PENABLE在下一个周期拉高表示数据有效PWRITE决定读写PADDR是地址PWDATA是写数据PRDATA是读数据。关键点是PENABLE拉高的那个周期PWDATA和PADDR才是稳定的。卷积器需要挂到APB上通常要暴露这几组寄存器地址偏移名称读写说明0x00CTRLRWbit0: start, bit1: soft_reset0x04STATUSRObit0: busy, bit1: done0x08DEPTH_CFGRW实际使用的深度≤2^DEPTH_LOG20x0C~0x4CCOEF[0:15]RW系数寄存器组0x50DATA_INWO写入待卷积数据0x54DATA_OUTRO读出卷积结果这里有个设计决策系数是逐个写还是批量写。逐个写简单但软件要写16次。批量写需要地址自增逻辑。我选的是逐个写因为APB本来就不追求吞吐而且逐个写调试起来直观软件想改哪个改哪个。3.2 strobe信号到底怎么用APB3没有PSTRBAPB4才有。PSTRB是字节选通表示PWDATA的哪些字节有效。很多人在挂卷积器的时候直接把PWDATA整个写进寄存器忽略了PSTRB结果软件做字节写的时候把其他字节覆盖了。正确的做法是always (posedge clk or negedge rst_n) begin if (!rst_n) begin coef_reg[addr_idx] 0; end else if (apb_write psel penable) begin for (i0; i4; ii1) begin if (pstrb[i]) coef_reg[addr_idx][8*i : 8] pwdata[8*i : 8]; end end end如果你的卷积器数据位宽是16位而APB数据位宽是32位那就要考虑一个寄存器里放两个系数还是一个寄存器放一个系数。我选的是一个寄存器放一个系数低16位有效高16位保留。这样软件写的时候直接写32位低16位是系数高16位写0PSTRB给4‘b0011。简单明了。注意如果你的APB是APB3没有PSTRB那就默认所有字节都有效软件必须写全32位。这种情况下建议在寄存器文档里明确写清楚避免软件同事只写低16位导致高16位被清零。3.3 读写时序中的等待状态处理APB支持等待状态通过PREADY拉低来插入等待。卷积器的系数寄存器读写都是单周期完成的PREADY可以恒为1。但DATA_OUT不一样如果卷积还没算完软件读出来的就是旧数据。我的做法是STATUS寄存器的done位拉高之前DATA_OUT读出来是0同时PREADY还是1不插等待。软件轮询done位再读数据。为什么不插等待因为APB的等待状态会拖慢整个总线如果软件不小心在没done的时候读总线就挂住了。用轮询的方式虽然软件麻烦一点但总线效率高也不会死锁。assign prdata (paddr DATA_OUT) ? (status_done ? acc_out : 0) : reg_file_rdata; assign pready 1b1; // 永远不插等待这里有个坑acc_out的位宽可能超过32位。如果DATA_WIDTH16COEF_WIDTH16DEPTH_LOG23那acc_out是38位。APB一次只能读32位所以要分两次读或者截断。我的做法是加一个DATA_OUT_H寄存器读0x54拿低32位读0x58拿高6位。软件拼一下就行。4. DFT插扫描链时复位逻辑的修改4.1 为什么DFT要改复位DFT插扫描链的时候工具会把所有寄存器串成移位链。测试模式下寄存器的复位端如果还连着功能复位那扫描链移位的时候会被复位拉死数据移不进去。所以测试模式下必须把复位旁路掉。常见的做法是加一个test_mode信号复位逻辑改成wire func_rst_n; wire test_rst_n; assign func_rst_n rst_n ~test_mode; // 或者更常见的复位端直接接scan_enable的反但这里有个细节不是所有寄存器都能旁路复位。比如状态机的状态寄存器如果测试模式下复位被旁路上电后状态是随机的可能导致测试逻辑混乱。我的做法是分两类处理数据通路寄存器移位链、流水线寄存器、系数寄存器测试模式下复位旁路让扫描链能正常移位。控制寄存器状态机、计数器测试模式下复位仍然有效或者用扫描链的复位专用端口。4.2 复位旁路的RTL实现具体到代码我一般这样写module conv_core #( parameter DEPTH_LOG2 3, parameter DATA_WIDTH 16 )( input wire clk, input wire rst_n, input wire test_mode, // DFT测试模式 input wire scan_en, // ... 其他端口 ); wire data_rst_n; assign data_rst_n test_mode ? 1b1 : rst_n; // 数据通路寄存器用data_rst_n always (posedge clk or negedge data_rst_n) begin if (!data_rst_n) begin // 复位 end else begin // 功能逻辑 end end // 控制寄存器用rst_n always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位 end else begin // 功能逻辑 end end这样综合工具在插扫描链的时候数据通路的寄存器复位端是可控的测试模式下不复位扫描链能正常工作。控制寄存器保持复位测试模式一进来就处于已知状态。4.3 扫描链插入后的功能验证改完复位逻辑一定要做扫描链插入后的功能回归。我见过太多案例DFT改完复位功能仿真过了但门级仿真挂了。原因是扫描链插入后寄存器的顺序变了某些依赖寄存器初始值的逻辑在测试模式下行为不一样。我的验证流程是纯功能仿真test_mode0跑完整的卷积case确保数据正确。扫描移位仿真test_mode1scan_en1往扫描链里灌一个已知pattern移出来对比确保扫描链本身没问题。扫描捕获仿真test_mode1scan_en0给一个时钟脉冲检查捕获到的值是否符合预期。门级仿真带SDF反标跑上面三个case确保时序没问题。这四步走完基本能覆盖DFT带来的所有影响。如果时间紧至少要做第1步和第4步。5. 实测中的性能与资源数据5.1 不同深度下的综合结果对比我用的是某40nm工艺库综合工具是DC目标频率200MHz。DEPTH_LOG2从2到6分别综合了一遍DEPTH_LOG2深度乘法器寄存器LUT关键路径是否满足200MHz2441863122.1ns是3883425872.8ns是4161665811243.5ns是53232128422104.9ns否66464251243806.7ns否可以看到DEPTH_LOG24深度16是个拐点。再往上关键路径就压不住了。如果非要跑32深度要么降频要么在加法树里多插一级流水。5.2 APB读写吞吐率实测APB时钟50MHz卷积器时钟200MHz。软件通过APB写16个系数每个系数一次写操作APB一次写要2个周期SETUPACCESS所以16个系数要32个APB周期也就是640ns。这期间卷积器可以继续跑因为系数寄存器是双缓冲的——软件写的时候先写到影子寄存器写完最后一个系数再触发更新。读数据的时候如果done已经拉高一次读操作2个周期100MHz下20ns就能拿到结果。但如果软件轮询done每次轮询也要2个周期轮询间隔取决于软件。我一般建议软件用中断而不是轮询这样CPU不用一直盯着。提示如果你的系统里APB时钟和卷积器时钟是异步的那就要做跨时钟域处理。系数寄存器和STATUS寄存器都要加同步器。我一般用两级触发器同步对于多bit的STATUS用格雷码或者握手信号。6. 几个容易翻车的细节6.1 系数更新时的数据一致性前面提到系数是双缓冲的但实现的时候有个坑影子寄存器和主寄存器之间的切换要在数据流的间隙进行。如果卷积器正在跑你突然切换系数那当前这批数据就会用一半旧系数一半新系数结果就错了。我的做法是加一个coef_update_pending信号。软件写完所有系数后置位这个信号。卷积器在完成当前一批数据后检查这个信号如果置位就切换系数同时清掉pending。这样保证系数切换永远发生在数据边界。always (posedge clk or negedge rst_n) begin if (!rst_n) begin coef_active 0; end else if (coef_update_pending data_batch_done) begin coef_active coef_shadow; coef_update_pending 0; end end6.2 复位释放时的亚稳态复位释放的时候如果rst_n的释放沿刚好在时钟沿附近寄存器的输出可能进入亚稳态。虽然仿真看不出来但实际芯片上可能出问题。我的做法是在复位释放路径上加两级同步器reg [1:0] rst_sync; always (posedge clk or negedge rst_n) begin if (!rst_n) rst_sync 2b00; else rst_sync {rst_sync[0], 1b1}; end wire rst_n_sync rst_sync[1];然后所有寄存器用rst_n_sync做复位。这样复位释放和时钟沿对齐不会出现亚稳态。6.3 深度参数改变时的位宽推导最后说一个参数化设计的坑。如果你把DEPTH_LOG2从3改成4所有依赖它的位宽都要跟着变。但Verilog的位宽推导有时候不按你想的来。比如output reg [DATA_WIDTHCOEF_WIDTHDEPTH_LOG2-1:0] acc_out;这个表达式里DATA_WIDTH和COEF_WIDTH是parameterDEPTH_LOG2也是parameter综合工具能算对。但如果你写成output reg [DATA_WIDTHCOEF_WIDTH$clog2(DEPTH)-1:0] acc_out;$clog2是系统函数有些老版本的综合工具不支持在端口位宽里用。所以我还是推荐用DEPTH_LOG2参数别用$clog2。另外for循环的边界也要用DEPTH而不是硬编码的数字。我见过有人写for (i0; i8; ii1)结果DEPTH改成16的时候忘了改循环仿真直接少算一半。这种错误编译不报错仿真波形看着也像那么回事但数据就是不对。所以所有循环边界必须用parameter推导这是铁律。7. 从RTL到GDS的检查清单7.1 综合前的RTL lint在跑综合之前我一般会用Spyglass或者Verilator做一遍lint。重点检查这几项位宽不匹配乘法器的输入位宽和输出位宽是否一致。跨时钟域APB时钟和卷积器时钟之间的信号有没有同步。复位域有没有寄存器用了不同的复位。组合逻辑环加法树里有没有意外的反馈。lint过了再跑综合能省很多调试时间。我见过有人直接跑综合结果工具报了几百个warning一个个看下来发现是位宽截断改完再跑一整天就没了。7.2 形式验证的等价性检查RTL改完DFT逻辑之后一定要做形式验证确保改完的RTL和改之前功能等价。我用的是Formality流程是读入原始RTL作为reference。读入DFT修改后的RTL作为implementation。设置test_mode0跑等价性检查。设置test_mode1再跑一遍但这时候只检查控制逻辑数据通路因为复位旁路可能不等价要设成dont care。这一步能抓出很多手改代码引入的bug。我有一次改复位逻辑不小心把一个寄存器的复位值从0改成了1功能仿真没跑到那个case形式验证直接报出来了。7.3 门级仿真的向量准备门级仿真需要带SDF反标跑起来很慢。所以testbench的向量要精简但覆盖要全。我一般准备三组向量短向量几十个周期覆盖基本读写和一次完整卷积。中向量几千个周期覆盖系数切换、反压、深度切换。长向量几万个周期随机数据跑回归。短向量用来快速验证门级网表的基本功能中向量验证边界情况长向量跑夜间回归。这样既保证了覆盖率又不会让门级仿真跑太久。8. 写在最后的一些个人体会这个卷积器我从第一版RTL到最终签核前后改了七版。最大的体会是参数化设计不是把数字换成parameter就完事了而是要把所有依赖这个数字的逻辑都推导出来。位宽、循环边界、地址位宽、状态机状态数一个都不能漏。漏一个换参数的时候就翻车。另一个体会是DFT真的不能等到最后才做。我第一版没考虑DFT复位逻辑写得很随意结果插扫描链的时候发现有一半的寄存器复位端接的是组合逻辑产生的复位工具直接报错。后来重新梳理了复位树把复位分成功能复位和测试复位两套才顺利插进去。所以如果你做的IP最终要流片从第一版RTL就要考虑DFT别等到后端再补。APB那块strobe和data的关系看着简单但实际项目中软件同事经常忘了设PSTRB导致写寄存器的时候把其他字节清了。我的建议是在寄存器文档里用表格明确写出每个寄存器的PSTRB应该设成什么值最好再提供一个软件驱动示例这样能省很多沟通成本。最后说一个调试技巧卷积器这种数据通路的模块仿真波形很容易看花眼。我一般会在testbench里加一个参考模型用C或者Python算好期望结果存成文件仿真的时候自动对比。这样不用人眼看波形跑完直接看pass/fail。这个参考模型花不了多少时间但能省下大量调试精力。