1. 从“/”运算符的局限说起为什么FPGA里不能直接做除法刚接触FPGA开发的工程师尤其是从软件转过来的朋友常常会有一个疑问在Verilog里写个c a / b不就行了吗仿真看起来也对为什么前辈和手册都告诫不要直接用除法运算符这个问题我踩过坑也见过不少团队在项目后期为时序收敛和资源爆炸头疼根源往往就埋在这些看似简单的操作里。实际上Verilog HDL语言标准IEEE Std 1364中定义的除法运算符“/”其行为在综合时存在极大的不确定性。大多数主流综合工具如Vivado、Quartus的Synthesis引擎对于除数是非2的幂次方的除法操作要么直接报错拒绝综合要么会调用其内部预置的、未经优化的IP核来实现。这种自动生成的电路通常非常“笨重”会消耗大量的查找表LUT和寄存器Register资源并且关键路径延迟很长很难满足高性能或低功耗的设计要求。即使除数是2的幂次方如248...综合工具会将其优化为简单的右移操作这属于特例。因此在FPGA/ASIC设计中实现一个高效、可控的整数除法器几乎是一个必须掌握的“轮子”。那么这个“轮子”该怎么造呢算法上主要分两大流派基于减法和基于乘法。基于乘法的方法例如将除法转化为乘以其倒数需要查找表或CORDIC算法计算倒数在需要高精度或特定数值范围如定点数时有优势。而基于减法的方法尤其是移位相减算法Restoring Division Algorithm因其原理直观、结构规整、易于流水线化成为实现整数除法器最经典和常用的方法。今天我们就来彻底拆解这个算法并用Verilog实现一个可配置、可综合的除法器模块。无论你是正在学习数字逻辑的学生还是需要在实际项目中集成除法功能的工程师这篇文章都能给你一份可以直接“抄作业”的代码和背后的完整思考逻辑。2. 移位相减算法核心思想模拟手算的硬件化要理解硬件除法器最好的起点就是我们小学学过的十进制列竖式除法。以13除以2为例6 ----- 2 ) 1 3 1 2 ----- 1过程是从被除数最高位开始看1比2小不够除商0看下一位变成1313除以2得6商6余数为1。二进制除法一模一样只是数字变成了0和1。移位相减算法就是将这个过程完全硬件化、步骤化。我们以一个4位的无符号除法a / b例如1101/0010即13/2来推导目标是得到4位的商和4位的余数。算法的核心状态机可以概括为比较、减、移位、记录商。初始化准备一个宽度为除数位数两倍的寄存器这里就是8位我们称它为ACCAccumulator累加器。将被除数放在ACC的低4位高4位置0。所以初始ACC 0000_1101。除数b 0010。循环执行4次因为被除数是4位步骤A整体左移。将ACC寄存器整体左移1位。第一次左移后ACC 0001_1010。这个操作相当于把被除数的下一位“挪”上来参与运算。步骤B比较与减法比较ACC当前的高4位0001和除数b0010。如果高4位 b则执行高4位 高4位 - b并且将ACC的最低位置1因为商1否则ACC保持不变相当于商0最低位本来就是左移进来的0。第一次比较0001 0010不够减。所以ACC保持不变此时ACC 0001_1010最低位是左移进来的0即商0。重复步骤A第二次左移ACC 0011_0100。重复步骤B比较0011和00100011 0010够减。执行减法高4位0011 - 0010 0001。同时将ACC最低位置1。所以ACC 0001_0101。继续循环完成剩下的2次操作。结果提取4次循环结束后ACC寄存器的高4位就是余数低4位就是商。我们可以手动演算一下全过程循环 | 左移前ACC | 左移后ACC比较前 | 高4位 vs 除数(0010) | 操作减/置位 | 操作后ACC -----|-------------------|-------------------|-------------------|----------------|----------- 初始 | | | | | 0000_1101 1 | 0000 1101 | 0001 1010 | 0001 0010 | 无 | 0001 1010 (商0) 2 | 0001 1010 | 0011 0100 | 0011 0010 | 减置位 | 0001 0101 (商1) 3 | 0001 0101 | 0010 1010 | 0010 0010 | 减置位 | 0000 1011 (商1) 4 | 0000 1011 | 0001 0110 | 0001 0010 | 无 | 0001 0110 (商0) 结束 | | | | | 余数:0001(高4位), 商:0110(低4位)结果余数0001即1商0110即6。完全正确为什么是两倍位宽这是关键。因为在除法过程中余数可能最大达到除数的值当够减时而商的最大位数和被除数相同。我们需要一个足够宽的寄存器来同时存放中间产生的“部分余数”和逐步形成的“商”。将初始被除数放在低位高位补零左移操作就自然地将被除数位逐位移入“部分余数区”高位而商则从最低位开始逐步累加。循环结束后高低位自然分离设计非常精妙。3. Verilog实现解析从组合逻辑到时序逻辑理解了算法我们来看一个基础的Verilog实现。原始资料给出的是一个组合逻辑的实现它在一个时钟周期内通过一个for循环完成所有32次迭代。我们先分析它再指出其问题并改进。3.1 基础组合逻辑实现代码拆解module div_rill ( input [31:0] a, // 被除数 input [31:0] b, // 除数 output reg [31:0] yshang, // 商 output reg [31:0] yyushu // 余数 ); reg [31:0] tempa; reg [31:0] tempb; reg [63:0] temp_a; // 64位累加器对应算法中的ACC reg [63:0] temp_b; // 64位除数高32位为除数低32位为0 integer i; // 第一个always块输入缓冲。这是一个纯组合逻辑的缓冲通常可以省略直接使用a,b。 always (a or b) begin tempa a; tempb b; end // 第二个always块核心计算逻辑 always (tempa or tempb) begin // 1. 初始化temp_a {32‘h0, tempa}, temp_b {tempb, 32’h0} temp_a {32h00000000, tempa}; temp_b {tempb, 32h00000000}; // 2. 32次循环迭代 for(i 0; i 32; i i 1) begin // 2.1 左移将temp_a整体左移1位最低位补0 temp_a {temp_a[62:0], 1b0}; // 2.2 比较与条件减法 if(temp_a[63:32] tempb) // 比较ACC高32位与原始除数b // 够减高32位减去除数b并且将最低位置1因为商1 temp_a temp_a - temp_b 1b1; else // 不够减保持temp_a不变最低位是左移进来的0即商0 temp_a temp_a; end // 3. 输出赋值循环结束后temp_a低32位是商高32位是余数 yshang temp_a[31:0]; yyushu temp_a[63:32]; end endmodule这段代码的问题与风险巨大的组合逻辑路径这个always块包含了32级连续的比较、减法、选择和数据通路。综合后会形成一条非常深的组合逻辑链。这会导致极差的时序性能关键路径延迟Tco会很长严重限制系统所能运行的最高时钟频率Fmax。难以进行静态时序分析STA工具可能报告难以满足时序约束。潜在的毛刺Glitch风险在输入变化到输出稳定的过程中中间节点会产生复杂的毛刺如果被后续电路采样可能导致功能错误。不可综合性的争议虽然带循环的组合逻辑for在Verilog中是可综合的但综合工具会将其展开Unroll为32个完全相同的硬件单元并行处理。这并不意味着在一个周期内“循环”了32次而是生成了32级硬件。这消耗了大量资源并且如前所述时序极差。在实际工程中这种写法是强烈不推荐的它几乎无法在真实的FPGA设计中正常工作。对除数b为0的情况未处理这是一个严重的功能缺陷。当b0时除法无定义。在硬件中temp_a[63:32] 0永远为真会导致连续执行减法并置位产生错误结果甚至可能引起仿真与综合的异常。3.2 时序逻辑改造一个周期完成一步为了解决组合逻辑实现的时序问题我们必须将其改造为时序逻辑也就是状态机。核心思想是每个时钟周期只完成算法中的一个“左移-比较-减法”步骤。对于一个32位的除法就需要32个时钟周期来完成计算。此外我们还需要增加一些控制信号使模块变得可控制、可复用。一个典型的接口需要包括输入数据总线、启动信号、忙指示信号、输出数据总线和输出有效信号。下面是一个改进后的、可综合的时序逻辑除法器设计module div_sequential #( 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 S_IDLE 2‘b00; // 空闲 localparam S_CALC 2’b01; // 计算中 localparam S_DONE 2‘b10; // 计算完成 reg [1:0] state, next_state; reg [WIDTH-1:0] cnt; // 迭代计数器 reg [2*WIDTH-1:0] acc; // 64位累加器 (余数被除数) reg [WIDTH-1:0] div_reg; // 除数寄存器 // 状态机第一段时序逻辑状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else state next_state; end // 状态机第二段组合逻辑次态判断 always (*) begin next_state state; case(state) S_IDLE: begin if (start) next_state S_CALC; end S_CALC: begin if (cnt WIDTH - 1) // 已完成WIDTH次迭代 next_state S_DONE; end S_DONE: begin next_state S_IDLE; // 完成状态只保持一个周期自动回到空闲 end default: next_state S_IDLE; endcase end // 状态机第三段时序逻辑输出与控制 always (posedge clk or negedge rst_n) begin if (!rst_n) begin busy 1‘b0; done 1’b0; quotient 0; remainder 0; cnt 0; acc 0; div_reg 0; end else begin done 1‘b0; // 默认done为0只在完成周期拉高 case(state) S_IDLE: begin busy 1’b0; cnt 0; if (start) begin busy 1‘b1; // 初始化acc高WIDTH位为0低WIDTH位为被除数 acc {{WIDTH{1‘b0}}, dividend}; // 保存除数 div_reg divisor; // 注意这里可以加入除数为0的判断和异常处理 end end S_CALC: begin // 每个周期执行一次“左移-比较-减法”操作 // 1. 左移 acc {acc[2*WIDTH-2:0], 1‘b0}; // 2. 比较与条件减法使用上一周期左移后的结果这里有问题 // 我们需要用左移前的acc高WIDTH位进行比较。所以需要调整顺序。 end S_DONE: begin // 计算完成输出结果 quotient acc[WIDTH-1:0]; // 商在低WIDTH位 remainder acc[2*WIDTH-1:WIDTH]; // 余数在高WIDTH位 done 1’b1; busy 1‘b0; end endcase end end endmodule注意上面的代码在S_CALC状态存在一个逻辑错误。我们不能在同一个always块中对acc既进行左移赋值又用其新值进行比较。我们需要一个额外的寄存器来保存左移前的“部分余数”或者调整操作顺序。这是实现中的一个关键细节下面给出修正版本。3.3 修正后的核心计算逻辑正确的顺序应该是先比较当前ACC的高位与除数根据比较结果决定是否做减法然后再将整个ACC左移并将商比较结果移入最低位。但这样操作商位会“滞后”一位。更常见的做法是先左移然后用左移后的高位去比较和操作。为了清晰我们采用第二种并引入一个中间寄存器acc_shifted。修正后的S_CALC段逻辑// 在寄存器声明部分增加一个中间寄存器 reg [2*WIDTH-1:0] acc_shifted; // 在S_CALC状态中 S_CALC: begin // 先左移将上一周期的结果左移 acc_shifted {acc[2*WIDTH-2:0], 1‘b0}; // 比较左移后的高位与除数 if (acc_shifted[2*WIDTH-1:WIDTH] div_reg) begin // 够减执行减法并将商置1即最低位置1 acc acc_shifted - {{div_reg}, {WIDTH{1’b0}}} 1‘b1; end else begin // 不够减保持不变最低位是左移进来的0 acc acc_shifted; end // 计数器递增 cnt cnt 1; end这个版本逻辑清晰每个时钟周期我们基于上一周期结束时的acc先左移得到acc_shifted然后对acc_shifted的高位进行比较和减法操作结果写回acc作为下一周期的起始值。循环32次后acc的高32位是余数低32位是商。4. 工程化完善处理边界条件与性能优化一个健壮的、可用于实际项目的除法器还需要考虑更多细节。4.1 除数为零的处理这是一个必须处理的异常情况。通常有两种策略饱和输出当检测到除数为0时商输出设置为最大值全1余数输出设置为被除数或0并拉高一个错误标志。保持原值/输出特定值输出保持不变或输出一个预设的无效值。我们在代码中加入检查// 在S_IDLE状态当start信号有效时 if (start) begin if (divisor 0) begin // 除数为零处理 next_state S_DONE_ERR; // 跳转到一个错误完成状态 // 或者直接在这里设置输出 // quotient {WIDTH{1‘b1}}; // remainder dividend; // done 1’b1; end else begin // 正常初始化流程 busy 1‘b1; acc {{WIDTH{1’b0}}, dividend}; div_reg divisor; cnt 0; end end4.2 流水线化设计提高吞吐率上述时序设计是迭代式的完成一次32位除法需要32个周期吞吐率较低每32周期一个结果。对于需要连续进行大量除法运算的场景我们可以采用流水线Pipeline设计。流水线的思想是将32次迭代拆分成32级独立的硬件单元每一级只完成一次“左移-比较-减法”操作。数据像流水一样依次通过每一级。这样虽然单个结果从输入到输出仍然需要32个周期延迟 Latency但每经过一个周期就有一个新的结果计算完成并输出吞吐率 Throughput达到每周期1个结果。流水线除法器结构示意Stage 1: 输入 - [移位比较单元1] - 中间结果1 Stage 2: 中间结果1 - [移位比较单元2] - 中间结果2 ... Stage 32: 中间结果31 - [移位比较单元32] - 最终结果(商余数)每一级都是一个相同的组合逻辑模块就是之前for循环里的一轮操作但前后用寄存器隔开。这样在同一个时钟沿第1级在处理新的输入第2级在处理上一个输入的第二轮操作...第32级在输出更早之前输入的计算结果。流水线设计的代码结构会有所不同需要实例化多级相同的模块或用generate语句生成这里不展开详细代码但其核心单元与单周期迭代单元一致。4.3 测试平台Testbench的编写要点一个完善的测试平台对于验证设计至关重要。除了随机测试必须加入边界测试和特殊情况测试。timescale 1ns / 1ps module tb_div_sequential(); parameter WIDTH 32; parameter CLK_PERIOD 10; 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 done; wire busy; // 实例化被测模块 div_sequential #(.WIDTH(WIDTH)) uut ( .clk(clk), .rst_n(rst_n), .start(start), .dividend(dividend), .divisor(divisor), .quotient(quotient), .remainder(remainder), .done(done), .busy(busy) ); // 时钟生成 initial clk 0; always #(CLK_PERIOD/2) clk ~clk; // 测试过程 initial begin // 1. 初始化 rst_n 0; start 0; dividend 0; divisor 0; #100; rst_n 1; #100; // 2. 随机测试 $display(“开始随机测试...”); repeat(100) begin dividend {$random} % (1 (WIDTH/2)); // 限制范围避免溢出 divisor {$random} % (1 (WIDTH/4)) 1; // 除数至少为1 start 1; (posedge clk); start 0; // 等待计算完成 wait(done 1); (posedge clk); // 验证结果 if (quotient * divisor remainder ! dividend) begin $error(“计算错误 %d / %d 商 %d, 余 %d, 期望商 %d”, dividend, divisor, quotient, remainder, dividend/divisor); end #10; end // 3. 边界测试 $display(“开始边界测试...”); // 测试除数为1 dividend {WIDTH{1‘b1}}; // 最大值 divisor 1; start 1; (posedge clk); start 0; wait(done); // 验证... // 测试被除数为0 dividend 0; divisor 123; start 1; (posedge clk); start 0; wait(done); // 验证... // 测试被除数等于除数 dividend 100; divisor 100; // ... 操作与验证 // 4. 除数为0测试如果设计包含处理逻辑 // dividend 100; // divisor 0; // start 1; // (posedge clk); // start 0; // wait(done或错误标志); // 检查输出是否符合预期如全1或特定值 $display(“所有测试通过”); $finish; end // 监控输出 always (posedge clk) begin if (done) begin $display(“Time%t: %d / %d %d ... %d”, $time, dividend, divisor, quotient, remainder); end end endmodule5. 常见问题、调试技巧与替代方案5.1 仿真与综合结果不一致这是硬件设计常见问题。可能原因未初始化的寄存器在仿真开始时是X未知而综合后硬件上电可能是随机值。务必在所有时序always块中使用复位信号对寄存器进行初始化。阻塞赋值与非阻塞赋值混用在描述组合逻辑时用阻塞赋值在描述时序逻辑时用非阻塞赋值。规则是在always (posedge clk)块中用在always (*)组合逻辑块中用。混用会导致仿真与综合后功能不符。锁存器Latch推断在组合逻辑always块中如果条件分支不完整会综合出锁存器。锁存器对毛刺敏感在FPGA中通常应避免。确保所有条件分支都赋值或加上default分支。5.2 时序违例Timing Violation在高速时钟下我们的迭代式设计可能遇到建立时间Setup Time或保持时间Hold Time违例。降低时钟频率最直接的方法但影响性能。增加流水线级数将32次迭代拆分成更多级每级做更少的工作例如两级之间只完成部分位的比较和减法缩短关键路径。这属于优化设计。寄存器重定时Retiming综合工具可以自动调整寄存器位置以平衡路径延迟但对此类规整逻辑优化空间有限。使用FPGA内置的DSP块对于某些FPGA如Xilinx的UltraScale Intel的Arria 10其DSP块内部集成了高性能的预加器、乘法器和模式检测器可以通过特定的配置来实现除法功能性能和资源利用率远优于用通用逻辑LUT搭建的除法器。这需要查阅厂商的IP文档如Xilinx的div_genIP核。5.3 有符号数除法的扩展本文讨论的是无符号整数除法。对于有符号数补码表示处理起来稍复杂符号处理记录被除数和除数的符号位。将两者都转换为正数取绝对值然后调用无符号除法器进行计算。商和余数的符号商的符号 被除数符号 ^ 除数符号异或。余数的符号通常与被除数相同在大多数编程语言如C语言中如此规定。计算完成后根据符号位对商和余数进行补码转换。5.4 何时该用IP核何时该自己写使用厂商IP核如Xilinxdiv_gen优点经过高度优化性能频率、吞吐率最好资源利用率高支持有符号/无符号、多种位宽、流水线深度可配置并且经过了严格验证。缺点可能在不同厂商、不同系列FPGA间移植性稍差虽然接口类似且有时需要额外的License。适用场景对性能、资源有严格要求的生产项目快速原型开发。自己编写RTL代码优点完全透明可控易于理解和定制如特定的舍入模式、异常处理移植性好。缺点需要自己验证优化程度可能不如IP核。适用场景教学、学习原理对除法性能要求不高的辅助功能需要非常特殊定制功能时。我个人在项目中的经验是对于关键路径或高性能需求首选经过验证的IP核。对于控制逻辑、低速或非关键路径中的简单除法为了代码的简洁和可移植性我会自己实现一个类似本文的时序逻辑除法器并做好充分的仿真验证。理解底层原理能让你在即使使用IP核时也能更好地配置和理解其行为。最后再分享一个调试小技巧在仿真中除了看最终的商和余数可以把中间寄存器acc的值也打印出来观察它每一个时钟周期的变化。这能非常直观地帮你验证算法每一步是否正确执行尤其是在遇到边界条件或奇怪错误时逐周期跟踪是定位问题的利器。