
搞FPGA或者数字IC的同行应该都遇到过这种场景需要做除法但又觉得直接调IP核心里没底。IP核的配置界面虽然省事可位宽怎么配、延迟多少拍、资源占用多少全被封装在黑盒里遇到定制需求反而束手束脚。我去年做一个通信模块时就需要一个参数可配、时序完全可控的整数除法器折腾了一圈最后还是决定自己用Verilog写一个基于不恢复余数法把RTL和仿真完整过了一遍实测稳定。这篇就把从原理到代码再到仿真验证的过程整理出来顺便聊聊踩过的坑。如果你正在学数字电路、准备面试、或者项目中确实需要一个不依赖厂商IP的除法器这篇文章应该能帮你省不少时间。1. 除法器方案选型为什么不用IP核为什么选不恢复余数法1.1 常见除法实现方式对比除法是四则运算里最麻烦的一个它无法像加法乘法那样直接通过组合逻辑或少量流水级完成。工程上常见的实现方案大致有五类恢复余数法、不恢复余数法加减交替法、基-4 SRT算法、基于乘法的牛顿迭代法以及直接调厂商IP核。方案硬件开销完成延迟参数化难度典型场景恢复余数法小加减法器加状态机N个周期容易教学、低速控制不恢复余数法小加减法器加状态机N个周期容易通用FPGA逻辑、MCU协处理器基-4 SRT算法中需要编码表和冗余表示N/2个周期中等复杂中高速算术单元牛顿迭代法大需要硬件乘法器几到几十个周期较复杂CPU/GPU浮点除法厂商IP核由工具自动生成可配置受IP配置限制项目快速集成如果你只是想把功能跑起来IP核当然是最快的。但IP核有几个让人头疼的地方第一位宽组合可选的有限某些非标准位宽需要向上取整第二IP核的时序模型是黑盒的如果你想在某个特定时钟节拍拿到结果还得花时间读文档第三有些FPGA厂商的低端型号或者ASIC流程里IP核的可用性受限。自己写除法器可以把时序、位宽、握手协议完全握在手里这是最大的吸引力。1.2 不恢复余数法的工程价值在五类方案里恢复余数法概念上最简单但每个周期都要根据减法结果决定是否加回除数控制逻辑里多一个“恢复”分支状态机稍微复杂一点而且商位的计算和恢复操作之间存在组合路径。不恢复余数法省掉了恢复步骤用一个统一的“正余数减除数、负余数加除数”规则替代控制逻辑非常规整面积更小时序路径也更干净。我最终选不恢复余数法还有一个理由它的迭代次数恰好等于数据位宽N也就是说8位除法8个周期出结果16位除法16个周期出结果。这个特性对做时序规划非常友好你不需要猜测延迟结果就在第N1个周期稳定输出。SRT算法虽然周期更少但它的查表编码和冗余数字处理会让代码复杂度上一个台阶对大多数应用来说性价比不高。2. 不恢复余数法原理拆解从竖式除法到加减交替2.1 手工竖式除法的启发回想一下小学做除法竖式的过程从被除数的最高位开始逐位移下来试着用当前余数减去除数够减就上商1不够减就上商0然后继续下一位。硬件实现“恢复余数法”就是直接模仿这个过程——每步都减一次除数如果减完发现余数变成负数不够减就加回除数商位记0。不恢复余数法巧妙的地方在于它发现“减过头”之后不急着恢复而是把这个负余数保留下来在下一次迭代里用“加上除数”来修正。为什么可以这样因为每一步迭代都要把余数左移一位相当于乘以2。如果上一步产生了负余数R-B恢复余数法会把它加回成R下一步算2R-B而不恢复余数法保留R-B下一步算2(R-B)B展开后正好等于2R-B。数学上两者完全等价但少了一次加回操作控制逻辑简化了一大截。2.2 算法规则与商位判断整个算法的迭代规则可以浓缩成一句话如果当前部分余数非负下一步左移后减去除数如果当前部分余数为负下一步左移后加上除数。每次迭代做完加减后根据新余数的符号决定商位新余数非负商位记1新余数为负商位记0。注意这里的“当前部分余数”指的是上一次迭代的符号而不是本次加减之后的符号这个先后顺序非常容易写错后面调试部分会专门讲。算法执行完N次迭代后如果部分余数还是负数需要最后做一次“恢复”把除数加回去得到真正的余数。这一步容易被忽略一旦漏掉余数就会带符号输出结果对不上。2.3 一个手算实例25除以7拿8位无符号数25除以7来走一遍流程。被除数A258b00011001除数B78b00000111商期望是3余数期望是4。迭代左移后的部分余数操作新部分余数符号判断商位10减7-7负02-14加7-7负03-14加7-7负04-13加7-6负05-11加7-4负06-8加7-1负07-2加75正1811减74正1把每次迭代产生的商位从高位到低位排列得到二进制00000011正好是3。最后部分余数是4不需要恢复就是最终余数。这个例子也解释了为什么叫“加减交替法”正的时候减负的时候加符号交替变化逐渐把余数收敛到[0, B-1]范围。硬件上这就是一个带符号的加减法器加上两个移位寄存器的事。3. RTL设计可综合的Verilog实现3.1 模块接口与信号定义我设计的除法器模块支持参数化位宽默认8位接口采用常见的start/busy/done握手方式。核心信号包括部分余数寄存器R位宽WIDTH1最高位兼任符号位、商寄存器Q初始保存被除数迭代过程中逐位移出被除数并填入商位、除数寄存器divisor_reg和迭代计数器cnt。module div_unsigned #( parameter WIDTH 8 )( 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 busy, output reg done );为什么要用WIDTH1位存R因为迭代过程中余数会变成负数需要保存符号信息。最高位R[WIDTH]为1表示当前部分余数为负为0表示非负。这个符号位也直接控制了MUX选择加法还是减法。3.2 状态机设计状态机只需要三个状态IDLE、COMP、FIN。IDLE等待start信号把除数复制到内部寄存器COMP执行WIDTH次迭代FIN锁存商和余数拉高done脉冲后回到IDLE。localparam IDLE 2d0; localparam COMP 2d1; localparam FIN 2d2; reg [1:0] state; reg [WIDTH-1:0] cnt; reg [WIDTH:0] R; reg [WIDTH-1:0] Q; reg [WIDTH-1:0] divisor_reg;迭代次数计数器cnt从0开始每个COMP周期加1。比较隐蔽的点是在cnt等于WIDTH-1的那个周期还要正常执行最后一次迭代、正常更新R和Q只是状态在下一个时钟沿跳到FIN。如果把跳转条件放在迭代更新之后才判断会少算一次迭代商完全错乱。3.3 核心迭代逻辑移位、加减、填商位每次迭代的组合逻辑可以拆成三步。第一步把R和Q拼接后左移一位R最低位移入Q的最高位也就是把被除数当前最高位送入余数参与运算第二步根据旧R的符号选择加还是减除数第三步根据新R的符号确定商位填入Q的最低位。wire [WIDTH:0] shifted_R; wire [WIDTH-1:0] shifted_Q; wire [WIDTH:0] updated_R; wire [WIDTH-1:0] updated_Q; assign shifted_R {R[WIDTH-1:0], Q[WIDTH-1]}; assign shifted_Q {Q[WIDTH-2:0], 1b0}; assign updated_R (R[WIDTH] 1b1) ? (shifted_R {1b0, divisor_reg}) : (shifted_R - {1b0, divisor_reg}); assign updated_Q {shifted_Q[WIDTH-1:1], ~updated_R[WIDTH]};这里的减法是直接加补码实现的加法器位宽WIDTH1位。注意updated_Q末尾的商位用的是~updated_R[WIDTH]新余数符号位为0正时商位是1符号位为1负时商位是0取反正好。再强调一下为什么用旧R的符号而不是新R的符号来做加减选择。不恢复余数法的规则是“正余数减除数、负余数加除数”这个“正负”指的是参与本次迭代之前的余数符号。代码里R[WIDTH]在非阻塞赋值更新前仍是旧值所以组合逻辑读取到的是正确的旧符号。如果你在同一个always块里先把R左移再判断符号就会拿到移位后的错误符号得到完全错误的结果。3.4 完整RTL代码module div_unsigned #( parameter WIDTH 8 )( 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 busy, output reg done ); localparam IDLE 2d0; localparam COMP 2d1; localparam FIN 2d2; reg [1:0] state; reg [WIDTH-1:0] cnt; reg [WIDTH:0] R; reg [WIDTH-1:0] Q; reg [WIDTH-1:0] divisor_reg; wire [WIDTH:0] shifted_R {R[WIDTH-1:0], Q[WIDTH-1]}; wire [WIDTH-1:0] shifted_Q {Q[WIDTH-2:0], 1b0}; wire [WIDTH:0] updated_R; wire [WIDTH-1:0] updated_Q; assign updated_R (R[WIDTH] 1b1) ? (shifted_R {1b0, divisor_reg}) : (shifted_R - {1b0, divisor_reg}); assign updated_Q {shifted_Q[WIDTH-1:1], ~updated_R[WIDTH]}; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cnt 0; R 0; Q 0; divisor_reg 0; busy 1b0; done 1b0; quotient 0; remainder 0; end else begin case (state) IDLE: begin done 1b0; if (start) begin state COMP; busy 1b1; cnt 0; R 0; Q dividend; divisor_reg divisor; end end COMP: begin if (cnt WIDTH - 1) begin state FIN; end cnt cnt 1b1; R updated_R; Q updated_Q; end FIN: begin state IDLE; busy 1b0; done 1b1; quotient Q; if (R[WIDTH] 1b1) remainder R {1b0, divisor_reg}; else remainder R; end default: state IDLE; endcase end end endmodule一个容易被忽略的细节在FIN状态如果R最高位为1说明部分余数为负必须加一次除数恢复成正余数再输出。如果R已经是正的直接输出。恢复操作在同步逻辑里用非阻塞赋值完成因为这时R已经稳定为最终迭代结果不会影响COMP阶段的时序。3.5 关键参数与位宽取舍除法器的迭代周期等于WIDTH。8位除法从start拉高到done拉高需要WIDTH1个时钟周期WIDTH个COMP周期加上1个FIN周期。如果你想压缩延迟可以在FIN状态用组合逻辑提前输出商和余数不过要注意这样会让done与结果之间存在亚稳态风险建议还是用同步锁存。R寄存器为什么是WIDTH1而不是WIDTH2直觉上部分余数可能因为加减法产生进位。实际上不恢复余数法有个性质部分余数的绝对值始终小于2倍的除数而除数本身不超过WIDTH位所以WIDTH1位足够容纳带符号的部分余数最高位正好作为符号位。这个性质保证了算法不会溢出。如果你把R扩宽也不会出错只是浪费寄存器但如果你缩窄到WIDTH位符号位被截掉结果必然不对。4. Testbench设计与仿真验证4.1 测试平台结构Testbench的思路很简单例化被测模块用任务封装一次完整的除法操作。任务内部先在时钟下降沿给出被除数和除数拉高start一拍然后等待done信号到来最后打印商和余数。module tb_div_unsigned; parameter WIDTH 8; reg clk; reg rst_n; reg start; reg [WIDTH-1:0] dividend; reg [WIDTH-1:0] divisor; wire [WIDTH-1:0] quotient; wire [WIDTH-1:0] remainder; wire busy; wire done; div_unsigned #(.WIDTH(WIDTH)) dut ( .clk (clk), .rst_n (rst_n), .start (start), .dividend (dividend), .divisor (divisor), .quotient (quotient), .remainder (remainder), .busy (busy), .done (done) ); always #5 clk ~clk; task run_test; input [WIDTH-1:0] a; input [WIDTH-1:0] b; begin (negedge clk); dividend a; divisor b; start 1b1; (negedge clk); start 1b0; wait (done 1b1); $display(a%0d b%0d q%0d r%0d, a, b, quotient, remainder); (negedge clk); end endtask initial begin clk 0; rst_n 0; start 0; dividend 0; divisor 0; #20 rst_n 1; run_test(25, 7); run_test(255, 1); run_test(5, 10); run_test(0, 7); run_test(100, 100); #50; $finish; end endmodule注意任务里的wait (done 1b1)必须在start拉低之后。如果start一直保持高电平IDLE状态会在done出现的同一拍又被start触发进入COMP造成连续除法虽然这不是错误但会让你在观察波形时分不清边界。4.2 边界用例与随机验证固定用例要覆盖几类边界情况被除数是0、被除数小于除数、整除的情况、最大数除以1。这些用例能快速暴露算法在极限条件下的问题。比如5除以10商应该是0余数应该是5如果输出余数是负数说明最后一步恢复逻辑有bug255除以1商应该是255余数是0如果Q的最高位填错了商位就会出现异常。固定用例通过之后建议做一轮随机测试。随机测试要避免除数为0因为硬件除法遇到除数为0结果无定义。随机用例可以用Verilog自带的$random生成同时用/和%运算符计算期望值自动比对。integer i; reg [WIDTH-1:0] a, b; reg [WIDTH-1:0] exp_q, exp_r; initial begin for (i 0; i 100; i i 1) begin a $random % 256; b ($random % 255) 1; run_test(a, b); exp_q a / b; exp_r a % b; if ((quotient ! exp_q) || (remainder ! exp_r)) $display(MISMATCH: a%0d b%0d got q%0d r%0d exp q%0d r%0d, a, b, quotient, remainder, exp_q, exp_r); end end在testbench里用/和%没问题因为这些运算符只是仿真参考模型不会被综合。这种“参考模型比对”的验证思路在数字芯片验证里叫对拍是除法器验证最有效的手段。4.3 仿真结果解读用开源的Icarus Verilog跑一遍命令行编译iverilog -o tb_div_unsigned.vvp tb_div_unsigned.v div_unsigned.v vvp tb_div_unsigned.vvp我实测的输出结果如下a25 b7 q3 r4 a255 b1 q255 r0 a5 b10 q0 r5 a0 b7 q0 r0 a100 b100 q1 r0从波形上看start拉高后的第一个时钟沿进入COMPR和Q开始变化8个时钟周期后状态机进入FIN在这一拍done拉高quotient和remainder同时有效。这里建议把done和quotient一起在波形里观察确认结果是在done拉高的同一拍有效而不是下一拍才稳定。想查看波形就在编译时生成VCD文件initial begin $dumpfile(tb_div_unsigned.vcd); $dumpvars(0, tb_div_unsigned); end然后用GTKWave打开VCD重点观察R[WIDTH]这个符号位的变化规律它会在0和1之间交替跳动最终收敛到0。这正是不恢复余数法“加减交替”的直观体现。4.4 时序验证的额外建议如果你在真实的FPGA工程里使用这个模块建议在COMP状态用综合工具看关键路径。8位设计的关键路径通常是从R[WIDTH]经过MUX选择到加法器再到更新寄存器的路径在一般FPGA上跑到100MHz以上没问题。如果位宽扩到32位这条加法器链会变长可能需要把加法器拆成两级或者把WIDTH次迭代改成流水线结构用面积换吞吐率。5. 常见问题与调试技巧实录5.1 商位错乱移位和填位的顺序搞反我调试时遇到最多的问题就是商不对。原因是把“先左移再填商位”写成了“先填商位再左移”。在Verilog里非阻塞赋值右端的表达式都在同一个时间步内计算所以{shifted_Q[WIDTH-1:1], ~updated_R[WIDTH]}的写法是先基于旧Q算出左移结果再用当前周期的新余数符号位覆盖最低位。如果你用阻塞赋值或者分成两个always写很容易把商位填到错误的位置。检查方法很简单在testbench里手算前两拍的结果用$display打印每拍的R和Q值和自己脑中的推演比对。商位从高位到低位排列最终应该与参考除法结果吻合。5.2 余数符号错误FIN状态忘了恢复如果商的输出正确但余数不对十有八九是FIN状态的“恢复”步骤出了问题。检查R[WIDTH]为1时有没有加回除数。注意这里加回除数之后结果可能仍然大于等于除数但因为部分余数的绝对值小于2倍除数恢复之后恰好落在[0, 除数-1]区间内。另一个小坑是恢复加法的位宽是WIDTH1而remainder输出是WIDTH位。Verilog赋值时会自动截断最高位这个截断是正确的因为恢复后的余数一定不超过WIDTH位。如果你多此一举地做了符号扩展反而会出错。5.3 除数为0的防护对除数为0的情况不恢复余数法本身没有定义硬件跑下去会得到一堆无意义的商位。实际工程中必须在IDLE状态显式判断if (start) begin if (divisor 0) begin done 1b1; quotient 0; remainder 0; end else begin state COMP; ... end end选择输出全0作为错误处理是一种约定具体输出什么取决于你的协议。关键是不能让状态机进入COMP后空转WIDTH个周期那样会干扰后续start脉冲的接收。5.4 状态机卡死或start脉冲丢失start脉冲要保持至少一个时钟周期高电平如果外部逻辑给的是组合逻辑产生的短脉冲有可能在时钟沿采样不到。建议在模块内部对start做两级同步再使用。反过来如果start一直被拉高IDLE状态会在done拉高的下一拍再次进入COMP变成连续除法模式。如果你不需要连续除法必须确保外部逻辑在start拉低之后再发起下一次除法。5.5 有符号除法的扩展思路上面的代码是无符号版本。如果需要支持有符号数不需要重写算法可以在外层包一层符号处理分别寄存被除数和除数的符号位将被除数和除数都转成绝对值调用无符号除法最后结果再转回有符号。商的符号等于两个操作数符号的异或余数的符号跟随被除数。这样的好处是核心算法保持不变符号处理逻辑独立成一个小模块方便复用和测试。wire sign_q dividend_s ^ divisor_s; wire sign_r dividend_s;转换的时候注意位宽取绝对值要保留符号位空间比如8位有符号数取绝对值后还是8位但如果被除数是-128绝对值128无法用8位表示需要扩展位宽。实际设计时可以把绝对值寄存器的位宽设为WIDTH1再传给无符号除法器。5.6 面积和延迟的进一步优化如果资源紧张可以把R和Q的移位逻辑合并减少MUX数量。如果追求吞吐率可以把WIDTH次迭代展开成流水线每个流水级完成一次迭代这样除法器可以连续输入数据每拍输出一个结果。代价是面积增加WIDTH倍。如果只需要除常数的专用除法器可以把加减法器替换成移位加常数的组合优化结构面积会更小。最后再分享一个实用技巧我在做随机验证的时候发现一个特别管用的办法先用Python写一个除法器的参考模型把RTL每拍的状态变化用Python模拟一遍再和testbench打印的波形比对。这样在调试初期就能定位是算法理解错了还是RTL编码错了而不是等波形跑完才两眼一抹黑。具体做法就是把上面表格里的迭代流程写成一个Python函数输入被除数和除数返回每次迭代后的R、Q值然后和仿真波形逐拍比对。这个方法让我在调32位版本时少走了不少弯路。如果你也准备自己动手写除法器建议从8位开始验证确认无误后再参数化扩展。算法本身的数学推导并不复杂真正容易出错的是移位顺序和符号处理这些细节工程上叫“最后一公里的坑”。希望这篇记录能帮你把这一公里走得顺畅一点。