ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

原码二位乘法器Verilog实现:移位相加原理到RTL仿真验证

原码二位乘法器Verilog实现:移位相加原理到RTL仿真验证 之前写过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 两位一起处理的核心收益迭代次数减半那么问题来了能做一位乘法器为什么还要做二位的关键点在于迭代次数。一位乘法器每拍处理乘数的一个bitN位乘数需要的拍数是N拍二位乘法器每拍处理乘数的两个bit拍数变成N/2拍。当N从4变成16、32时这个差异会显著影响吞吐。虽然二位乘法器需要额外处理“3倍被乘数”这种稍复杂的加数还要留出DW2位加法器的余量但换来的是迭代次数减半在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。把这些部分积累加就得到最终乘积。二位乘法器把逻辑推进了一层与其逐位判断不如每次从乘数最低位开始看两位。两位乘数可能出现的四种情况对应四种加数乘数最低两位实际含义本次应加的部分积硬件生成方式0000直接给0011被乘数A取A1022AA左移1位1133AA (A左移1位)每次根据查表结果把对应的加数加到“部分积”上然后整体右移两位把下一组两位乘数移动到最低位。重复这个过程直到乘数全部被吞掉部分积里剩下的就是乘积的数值部分。从竖式角度看这一步的本质是把“乘数每一位”的判断合并成了“乘数每两位”的判断每一步的信息量更大所以总迭代次数减半。2.3 3倍被乘数为什么是个易错点四种加数里0、A、2A都很容易生成A就是被乘数本身2A就是被乘数左移一位。唯独3A需要做一次加法3A A 2A。这里最容易出问题的地方是位宽。如果被乘数A是4位数值位A最大15则3A最大45写成二进制是101101需要6位才能表示完整。所以加数寄存器的位宽至少要是DW26位否则3A的高位会被截掉结果必然出错。我在初学时犯过一个低级错误把addend定义成DW位然后仿真验证时发现15×15的结果总是错。排查半天才意识到3A45这个值在4位总线里被截成了13后面所有计算都基于错误的部分积进行自然全盘皆错。这个教训后面专门用一节来讲。2.4 迭代次数与乘积位宽怎么定假设A和B的数值位宽都是DW则乘积的数值位宽是2DW。例如4位×4位乘积最大是15×15225需要8位表示恰好是248位。二位乘法每次消耗乘数的两位所以迭代次数是DW/2。这里有个隐含假设DW必须是偶数。如果乘数数值位是奇数位宽怎么办补一个0在最高位把它扩展成偶数即可。这个操作不影响乘积的数值大小只是让迭代次数从ceil(DW/2)变成(DW1)/2。在Verilog实现里这个迭代次数对应一个循环计数器loop_cnt初始为0每算一拍加1计数到DW/2 - 1时表示最后一轮。下面看代码实现时要注意这个计数的边界。3. 动手写RTL之前寄存器位宽和加法器位宽怎么推算3.1 模块接口如何设计先明确接口。原码二位乘法器的输入是两个原码表示的数输出是原码表示的乘积。参数DW表示数值位宽总位宽是DW1符号位数值位。以DW4为例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*DW2。为什么是这个宽度低DW位用来存放乘数B的数值位。每一轮右移两位乘数的两位被移出后高位移入的是部分积的结果。高DW2位用来累加部分积。因为加数最大是3A需要DW2位才不会截断所以部分积这一段也预留成DW2位。初始化时S的高DW2位清零低DW位装入B的数值位也就是S {{DW2{1b0}}, B[DW-1:0]}。然后每一轮执行的核心操作是S ({S[2*DW1:DW] addend, S[DW-1:0]}) 2;这个语句可以拆成三步理解。先从S里取出高DW2位当前部分积加上本轮的addend将这个和与S的低DW位剩余乘数拼接到一起构成一个2*DW2位的临时数再整体右移两位。右移后原S低两位刚才查表用的乘数两位被丢弃乘数新的两位移入低位加法结果的高位则慢慢从左侧进入部分积区域。3.3 加法器位宽为什么是DW2就够了这是整个设计里最值得深究的地方。为什么加法器不设计成2*DW位那么宽而只用DW2位计算一下最大值的演变过程。第一轮迭代时部分积S高段是0加数最大是3A_max 3*(2^DW-1)这个值不超过2^(DW2)所以DW2位加法器放得下。第一轮结束后S整体右移两位相当于部分积最大值变成约(32^DW)/4 0.752^DW这是第二轮开始时高段部分积的上界。第二轮加数最大仍然是3*(2^DW-1)两者相加不超过4*2^DW 2^(DW2)刚好在DW2位加法器范围内。之后的每一轮都维持这个规律因为每次右移两位会把部分积缩小到原来的1/4而新的加数又把它拉回到不超过2^(DW2)。所以DW2位加法器正好够用不浪费也不截断。我之前看到一些实现里直接把S开成2DW位加法器也做满2DW位功能没错但综合出来面积更大。如果能先算清这个“增长上界”就能用最小的寄存器成本写对设计这也是手写RTL和纯粹调用IP核在思维上的重要区别。3.4 addend生成逻辑就是一个查表多路选择器根据S[1:0]当前乘数最低两位的不同组合选择不同的加数always (*) begin case (S[1:0]) 2b00: addend {DW2{1b0}}; 2b01: addend {{2{1b0}}, A[DW-1:0]}; 2b10: addend {{1{1b0}}, A[DW-1:0], 1b0}; 2b11: addend ({{2{1b0}}, A[DW-1:0]}) ({1b0, A[DW-1:0], 1b0}); default: addend {DW2{1b0}}; endcase end第1行对应乘数两位为00部分积加0。第2行对应01加被乘数A的数值位前面补2个0凑成DW2位。第3行对应10A左移1位得到2A注意左移后低位补0高位只需补1个0就是DW2位。第4行对应11DW2位的A加DW2位的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开始计数。以DW4为例迭代次数DW/22轮。第1轮loop_cnt0执行计算loop_cnt变成1第2轮进入CALC时判断loop_cnt1也就是DW/2-11所以知道这是最后一轮计算后直接跳到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 3d0; localparam CALC 3d1; localparam DONE 3d2; reg [2:0] state; reg [DW-1:0] loop_cnt; reg [2*DW1:0] S; reg [DW1:0] addend; wire sign_out A[DW] ^ B[DW]; always (*) begin case (S[1:0]) 2b00: addend {DW2{1b0}}; 2b01: addend {{2{1b0}}, A[DW-1:0]}; 2b10: addend {{1{1b0}}, A[DW-1:0], 1b0}; 2b11: addend ({{2{1b0}}, A[DW-1:0]}) ({1b0, A[DW-1:0], 1b0}); default: addend {DW2{1b0}}; endcase end always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; done 1b0; P 0; S 0; loop_cnt 0; end else begin case (state) IDLE: begin done 1b0; if (start) begin S {{DW2{1b0}}, B[DW-1:0]}; loop_cnt 0; state CALC; end end CALC: begin S ({S[2*DW1:DW] addend, S[DW-1:0]}) 2; if (loop_cnt DW/2 - 1) begin state DONE; end else begin loop_cnt loop_cnt 1b1; end end DONE: begin P {sign_out, S[2*DW-1:0]}; done 1b1; 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 {1b0, i[3:0]}; B {1b0, 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(4d7, 4d7); // 7 * 7 49 test_signed(4d7, 4d13); // 7 * -3 -21 test_signed(4d13, 4d7); // -3 * 7 -21 test_signed(4d13, 4d13); // -3 * -3 9 $display(Test finish); $finish; end task test_signed(input [3:0] a, input [3:0] b); begin A {1b0, a}; B {1b1, 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)490_00110001通过0_1111 (15)0_1111 (15)2250_11100001通过0_0000 (0)0_1111 (15)00_00000000通过1_0011 (-3)0_0111 (7)-211_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最大153倍也才45用5位似乎够确实45用6位才能表示5位只能表示到31所以3A45被截断成13从第一轮开始部分积就错了。这个坑之所以隐蔽是因为小数值测试根本看不出来。比如2×33A65位完全放得下结果全对一旦A做到8以上3A超过31就开始出错。我最后是用for循环遍历所有组合对比每一组结果才发现错误集中在A≥8的场景。修法就是把addend位宽改成DW2即6位。加上A本身是4位数值位左移一位变5位3A变6位DW2是最小安全宽度。这个结论可以直接推广到任何DW加数最大3*(2^DW-1)刚好能用DW2位表达。6.2 S寄存器装载时忘记清空部分积高位另一个常见的低级错误是在IDLE里只装了乘数忘了把S高段清零。结果第一轮的部分积是从上一轮残留值开始加的算出来的结果带着上一笔乘法的“余烬”。调试方法也很简单在rst_n复位时把S全部清0还不够在每次start拉高时也要显式地把高段清零。正确写法是IDLE状态里S {{DW2{1b0}}, 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]变成{1b0, B[3:0]}再用DW4的乘法器去算。扩展后数值大小不变但位宽变成偶数迭代次数就整了。这个“补0扩展”的小操作在语言层面也叫零扩展是偶数迭代结构的通用适配手段。7. 往Booth算法和流水线方向延伸的思考7.1 原码二位乘法器和基-4 Booth的关系如果你去查Booth乘法器会发现基-4 Booth编码表长这样乘数三位含一位重叠位编码结果0000001A010A0112A100-2A101-A110-A1110相比原码二位乘法器的“00/01/10/11 → 0/A/2A/3A”Booth编码最大的改进是用“减”来处理3A乘数三位为011时实际加2A为100时减2A配合补码的符号扩展跳过了生成3A的额外加法。这种“用减法和下一次修正替代复杂倍数”的思路本质上是把加数表改得更适合补码运算。所以现在再看原码二位乘法器你会发现它就是补码Booth乘法器的“非补码特例”。先掌握简单版本再去看Booth就只是一张表之差。7.2 位宽变大时的两个现实问题当DW从4变成16、32时原码二位乘法器的两个问题会浮现出来。第一迭代次数变成DW/2DW32时是16拍延迟有点高了。如果用在需要每个时钟节拍都能吞吐数据的流水线里通常做成四级流水甚至更多级把“查表加部分积右移”分摊到多拍提升时钟频率。第二加数生成里的3A需要额外的加法器位宽越大这个加法器规模越明显。一个优化技巧是提前把A和2A同时存成寄存器在查表时用多路选择器去选3A还是其他而不是每个cycle都现场算一次3A。另一种方式是在状态机里遇到11时再下一轮做修正也就是直接采用Booth的思想完全避免3A。7.3 这个模块在FPGA项目里能接到哪里回到学习路线本身。原码二位乘法器虽然是个入门级应用但它直接服务于很多高阶场景FIR滤波器里的大量乘加运算、图像卷积里的加权求和、FFT蝶形运算、浮点乘法器的尾数乘法部分。这些方向在热搜词里也是FPGA开发者持续关注的热点。如果你学完本文能够在脑海中形成完整的“乘法器状态机移位寄存器加法器”的画面再去看这些应用时就不会觉得是黑盒子。建议下一步自己试做两件事一是改写成本文提到的基-4 Booth版本对比两者的资源占用和时序二是把乘法器封装成带AXI-Stream接口的IP接到一个简单SoC总线上感受一下模块化设计的完整流程。我在实际调试中最深的一个体会是位宽设计到位这个乘法器一遍就能跑通位宽差一点排查要花的时间呈指数上升。所以写RTL之前先拿起笔算一算“最大值会不会溢出”、“最少几位才不会截断”比急着敲代码划算得多。再分享一个小习惯不管多简单的模块我都坚持用testbench把数值位全遍历一遍符号位再手动补几组。乘法器这类模块最怕孤例测试通过换个边界值就翻车。穷举虽然笨但它是验证乘法器最让人放心的手段。
返回列表