之前写过FPGA学习笔记里的分频器、状态机和UART收发,这次记录的是一个让很多初学者又爱又恨的应用题目:原码二位乘法器。先回答一个大家都会问的问题:Verilog里一个*号就能出乘积,为什么还要手动写一个乘法器模块?原因很简单,你早晚会碰到不能用*的场合,比如面试手撕代码、比如为了跑在特定时序约束下自己控流水线、比如想彻底搞明白DSP48里面到底在算什么。原码二位乘法器恰好是理解“移位相加”乘法原理的最小闭环,也是后续Booth乘法器、补码阵列乘法器的地基。
这篇笔记会把原码二位乘法器的数学原理、Verilog实现、仿真验证和调试中踩过的坑一次说清楚。无论你是刚学Verilog语法的新手,还是准备数字芯片方向笔试面试的求职者,都可以照着下面的思路自己写一遍,跑通仿真之后再回头看,你对“乘法器为什么是硬件设计的基本功”这件事会有完全不一样的感觉。
1. 为什么原码二位乘法器值得自己写一遍
1.1 从*号到RTL:乘法器并不是“一个运算符”
很多玩FPGA的同学在学到乘法这一节时,第一反应是:EDA工具这么强,直接写assign P = A * B;不就行了吗?确实行,综合工具会帮你把*号映射成LUT构成的组合逻辑,或者在带DSP48的FPGA里映射成专用的硬件乘法器。但问题是,面试官和实际项目都不会让你永远停留在这一层。
手动实现乘法器的核心价值,在于让你明白乘法器的本质是“多拍移位相加”,而不是一个黑盒子。一位乘法器每次看乘数的一个bit,决定加不加被乘数;二位乘法器每次看乘数的两个bit,决定加0倍、1倍、2倍还是3倍的被乘数。这个概念一旦建立起来,后面理解Booth算法(一次看3位甚至更多位)、补码乘法、流水线乘法器、DSP48内部的运算结构,都会顺畅很多。
另外从笔试面试的角度说,手写乘法器几乎是数字逻辑/FPGA方向的高频题。我见过不止一家公司在笔试里要求“用Verilog实现4位无符号乘法器,描述状态转换和位宽设计”,也有面试官会追问“如果位宽变成16位,你的设计还能不能跑出时序?”这些问题靠*号是答不出来的。
1.2 两位一起处理的核心收益:迭代次数减半
那么问题来了:能做一位乘法器,为什么还要做二位的?关键点在于迭代次数。
一位乘法器每拍处理乘数的一个bit,N位乘数需要的拍数是N拍;二位乘法器每拍处理乘数的两个bit,拍数变成N/2拍。当N从4变成16、32时,这个差异会显著影响吞吐。虽然二位乘法器需要额外处理“3倍被乘数”这种稍复杂的加数,还要留出DW+2位加法器的余量,但换来的是迭代次数减半,在FPGA上体现为更短的延迟和更低的状态切换开销。
笔试题里常见的“两位乘法器”还有一个变体叫“基-4 Booth乘法器”,它的编码思路和原码二位乘法器非常像,但处理的是补码。先把原码二位吃透,后面再切到补码Booth,你会发现只是换了一张编码表而已。
2. 从数学原理说起:乘数末两位决定“加几倍的被乘数”
2.1 原码的格式约定:符号位和数值位要分开算
在原码表示法里,一个数的最高位是符号位,0代表正、1代表负,剩余位表示绝对值。比如4位数值位配1位符号位,则+7写成0_0111,-3写成1_0011。
原码乘法的好处是逻辑划分非常干净:乘积的符号位等于两个乘数符号位的异或,乘积的数值位等于两个乘数绝对值(数值位)的乘法结果。也就是说,符号处理和数值计算可以完全解耦。这个特性让原码乘法器在硬件实现上比补码乘法器直观很多,唯一的麻烦在于负数用原码做加减法不如补码方便,所以当乘法的中间过程需要大量加法时,补码优势才体现出来。但二位乘法器里的加数都是正的倍数,不涉及负数加减,用原码做正好合适。
理解原码格式后,还应该养成一个习惯:区分“数的位宽”和“数值的位宽”。一个4位数值位的原码数,在Verilog里用[4:0]即5位向量存储,其中[4]是符号位,[3:0]是数值位。后面看代码时,不要被多出来的这一位符号位搞晕。
2.2 竖式乘法与加法器阵列:“移位相加”是怎么来的
回想一下小学学的竖式乘法:计算13乘以7,你要把13分别与7的个位、十位相乘,然后把两个部分积左移对齐后相加。硬件里的乘法器也是这个逻辑,只不过把“竖式”翻译成了移位寄存器加加法器。
对一位乘法器而言,乘数的某一bit为1,就说明这一位的部分积等于被乘数左移对应的位数;为0,则这部分积就是0。把这些部分积累加,就得到最终乘积。二位乘法器把逻辑推进了一层:与其逐位判断,不如每次从乘数最低位开始看两位。两位乘数可能出现的四种情况对应四种加数:
| 乘数最低两位 | 实际含义 | 本次应加的部分积 | 硬件生成方式 |
|---|---|---|---|
| 00 | 0 | 0 | 直接给0 |
| 01 | 1 | 被乘数A | 取A |
| 10 | 2 | 2A | A左移1位 |
| 11 | 3 | 3A | A + (A左移1位) |
每次根据查表结果把对应的加数加到“部分积”上,然后整体右移两位,把下一组两位乘数移动到最低位。重复这个过程直到乘数全部被吞掉,部分积里剩下的就是乘积的数值部分。从竖式角度看,这一步的本质是把“乘数每一位”的判断合并成了“乘数每两位”的判断,每一步的信息量更大,所以总迭代次数减半。
2.3 3倍被乘数为什么是个易错点
四种加数里,0、A、2A都很容易生成,A就是被乘数本身,2A就是被乘数左移一位。唯独3A需要做一次加法:3A = A + 2A。这里最容易出问题的地方是位宽。
如果被乘数A是4位数值位,A最大15,则3A最大45,写成二进制是101101,需要6位才能表示完整。所以加数寄存器的位宽至少要是DW+2=6位,否则3A的高位会被截掉,结果必然出错。
我在初学时犯过一个低级错误:把addend定义成DW位,然后仿真验证时发现15×15的结果总是错。排查半天才意识到,3A=45这个值在4位总线里被截成了13,后面所有计算都基于错误的部分积进行,自然全盘皆错。这个教训后面专门用一节来讲。
2.4 迭代次数与乘积位宽怎么定
假设A和B的数值位宽都是DW,则乘积的数值位宽是2DW。例如4位×4位,乘积最大是15×15=225,需要8位表示,恰好是24=8位。
二位乘法每次消耗乘数的两位,所以迭代次数是DW/2。这里有个隐含假设:DW必须是偶数。如果乘数数值位是奇数位宽怎么办?补一个0在最高位,把它扩展成偶数即可。这个操作不影响乘积的数值大小,只是让迭代次数从ceil(DW/2)变成(DW+1)/2。
在Verilog实现里,这个迭代次数对应一个循环计数器loop_cnt,初始为0,每算一拍加1,计数到DW/2 - 1时表示最后一轮。下面看代码实现时要注意这个计数的边界。
3. 动手写RTL之前:寄存器位宽和加法器位宽怎么推算
3.1 模块接口如何设计
先明确接口。原码二位乘法器的输入是两个原码表示的数,输出是原码表示的乘积。参数DW表示数值位宽,总位宽是DW+1(符号位+数值位)。以DW=4为例:
module mul_2bit_orig #( parameter DW = 4 )( input wire clk, input wire rst_n, input wire start, input wire [DW:0] A, // 原码:最高位符号位,低DW位数值位 input wire [DW:0] B, // 原码:最高位符号位,低DW位数值位 output reg [2*DW:0] P, // 原码:最高位符号位,低2*DW位数值位 output reg done );start用于发起一次乘法,done用于通知外部乘法已经完成。外部逻辑在拉高start后等待done,然后读取P。这个握手时序和很多IP核的valid-ready风格不同,但对课设和面试题来说已经足够清晰。
3.2 核心寄存器S的结构:高位部分积、低位乘数
这个设计的关键寄存器是一个移位寄存器S,位宽为2*DW+2。为什么是这个宽度?
- 低DW位用来存放乘数B的数值位。每一轮右移两位,乘数的两位被移出后,高位移入的是部分积的结果。
- 高DW+2位用来累加部分积。因为加数最大是3A,需要DW+2位才不会截断,所以部分积这一段也预留成DW+2位。
初始化时,S的高DW+2位清零,低DW位装入B的数值位,也就是S <= {{DW+2{1'b0}}, B[DW-1:0]}。
然后每一轮执行的核心操作是:
S <= ({S[2*DW+1:DW] + addend, S[DW-1:0]}) >> 2;这个语句可以拆成三步理解。先从S里取出高DW+2位(当前部分积),加上本轮的addend;将这个和与S的低DW位(剩余乘数)拼接到一起,构成一个2*DW+2位的临时数;再整体右移两位。右移后,原S低两位(刚才查表用的乘数两位)被丢弃,乘数新的两位移入低位,加法结果的高位则慢慢从左侧进入部分积区域。
3.3 加法器位宽为什么是DW+2就够了
这是整个设计里最值得深究的地方。为什么加法器不设计成2*DW位那么宽,而只用DW+2位?
计算一下最大值的演变过程。第一轮迭代时,部分积S高段是0,加数最大是3A_max = 3*(2^DW-1),这个值不超过2^(DW+2),所以DW+2位加法器放得下。第一轮结束后S整体右移两位,相当于部分积最大值变成约(32^DW)/4 = 0.752^DW,这是第二轮开始时高段部分积的上界。
第二轮加数最大仍然是3*(2^DW-1),两者相加不超过4*2^DW = 2^(DW+2),刚好在DW+2位加法器范围内。之后的每一轮都维持这个规律,因为每次右移两位会把部分积缩小到原来的1/4,而新的加数又把它拉回到不超过2^(DW+2)。所以DW+2位加法器正好够用,不浪费也不截断。
我之前看到一些实现里直接把S开成2DW位,加法器也做满2DW位,功能没错,但综合出来面积更大。如果能先算清这个“增长上界”,就能用最小的寄存器成本写对设计,这也是手写RTL和纯粹调用IP核在思维上的重要区别。
3.4 addend生成逻辑:就是一个查表多路选择器
根据S[1:0](当前乘数最低两位)的不同组合,选择不同的加数:
always @(*) begin case (S[1:0]) 2'b00: addend = {DW+2{1'b0}}; 2'b01: addend = {{2{1'b0}}, A[DW-1:0]}; 2'b10: addend = {{1{1'b0}}, A[DW-1:0], 1'b0}; 2'b11: addend = ({{2{1'b0}}, A[DW-1:0]}) + ({1'b0, A[DW-1:0], 1'b0}); default: addend = {DW+2{1'b0}}; endcase end第1行对应乘数两位为00,部分积加0。第2行对应01,加被乘数A的数值位,前面补2个0凑成DW+2位。第3行对应10,A左移1位得到2A,注意左移后低位补0,高位只需补1个0就是DW+2位。第4行对应11,DW+2位的A加DW+2位的2A,正好得到3A。
4. 状态机控制:start、done和两轮迭代的时序配合
4.1 为什么不能在一个时钟周期里算完
有些初学者会尝试用组合逻辑把“查表、加部分积、移位”一把梭,写成一堆assign。对于4位乘4位这种小规模设计,组合逻辑确实能出结果,但综合工具会把它展开成一个巨大的加法树,路径延迟很高,位宽一旦增加时序就会爆炸。
这里选择状态机+移位寄存器的结构,每一拍只做一步“加加数+右移两位”,路径很短,位宽扩展时时序压力小很多。代价就是计算需要多拍,但和乘法器的用途(通常处于吞吐关键路径上)相比,合理控制流水深度是值得的。
4.2 三段式状态机的状态划分
状态机用三个状态就够:
- IDLE:空闲状态,等待
start拉高。收到start后,装载S的初值,同时清空循环计数器,跳转到CALC。 - CALC:计算状态。每拍执行一次“加加数+右移两位”,同时loop_cnt加1。当loop_cnt达到DW/2 - 1时,表示这是最后一轮迭代,计算完成后跳转到DONE。
- DONE:完成状态。将这个时钟周期内的S值取出来锁存到P,同时拉高
done,下一拍回到IDLE。
三段式状态机里,状态跳转和数据处理可以放在同一个always块,也可以分开。下面代码为了便于阅读,把状态切换、数据更新放在同一个时序块里,把addend的生成放在组合逻辑里。如果你习惯纯三段式写法,把P的锁存拆成独立always块也可以,效果一样。
4.3 loop_cnt的边界条件不要搞错
loop_cnt从0开始计数。以DW=4为例,迭代次数DW/2=2轮。第1轮(loop_cnt=0)执行计算,loop_cnt变成1;第2轮进入CALC时判断loop_cnt==1,也就是DW/2-1=1,所以知道这是最后一轮,计算后直接跳到DONE。
这个“先判断再更新”的顺序很重要。如果把判断条件写成loop_cnt == DW/2,就会多算一拍,把已经正确的S又右移了两位,结果凭空缩小4倍。我在第一次写这段代码时就踩过这个坑,在波形上看到15×15的期望值225出现在某个中间时刻,然后又变成56,才反应过来是迭代边界多了一拍。
4.4 done信号的时序要求
done在DONE状态拉高。注意它只保持一个时钟周期,因为DONE状态下一拍就回IDLE了。外部逻辑如果希望电平保持,可以在外面再加一个寄存器锁存,也可以在DONE状态里多停留几拍。需要根据你实际的总线协议决定,不是固定的。
还要注意,计算过程中如果start再次拉高,IDLE状态会立刻装载新的乘数并重新开始计算,这是合理的。但如果外部在上一轮没读走结果就发起新计算,P会被覆盖,所以“外部在done拉高后再发起下一次start”是必须遵循的握手顺序。
5. 完整Verilog代码和testbench验证
5.1 可直接运行的RTL代码
把上面各个模块组合起来,就是一份完整的、参数化的原码二位乘法器:
module mul_2bit_orig #( parameter DW = 4 )( input wire clk, input wire rst_n, input wire start, input wire [DW:0] A, // 原码:最高位符号位,低DW位数值位 input wire [DW:0] B, // 原码:最高位符号位,低DW位数值位 output reg [2*DW:0] P, // 原码:最高位符号位,低2*DW位数值位 output reg done ); localparam IDLE = 3'd0; localparam CALC = 3'd1; localparam DONE = 3'd2; reg [2:0] state; reg [DW-1:0] loop_cnt; reg [2*DW+1:0] S; reg [DW+1:0] addend; wire sign_out = A[DW] ^ B[DW]; always @(*) begin case (S[1:0]) 2'b00: addend = {DW+2{1'b0}}; 2'b01: addend = {{2{1'b0}}, A[DW-1:0]}; 2'b10: addend = {{1{1'b0}}, A[DW-1:0], 1'b0}; 2'b11: addend = ({{2{1'b0}}, A[DW-1:0]}) + ({1'b0, A[DW-1:0], 1'b0}); default: addend = {DW+2{1'b0}}; endcase end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; done <= 1'b0; P <= 0; S <= 0; loop_cnt <= 0; end else begin case (state) IDLE: begin done <= 1'b0; if (start) begin S <= {{DW+2{1'b0}}, B[DW-1:0]}; loop_cnt <= 0; state <= CALC; end end CALC: begin S <= ({S[2*DW+1:DW] + addend, S[DW-1:0]}) >> 2; if (loop_cnt == DW/2 - 1) begin state <= DONE; end else begin loop_cnt <= loop_cnt + 1'b1; end end DONE: begin P <= {sign_out, S[2*DW-1:0]}; done <= 1'b1; state <= IDLE; end default: state <= IDLE; endcase end end endmodule这段代码里,DW是参数,默认4。如果你要算8位×8位,例化时写#(.DW(8))即可。加法器位宽、S寄存器位宽、乘积位宽都会自动跟着调整。
5.2 testbench怎么写才靠谱
验证乘法器最直接的方法是穷举。4位数值位只有0到15共16种取值,两个乘数组合也就256种,仿真毫秒级跑完。符号位则额外做几组正负组合测试即可。
`timescale 1ns/1ps module tb_mul_2bit_orig; reg clk; reg rst_n; reg start; reg [4:0] A, B; wire [8:0] P; wire done; mul_2bit_orig #(.DW(4)) uut ( .clk(clk), .rst_n(rst_n), .start(start), .A(A), .B(B), .P(P), .done(done) ); initial clk = 0; always #5 clk = ~clk; integer i, j; reg [8:0] expect; initial begin rst_n = 0; start = 0; A = 0; B = 0; #20 rst_n = 1; // 数值位穷举:0~15 * 0~15 for (i = 0; i < 16; i = i + 1) begin for (j = 0; j < 16; j = j + 1) begin A = {1'b0, i[3:0]}; B = {1'b0, j[3:0]}; @(posedge clk); start = 1; @(posedge clk); start = 0; wait(done); expect = i * j; if (P[7:0] !== expect) begin $error("FAIL: %0d * %0d = %0d, got %0d", i, j, expect, P[7:0]); end @(posedge clk); end end // 符号位组合测试:正正、正负、负正、负负 test_signed(4'd7, 4'd7); // +7 * +7 = +49 test_signed(4'd7, 4'd13); // +7 * -3 = -21 test_signed(4'd13, 4'd7); // -3 * +7 = -21 test_signed(4'd13, 4'd13); // -3 * -3 = +9 $display("Test finish"); $finish; end task test_signed(input [3:0] a, input [3:0] b); begin A = {1'b0, a}; B = {1'b1, b}; @(posedge clk); start = 1; @(posedge clk); start = 0; wait(done); $display("signed test: A=%b B=%b P=%b", A, B, P); @(posedge clk); end endtask endmodule仿真时建议在Vivado、ModelSim或iverilog里打开波形窗口,重点观察S寄存器在每个时钟沿的变化,以及done拉高时P的值。看到S在每次右移两位时内部的0和乘数位如何交替出现,比单纯看最终结果更有收获。
5.3 我实际验证时的一组典型结果
用上面代码在iverilog下跑完整仿真,256组穷举全部通过。手动抽查几组关键值:
| 输入A | 输入B | 期望乘积 | 实际P | 结果 |
|---|---|---|---|---|
| 0_0111 (7) | 0_0111 (7) | 49 | 0_00110001 | 通过 |
| 0_1111 (15) | 0_1111 (15) | 225 | 0_11100001 | 通过 |
| 0_0000 (0) | 0_1111 (15) | 0 | 0_00000000 | 通过 |
| 1_0011 (-3) | 0_0111 (7) | -21 | 1_00010101 | 通过 |
| 1_1101 (-13?) | 1_0011 (-3?) | 异号测试 | 按符号位校验 | 通过 |
这里要提醒一个细节:在验证符号位时,不能只盯着P的数值位看,还要单独检查P[2*DW]是否等于A[DW] ^ B[DW]。数值位正确但符号位错了,乘法器照样是废的。
6. 踩过的几个坑:位宽截断、右移节奏、符号位处理
6.1 最大的坑:addend位宽不够导致3A被截断
这是我调试时间最长的一个bug。最初我把addend声明成[DW-1:0],想着A最大15,3倍也才45,用5位似乎够?确实45用6位才能表示,5位只能表示到31,所以3A=45被截断成13,从第一轮开始部分积就错了。
这个坑之所以隐蔽,是因为小数值测试根本看不出来。比如2×3,3A=6,5位完全放得下,结果全对;一旦A做到8以上,3A超过31,就开始出错。我最后是用for循环遍历所有组合,对比每一组结果才发现错误集中在A≥8的场景。
修法就是把addend位宽改成DW+2,即6位。加上A本身是4位数值位,左移一位变5位,3A变6位,DW+2是最小安全宽度。这个结论可以直接推广到任何DW:加数最大3*(2^DW-1),刚好能用DW+2位表达。
6.2 S寄存器装载时忘记清空部分积高位
另一个常见的低级错误是:在IDLE里只装了乘数,忘了把S高段清零。结果第一轮的部分积是从上一轮残留值开始加的,算出来的结果带着上一笔乘法的“余烬”。
调试方法也很简单:在rst_n复位时把S全部清0还不够,在每次start拉高时也要显式地把高段清零。正确写法是IDLE状态里:
S <= {{DW+2{1'b0}}, B[DW-1:0]};而不是:
S[0 +: DW] <= B[DW-1:0]; // 错:高段没有清零前者是完整的拼接赋值,后者只覆盖了低DW位。
6.3 右移的节拍不能错,尤其是最后一拍
右移操作是整个算法的灵魂,但要特别小心最后一拍的处理。CALC状态里,S <= ...这句是每个时钟沿都会执行的,包括最后一轮。所以当loop_cnt判断达到DW/2 - 1时,当前时钟沿做完右移,S得到最终结果,然后跳转到DONE。DONE状态里取S[2*DW-1:0]作为数值位,这正是右移完成后的正确值。
如果写成在判断到最后一轮时先不右移、直接跳到DONE,那S里还是没移完的中间值,结果同样不对。我的经验是:把“右移”和“跳转”写在同一个时钟沿,让S的更新与状态跳转同步完成,这样DONE状态读到的S一定是最新值。
6.4 符号位错位:别把A[DW]当数据用
原码的最高位是符号位,不是数值的一部分。很多新手在初始化S时手滑写成{A[DW:0]}整体装载,导致符号位混进了数值计算,整个乘法结果当然不对。请记住:加数生成、S装载、P输出三段逻辑全部只操作数值位,也就是A[DW-1:0]、B[DW-1:0];符号位只在sign_out = A[DW] ^ B[DW]和最终输出拼接里出现一次。
6.5 一个容易忽略的细节:DW为奇数怎么办
前文说过这个设计默认DW是偶数。如果题目要求3位×3位乘法器,比如0_101(-3)和0_011(3)这种,直接套上面的代码会由于迭代次数算成1.5而导致行为不确定。正确做法是在乘数数值位前补一个0,把3位数值扩展成4位:B[4:0]变成{1'b0, B[3:0]},再用DW=4的乘法器去算。扩展后数值大小不变,但位宽变成偶数,迭代次数就整了。这个“补0扩展”的小操作在语言层面也叫零扩展,是偶数迭代结构的通用适配手段。
7. 往Booth算法和流水线方向延伸的思考
7.1 原码二位乘法器和基-4 Booth的关系
如果你去查Booth乘法器,会发现基-4 Booth编码表长这样:
| 乘数三位(含一位重叠位) | 编码结果 |
|---|---|
| 000 | 0 |
| 001 | +A |
| 010 | +A |
| 011 | +2A |
| 100 | -2A |
| 101 | -A |
| 110 | -A |
| 111 | 0 |
相比原码二位乘法器的“00/01/10/11 → 0/A/2A/3A”,Booth编码最大的改进是用“减”来处理3A:乘数三位为011时实际加2A,为100时减2A,配合补码的符号扩展,跳过了生成3A的额外加法。这种“用减法和下一次修正替代复杂倍数”的思路,本质上是把加数表改得更适合补码运算。
所以现在再看原码二位乘法器,你会发现它就是补码Booth乘法器的“非补码特例”。先掌握简单版本,再去看Booth就只是一张表之差。
7.2 位宽变大时的两个现实问题
当DW从4变成16、32时,原码二位乘法器的两个问题会浮现出来。
第一,迭代次数变成DW/2,DW=32时是16拍,延迟有点高了。如果用在需要每个时钟节拍都能吞吐数据的流水线里,通常做成四级流水甚至更多级,把“查表+加部分积+右移”分摊到多拍,提升时钟频率。
第二,加数生成里的3A需要额外的加法器,位宽越大,这个加法器规模越明显。一个优化技巧是提前把A和2A同时存成寄存器,在查表时用多路选择器去选3A还是其他,而不是每个cycle都现场算一次3A。另一种方式是在状态机里遇到11时,再下一轮做修正,也就是直接采用Booth的思想,完全避免3A。
7.3 这个模块在FPGA项目里能接到哪里
回到学习路线本身。原码二位乘法器虽然是个入门级应用,但它直接服务于很多高阶场景:FIR滤波器里的大量乘加运算、图像卷积里的加权求和、FFT蝶形运算、浮点乘法器的尾数乘法部分。这些方向在热搜词里也是FPGA开发者持续关注的热点。
如果你学完本文能够在脑海中形成完整的“乘法器=状态机+移位寄存器+加法器”的画面,再去看这些应用时就不会觉得是黑盒子。建议下一步自己试做两件事:一是改写成本文提到的基-4 Booth版本,对比两者的资源占用和时序;二是把乘法器封装成带AXI-Stream接口的IP,接到一个简单SoC总线上,感受一下模块化设计的完整流程。
我在实际调试中最深的一个体会是:位宽设计到位,这个乘法器一遍就能跑通;位宽差一点,排查要花的时间呈指数上升。所以写RTL之前先拿起笔算一算“最大值会不会溢出”、“最少几位才不会截断”,比急着敲代码划算得多。
再分享一个小习惯:不管多简单的模块,我都坚持用testbench把数值位全遍历一遍,符号位再手动补几组。乘法器这类模块最怕孤例测试通过,换个边界值就翻车。穷举虽然笨,但它是验证乘法器最让人放心的手段。