☰
基于不恢复余数法的FPGA除法器设计:Verilog实现与仿真验证
2026/10/20 9:13:38 网站建设 项目流程

搞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个周期出结果。这个特性对做时序规划非常友好,你不需要猜测延迟,结果就在第N+1个周期稳定输出。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来走一遍流程。被除数A=25(8'b00011001),除数B=7(8'b00000111),商期望是3,余数期望是4。

迭代左移后的部分余数操作新部分余数符号判断商位
10减7-7负0
2-14加7-7负0
3-14加7-7负0
4-13加7-6负0
5-11加7-4负0
6-8加7-1负0
7-2加75正1
811减74正1

把每次迭代产生的商位从高位到低位排列,得到二进制00000011,正好是3。最后部分余数是4,不需要恢复,就是最终余数。

这个例子也解释了为什么叫“加减交替法”:正的时候减,负的时候加,符号交替变化,逐渐把余数收敛到[0, B-1]范围。硬件上,这就是一个带符号的加减法器加上两个移位寄存器的事。

3. RTL设计:可综合的Verilog实现

3.1 模块接口与信号定义

我设计的除法器模块支持参数化位宽,默认8位,接口采用常见的start/busy/done握手方式。核心信号包括部分余数寄存器R(位宽WIDTH+1,最高位兼任符号位)、商寄存器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 );

为什么要用WIDTH+1位存R?因为迭代过程中余数会变成负数,需要保存符号信息。最高位R[WIDTH]为1表示当前部分余数为负,为0表示非负。这个符号位也直接控制了MUX选择加法还是减法。

3.2 状态机设计

状态机只需要三个状态:IDLE、COMP、FIN。IDLE等待start信号,把除数复制到内部寄存器;COMP执行WIDTH次迭代;FIN锁存商和余数,拉高done脉冲后回到IDLE。

localparam IDLE = 2'd0; localparam COMP = 2'd1; localparam FIN = 2'd2; 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], 1'b0}; assign updated_R = (R[WIDTH] == 1'b1) ? (shifted_R + {1'b0, divisor_reg}) : (shifted_R - {1'b0, divisor_reg}); assign updated_Q = {shifted_Q[WIDTH-1:1], ~updated_R[WIDTH]};

这里的减法是直接加补码实现的,加法器位宽WIDTH+1位。注意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 = 2'd0; localparam COMP = 2'd1; localparam FIN = 2'd2; 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], 1'b0}; wire [WIDTH:0] updated_R; wire [WIDTH-1:0] updated_Q; assign updated_R = (R[WIDTH] == 1'b1) ? (shifted_R + {1'b0, divisor_reg}) : (shifted_R - {1'b0, 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 <= 1'b0; done <= 1'b0; quotient <= 0; remainder <= 0; end else begin case (state) IDLE: begin done <= 1'b0; if (start) begin state <= COMP; busy <= 1'b1; cnt <= 0; R <= 0; Q <= dividend; divisor_reg <= divisor; end end COMP: begin if (cnt == WIDTH - 1) begin state <= FIN; end cnt <= cnt + 1'b1; R <= updated_R; Q <= updated_Q; end FIN: begin state <= IDLE; busy <= 1'b0; done <= 1'b1; quotient <= Q; if (R[WIDTH] == 1'b1) remainder <= R + {1'b0, 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拉高需要WIDTH+1个时钟周期(WIDTH个COMP周期加上1个FIN周期)。如果你想压缩延迟,可以在FIN状态用组合逻辑提前输出商和余数,不过要注意这样会让done与结果之间存在亚稳态风险,建议还是用同步锁存。

R寄存器为什么是WIDTH+1而不是WIDTH+2?直觉上部分余数可能因为加减法产生进位。实际上不恢复余数法有个性质:部分余数的绝对值始终小于2倍的除数,而除数本身不超过WIDTH位,所以WIDTH+1位足够容纳带符号的部分余数,最高位正好作为符号位。这个性质保证了算法不会溢出。如果你把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 <= 1'b1; @(negedge clk); start <= 1'b0; wait (done == 1'b1); $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 == 1'b1)必须在start拉低之后。如果start一直保持高电平,IDLE状态会在done出现的同一拍又被start触发进入COMP,造成连续除法,虽然这不是错误,但会让你在观察波形时分不清边界。

4.2 边界用例与随机验证

固定用例要覆盖几类边界情况:被除数是0、被除数小于除数、整除的情况、最大数除以1。这些用例能快速暴露算法在极限条件下的问题。比如5除以10,商应该是0,余数应该是5,如果输出余数是负数,说明最后一步恢复逻辑有bug;255除以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

我实测的输出结果如下:

a=25 b=7 q=3 r=4 a=255 b=1 q=255 r=0 a=5 b=10 q=0 r=5 a=0 b=7 q=0 r=0 a=100 b=100 q=1 r=0

从波形上看,start拉高后的第一个时钟沿进入COMP,R和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]区间内。

另一个小坑是恢复加法的位宽是WIDTH+1,而remainder输出是WIDTH位。Verilog赋值时会自动截断最高位,这个截断是正确的,因为恢复后的余数一定不超过WIDTH位。如果你多此一举地做了符号扩展,反而会出错。

5.3 除数为0的防护

对除数为0的情况,不恢复余数法本身没有定义,硬件跑下去会得到一堆无意义的商位。实际工程中必须在IDLE状态显式判断:

if (start) begin if (divisor == 0) begin done <= 1'b1; 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位表示,需要扩展位宽。实际设计时可以把绝对值寄存器的位宽设为WIDTH+1,再传给无符号除法器。

5.6 面积和延迟的进一步优化

如果资源紧张,可以把R和Q的移位逻辑合并,减少MUX数量。如果追求吞吐率,可以把WIDTH次迭代展开成流水线,每个流水级完成一次迭代,这样除法器可以连续输入数据,每拍输出一个结果。代价是面积增加WIDTH倍。如果只需要除常数的专用除法器,可以把加减法器替换成移位加常数的组合优化结构,面积会更小。

最后再分享一个实用技巧

我在做随机验证的时候,发现一个特别管用的办法:先用Python写一个除法器的参考模型,把RTL每拍的状态变化用Python模拟一遍,再和testbench打印的波形比对。这样在调试初期就能定位是算法理解错了还是RTL编码错了,而不是等波形跑完才两眼一抹黑。具体做法就是把上面表格里的迭代流程写成一个Python函数,输入被除数和除数,返回每次迭代后的R、Q值,然后和仿真波形逐拍比对。这个方法让我在调32位版本时少走了不少弯路。

如果你也准备自己动手写除法器,建议从8位开始验证,确认无误后再参数化扩展。算法本身的数学推导并不复杂,真正容易出错的是移位顺序和符号处理这些细节,工程上叫“最后一公里的坑”。希望这篇记录能帮你把这一公里走得顺畅一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询