ARTICLE DETAIL

资讯详情

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

FPGA 16位有符号Booth乘法器:从组合逻辑到流水线实现

FPGA 16位有符号Booth乘法器:从组合逻辑到流水线实现 简介面向计算机组成原理与FPGA学习者提供Booth算法乘法器的两种Verilog实现组合逻辑版本和四级流水线版本。组合逻辑版先将输入操作数锁存一拍再通过组合逻辑求出乘积并寄存器输出流水线版采用四级流水线完成4位乘法有助于理解时序设计中的数据划分与寄存器平衡。每个版本均配有基于$random的testbench可自动产生随机操作数并与预期结果比对最终生成report文件省去手动仿真验证的麻烦。资源包共279个文件除Verilog源文件与测试文件外还包含Quartus工程文件、仿真报告.rpt及多种工程辅助文件.dat/.asm/.vhd等整体大小仅1.02MB。已有3221人学习适合正在学习计算机组成原理、数字逻辑或FPGA设计的读者用来掌握Booth编码、部分积生成以及流水线设计技巧为更复杂的计算单元开发提供参考。 前几天帮朋友调一个FPGA项目瓶颈卡在16位有符号乘法器上。直接用*写资源倒是省了但组合路径太长时序怎么都收不住。后来我把乘法器手写成Booth算法的Verilog实现分别做了组合逻辑版本和流水线版本才真正把频率提了上去。这篇文章就把这两个版本的完整思路拆开讲从Booth算法的编码原理、符号扩展的坑到加法树压缩、流水线valid信号的对齐每一步都给出可复现的代码。适合正在做数字IC/FPGA课设的学生也适合想自己控制数据通路、不想被综合器“黑盒”的工程师。1. 为什么还要自己写乘法器Booth算法的工程价值1.1 直接写*不香吗很多初学者会问Verilog里不是有*运算符吗综合器会自己生成乘法器FPGA上还有现成的DSP硬核块为什么还要手写Booth乘法器原因很简单*确实会被综合成乘法器但你控制不了它的内部结构。在ASIC流程里综合工具通常会从标准单元库里挑选乘法器结构老旧的工艺库或者特殊时钟约束下经常需要你手写一个结构明确的乘法器再配合retiming、pipeline切级来做时序收敛。FPGA上虽然有DSP块但工程里经常出现DSP块不够用、需要纯逻辑实现乘法的情况比如同时挂很多通道的FIR滤波器。另一个重要场景是教学和面试。数字IC岗位的面试题里手写Booth乘法器几乎是必考项。面试官要看的不是你背了多少代码而是你对补码乘法、部分积压缩、流水线节奏这些底层问题的理解。这几个点搞明白了后面做复杂信号处理通路会顺手很多。1.2 Booth算法的核心思想一句话版Booth算法的本质是把乘数里连续的1序列统一处理减少部分积的个数。比如做1011 * 0010传统乘法器遇到乘数里每个为1的位都要生成一个部分积4位乘数最多生成4个部分积Booth编码则通过把二进制数改写成更聪明的形式让部分积从“每一位一个”变成“每两位甚至每三位一个”。位宽越大节省越明显。最常用的是radix-4 Booth编码也叫改进型Booth编码。它每次看乘数的3位当前位、下一位、上一位最低位时上一位补0。这3位组合对应一个操作乘数窗口 B[i1:i-1]操作000 或 111加0001 或 010加被乘数011加2倍被乘数100减2倍被乘数101 或 110减被乘数这个表不需要死记自己推一遍就懂。三位窗口本质是看“当前段是上升沿还是下降沿”以及“跨了几位”。用法也很机械根据窗口取值把被乘数变成0、±X、±2X再按权重移位累加。一个16位乘法器如果用普通乘法思路可能生成16个部分积采用radix-4 Booth后只有8个部分积加法树的层数直接少一级这就是性能提升的关键。2. 整体设计思路组合逻辑和流水线怎么选2.1 组合逻辑版延迟优先组合逻辑版本的目标很纯粹输入给到乘法器经过纯组合逻辑直接得到结果。没有时钟、没有寄存器适合用在时钟频率不太高、但对输出延迟要求苛刻的场景。比如以前我在某个数据通路里做一个单周期乘法操作时钟只有50MHz组合路径完全能扛住就直接用组合逻辑版省掉流水线带来的一堆valid对齐逻辑。但组合逻辑版的缺点也很明显Booth编码、部分积移位、符号扩展、加法树压缩这一长串组合路径全部串在一起位宽一大路径延迟飞快上升。在200MHz以上的时钟约束下基本很难收敛。2.2 流水线版用延迟换吞吐率流水线版本的思路是把组合逻辑切成几段每段之间插入寄存器。这样整体延迟增加了几个时钟周期但每一拍都能输入一个乘法运算吞吐率变为每周期一个结果。代价是三个方面的。第一输出有固定的latency我下面这个例子是3拍控制逻辑要接受这个延迟第二寄存器数量明显增加第三如果乘法器用在一个反馈回路上比如累加器流水线延迟会破坏时序关系必须重排算法结构。所以选择哪种方案核心判断标准就是你的系统是“追求单次计算快”还是“追求持续吞吐高”。前者选组合逻辑后者选流水线。2.3 参数化设计思路两种版本我都用参数化写法位宽通过parameter WIDTH配置改一次就能复用。考虑到布斯编码的窗口是3位一组部分积数量等于WIDTH/2WIDTH为偶数时下面代码默认WIDTH16。如果想支持奇数位宽需要根据WIDTH是否奇数来调整部分积个数这里暂时不展开工程上一般会把数据位宽对齐到偶数。3. 组合逻辑Booth乘法器实现3.1 部分积生成与编码逻辑组合逻辑版本的核心代码分成两块第一部分是“Booth编码 部分积生成”第二部分是“加法树压缩”。先看部分积生成。我预设了一个32位的a_ext和a2_ext分别是做符号扩展后的被乘数X和2X。然后针对每个部分积根据乘数b的三位窗口来选择对应值最后左移到对应权重位置。module booth_multiplier_comb #( parameter WIDTH 16 )( input wire [WIDTH-1:0] a, input wire [WIDTH-1:0] b, output wire [2*WIDTH-1:0] product ); localparam PARTIAL_NUM WIDTH / 2; wire signed [2*WIDTH-1:0] a_ext {{WIDTH{a[WIDTH-1]}}, a}; wire signed [2*WIDTH-1:0] a2_ext a_ext 1; reg signed [2*WIDTH-1:0] pp [0:PARTIAL_NUM-1]; integer i; always (*) begin for (i 0; i PARTIAL_NUM; i i 1) begin case ({b[2*i1], b[2*i], (i 0) ? 1b0 : b[2*i-1]}) 3b000, 3b111: pp[i] {2*WIDTH{1b0}}; 3b001, 3b010: pp[i] a_ext (2*i); 3b011: pp[i] a2_ext (2*i); 3b100: pp[i] ~(a2_ext (2*i)) 1; default: pp[i] ~(a_ext (2*i)) 1; endcase end end ... endmodule这里有个细节值得多说一句为什么先做32位符号扩展再移位最后累加因为如果把“部分积的16位结果”先算好再在每个部分积上单独做符号扩展来对齐很容易扩展出错——每一行的符号扩展量不同写起来容易乱仿真时经常出现结果只在特定符号组合下才正确的情况。先扩展到全位宽再统一移位逻辑上更不容易出错。a2_ext用算术左移得到2倍被乘数。注意在Verilog里对有符号数做算术左移低位补0效果和逻辑左移一样但表达“这个数是带符号的”语义更清楚。减2倍和减1倍用“取反加1”实现这是补码减法的标准做法比直接写-运算符更接近硬件结构。3.2 加法树压缩与最终求和得到8个32位部分积之后最直接的做法是assign product pp[0] pp[1] pp[2] pp[3] pp[4] pp[5] pp[6] pp[7];这样写完全没问题综合器会自动做优化。但如果想在工程上明确控制关键路径的层次最好手动搭加法树。加法树的核心思想是“分治加”先两两相加得到4个结果再两两相加得到2个结果最后相加得到最终结果。这样8个数的加法从“串行7级”变成“并行3级”路径延迟显著降低。wire [2*WIDTH-1:0] sum_l1_0 pp[0] pp[1]; wire [2*WIDTH-1:0] sum_l1_1 pp[2] pp[3]; wire [2*WIDTH-1:0] sum_l1_2 pp[4] pp[5]; wire [2*WIDTH-1:0] sum_l1_3 pp[6] pp[7]; wire [2*WIDTH-1:0] sum_l2_0 sum_l1_0 sum_l1_1; wire [2*WIDTH-1:0] sum_l2_1 sum_l1_2 sum_l1_3; assign product sum_l2_0 sum_l2_1; endmodule这三级加法树的位宽全部保持在32位。有同学会问8个32位补码数相加理论上可能需要更多位宽防止溢出为什么32位就够了因为Booth部分积的本质是带符号扩展的补码数高位只是符号位的重复。16位乘16位的结果最多32位有效符号扩展位在累加过程中会自然消掉不会真的进到高位的更高位。只要product的输出位宽是2*WIDTH就不存在截断问题。3.3 组合逻辑版本的时序表现在某个中端FPGA上实测16位组合逻辑版本Booth编码部分积生成的组合逻辑大约占掉4~6级查找表三级加法树又占掉6~8级查找表整体路径长度大概等效12~15级LUT。在默认约束下最高跑到100MHz左右就没法再往上了。这个数据仅供参考换一个封装、温度、电压档位都会变但结构上的瓶颈是确定的组合路径太长。如果这个速度不够用就下面到流水线版本。4. 流水线Booth乘法器实现4.1 流水划分到底插几级寄存器流水线拆分的原则是让每一级的组合逻辑量大致相等并且满足时钟周期约束。16位Booth乘法器我通常切成三级第一级Booth编码 部分积生成第二级前4个部分积、后4个部分积分别两两相加第三级最后的一级加法输出最终结果为什么是三级不是两级或四级两级虽然latency更小但第一级里可能既要生成部分积又要做部分加法组合逻辑量还是偏大四级则寄存器开销偏大对提升频率的边际收益不明显。对于16位这一个规格三级是比较均衡的选择。4.2 valid信号的打拍与对齐流水线版本最容易被忽略的就是数据有效信号的对齐。乘法器同时处理多组输入时输出端必须知道哪一拍的product是“真结果”哪一拍是无效的垃圾值。方法就是让valid_in跟着数据一起打拍数据切到哪一级valid就打一拍到哪一级。如果multipler被集成到一个大系统里上游模块根据valid_out来决定是否接收结果信号对不上会导致“拿到结果以为有效实际是上一拍残留”的严重问题。4.3 流水线版本核心代码module booth_multiplier_pipe #( parameter WIDTH 16 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] a, input wire [WIDTH-1:0] b, input wire valid_in, output reg [2*WIDTH-1:0] product, output reg valid_out ); localparam PARTIAL_NUM WIDTH / 2; wire signed [2*WIDTH-1:0] a_ext {{WIDTH{a[WIDTH-1]}}, a}; wire signed [2*WIDTH-1:0] a2_ext a_ext 1; // stage0: combinational pp generation reg signed [2*WIDTH-1:0] pp_comb [0:PARTIAL_NUM-1]; integer i; always (*) begin for (i 0; i PARTIAL_NUM; i i 1) begin case ({b[2*i1], b[2*i], (i 0) ? 1b0 : b[2*i-1]}) 3b000, 3b111: pp_comb[i] {2*WIDTH{1b0}}; 3b001, 3b010: pp_comb[i] a_ext (2*i); 3b011: pp_comb[i] a2_ext (2*i); 3b100: pp_comb[i] ~(a2_ext (2*i)) 1; default: pp_comb[i] ~(a_ext (2*i)) 1; endcase end end // pipeline registers: stage1 reg signed [2*WIDTH-1:0] pp_r [0:PARTIAL_NUM-1]; reg valid_s1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i PARTIAL_NUM; i i 1) pp_r[i] {2*WIDTH{1b0}}; valid_s1 1b0; end else begin for (i 0; i PARTIAL_NUM; i i 1) pp_r[i] pp_comb[i]; valid_s1 valid_in; end end // stage2: partial sum pairs reg signed [2*WIDTH-1:0] sum_l1_0_r, sum_l1_1_r, sum_l1_2_r, sum_l1_3_r; reg valid_s2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum_l1_0_r {2*WIDTH{1b0}}; sum_l1_1_r {2*WIDTH{1b0}}; sum_l1_2_r {2*WIDTH{1b0}}; sum_l1_3_r {2*WIDTH{1b0}}; valid_s2 1b0; end else begin sum_l1_0_r pp_r[0] pp_r[1]; sum_l1_1_r pp_r[2] pp_r[3]; sum_l1_2_r pp_r[4] pp_r[5]; sum_l1_3_r pp_r[6] pp_r[7]; valid_s2 valid_s1; end end // stage3: final sum reg signed [2*WIDTH-1:0] sum_l2_0_r, sum_l2_1_r; reg valid_s3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum_l2_0_r {2*WIDTH{1b0}}; sum_l2_1_r {2*WIDTH{1b0}}; valid_s3 1b0; end else begin sum_l2_0_r sum_l1_0_r sum_l1_1_r; sum_l2_1_r sum_l1_2_r sum_l1_3_r; valid_s3 valid_s2; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin product {2*WIDTH{1b0}}; valid_out 1b0; end else begin product sum_l2_0_r sum_l2_1_r; valid_out valid_s3; end end endmodule这段代码有几个容易写错的地方我挨个说。第一valid信号必须和product完全同步。看上面代码product是在第三级之后的寄存器输出所以valid_out也必须是在第三级之后打拍得到的valid_s3。如果你顺手写成了valid_s2仿真时大概率前几个有效结果会被漏掉。第二复位时部分积数组要清零。虽然有效数据的valid在复位时不拉高但仿真中的X态很容易传播导致波形难看不方便调试。所有寄存器我都做了同步复位清零。第三部分积数组pp_comb是纯组合逻辑不需要复位它每一拍都会根据当前输入的a、b重新计算。注意pp_r是寄存器而pp_comb是wire类型的组合逻辑数组。如果按上面这个结构前两级实际上是纯组合逻辑计算后立即打拍所以一级级传递下来乘法器的latency是3拍输入a、b在第0拍有效product在第3拍有效。吞吐率则达到每周期1个结果。在同样的FPGA上三级流水版本可以跑到250MHz以上代价是寄存器数量明显增多逻辑资源中的FF占用很高。5. 仿真验证与常见问题排查5.1 testbench写法的几个关键点写testbench最核心的目标就一句话自动比对而不是肉眼比对尤其要覆盖边界条件和随机向量。对于乘法器我习惯这样组织测试用例边界值16sh8000最小负数、16sh7fff最大正数、0、-1、1特殊组合乘数和被乘数都为负数、一正一负、正正、负负随机值用$random生成大量随机数比对的基准用系统自带的$signed乘expected $signed(a) * $signed(b); if (product ! expected) begin $error(mismatch: a%h b%h got%h exp%h, a, b, product, expected); end流水线版本的testbench有个坑product不是组合输出而是延迟3拍后的结果。测试时不能输入a、b后立刻比对而要等valid_out拉高后再比对对应的product。简单做法是记录下三个时钟周期前的a、b值在valid_out为高时用那组预期值比对当前product。这也是验证流水线模块非常标准的写法。5.2 ModelSim/Vivado快速仿真流程命令行仿真用ModelSim/Questa最核心的几步也就是vlib work vlog booth_multiplier_comb.v booth_multiplier_pipe.v tb_booth_multiplier.v vsim -voptargsacc work.tb_booth_multiplier run -all在Vivado里则更简单新建工程把RTL文件和testbench文件都加进去在Flow Navigator里点击Run Simulation - Run Behavioral Simulation然后看波形。如果用了$error仿真窗口的Tcl Console会有红色报错信息哪里不匹配一目了然。5.3 我踩过的几个典型坑第一个坑符号扩展写成了零扩展。这是Booth乘法器最经典的问题。部分积里出现了-X如果只是简单地把16位取反加1后当作正数做高位补零累加结果就会出错。解决方法是像我代码里那样先把被乘数整体做符号扩展再进入后续运算不要在任何一步使用{16b0, a}这种零扩展写法。第二个坑Booth编码窗口取位错误。radix-4编码需要看乘数的b[2*i1]、b[2*i]、b[2*i-1]三位最容易漏的是i0时b[-1]应该补0而不是用b[0]重复。这种错误仿真时会出现“奇数组合都在错、偶数组合都对”的诡异现象排查起来很费神。第三个坑流水线valid信号错位。我前期调试时有过一次结果是“每3拍出一次正确结果其余拍垃圾”后来发现就是valid打拍深度比数据少一级。调试这类问题最直接的方法是画个时间轴在波形里同时拉出valid_in、valid_s1、valid_s2、valid_out和product一看就能定位是哪一级对不上。6. 两种实现的对比结果与后续扩展最后给一份我在实际工程里记录下来的对比数据基于某一款中端FPGA16位乘法资源利用大致情况比较项组合逻辑版3级流水线版输出延迟纯组合约10~15ns关键路径3个时钟周期latency吞吐率每周期1个但受限于频率每周期1个频率明显更高最高频率实测参考约100MHz约250MHz逻辑资源LUT多FF少LUT相近FF明显增加典型场景低频、单拍出结果高速数据流、FIR滤波两种版本没有绝对的优劣只有合不合适。数据通路吞吐要求高选流水线控制通路要求单拍出结果且频率不高选组合逻辑。这个选择题做多了你对“面积换速度”这句话的理解会比只看书深刻得多。如果你还想在这个基础上继续扩展几个方向供参考。一个是从radix-4升级到radix-8部分积个数进一步减少但单个部分积的生成逻辑更复杂需要3X、-3X需要先用一个额外的加法器算3倍被乘数是否划算取决于位宽和工艺约束。另一个是改用Wallace树做部分积压缩替代我这里的普通加法树在部分积数量多时压缩效率更高。再一个就是把乘法器真正用起来比如把流水线版接到FIR滤波器或者FFT蝶形运算里用valid_in/valid_out做数据流握手这时候你就会发现前面花在valid对齐上的功夫完全不白费。最后再分享一个个人习惯。写RTL乘法器时我总会在文件顶部注释里写清楚三件事输入数据是补码还是原码、位宽多少、输出有几个周期的流水延迟。以前吃过亏一个模块只用了个把月再翻出来重构时已经忘了latency是几拍结果连到系统里数据全错。注释这个东西关键时刻真的能救命。本文还有配套的精品资源点击获取
返回列表