前阵子在项目里需要做一组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位,防止溢出。我在代码里直接用了WIDTH+1位。
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 = 2'd0; localparam COMPUTE = 2'd1; localparam FINISH = 2'd2; 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 - {1'b0, d}; wire [WIDTH:0] add_out = r + {1'b0, d}; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; busy <= 1'b0; done <= 1'b0; cnt <= 0; r <= 0; q <= 0; quotient <= 0; remainder <= 0; end else begin case (state) IDLE: begin done <= 1'b0; if (start) begin busy <= 1'b1; 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], 1'b0}; // 加减交替 if (r[WIDTH] == 1'b0) 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 + 1'b1; end end FINISH: begin // 最后一步修正 if (r[WIDTH] == 1'b1) begin r <= r + {1'b0, d}; end quotient <= q; remainder <= r[WIDTH-1:0]; busy <= 1'b0; done <= 1'b1; state <= IDLE; end default: state <= IDLE; endcase end end endmodule说几个关键点:
- 余数寄存器
r位宽是WIDTH+1。实际上,在左移和加减交替的过程中,高位可能临时变成1,所以用WIDTH+1位保障不会截断。 - 商寄存器
q的移入逻辑是:当加减结果最高位为0(非负)时,当前商位为1;最高位为1(负数)时,当前商位为0。代码里用~sub_out[WIDTH]和~add_out[WIDTH]来实现这个判断。 - 状态机只有三个状态,逻辑很直接。IDLE等待start,COMPUTE做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 + 33'd1) : {1'b0, dividend}; abs_divisor = divisor[WIDTH-1] ? (~divisor + 33'd1) : {1'b0, 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 + 1'b1) : abs_div_out; remainder <= sign_r ? (~abs_rem_out + 1'b1) : abs_rem_out; done <= 1'b1; busy <= 1'b0; end else if (start) begin busy <= 1'b1; done <= 1'b0; end end endmodule注意看这里的几个逻辑:
abs_dividend和abs_divisor声明成WIDTH+1位(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 == 32'h8000_0000 && divisor == 32'hFFFF_FFFF) begin // 设置overflow标志,商饱和到最大值 0x7FFF_FFFF end这类特殊值处理,看起来简单,但在实际项目中很容易漏掉。如果你做的是芯片验证或者协议栈,务必把边界测试向量加进去。
4. 仿真验证与性能优化
4.1 测试向量设计
我写数字逻辑有个习惯:先做定向测试,再做随机测试。定向测试覆盖边界值和典型值,随机测试覆盖中间各种情况。
定向测试向量至少包含这些场景:
| 测试类型 | 输入示例 | 期望结果 |
|---|---|---|
| 除以1 | a=任意, b=1 | 商=a, 余数=0 |
| 除以自身 | a=b | 商=1, 余数=0 |
| 除以0 | a=任意, b=0 | 需要明确处理,建议置标志位 |
| 最大值/最小值 | a=0xFFFF_FFFF, b=2 | 商=0x7FFF_FFFF, 余数=1 |
| 最小负数除以-1 | a=0x8000_0000, b=0xFFFF_FFFF | 溢出,饱和或置标志 |
| 典型负数 | a=-7, b=2 | 商=-3(舍入到0), 余数=-1 |
| 大数除以小数 | a=0x7FFF_FFFF, b=1 | 商=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版本,不要随手“优化”成别人看不懂的形态。项目迭代中,我无数次需要回看老版本代码确认某个边界行为是怎么处理的,有干净的版本可查,会省下大量时间。如果你正在做类似模块,也建议顺手把测试向量和参考脚本一并归档,方便后续回归。