
写RTL的人大概率都遇到过这种场景系统里需要做一次除法图省事直接在代码里写了a / b综合一看资源报告LUT暴涨一大片时序还有可能直接崩掉。FPGA和CPU不一样CPU的除法是硬核指令FPGA里没有这种“免费午餐”。综合工具碰到除号要么给你推断出一堆查找表要么搞出一个巨大无比的组合逻辑链位宽稍微上去一点就非常痛苦。所以“基于FPGA的任意四位除法器设计与实现”这个题目看起来只是个简单的小模块实际上把定点运算里最核心的几个问题全串起来了算法选型怎么权衡、状态机怎么写、握手信号怎么设计、仿真怎么把边界情况全部覆盖。这篇文章会把整个设计过程从算法推到RTL再推到仿真和板上验证全部摊开讲清楚。想直接抄作业的第三章有完整可综合代码和配套Testbench想弄懂原理的第二章的推导过程能帮你把十进制长除法换成二进制一步步落到硬件上。这个项目适合三类人正好在课设里遇到除法器任务的在校生、需要在资源有限的FPGA里塞进除法运算的嵌入式开发者以及想系统梳理定点运算和状态机的初学者。四位数字听起来很窄但完整做一遍之后把位宽从4改成8位、16位其实只是参数化那一步的事内核完全不变。1. 先想清楚四位除法器到底该怎么做1.1 为什么要在FPGA里单独设计除法器先回答一个最基础的问题FPGA里不写除法运算符那除法怎么做答案是自己搭一个专用的运算模块。综合工具对除法运算符也就是/的处理方式大致有三种查表法直接枚举所有输入组合把结果存成一张大表。位宽小还能忍位宽一大LUT和寄存器消耗直接起飞。乘法器复用法把除以一个数变成乘以它的倒数看着聪明但会引入额外精度问题而且依赖DSP单元。迭代算法法按位处理每一拍往结果里“挤”一位用简单的移位、比较、减法实现除法。对小位宽、小资源的场景第三种最稳。不依赖DSP消耗的LUT也很少时序还能压得住。那四位除法器的实际用途在哪我至少见过三类ADC采样之后做归一化比如采样值除以满量程再乘刻度系数一些简单控制算法里算偏差比例还有教学演示用的简易CPUALU里就差一个除法指令。这些场景的共同点是数据量不大、对延迟不敏感、但不希望为了一次除法消耗掉一大片资源。所以自己写一个小的除法器往往比综合工具直接“除”更可控。1.2 “任意四位”到底指什么标题里的“任意四位”我理解成被除数和除数都是4位无符号二进制数取值范围0到15输出商和余数也都是4位。商为什么能保证4位放得下因为最大情况是15除以1结果是15正好落在4位二进制能表达的范围内。余数最大也就144位同样放得下。这里有一个容易踩的坑如果改成有符号数位宽、符号扩展、结果定义全部要重新设计。无符号除法和补码除法在硬件实现上有本质差别这一点第五章会展开讲。“任意”的另一层意思是覆盖所有输入组合。工程上最稳妥的办法是穷举仿真总共256组输入一组不漏地比对商和余数。别嫌麻烦四位除法器的功能正确性就建立在“任意”两个字上。边界情况一旦漏了后面板子跑起来出了问题定位成本远高于写仿真用例的成本。1.3 方案概览组合逻辑、状态机还是查表接到这个需求我脑子里冒出来的第一个方案是把256种组合全部用case语句写成一张大表。这种方案延迟极低一拍出结果。但问题也很明显4位还能凑合位宽上到8位就是65536种组合LUT消耗直接爆炸没有任何扩展性。第二个方案是组合逻辑迭代用for循环不断减除数。代码写起来简单也能综合但映射出来的组合逻辑链路长度是按位宽指数增长的位宽一大时序收敛就是噩梦。第三个方案是时序状态机用恢复余数法逐位迭代。我最终选的就是这个4拍完成一次除法时钟频率容易控制资源占用非常小参数化扩展也容易。虽然延迟不是1拍但在绝大多数非实时场景下完全够用。方案延迟资源消耗扩展性推荐场景case查表1拍极高差位宽极小或固定不变for循环组合逻辑1拍高一般位宽很小且无时序压力恢复余数法状态机N拍低好通用小资源场景2. 算法选型为什么最终用了恢复余数法2.1 先把十进制长除法搬到二进制小学学过的竖式除法放到二进制里就是恢复余数法的原型。手算过程拆成四步第一步把余数寄存器左移一位空出来的最低位用来接收被除数当前位的值。第二步用移位后的余数去减除数。第三步如果够减商的当前位置1如果不够减当前位置0。第四步如果不够减要把减掉的除数加回去让余数恢复原状这正是“恢复余数法”这个名称的来源。对应到4位被除数从最高位到最低位正好迭代4拍。每一拍只做一次比较、一次条件减法控制逻辑极其简单。所谓“恢复”在硬件里就是当比较结果为不够减时需要额外执行一次加法把除数加回来。这一拍本质上浪费了但对4位除法器来说最多浪费4拍完全不需要为了省这一拍去上更复杂的非恢复余数法。我在实际代码里用了一种更清爽的写法不是先减再判断要不要恢复而是先比较判定够减才真正执行减法。这样逻辑行为完全等效于恢复余数法但不会出现数据被减掉又加回去的反复横跳代码更干净也不容易产生隐性锁存器。2.2 一个具体例子13除以3拿13除以3走一遍算法13的二进制是11013的二进制是0011。第1拍余数初始0000左移之后还是0000从被除数最高位拿一个1得到0001。0001小于0011不够减商位为0。第2拍余数0001左移变成0010再拿被除数第二位1得到0011。0011正好等于0011够减余数变成0000商位为1。第3拍余数0000左移还是0000拿被除数第三位0结果0000。0000小于0011不够减商位为0。第4拍余数0000左移还是0000拿被除数第四位1结果0001。0001小于0011不够减商位为0。最后商是0100也就是4余数是0001也就是1。13除以3商4余1完全正确。这个手算过程非常关键因为硬件实现完全就是按这个流程走的。每一拍里“余数左移、加被除数的下一位、比较、条件减法”这四个动作就是状态机每一拍要做的事。2.3 为什么不是非恢复余数法资料看多了一定有人跟你说非恢复余数法更好。确实非恢复余数法的思想是不恢复余数而是根据上一次余数的符号决定下一步是做减法还是做加法。它在迭代次数上能省掉一些“恢复”周期而且求商的规则也不一样。但对四位除法器来说非恢复余数法带来的收益非常小代价是控制逻辑变复杂还要额外处理商校正逻辑。就我这个项目而言恢复余数法的实现已经能稳稳跑到4拍完成代码可读性高调试也简单扩展位宽时也完全够用。选型不能只看算法理论复杂度还要看实际工程里的综合成本。四位这个规模恢复余数法是最合适的。3. RTL实现可复用的四位除法器代码3.1 端口规划一个除法器需要哪些信号先定义接口端口表格如下端口名方向位宽功能clkinput1系统时钟rst_ninput1异步复位低有效startinput1启动除法高电平脉冲dividendinput4被除数无符号divisorinput4除数无符号quotientoutput4商无符号remainderoutput4余数无符号busyoutput1除法忙状态doneoutput1除法完成脉冲div_erroutput1除数为零标志start信号拉高一个周期除法器开始工作。busy在启动后的下一拍拉高4拍之后下降。done在最后一个周期拉高一个时钟表示当前结果有效。div_err是除数非法标志正常情况下用不到但工程上必须留出来。3.2 核心状态机逐位恢复余数法的RTL写法代码完全从算法推导过来核心是一个9位寄存器和4拍计数。需要9位的原因很关键当前余数虽然是4位但左移一位再加被除数的一位之后最大值可能达到29需要用5位去表达。如果只用4位最高位会被截掉结果就是错的。这个坑我见过好几个人踩包括我自己第一次写的时候也翻过车。直接看可综合代码module div4_restoring ( input wire clk, input wire rst_n, input wire start, input wire [3:0] dividend, input wire [3:0] divisor, output reg [3:0] quotient, output reg [3:0] remainder, output reg busy, output reg done, output reg div_err ); reg [3:0] cnt; // 迭代计数 reg [8:0] tmp; // {5-bit余数, 4-bit被除数/商} wire [4:0] r_cand {tmp[7:4], tmp[3]}; wire q_bit (r_cand {1b0, divisor}); wire [4:0] r_new q_bit ? (r_cand - {1b0, divisor}) : r_cand; wire [8:0] tmp_new {r_new, tmp[2:0], q_bit}; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 4d0; tmp 9d0; quotient 4d0; remainder 4d0; busy 1b0; done 1b0; div_err 1b0; end else if (start !busy) begin cnt 4d4; tmp {5b0, dividend}; busy 1b1; done 1b0; div_err (divisor 4d0); end else if (busy) begin if (!div_err) begin if (cnt 4d1) begin busy 1b0; done 1b1; quotient tmp_new[3:0]; remainder tmp_new[8:4]; end tmp tmp_new; end else begin busy 1b0; done 1b1; quotient 4d0; remainder 4d0; end cnt cnt - 1b1; end else begin done 1b0; end end endmodule这段代码有几点值得细看r_cand {tmp[7:4], tmp[3]}本质上就是余数左移一位再把tmp[3]里的被除数当前位填进最低位。tmp[3]是当前剩余被除数的最高位。q_bit就是商的当前位够减为1不够减为0。tmp_new把新余数放回高5位把被除数剩余的2位保留下来最低位填商位。cnt从4递减到1执行4次迭代。当cnt等于1时代表最后一次迭代这个时候把结果打出去。这个写法没有用for循环也没有用组合逻辑的大规模迭代映射出来的电路非常规整一个比较器、一个减法器、一组移位寄存器外加一个4位计数器。资源占用非常小而且关键路径很短。3.3 组合逻辑版对比什么时候不适合用网上也有很多人直接给组合逻辑版本比如这样module div4_comb ( input [3:0] dividend, input [3:0] divisor, output [3:0] quotient, output [3:0] remainder ); assign quotient (divisor 0) ? 4d0 : dividend / divisor; assign remainder (divisor 0) ? 4d0 : dividend % divisor; endmodule这段代码综合不是不行现代综合工具确实能把它推断出来。问题在于综合工具为了支持所有可能组合会展开成一大片组合逻辑。4位的时候看起来还行LUT数量也就几十个。但如果哪天你把这个模块的位宽改成8位、12位组合逻辑路径会迅速变长时序马上出问题。所以我个人建议凡是数据要进入系统做持续运算而不是一次性静态计算的最好都用状态机版本。组合逻辑版更适合那种只算一次、结果直接进寄存器的场景。3.4 综合与资源评估我在一块很普通的入门级FPGA上综合过这个模块只用了15到25个LUT十来个FF没有用DSP。Fmax随随便便就能跑到200MHz以上。对比综合工具直接生成的除法器资源能少一半以上。对比case枚举大表扩展性更是天壤之别。如果要把模块参数化可以再加一个parameter N 4把输入端宽度改成N中间余数寄存器的宽度改成N1迭代次数改成N。逻辑框架完全不用动。这也是我强烈推荐状态机方案的原因前期的设计成本花得很值。4. 仿真与实测从Testbench到板上验证4.1 Testbench设计256组输入穷举功能验证我选择最暴力的方式把所有可能的输入组合全部跑一遍。因为被除数和除数都只有4位总组合数是16乘以16刚好256组仿真时间非常短。Testbench里重点要做三件事验证正常除法商和余数是否符合预期。验证除数为零是否正确报告div_err。验证启动时序忙状态下再拉start是否会被正确处理。可以直接参考这个Testbench核心代码module tb_div4(); reg clk 0; reg rst_n 0; reg start 0; reg [3:0] dividend; reg [3:0] divisor; wire [3:0] quotient; wire [3:0] remainder; wire busy; wire done; wire div_err; div4_restoring uut ( .clk(clk), .rst_n(rst_n), .start(start), .dividend(dividend), .divisor(divisor), .quotient(quotient), .remainder(remainder), .busy(busy), .done(done), .div_err(div_err) ); always #10 clk ~clk; integer i, j; initial begin #30 rst_n 1; // 穷举256组输入 for (i 0; i 16; i i 1) begin for (j 0; j 16; j j 1) begin dividend i; divisor j; start 1; (posedge clk); start 0; // 等待5个时钟4拍迭代加1个输出稳定周期 repeat(5) (posedge clk); if (j 0) begin if (!div_err) begin $display(ERROR: divisor0 but div_err not set, i%0d, j%0d, i, j); end end else begin if (quotient ! i / j || remainder ! i % j) begin $display(ERROR: %0d/%0d q%0d r%0d, expect q%0d r%0d, i, j, quotient, remainder, i / j, i % j); end end end end $display(All tests finished.); $stop; end endmodule这里的repeat(5)不是凭空拍脑袋定的。状态机在start拉高后的第一个时钟沿启动cnt4之后需要4个时钟沿完成4次迭代再等一个时钟让输出稳定。所以固定等5拍时机刚刚好。4.2 仿真结果分析三组典型波形跑完仿真重点看三个波形区间。第一组是正常除法比如dividend13divisor3。启动后busy拉高4拍之后done拉高同时quotient变成4remainder变成1。波形很清楚。第二组是边界情况比如dividend15divisor1。此时商应该是15余数是0。这个用例专门用来验证商的位宽够不够有没有溢出。15正好是4位的最大值如果商逻辑没写好这里很容易变成0或者直接报X态。第三组是除数为零比如dividend0divisor0。start拉高后div_err拉高busy在下一拍下降done拉高输出的商和余数都不再更新。整个流程不报错、不死循环。4.3 板上验证流程与注意事项仿真过了不代表板上就一定没问题。我在板上验证时一般这么操作先用按键产生start信号把被除数和除数从拨码开关读进来结果用LED灯直接显示。四位被除数、四位除数、四位商、四位余数加上一个done灯刚好二十来个LED很容易观察。有一点要特别注意板子上的机械按键直接接入FPGA会产生抖动。如果start信号接按键一定要做去抖处理否则一次按键可能被识别成多次除法器会反复启动。去抖的方法很简单用一个计数器延时稳定或者直接用边沿检测加同步器。我习惯用两级触发器做异步信号同步再接一个窄脉冲检测把按键抖动滤掉。还有一种做法是直接用串口或者调试工具把被除数和除数从上位机发下来结果打印回去。这样定位问题更方便就是实现起来要多花一些时间。5. 踩坑记录除零、时序和综合优化5.1 除数为零该报错就报错除数为零在任何除法器里都是非法输入。很多初学RTL的朋友直接忽略了这一点结果仿真里一除零整个状态机就异常要么一直忙要么输出未知态。我的处理方式很直接启动除法的那一刻就判断divisor 0把它锁存到div_err寄存器里。后续状态机不再执行迭代直接完成一次“假除法”输出全零同时把div_err拉高。这样上层模块就知道这次运算结果无效而不会卡死主流程。要注意div_err信号在start之后的第一个周期就拉高但busy还会维持一小拍。这么做是为了让状态机有统一的退出路径不至于为除零单独再开一套状态。代码上我已经处理好了直接复制即可。5.2 有符号除法和无符号除法不是一回事标题里说的是无符号四位除法器。函数式验证的代码里商和余数都是用无符号整数比对。如果目标是带符号除法事情就麻烦了。补码除法的结果有两个细节商的舍入方向是向零取整余数的符号和被除数保持一致。换句话说-7除以3商应该是-2余数应该是-1而不是商-3余2。这一点和C语言里的整数除法语义是一样的非常容易被忽略。实现带符号除法时我的建议是先在输入侧做符号扩展转成正数运算算出结果后再根据被除数和除数的符号关系对商取反。余数则单独看被除数的符号。这个方案虽然多几个周期但逻辑清晰不容易出错。5.3 时序收敛4位不用怕扩展要当心四位除法器的关键路径非常短正常情况下不可能出现时序违例。真正要注意的是把模块参数化扩展到8位、16位以后。扩展位宽之后比较器和减法器的位宽一起增加组合逻辑延迟变大。如果迭代次数还是N拍那Fmax会明显下降。这时候有几个优化手段在减法器和比较器之间插寄存器把关键路径打断。用流水线结构让除法器同时处理多组数据。对于更大位宽的运算直接换用专用的DSP配合迭代算法或者考虑使用综合工具里更成熟的除法IP。另外综合工具偶尔会把/运算符推断成用乘法器实现。如果我希望强制走迭代逻辑可以在模块前加一句综合属性把DSP推断关掉。具体写法看工具厂商一般都有类似(* use_dsp no *)的语法用上会更稳妥。5.4 调试技巧汇总从波形到总线最后整理几条我实际调试中用得上的经验。第一条先看done和busy的波形再对商和余数。很多除法器算错其实是启动时序没对齐。先确认busy持续了4拍done在正确位置拉高基本状态机就对了。第二条中间余数溢出要查位宽。如果你把tmp寄存器设计成8位而不是9位某些输入组合比如13除以3的结果可能偶尔对偶尔错。这种边界型错误最容易出现在仿真中后发现排查起来非常恼火。位宽的十六字口诀先算最大再定位数留出冗余宁多勿少。第三条多个除法请求连续来时必须做握手。如果上层模块在前一个除法还没结束时又拉高start我的代码会通过start !busy条件直接忽略掉。工程上如果需要排队可以再加一个小的FIFO把所有请求缓存起来避免丢失。第四条验证别只靠一组输入。你永远不知道下一组输入会不会触发某个隐藏的逻辑错误。穷举256组是本项目的低成本福利做完之后对代码的信心会完全不同。我在实际使用中还有个习惯每次改完代码先把Testbench跑一遍全量回归再对着综合报告扫一眼资源最后才上板。这套流程看起来麻烦但能省掉很多在实验室里对着示波器发呆的时间。这个四位除法器做下来最大的收获不是那几十行Verilog而是明白了“先用算法想清楚再写代码”这句话到底有多重要。