ARTICLE DETAIL

资讯详情

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

Verilog手写32位除法器IP:RTL实现与仿真验证

Verilog手写32位除法器IP:RTL实现与仿真验证 前阵子在项目里需要做一组32位除法运算我最初图省事直接写了a / b结果综合时发现工具把除法器优化得乱七八糟时序直接崩了。后来规规矩矩用Verilog手搓了一个除法器IP支持32位无符号和带符号两种模式才算把这块硬骨头啃下来。这篇帖子就把整个实现过程完整梳理一遍从算法选型、RTL代码到仿真验证、性能优化再到我在实际项目中踩过的坑。无论你是刚学Verilog的学生还是在FPGA上做信号处理、通信协议栈的工程师这篇文章都适合你。如果你正准备在项目里使用除法器或者正在纠结是直接用/还是自己写IP这篇内容可以帮你少走不少弯路。1. 整体设计思路为什么不用/除法器IP该怎么选1.1 直接用/的问题先把话说清楚在RTL里直接写a / b很多综合工具能自动推断出除法器但实际用起来有几个麻烦。综合工具生成的除法器大概率是组合逻辑实现的面积大、路径长时序收敛困难。除法器的位宽、时钟约束、流水级数这些参数工具未必能按你的需求自动优化。我见过一个项目里工具推断出来的除法器用了好几百个LUT还占用了大量DSP结果关键路径时序违例严重。仿真和综合行为可能存在差异。某些工具里/在仿真时是行为级模型综合后却是另一套结构前后仿真不一致排查问题很痛苦。所以在对时序和资源有明确要求的场景自己写一个可控的除法器IP是更稳的方案。自己写的好处是你可以精确控制迭代周期数、资源占用和流水线结构还能按需加入异常处理比如除零标志。1.2 除法器的主流实现方案对比除法器的实现方案有好几种我简单列一下它们的优劣方便你对号入座。方案基本思路优点缺点适用场景恢复余数法逐位比较余数与除数不够减则回退原理简单控制逻辑少周期数多每bit可能回退一次通用场景最稳妥不恢复余数法余数为负时下次加除数而非减周期固定最多N个周期完成N位除法符号处理稍复杂位宽较大时的标准方案SRT算法基于冗余数系统每次产生多位商速度快常做高基实现复杂高性能CPU浮点单元查找表法将小位宽除法结果存成表极快一次性输出只适合小位宽除数和被除数都很小的场景级数展开法用乘法和牛顿迭代逼近商的倒数适合高速流水线需要额外乘法器精度控制复杂DSP计算密集场景我这次选择的是不恢复余数法加减交替法。它的周期数是固定的方便做流水线和状态机控制也不需要像恢复余数法那样做额外的回退判断硬件实现上更干净。再说一个小点除法器IP的接口设计也很重要。我习惯把start、done、busy这类握手信号都做出来方便挂在总线上也方便嵌入到更大的状态机里。这种接口风格在任何项目里都通用复用性很高。2. 无符号32位除法器的RTL实现2.1 算法核心加减交替法的原理这可能是很多人卡住的地方。我先用人话把这个算法的原理讲透。手工做十进制除法的时候你是从最高位开始试商的先看被除数的前几位够不够除够就上商不够就补一位继续看。二进制除法本质一样只不过每一位商非0即1判断条件更简单。恢复余数法的思路就是最原始的“试商”每左移一位进来先用余数减一次除数如果结果为正说明够减商1如果为负说明不够减得把除数加回去恢复余数商0。这个“加回去”的操作既多花一个周期又多一套逻辑。不恢复余数法的改进在于余数为负时不恢复余数而是记下这个负余数下次左移后直接做加法加上除数来“补偿”。这样做每步只需要一次加法或减法操作周期固定逻辑也简单。具体流程这样理解初始余数 0。将被除数逐位移入余数寄存器的高端实际上是余数左移一位空出的低位填被除数的下一位。每个周期做一次加法或减法判断结果符号若余数非负商位为1下一步做减法若余数为负商位为0下一步做加法。循环N次N位宽所有位处理完后需要根据最后一次余数的符号做一次修正。最后的余数可能还需要加一次除数才能恢复为正。这里有个容易忽略的细节被除数最高位左移进余数寄存器时余数可能超过N位。所以余数寄存器通常要比位宽多出1到2位防止溢出。我在代码里直接用了WIDTH1位。2.2 RTL代码状态机驱动的无符号除法器下面这段代码就是我实际用在项目里的版本做了精简但保留了核心逻辑方便你直接理解并移植。module div_unsigned #( parameter WIDTH 32 )( input wire clk, input wire rst_n, input wire start, // 启动除法 input wire [WIDTH-1:0] dividend, // 被除数 input wire [WIDTH-1:0] divisor, // 除数 output reg [WIDTH-1:0] quotient, // 商 output reg [WIDTH-1:0] remainder, // 余数 output reg done, // 除法完成 output reg busy // 忙标志 ); localparam IDLE 2d0; localparam COMPUTE 2d1; localparam FINISH 2d2; reg [1:0] state; reg [$clog2(WIDTH)-1:0] cnt; // 迭代计数器 reg [WIDTH:0] r; // 余数寄存器多1位防溢出 reg [WIDTH-1:0] d; // 除数缓存 reg [WIDTH-1:0] q; // 商寄存器 reg [WIDTH-1:0] dividend_tmp; wire [WIDTH:0] sub_out r - {1b0, d}; wire [WIDTH:0] add_out r {1b0, d}; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; busy 1b0; done 1b0; cnt 0; r 0; q 0; quotient 0; remainder 0; end else begin case (state) IDLE: begin done 1b0; if (start) begin busy 1b1; d divisor; dividend_tmp dividend; r 0; q 0; cnt 0; state COMPUTE; end end COMPUTE: begin // 左移余数左移一位被除数最高位移入余数最低位 r {r[WIDTH-1:0], dividend_tmp[WIDTH-1]}; dividend_tmp {dividend_tmp[WIDTH-2:0], 1b0}; // 加减交替 if (r[WIDTH] 1b0) begin // 上一步余数非负减除数 r sub_out; q {q[WIDTH-2:0], ~sub_out[WIDTH]}; end else begin // 上一步余数为负加除数 r add_out; q {q[WIDTH-2:0], ~add_out[WIDTH]}; end if (cnt WIDTH-1) begin state FINISH; end else begin cnt cnt 1b1; end end FINISH: begin // 最后一步修正 if (r[WIDTH] 1b1) begin r r {1b0, d}; end quotient q; remainder r[WIDTH-1:0]; busy 1b0; done 1b1; state IDLE; end default: state IDLE; endcase end end endmodule说几个关键点余数寄存器r位宽是WIDTH1。实际上在左移和加减交替的过程中高位可能临时变成1所以用WIDTH1位保障不会截断。商寄存器q的移入逻辑是当加减结果最高位为0非负时当前商位为1最高位为1负数时当前商位为0。代码里用~sub_out[WIDTH]和~add_out[WIDTH]来实现这个判断。状态机只有三个状态逻辑很直接。IDLE等待startCOMPUTE做32轮迭代FINISH做最后一次余数修正并输出结果。2.3 时序行为分析无符号除法器的时序是这样的第1个周期IDLE状态下接收start采样被除数和除数进入COMPUTE。第2到第33个周期COMPUTE状态循环32次每次处理一位。第34个周期FINISH状态完成余数修正拉高done回到IDLE。所以一次除法从start到done一共要34个时钟周期。对于大多数总线接口和计算任务来说这个延迟完全够用。如果时钟频率是100MHz一次除法大约340ns对于慢速外设接口如I2C、UART绰绰有余对于高速数据通路可能需要考虑流水线优化这个放到后面专门说。3. 带符号除法器扩展3.1 符号处理的经典套路带符号除法本质上比无符号多一层符号处理。常规做法是三步走先把输入的被除数和除数都转成绝对值。调用无符号除法核心算出绝对值形式的商和余数。再根据输入符号确定最终的商和余数符号。这里面有个细节非常容易踩坑32位补码可表示的最小负数是-2147483648它的绝对值是2147483648已经超出了32位无符号数的表达范围0到4294967295。所以取绝对值时不能直接对补码取反加一得先扩展到33位再做处理。另外商的舍入规则也要提前定好。标准整数除法中商向零取整。比如-7 / 2数学结果是-3.5向零取整就是-3余数则是-1。余数符号和被除数保持一致。这些规则如果不预先定死仿真阶段不同模块之间的理解一不一致后面联调时会非常痛苦。3.2 完整RTL实现我实现带符号除法器时直接复用无符号核心符号处理逻辑放在外层。下面这段代码展示了核心思路重点是符号寄存器和绝对值转换部分。module div_signed #( parameter WIDTH 32 )( input wire clk, input wire rst_n, input wire start, input wire signed [WIDTH-1:0] dividend, // 有符号被除数 input wire signed [WIDTH-1:0] divisor, // 有符号除数 output reg signed [WIDTH-1:0] quotient, // 有符号商 output reg signed [WIDTH-1:0] remainder, // 有符号余数 output reg done, output reg busy ); // 符号标志 reg sign_q; // 商的符号被除数符号 ^ 除数符号 reg sign_r; // 余数符号跟随被除数 reg [WIDTH:0] abs_dividend; // 绝对被除数33位防溢出 reg [WIDTH:0] abs_divisor; // 绝对除数33位防溢出 reg [WIDTH-1:0] q_tmp; reg [WIDTH-1:0] r_tmp; wire [WIDTH-1:0] abs_div_out; wire [WIDTH-1:0] abs_rem_out; wire abs_done; wire abs_busy; div_unsigned #(.WIDTH(WIDTH)) u_div_unsigned ( .clk (clk), .rst_n (rst_n), .start (start), .dividend (abs_dividend[WIDTH-1:0]), .divisor (abs_divisor[WIDTH-1:0]), .quotient (abs_div_out), .remainder (abs_rem_out), .done (abs_done), .busy (abs_busy) ); // 取绝对值注意最小负数溢出问题 always (*) begin abs_dividend dividend[WIDTH-1] ? (~dividend 33d1) : {1b0, dividend}; abs_divisor divisor[WIDTH-1] ? (~divisor 33d1) : {1b0, divisor}; end // 符号计算 always (*) begin sign_q dividend[WIDTH-1] ^ divisor[WIDTH-1]; sign_r dividend[WIDTH-1]; end always (posedge clk or negedge rst_n) begin if (!rst_n) begin quotient 0; remainder 0; done 0; busy 0; end else if (abs_done) begin // 根据符号修正输出 quotient sign_q ? (~abs_div_out 1b1) : abs_div_out; remainder sign_r ? (~abs_rem_out 1b1) : abs_rem_out; done 1b1; busy 1b0; end else if (start) begin busy 1b1; done 1b0; end end endmodule注意看这里的几个逻辑abs_dividend和abs_divisor声明成WIDTH1位33位这样最小负数取绝对值时才不会溢出。-2^31的绝对值2^31可以安全放进33位无符号数里。除法核心输出的abs_div_out和abs_rem_out直接取了低32位因为绝对值除法完成后商的绝对值结果一定在32位范围内除非发生-2147483648 / -1这种溢出情况这个后面讲。符号修正用补码取反加一即负数补码转正数。这句话的意思是如果商符号为负就对无符号结果取反加一得到负数的补码表示。余数符号直接跟随被除数符号这是整数除法里最常用的约定。3.3 边界情况最小负数除以-1上面代码里提到一个经典坑-2147483648 / -1在数学上等于2147483648但32位有符号数最大只能表示2147483647必然溢出。实际处理中我一般会在模块里加一个溢出标志或者在使用侧保证不会出现这种情况。如果确实要处理可以在状态机里提前判断// 溢出检测伪代码 if (dividend 32h8000_0000 divisor 32hFFFF_FFFF) begin // 设置overflow标志商饱和到最大值 0x7FFF_FFFF end这类特殊值处理看起来简单但在实际项目中很容易漏掉。如果你做的是芯片验证或者协议栈务必把边界测试向量加进去。4. 仿真验证与性能优化4.1 测试向量设计我写数字逻辑有个习惯先做定向测试再做随机测试。定向测试覆盖边界值和典型值随机测试覆盖中间各种情况。定向测试向量至少包含这些场景测试类型输入示例期望结果除以1a任意, b1商a, 余数0除以自身ab商1, 余数0除以0a任意, b0需要明确处理建议置标志位最大值/最小值a0xFFFF_FFFF, b2商0x7FFF_FFFF, 余数1最小负数除以-1a0x8000_0000, b0xFFFF_FFFF溢出饱和或置标志典型负数a-7, b2商-3(舍入到0), 余数-1大数除以小数a0x7FFF_FFFF, b1商0x7FFF_FFFF, 余数0随机测试我习惯用Python脚本生成大量的(a, b)向量然后和参考模型对比。参考模型直接用Python的//和%但要注意Python的//是向下取整不是向零取整所以对比时要自己做修正def ref_div(a, b): if b 0: return None, None q int(a / b) # 向零取整 r a - q * b return q, r把生成的向量直接写进测试文件在Testbench里读取并比对比对不通过就报错。用这套流程跑完几万个随机向量模块的正确性基本就有保障了。4.2 基于随机约束的Testbench示例下面是一段简单的SystemVerilog约束随机测试代码我在Vivado和QuestaSim里都能跑通module tb_div_signed; logic clk 0; logic rst_n 0; logic start 0; logic signed [31:0] dividend; logic signed [31:0] divisor; logic signed [31:0] quotient; logic signed [31:0] remainder; logic done; logic busy; div_signed #(.WIDTH(32)) dut ( .clk(clk), .rst_n(rst_n), .start(start), .dividend(dividend), .divisor(divisor), .quotient(quotient), .remainder(remainder), .done(done), .busy(busy) ); always #5 clk ~clk; initial begin repeat(2) (posedge clk); rst_n 1; repeat (10000) begin (posedge clk); dividend $urandom(); divisor $urandom(); if (divisor 0) divisor 1; start 1; (posedge clk); start 0; wait (done 1); // 在这里做结果比对 end $finish; end endmodule这个Testbench的优势在于它完全随机能覆盖到人工想不到的边界值。实际使用中我还会加入受约束的随机比如让divisor落在某个范围区间或者让被除数随机但排除一些特殊情况目的是让测试分布更符合真实场景。4.3 性能优化流水线和多比特处理如果你觉得34个周期太慢有两条优化路线。第一条路线是流水线化。把状态机拆成多级流水线每一级只处理固定的一位这样可以在每个时钟周期接收新的除法请求吞吐率提升到每个周期一次除法代价是延迟仍然是32个周期但资源会增加加法器需要32个。第二条路线是多比特迭代。典型的做法是radix-4或radix-8每个周期处理2到3位商周期数下降但没有流水线那么极端。这种实现复杂一些因为每位需要做3倍或7倍的除数比较硬件的多路选择逻辑会更重。我建议先评估项目实际需求。如果是通信协议里的偶发计算34个周期完全够用没必要做radix-4但如果是GPU代码里的批量顶点变换流水线化就非常有必要了。4.4 时序收敛的几个小建议自研除法器在综合时很容易遇到时序问题尤其是位宽较大或时钟频率较高时。给几个实用建议在流水线级的每个计算阶段之间插寄存器避免组合逻辑过长。对除数和被除数先寄存一拍不要在IDLE状态直接做运算。如果目标器件有DSP或专用乘法器可以考虑用乘法器辅助实现除法的近似方案牛顿迭代但精度需要额外校准。综合时把除法器约束成独立模块用dont_touch或preserve属性保护其结构避免工具过度优化影响整体时序。5. 常见问题与避坑经验5.1 仿真与综合不一致这是最让人头疼的问题。仿真里除法器算得慢一点还能接受但如果前仿真正确、后仿真错误多半出在综合工具对运算符的处理上。如果你写的代码里既有/又有自研状态机工具可能把/优化成ROM查找表或多周期路径导致时序行为改变。我的建议是自研除法器模块里禁止使用/运算全部用加减和移位实现。这样工具就没法“自作主张”了。5.2 余数符号搞反带符号除法最常见的错误就是余数符号。很多人在做带符号运算时习惯让商为正余数也为正但整数除法的标准约定是商向零取整余数与被除数同号。如果你的模块给别的模块用一定要在接口注释里写明这个约定否则联调时两组人理解不一致排查起来非常费时间。5.3 除零的处理除零是任何除法器都必须提前想清楚的问题。自研除法器的情况除数为0时减法会一直得到正数最后结果是商全部为1余数等于被除数这显然不对。常规做法是加一个除零标志让上层模块提前判断。在Testbench里也务必加上除零测试确认模块不会挂死。5.4 最小负数取绝对值溢出这个细节特别容易被新手忽略。求绝对值时如果直接对输入的补码取反加一-2147483648还是-2147483648因为32位范围放不下2147483648。我在3.2节的代码里扩展成33位就是为了解决这个问题。这一条做芯片验证和算法实现的朋友一定要重视。5.5 综合资源评估自研除法器的资源开销主要在一个加减法器上如果只是做32位一个32位加减法器加上少量控制逻辑在FPGA上也就几十个LUT加一些触发器。相比工具自动生成的除法器我实测资源能少一半左右时序也更稳定。但如果你需要高吞吐率流水线化之后资源会明显上升属于用面积换速度。5.6 状态机卡死的排查技巧如果仿真时发现done信号迟迟不拉高多半是状态机卡在COMPUTE状态。常见原因有两个一是cnt没有正确递增二是start信号在TIMING上恰好和状态转换冲突。排查时先在Waveform里看cnt信号是否计数到31再把rst_n的复位时序拉长一点通常都能找到问题。结尾一点实际体会回头再看这个除法器IP我觉得它最大的价值不在于“能算除法”而在于它把算法原理、状态机设计、时序控制、边界处理这些数字IC基本功全部串了起来。做这个模块的过程中我对补码的理解、对状态机的把握、对验证流程的熟练度都有了实打实的提升。后面我接手其他模块开发碰到类似的状态机设计、总线握手、边界处理问题时明显更有底气。最后再分享一个实操技巧自研除法器建议保留最原始的RTL版本不要随手“优化”成别人看不懂的形态。项目迭代中我无数次需要回看老版本代码确认某个边界行为是怎么处理的有干净的版本可查会省下大量时间。如果你正在做类似模块也建议顺手把测试向量和参考脚本一并归档方便后续回归。
返回列表