写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采样之后做归一化,比如采样值除以满量程再乘刻度系数;一些简单控制算法里算偏差比例;还有教学演示用的简易CPU,ALU里就差一个除法指令。这些场景的共同点是数据量不大、对延迟不敏感、但不希望为了一次除法消耗掉一大片资源。所以自己写一个小的除法器,往往比综合工具直接“除”更可控。
1.2 “任意四位”到底指什么
标题里的“任意四位”,我理解成被除数和除数都是4位无符号二进制数,取值范围0到15,输出商和余数也都是4位。商为什么能保证4位放得下?因为最大情况是15除以1,结果是15,正好落在4位二进制能表达的范围内。余数最大也就14,4位同样放得下。
这里有一个容易踩的坑:如果改成有符号数,位宽、符号扩展、结果定义全部要重新设计。无符号除法和补码除法在硬件实现上有本质差别,这一点第五章会展开讲。
“任意”的另一层意思,是覆盖所有输入组合。工程上最稳妥的办法是穷举仿真:总共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的二进制是1101,3的二进制是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 端口规划:一个除法器需要哪些信号
先定义接口,端口表格如下:
| 端口名 | 方向 | 位宽 | 功能 |
|---|---|---|---|
| clk | input | 1 | 系统时钟 |
| rst_n | input | 1 | 异步复位,低有效 |
| start | input | 1 | 启动除法,高电平脉冲 |
| dividend | input | 4 | 被除数,无符号 |
| divisor | input | 4 | 除数,无符号 |
| quotient | output | 4 | 商,无符号 |
| remainder | output | 4 | 余数,无符号 |
| busy | output | 1 | 除法忙状态 |
| done | output | 1 | 除法完成脉冲 |
| div_err | output | 1 | 除数为零标志 |
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 >= {1'b0, divisor}); wire [4:0] r_new = q_bit ? (r_cand - {1'b0, 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 <= 4'd0; tmp <= 9'd0; quotient <= 4'd0; remainder <= 4'd0; busy <= 1'b0; done <= 1'b0; div_err <= 1'b0; end else if (start && !busy) begin cnt <= 4'd4; tmp <= {5'b0, dividend}; busy <= 1'b1; done <= 1'b0; div_err <= (divisor == 4'd0); end else if (busy) begin if (!div_err) begin if (cnt == 4'd1) begin busy <= 1'b0; done <= 1'b1; quotient <= tmp_new[3:0]; remainder <= tmp_new[8:4]; end tmp <= tmp_new; end else begin busy <= 1'b0; done <= 1'b1; quotient <= 4'd0; remainder <= 4'd0; end cnt <= cnt - 1'b1; end else begin done <= 1'b0; 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) ? 4'd0 : dividend / divisor; assign remainder = (divisor == 0) ? 4'd0 : dividend % divisor; endmodule这段代码综合不是不行,现代综合工具确实能把它推断出来。问题在于,综合工具为了支持所有可能组合,会展开成一大片组合逻辑。4位的时候看起来还行,LUT数量也就几十个。但如果哪天你把这个模块的位宽改成8位、12位,组合逻辑路径会迅速变长,时序马上出问题。
所以我个人建议,凡是数据要进入系统做持续运算,而不是一次性静态计算的,最好都用状态机版本。组合逻辑版更适合那种只算一次、结果直接进寄存器的场景。
3.4 综合与资源评估
我在一块很普通的入门级FPGA上综合过这个模块,只用了15到25个LUT,十来个FF,没有用DSP。Fmax随随便便就能跑到200MHz以上。对比综合工具直接生成的除法器,资源能少一半以上。对比case枚举大表,扩展性更是天壤之别。
如果要把模块参数化,可以再加一个parameter N = 4,把输入端宽度改成N,中间余数寄存器的宽度改成N+1,迭代次数改成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: divisor=0 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拉高后的第一个时钟沿启动,cnt=4,之后需要4个时钟沿完成4次迭代,再等一个时钟让输出稳定。所以固定等5拍,时机刚刚好。
4.2 仿真结果分析:三组典型波形
跑完仿真,重点看三个波形区间。
第一组是正常除法,比如dividend=13,divisor=3。启动后busy拉高,4拍之后done拉高,同时quotient变成4,remainder变成1。波形很清楚。
第二组是边界情况,比如dividend=15,divisor=1。此时商应该是15,余数是0。这个用例专门用来验证商的位宽够不够,有没有溢出。15正好是4位的最大值,如果商逻辑没写好,这里很容易变成0或者直接报X态。
第三组是除数为零,比如dividend=0,divisor=0。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,而是明白了“先用算法想清楚再写代码”这句话到底有多重要。