FPGA除法器设计实战:从移位相减原理到RTL实现与仿真

发布时间:2026/10/8 15:28:51
FPGA除法器设计实战:从移位相减原理到RTL实现与仿真 搞FPGA的应该都会遇到一个尴尬Verilog里敲个除号很简单但综合工具一报资源逻辑单元蹭蹭涨时序还经常跑不过。除法在硬件里确实不是省油的灯尤其当你要控制固定延迟、支持握手、还要兼容正负数时就得自己动手写。这篇文章把32位无符号和带符号除法器的实现完整复盘从最基础的移位相减原理到RTL代码里每一个寄存器的选取理由再到仿真里真实踩过的边界条件坑一次讲透。项目背景是做一个通用计算模块需要在FPGA里完成定点除法对延迟和时序都有明确要求所以没有直接用/运算符而是自己实现了一个可控的状态机版本。1. 思路梳理与方案选型1.1 硬件除法为什么“难”先泼一盆冷水除法在FPGA里属于“看起来简单、做起来麻烦”的运算。加法和乘法可以靠组合逻辑直接展开输入进去很快就能得到结果但除法不同它的每一位商都依赖于上一步的余数是一个天然的迭代过程。说得形象一点加法像把几堆货直接倒进仓库除法像按顺序一件一件分货分完一件才能决定下一件放哪个格子。如果用Verilog直接写assign q a / b综合工具确实能帮你生成电路但代价是你管不住它的实现细节。常见的结果是组合逻辑除法器面积巨大32位除法的资源消耗可能会让你怀疑人生而且组合路径极长频率稍高就时序违约。在一些FPGA型号上综合器会尝试映射到DSP单元或IP核但资源冲突、时序不可控的问题仍然存在。如果你在一个系统里需要精确控制计算延迟或者需要频繁插入流水级手写除法器反而更实在。实际工程里除法器经常出现在这些场景CRC校验里的多项式求余、PID控制器里的比例系数计算、图像处理的比例缩放、协议解析中的地址映射、浮点运算单元中的尾数规格化。这些场景对“多少拍出结果”通常有硬性要求自己写状态机可以精准控制节拍。1.2 常用实现方案盘点实现除法器的方案不止一种先列个表对比一下后面再细说为什么选状态机实现方案核心思路延迟资源开销适用场景组合逻辑除法器直接用比较器和减法器展开所有位1拍纯组合极大32位可能消耗数千LUT位宽很小8位以下移位相减状态机每个周期处理一位循环N次N拍小几个寄存器比较器通用、面积敏感、位宽大流水线移位相减把N级迭代拆成N级流水N拍吞吐1拍启动间隔中每级一套比较/减法器高吞吐、位宽中等Newton-Raphson迭代先求倒数再乘需要查表和乘法器与精度相关通常几拍到几十拍中高依赖乘法器追求高性能浮点场景SRT算法每周期产生多位商每周期多位高基数越高控制逻辑越复杂高性能CPU实现组合逻辑方案在小位宽比如4位、8位下问题不大一旦到了32位环路里串了很多比较器路径延迟会非常夸张。Newton-Raphson方案性能确实好但它需要乘法器支撑而且迭代初值的查表、收敛判断这些逻辑都要额外设计对大多数FPGA项目来说属于杀鸡用牛刀。SRT算法更是处理器级别的追求普通RTL项目几乎不会碰。1.3 为什么选“移位相减状态机”我的设计目标是32位无符号和带符号各一套延迟固定、支持start/done握手、资源尽量小。移位相减状态机完美满足这些约束。它的面积优势很明显——本质上就是几个寄存器加一个比较器综合后资源占用比组合逻辑方案小一个数量级。更关键的是延迟完全可控无符号32轮固定32拍带符号33轮加收尾2拍外部逻辑可以精准安排数据流。可能有人会问流水线方案不是更香吗确实如果你需要连续不停地做除法流水线的吞吐优势就出来了。但这是用面积换速度每一级都要一套比较器和减法器32级流水下来资源开销不小。而且流水线方案的“首结果延迟”反而更高只是后续结果源源不断。我这次的需求是偶发除法运算不是连续除法流所以状态机方案最合适。这个选择也符合工程上“按需求定架构”的原则后面优化部分再展开讲流水线改造。2. 无符号32位除法器的完整实现2.1 算法本质手算竖式的硬件化移位相减除法的核心思想就是把小学学的竖式除法搬到硬件里。你还记得竖式怎么算吗从被除数的最高位开始一位一位往下看每次看“当前余数并上这一位数”够不够大够大就上商然后把减掉后的新余数带到下一位。硬件也一样每拍把被除数当前的最高位移到余数寄存器的低位同时余数寄存器左移一格然后拿这个新余数和除数比较够大就减掉并把商位写成1不够大就商位写0余数保持不变。这个算法有个优雅的地方被除数的位被逐位移出后原来的位置就不再需要保存了正好可以腾出来从低位往高位写商。所以用一个quo_r寄存器同时扮演“被除数暂存”和“商寄存器”两个角色节省了一个32位寄存器。这也是很多教材里的标准做法。需要注意的是每轮迭代都必须保证余数小于除数。这是算法的核心不变量因为只有保证这个前提每一轮的“减不减退”才是有效的二选一。理解了这一点后面很多边界问题都能推断出来。2.2 RTL代码与关键信号说明下面给出无符号32位除法器的完整代码。这个版本我测试过可以直接用module div_unsigned32 ( input wire clk, input wire rst_n, input wire start, input wire [31:0] dividend, // 被除数 input wire [31:0] divisor, // 除数 output wire [31:0] quotient, // 商 output wire [31:0] remainder, // 余数 output reg done ); localparam IDLE 2d0, CALC 2d1; reg [1:0] state; reg [32:0] rem_r; // 余数寄存器33位 reg [31:0] quo_r; // 被除数/商寄存器32位 reg [32:0] div_r; // 除数寄存器33位 reg [5:0] cnt; // 迭代计数 wire [32:0] rem_tmp {rem_r[31:0], quo_r[31]}; wire ge_div (rem_tmp div_r); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rem_r 33d0; quo_r 32d0; div_r 33d0; cnt 6d0; done 1b0; end else begin case (state) IDLE: begin done 1b0; if (start) begin div_r {1b0, divisor}; quo_r dividend; rem_r 33d0; cnt 6d32; state CALC; end end CALC: begin if (ge_div) begin rem_r rem_tmp - div_r; quo_r {quo_r[30:0], 1b1}; end else begin rem_r rem_tmp; quo_r {quo_r[30:0], 1b0}; end cnt cnt - 1b1; if (cnt 6d1) begin done 1b1; state IDLE; end end endcase end end assign quotient quo_r; assign remainder rem_r[31:0]; endmodule重点解释几个看起来“多余”的位宽设计。rem_r是33位而不是32位这是我踩过坑之后特意改的。每轮先执行左移操作rem_r原本最高位如果为1左移后会产生一个第33位的中间值这个值可能超过32位范围。如果rem_r只有32位这个溢出位会被直接丢弃后续的比较结果就是错的了。用一个33位寄存器既保证左移后的中间值不会截断又能让比较器直接拿完整值跟除数比逻辑上干净利落。div_r扩展成33位同理是为了和rem_tmp位数对齐避免比较器位宽不匹配的warning甚至错误。2.3 时序到底怎么走的这个状态机的时序并不复杂但要把start和done的配合想清楚。start拉高的下一个时钟沿控制器把被除数、除数锁存到内部寄存器cnt置32进入CALC状态。之后每个时钟沿做一轮移位相减cnt依次减到1。在cnt从1变0的那个沿done拉高同时状态跳回IDLE。所以从start到done有效正好32个时钟周期。结果什么时候能读无符号版本里quotient和remainder是组合逻辑直接输出的内部寄存器值所以当done为高电平时这两个输出已经是最终值可以直接采样。如果你希望结果保持时间更长可以在外部加一级寄存器锁存否则正常的做法是always (posedge clk or negedge rst_n) begin if (!rst_n) q_reg 32d0; else if (done) q_reg quotient; end一个容易忽略的细节是done只高一个周期下一拍回到IDLE后done会被拉低。所以外部逻辑不能靠“持续高电平”来判断结果而是靠上升沿或单拍高电平来触发采样。这个握手习惯在状态机里非常常见后面带符号版本会在这个基础上再加一个DONE状态道理是类似的。3. 带符号32位除法器符号处理是重头戏3.1 补码取绝对值为什么必须扩一位带符号除法的第一反应可能是“先取绝对值做完无符号除法再加符号”方向是对的但坑藏在取绝对值这一步。32位有符号数的范围是-2147483648 ~ 2147483647注意最左边的那个-2147483648它的绝对值是2147483648已经超出了32位无符号数能表示的范围。如果按习惯用32位寄存器取绝对值0x80000000取反加1得到的还是0x80000000符号位恰好是1看起来还是负数后面全乱套。正确做法是先把操作数扩成33位再取绝对值。33位能表示0 ~ 4294967296-2147483648扩展后是33h1_8000_0000取反加1得到33h0_8000_0000也就是2147483648这样才没有溢出。这个“扩一位再取绝对值”的处理是带符号除法器区别于无符号版本最核心的一点。很多初学者在这个地方翻车现象是算-2147483648 / 1得到负数或者莫名其妙的结果排查半天发现是取绝对值时丢了最高位。3.2 商和余数的符号判定规则取绝对值做完除法之后要把符号“还回去”。这里有两个规则来自C语言和Verilog的常见语义商的符号 被除数符号 XOR 除数符号。很好理解同号相除得正异号相除得负。余数的符号 被除数的符号。注意不是“和商相同”也不是“按数学上余数恒正”来。Verilog里的整数除法余数遵循“截断除法”语义结果向零靠拢余数符号与被除数一致。比如-7 / 2 -3余数是-1而不是1因为-3 * 2 -6-7 - (-6) -1。我在设计时先保存了被除数和除数的符号位然后算绝对值做完无符号除法后在输出阶段根据这两个符号位对商和余数分别取补码。这个流程只需要在最后加一步逻辑开销很小。另外要注意一个特殊场景-2147483648 / -1数学结果是2147483648仍然超出32位有符号范围。这种情况叫做商溢出需要给外部一个overflow标志。我加了判断如果商绝对值的最高位为1说明结果超过32位有符号上限overflow拉高。这个信号很重要否则外部拿到一个看似正常的0实际结果是错的。3.3 带符号除法RTL实现带符号版本完整的RTL如下module div_signed32 ( input wire clk, input wire rst_n, input wire start, input wire [31:0] dividend, input wire [31:0] divisor, output reg [31:0] quotient, output reg [31:0] remainder, output reg overflow, output reg done ); localparam IDLE 2d0, CALC 2d1, DONE 2d2; reg [1:0] state; reg [32:0] rem_r; reg [32:0] quo_r; reg [32:0] div_abs; reg [5:0] cnt; reg q_neg, r_neg; wire [32:0] dividend_ext {1b0, dividend}; wire [32:0] divisor_ext {1b0, divisor}; wire [32:0] dividend_abs dividend[31] ? ~dividend_ext 33d1 : dividend_ext; wire [32:0] divisor_abs_w divisor[31] ? ~divisor_ext 33d1 : divisor_ext; wire [32:0] rem_tmp {rem_r[31:0], quo_r[32]}; wire ge_div (rem_tmp div_abs); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rem_r 33d0; quo_r 33d0; div_abs 33d0; cnt 6d0; done 1b0; overflow 1b0; end else begin case (state) IDLE: begin done 1b0; if (start) begin div_abs divisor_abs_w; quo_r dividend_abs; rem_r 33d0; cnt 6d33; q_neg dividend[31] ^ divisor[31]; r_neg dividend[31]; overflow 1b0; state CALC; end end CALC: begin if (ge_div) begin rem_r rem_tmp - div_abs; quo_r {quo_r[31:0], 1b1}; end else begin rem_r rem_tmp; quo_r {quo_r[31:0], 1b0}; end cnt cnt - 1b1; if (cnt 6d1) state DONE; end DONE: begin if (q_neg) quotient ~quo_r[31:0] 1b1; else quotient quo_r[31:0]; if (r_neg) remainder ~rem_r[31:0] 1b1; else remainder rem_r[31:0]; overflow quo_r[32]; done 1b1; state IDLE; end endcase end end endmodule注意这里比无符号版本多了一个DONE状态。我一开始为了省拍数想在最后一次迭代的同一拍直接根据quo_r算符号结果结果死活不对。原因是CALC状态里用的是非阻塞赋值quo_r {quo_r[31:0], 1b1}这一句要等时钟沿才真正更新。如果我在同一拍里又写comp_result ~quo_r 1b1使用的还是旧值相当于用上一步的中间结果做符号处理最后一位被吃掉了。解决办法就是增加一个DONE状态让最后一次迭代的结果先稳定一个周期再在这个状态里做符号变换和结果输出。带符号版本的迭代次数是33轮因为绝对值最大是2147483648对应33位数据。不要为了省一轮把quo_r定义成32位否则-2147483648的绝对值放不下直接错误。4. 边界用例设计、仿真与避坑实录4.1 测试用例怎么设计才算“敢上线”写testbench的时候如果只用10/3这种简单用例等于没测。除法器的边界情况和中间寄存器状态非常刁钻我这次测试用例覆盖了四类常规正数、负数组合、最大负数除以小值、大数除以大数。下面这张表是核心用例每一项都手工算了期望值被除数除数预期商预期余数说明10331正正常规-103-3-1余数符号跟随被除数10-3-31商负余正-10-33-1同号相除商正0x8000000010x800000000最大负数除以1商可表示0x80000000-1溢出-overflow必须拉高0x8000000020x400000000最大负数除以2高位处理正确0xFFFFFFFF0xFFFFFFFE11大除数大被除数rem_tmp位宽验证12345717634随机场景0700被除数为0testbench的核心思路很简单拉高start等一个沿后拉低然后等待done有效在done为高的那一拍采样结果并比对。// 伪代码风格的核心测试片段 initial begin (posedge clk); start 1b1; (posedge clk); start 1b0; wait (done); (posedge clk); $display(quotient%0d, remainder%0d, quotient, remainder); end我特别想提醒的是第三类和第四类用例。-2147483648这类临界值最容易暴露取绝对值时的位宽问题而0xFFFFFFFF / 0xFFFFFFFE这种结果接近1但余数很大的用例容易暴露rem_tmp位宽是否够的问题。如果你的除法器只在中间量上用了一个32位寄存器这两个用例基本必挂。4.2 我踩过的几个坑第一个坑是rem_r位宽不够。早期版本无符号除法器的余数寄存器只用了32位自测的小数没问题但一上0xFFFFFFFF / 0xFFFFFFFE就出错。错误现象是结果完全不像样定位半天才发现左移后溢出的那一位被丢弃了。改成33位后问题消失。这个坑很隐蔽因为普通用例根本露不出来。第二个坑是在带符号版本里我一开始把迭代轮数写成了32轮。当时想当然地认为“被除数32位所以迭代32轮”。但带符号取绝对值后有效位数可能达到33位32轮会漏掉最高位导致-2147483648 / 1得到0而不是-2147483648。后面把计数器初值改成33一切正常。第三个坑是DONE状态。前面提过为了省一个周期在最后一次迭代的同一拍直接算符号结果导致最后一位丢失。这个坑的核心原因是没想清楚非阻塞赋值“同拍语句用旧值”的特性。后来老老实实加DONE状态问题解决。这个经验也让我以后写状态机时更谨慎不要在同一个always块里既更新一个变量、又马上用它计算另一个变量。第四个坑是start脉冲被DONE状态漏掉。带符号版本退出DONE回到IDLE如果外部在DONE期间拉高start下一拍回到IDLE时start已经拉低这次启动就丢失了。我的处理办法是外部握手保证在done有效之后、下一次start之前至少间隔一个时钟周期同时保留一个busy信号给外部使用。更稳妥一点可以在DONE状态也判断start但这个方案会多一套分支我用更简单的协议约定避开了。4.3 常见问题速查表把实际调试中可能遇到的问题整理成下面这个表方便你排查现象可能原因解决方法结果比预期小一半迭代轮数写少了一圈确认cnt初值被除数位数带符号取绝对值后是33位最大负数除以1结果变成0取绝对值时32位溢出操作数先扩成33位再取反加1大数除数场景结果错乱余数寄存器位宽不够左移溢出丢位余数寄存器用N1位如33位带符号最后一位商不对在最后一次迭代同拍处理商符号增加DONE状态等结果稳定再变换符号start脉冲偶尔无效start在DONE状态期间被拉高后被漏采握手协议里保证done后间隔一拍再拉start除数为0时结果异常没有除零检测增加error标志或调用方保证除数非0仿真里看到X态未复位或非阻塞赋值竞争检查rst_n时序检查是否存在同拍读旧值这里把除零单独拿出来说一句。上面两个模块都没有显式处理除数为0的情况。如果除数为0除法结果在数学上没有定义硬件里会进入一种“永远不收敛”的状态最终得到垃圾数据。我建议实际使用中要么在调用前判断要么在模块里增加一个zero_div_flag输出直接给外部一个错误信号。逻辑很简单就是在装载数据时判断divisor 0如果是直接置错误标志并跳过计算。后面还能怎么改这套除法器写完后我在项目里跑了一段时间整体稳定。有一点心得可以分享如果你需要更高的吞吐不要让状态机一轮一轮地等而是把迭代逻辑复制成N级流水线每一级做一个移位和减法。这样虽然面积变大但可以每拍启动一次除法吞吐提升了N倍。演示用的无符号32位版本是32轮迭代改成流水线后首结果延迟32拍但后续结果每个时钟沿出一个。至于45度角级别的优化可以用查表把前几级换成更宽的基数处理不过那是另一个故事了。最后再分享一个调试小习惯写这种迭代状态机时最好把cnt、rem_r、quo_r这几个内部信号引到ILA或者仿真波形里。别看代码逻辑不复杂一旦数据流错位光靠最终结果反推会非常痛苦。有了内部波形一眼就能看出商位有没有写错、余数从第几拍开始漂移问题往往几十秒就能定位。