ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FPGA原码二位乘法器设计:移位加法与Verilog实现详解

FPGA原码二位乘法器设计:移位加法与Verilog实现详解 最近在调一个FPGA练手项目——原码二位乘法器代码用Verilog写从原理推导到仿真上板跑通前前后后折腾了几天。这个模块属于数字逻辑里很经典的算术单元看着是“乘法”本质是“移位加法和状态控制的组合体”原码格式还要单独处理符号位细节不少。我把自己从算法推导到Verilog实现再到仿真排坑的完整过程整理出来给正在学计算机组成原理、或者刚入门FPGA的朋友一个可以直接照抄的参考也顺便把那些教材上不会明说的位宽、时序、拼接移位问题一次讲清楚。1. 先搞清楚一点二位乘法到底在优化什么1.1 从二进制竖式乘法说起十进制竖式乘法大家都写过把一个乘数的每一位去乘另一个乘数得到若干行部分积再逐列相加。二进制更简单因为每一位只能是0或1所以“某一位去乘被乘数”这个动作退化成了“选或不选”乘数某位是1就把被乘数加到结果里是0就什么都不加。剩下的活全交给移位——把被乘数左移一位相当于乘以2。硬件上最常见的实现是“一位乘法器”每个时钟周期检查乘数的一位根据它是0还是1决定是否给部分积加上被乘数然后把部分积和乘数一起右移一位。n位乘数就需要n个周期。8位乘法跑8拍16位跑16拍这在低速控制类场景里无所谓但一进图像处理、数字滤波这类循环里全是乘法的地方这个速度就有点不够看了。所以就有了“二位乘法器”这个经典改进思路既然一位乘法每个周期只处理乘数的一位那我能不能每个周期处理两位代价是多准备一点组合逻辑换来时钟周期数直接减半。8位乘法从8拍变成4拍这对实时性敏感的场景是实打实的收益。学习阶段做这个模块也能把“用面积换速度”这句硬件设计的老话理解得更透。1.2 每次看两位四种情况全拆开二位乘法的核心变化是把“乘数最低位是0还是1”的判断改成“乘数最低两位是什么组合”。两位一起看一共有四种情况乘数最低两位需要执行的操作硬件需要准备好的数00部分积加0001部分积加1倍被乘数X10部分积加2倍被乘数2X X左移1位11部分积加3倍被乘数3X 2X X00、01、10都比较直观麻烦的是11因为3倍被乘数不能靠单纯的移位得到。解决思路也简单在电路里提前用组合逻辑把3X算出来也就是先算2X左移一位再加上X得到一个比被乘数宽一位的数放在那里等着选。这里有一个新手很容易踩的坑位宽。被乘数X如果是8位2X就可能是9位3X也必须是9位。如果在代码里偷懒用了8位寄存器去存2X或3X最高位会被直接截掉乘法结果必然出错。这一点后面代码部分会详细说。1.3 为什么原码乘法能“符号单独算”题目里点名“原码二位乘法器”说明输入和输出都用原码格式。原码的特点是所有编码方式里最朴素的——最高位是符号位1代表负数0代表正数剩余位存绝对值。原码做乘法的好处是特别省心符号和数值彻底分离。正负号只取决于两个操作数的符号位同号为正、异号为负一个异或门就搞定了。数值部分则完完全全当成无符号数去乘不需要像补码乘法那样处理符号扩展、修正项这些东西。对硬件来说“异或符号位”“无符号数值乘法”是两个独立的电路各干各的最终拼到一起就行。正因为这样原码乘法器通常被安排在补码乘法器之前学习——先跑通最直观的移位加法流程再去碰Booth算法那种花活。我建议现在正看这篇文章的朋友如果还没有完全理解一位乘法的竖式推演过程先回去写一个一位乘法器再来做二位乘法会顺畅得多。2. 算法设计和手算推导不如先拿纸笔走一遍2.1 确定输入输出格式和位宽做硬件设计的第一步不是写代码而是先把接口和位宽定死。我这个模块的约定是输入 a、b 都是8位原码最高位是符号位低7位是数值位。输出 result 是16位原码bit15是符号位bit14固定为0bit13到bit0是14位乘积数值。为什么数值位是14位因为两个7位无符号数相乘最大是127×12716129换成二进制需要14位2的13次方是81922的14次方是16384刚好够。输出用16位纯粹是为了对齐常规总线宽度多出来的高位补0或者给符号位腾位置。这里要强调一个设计习惯位宽宁可多给不能少给。多给一位只是浪费一点点寄存器少给一位就是数据错误。实际工程里因为位宽截断查一天bug的情况太多了后面排坑部分会专门提。2.2 迭代步骤和移位拼接二位乘法的迭代流程我直接用寄存器拼接的方式来做这也是移位加法乘法器最正统的写法用16位寄存器 prod_reg 存部分积的高位部分初始为0。用8位寄存器 mult_reg 存乘数初始为被乘数的无符号数值部分。每个周期看 mult_reg 的最低两位决定要加什么数加到 prod_reg 上。加完后把“prod_reg 和 mult_reg 拼接起来的24位整体”右移2位再拆回 prod_reg高16位和 mult_reg低8位。这里有人会问为什么不直接让 prod_reg 右移2位就完事如果把 prod_reg 单独右移它低位移出来的那2位其实包含乘积的低位信息直接丢掉就错了。用拼接寄存器整体移位prod_reg 的低2位会恰好滚进 mult_reg 的高2位既不影响后面的乘数编码又把乘积低位完整保留下来。这个细节是很多自学文档一句带过、但实际实现时最容易出问题的地方。迭代次数也很好算每次处理乘数2位8位无符号乘数需要4次迭代。代码里用一个3位计数器从0数到3第4个周期后输出结果。2.3 一个完整的手算案例13 × 11口说无凭我直接带大家手算一遍。取被乘数 X13乘数 Y11。二进制表示X8b00001101它的无符号数值部分是 0000110113Y8b00001011数值部分是 0000101111。先算好三个候选加数X 13 0x0D2X 26 0x1A3X 39 0x27开始迭代。初始状态prod_reg16b0mult_reg8b00001011。第一轮mult_reg最低两位是11查表应该加3X39。sum 0 39 39。然后整体右移2位prod_reg新值 sum的高14位39右移2位 9mult_reg新值 由sum低2位二进制11和原mult_reg高6位000010拼起来得到 11000010即十进制的194。第二轮mult_reg最低两位是10查表应该加2X26。sum 9 26 35。再整体右移2位prod_reg新值 35右移2位 8mult_reg新值 sum低2位11加上原mult_reg高6位110000得到 11110000即十进制的240。第三轮mult_reg最低两位是00加0。sum 8 0 8。右移2位prod_reg新值 8右移2位 2mult_reg新值 sum低2位00加上原mult_reg高6位111100得到 00111100即十进制的60。第四轮mult_reg最低两位还是00加0。sum 2 0 2。右移2位prod_reg新值 2右移2位 0mult_reg新值 sum低2位10加上原mult_reg高6位001111得到 10001111即十进制的143。最终 prod_reg 和 mult_reg 拼起来是 0x008F也就是143和 13×11143 完全吻合。通过这个手算过程可以看到前两轮 sum 的低2位被滚进了 mult_reg 的高位最后得到的结果高位是0低位143跟竖式乘法的结果结构完全一致。这个手算过程强烈建议自己再推一遍弄懂了后面写代码和调仿真都顺。3. Verilog 代码实现与逐段解读3.1 模块端口和内部信号确定了算法代码就水到渠成了。模块顶层端口这么定义module mult_2bit_orig( input wire clk, input wire rst_n, input wire start, input wire [7:0] a, input wire [7:0] b, output reg [15:0] result, output reg done );clk 和 rst_n 是时钟和低有效复位start 是外部给的启动脉冲a、b是被乘数和乘数result 是乘积输出done 是完成标志。为什么要 start 和 done 这一对握手信号因为乘法不是每个周期都在算的如果设计成“永远在计算”白白消耗动态功耗而且外部模块也不知道什么时候能取结果。用 start 触发计算、done 通知完成是FPGA模块之间最常用的交互方式也方便后面挂在状态机里用。接着是核心的预处理逻辑wire sign_bit a[7] ^ b[7]; wire [7:0] mag_a {1b0, a[6:0]}; wire [7:0] mag_b {1b0, b[6:0]}; wire [8:0] double_a mag_a 1; wire [8:0] triple_a double_a {1b0, mag_a};sign_bit 就是符号位异或同号0、异号1。mag_a、mag_b 是把输入的高位符号位剥离后扩展成8位无符号数。这里要多说一句为什么要把7位数值位扩展成8位因为两次右移后乘数寄存器会被逐步清空如果乘数数值位是7位最后一轮只有1位有效数据编码会变得很别扭。高位补0把乘数扩展成8位后正好4轮用完清爽很多。double_a 和 triple_a 是提前算好的候选加数。注意 double_a 声明为9位宽因为 mag_a 是8位左移一位后最高位可能是18位寄存器会截断。triple_a 是9位加9位最大就是3819位足够。3.2 候选加数的多路选择接下来是根据 mult_reg 最低两位选出加数reg [15:0] prod_reg; reg [7:0] mult_reg; reg [2:0] cnt; reg busy; wire [8:0] addend; always (*) begin case (mult_reg[1:0]) 2b00: addend 9d0; 2b01: addend {1b0, mag_a}; 2b10: addend double_a; 2b11: addend triple_a; default: addend 9d0; endcase end wire [15:0] sum prod_reg {7b0, addend}; wire [15:0] product {prod_reg, mult_reg};addend 是9位加到16位的 prod_reg 上时要在前面补7个0对齐。这种位宽对齐的写法新手容易忽略直接用prod_reg addend也没报错但综合后行为往往不符合预期。我个人的习惯是只要两个数位宽不一样一律先手动扩展再相加宁可啰嗦一点也不给综合器留“猜”的空间。product 是拼接起来的完整乘积寄存器组后面取结果时直接用它的低14位就行。3.3 状态控制和迭代时序核心时序逻辑长这样always (posedge clk or negedge rst_n) begin if (!rst_n) begin prod_reg 16d0; mult_reg 8d0; cnt 3d0; busy 1b0; done 1b0; result 16d0; end else if (!busy) begin done 1b0; if (start) begin prod_reg 16d0; mult_reg mag_b; cnt 3d0; busy 1b1; end end else if (cnt 3d4) begin prod_reg {2b00, sum[15:2]}; mult_reg {sum[1:0], mult_reg[7:2]}; cnt cnt 1b1; end else begin busy 1b0; done 1b1; result {sign_bit, 1b0, product[13:0]}; end end这个状态机只有两拍空闲态和忙态。空闲时等待 startstart 拉高一拍后把所有寄存器初始化进入忙态。忙态里 cnt 从0数到3执行4轮移位加法cnt 到4的那一拍输出最终结果done 拉高一个时钟周期然后回到空闲。注意 prod_reg 和 mult_reg 的更新写法正是前面手算推的那套拼接移位prod_reg 的新值是 sum 右移2位后的高14位高位补00。mult_reg 的新值由 sum 的低2位和原 mult_reg 的高6位拼接而成。这个写法直接对应了“整体24位右移2位再拆开”的过程。sum 低2位滚进 mult_reg 高位mult_reg 最低2位被丢弃因为已经被消费完了。如果这里用了mult_reg mult_reg 2这种朴素写法丢掉的不是消费过的位而是还没有用的高位数据结果必然错。result 的赋值也要解释一下product 是16位无符号乘积但两个7位数值相乘最大1414不对最大是127×12716129高位是0。取 product 的低14位放进 result 的 bit13到bit0bit14补0bit15放符号位。这样输出格式就和接口约定完全一致了。3.4 完整代码汇总把上面的片段拼起来就是完整模块这里给一份可以直接用的全集module mult_2bit_orig( input wire clk, input wire rst_n, input wire start, input wire [7:0] a, input wire [7:0] b, output reg [15:0] result, output reg done ); wire sign_bit a[7] ^ b[7]; wire [7:0] mag_a {1b0, a[6:0]}; wire [7:0] mag_b {1b0, b[6:0]}; wire [8:0] double_a mag_a 1; wire [8:0] triple_a double_a {1b0, mag_a}; reg [15:0] prod_reg; reg [7:0] mult_reg; reg [2:0] cnt; reg busy; wire [8:0] addend; always (*) begin case (mult_reg[1:0]) 2b00: addend 9d0; 2b01: addend {1b0, mag_a}; 2b10: addend double_a; 2b11: addend triple_a; default: addend 9d0; endcase end wire [15:0] sum prod_reg {7b0, addend}; wire [15:0] product {prod_reg, mult_reg}; always (posedge clk or negedge rst_n) begin if (!rst_n) begin prod_reg 16d0; mult_reg 8d0; cnt 3d0; busy 1b0; done 1b0; result 16d0; end else if (!busy) begin done 1b0; if (start) begin prod_reg 16d0; mult_reg mag_b; cnt 3d0; busy 1b1; end end else if (cnt 3d4) begin prod_reg {2b00, sum[15:2]}; mult_reg {sum[1:0], mult_reg[7:2]}; cnt cnt 1b1; end else begin busy 1b0; done 1b1; result {sign_bit, 1b0, product[13:0]}; end end endmodule写完之后我对着代码检查过一遍四位里的进位高位全部预留了多路选择器有 default 分支不会生成锁存器复位用低有效同步复位这里统一了风格。初学者拿去综合应该不会报 latch 警告。4. 仿真验证与排坑心得4.1 Testbench 怎么写才有参考价值代码写完了接下来最关键的一步是仿真。我见过不少人拿到模块就直接上板结果指示灯乱闪也不知道哪一步出了问题。正确的做法是先在仿真环境里把边界情况全测一遍。下面是我这次用的 testbenchtimescale 1ns/1ps module tb_mult_2bit_orig; reg clk 0; reg rst_n 0; reg start 0; reg [7:0] a 0; reg [7:0] b 0; wire [15:0] result; wire done; mult_2bit_orig u_dut( .clk(clk), .rst_n(rst_n), .start(start), .a(a), .b(b), .result(result), .done(done) ); always #5 clk ~clk; integer err_cnt; task run_test(input [7:0] a_in, input [7:0] b_in, input [15:0] expect); begin (negedge clk); a a_in; b b_in; start 1b1; (negedge clk); start 1b0; wait(done 1b1); if (result ! expect) begin $display(FAIL: a%h b%h result%h expect%h, a, b, result, expect); err_cnt err_cnt 1; end else begin $display(PASS: a%d b%d result%d, a, b, result); end (negedge clk); end endtask initial begin err_cnt 0; #20 rst_n 1; run_test(8b00001101, 8b00001011, 16h008F); run_test(8b00000000, 8b00000000, 16h0000); run_test(8b01111111, 8b01111111, 16h3F01); run_test(8b10000001, 8b10000001, 16h0001); run_test(8b11111111, 8b01111111, 16hBF01); if (err_cnt 0) $display(ALL TEST PASSED); else $display(%0d TEST FAILED, err_cnt); $finish; end endmodule这5组用例覆盖了正常正数相乘、0乘0、最大值相乘、负数乘负数、负数乘正数基本把边界都碰到了。run_test 任务里用下降沿打 start再下降沿撤销是为了避开和时钟上升沿的竞争保证 dut 在上升沿稳定采到 start1。注意 127×127161290x3F01所以期望值是 16h3F01。-127×127 的符号位是1数值部分还是0x3F01拼上符号位得到0xBF01。测负数乘负数时-1×-1符号位异或为0数值1×11期望0x0001。4.2 波形里重点看什么仿真跑完别只看“PASS”就收工打开波形图按这几个点检查mult_reg 的变化每一轮应该右移2位高2位被 sum 的低2位填充。对照前面手算表看 mult_reg 是否按 00001011 → 11000010 → 11110000 → 00111100 → 10001111 这样变化。prod_reg 的变化根据加数和右移结果逐拍核对比如第一轮 039 再右移2位应该得到9。done 的时序从 start 拉高到 done 拉高正好是5个时钟周期1拍启动 4轮迭代。done 高电平只有1拍拉高那拍 result 必须已经是最终值。result 的符号位负数用例中result 的 bit15 应该为1正数和负负得正的情况 bit15 为0。只要这几个关键节点全对模块基本就是正确的。4.3 我自己踩过的几个坑这模块看着简单实操时坑其实不少列几个印象最深的位宽截断。最早我把 double_a 和 triple_a 声明成8位仿真时 3X 的381直接变成125结果全错。排查方法很简单在所有中间变量上右键看二进制值一眼就能发现高位没了。从那以后我写代码有个习惯拿不准位宽就先加一位综合器真报warning了再改小。移位方向写反。我有一次把拼接移位写成了左移波形乱成一团对照手算表才发现prod_reg越来越大的趋势完全不对。乘法器这类模块手算表就是最好的调试参考不要凭感觉猜。start 和时钟沿“打架”。testbench里我一开始用上升沿打 start结果 dut 在同一沿既采 start 又更新状态偶尔会漏掉启动。改成下降沿操作后问题消失。做模块互联时外部信号最好经过同步或至少避开时钟沿变化。wait(done) 死等。如果上一次 done 还没被消费下一次 wait(done) 可能永远等不到拉高。我的任务函数里在撤销 start 后先等一个沿再 wait就是为了避免这种状态残留。4.4 常见问题速查表现象可能原因检查思路结果比预期小很多3X或2X位宽被截断检查 double_a、triple_a 位宽是否9位结果是按位乱序移位方向或拼接写反对照手算表逐拍核对 mult_regstart 拉高后模块无反应start 与时钟沿竞争testbench 用下降沿或打一拍再给done 一直不拉高wait(done) 状态残留或 cnt 不递增检查 cnt 是否复位、busy 是否进入综合报 latch 警告case 缺少 default给 addend 选择补全 default负数结果符号错符号位没有异或检查 sign_bit 赋值和 result 拼接5. 综合结果与后续扩展思路5.1 综合下来资源到底花了多少用 Quartus 或 Vivado 随便综合一下这个8位二位乘法器资源消耗非常小也就几十个逻辑单元加十几个寄存器。关键路径基本落在“候选加数选择器 → 16位加法器 → 移位寄存器”这条链路上。16位加法器本身并不复杂所以这个模块跑个100MHz以上完全没压力。如果你想对比可以再写一个同样8位的一位乘法器周期数翻倍但组合逻辑少一个多路选择器和3X生成电路。二位乘法器本质上就是“多点组合逻辑少跑几个周期”的典型例子。实际工程怎么选要看系统里时钟余量够不够以及乘法在关键路径上出现的频率。5.2 参数化改成16位、32位模块里位宽全是写死的真要复用建议改成 parameter 参数化。比如定义parameter MUL_WIDTH 8内部所有位宽用宏代替迭代次数改成(MUL_WIDTH / 2)或者(MUL_WIDTH 1) / 2。改成参数化之后乘法器可以无缝用在不同的数据通路里学习阶段多花十分钟做这一步后面能省很多事。有一点要注意参数化后 addend 的位宽、拼接移位的表达式都要跟着变不能一边改位宽一边保留原来的硬编码否则低级错误又回来了。5.3 下一步可以试试补码乘法器和Booth算法原码乘法器只是把“符号位单独处理无符号数值相乘”这条路走通了。真正工程里用得更广泛的是补码乘法器因为补码的加减法统一不需要单独考虑符号。补码乘法最经典的实现是Booth算法它一次看乘数的两位或三位通过编码表决定加X、减X、还是不加能同时处理符号扩展速度更快、结构更规整。学完原码二位乘法器之后我的建议是尝试用同样的“拼接移位”框架写一个Booth乘法器。你会发现二者在寄存器结构、状态控制、位宽处理上有很多相似之处转换成本很低但理解深度会提升一大截。这也是我当初学完原码乘法器后做的第一件事收益很大。5.4 如果追求极致性能可以怎么办如果你不满足于“能跑”想挑战性能方向也不少流水线化把4轮迭代拆成4级流水每一级只做“加法和移位”这样吞吐率可以提升到每个周期出一个结果代价是增加寄存器。展开成组合逻辑用 generate 块把4轮迭代全部展开消除状态机变成纯组合乘法器一拍出结果。适合用在高速但不需要大量级联的场景。用DSP48硬核Xilinx、Altera 芯片里都有现成的乘法器硬核一个DSP48能直接算18位乘法面积和速度都比LUT搭出来的好。学习阶段不建议一上来就用但至少要知道有这个选项。我个人在实际操作中的体会是像原码二位乘法器这种小模块恰恰是练内功的好机会。一开始直接上Booth或者流水线容易被一堆术语绕晕踏踏实实把一位乘法、二位乘法、原码、补码逐个吃透再去碰高级架构就顺理成章。做这个项目时我最大的收获不是记住了移位加法的公式而是养成了“先手算推演、再写代码、最后对着波形验证”的习惯。FPGA开发里大部分难查的bug根源都在于你对硬件行为缺一张清晰的脑内时序图而这恰恰只能靠手算和画波形练出来。建议你拿到代码后先不要急着上板把13乘11的例子在纸上推一遍再开仿真看每一拍寄存器的值这个流程走完原码二位乘法器就真正属于你了。
返回列表