资讯动态

Verilog加法器实现全解析:从半加器到超前进位与仿真验证

发布时间:2026/10/14 18:20:55 来源:尧图企业网站定制
加法器看着基础但它是Arithmetic单元里绕不开的模块。用verilog实现加法器这件事听起来简单真到了位宽、进位、时序约束、Testbench这些细节上坑比想象中多。这篇文章想覆盖的不光是给你一段能跑的加法器代码而是把从半加器到全加器、从行波进位到超前进位、从RTL写法到仿真验证的完整链路讲清楚适合正在学FPGA、计算机组成原理或者准备流片验证的工程师参考。先说结论工程里直接用assign sum a b最省事综合工具会自动选合适的实现但如果你在学原理、做IP设计或者需要精确控制进位链结构就绕不开手工搭建加法器。下面会把每个结构为什么要存在、延迟出在哪里、代码怎么写、仿真怎么验一层层拆开。1. 从零搭加法器半加器到全加器的设计递进1.1 半加器的真值表与最小实现半加器解决的是两个1bit二进制数相加的问题输入只有a和b输出为sum和carry。真值表非常直接absumcarry0000011010101101从真值表可以直接看出两个布尔表达式sum a ^ bcarry a b。用Verilog写出来就是一个assign语句的事module half_adder ( input wire a, input wire b, output wire sum, output wire carry ); assign sum a ^ b; assign carry a b; endmodule这里有个细节值得说半加器虽然逻辑简单但它的carry输出不带进位输入所以没法直接拼接起来做多bit加法。你要把多位二进制数相加每一位都要处理来自低位的进位单靠半加器是不够的。1.2 全加器为什么多一个进位输入全加器把输入信号从两个变成三个a、b、cin。cin是低位的进位输出。真值表多了一列行为也更接近真实的手算加法abcinsumcout0000000110010100110110010101011100111111从逻辑表达式看sum a ^ b ^ cincout (a b) | (cin (a ^ b))。用半加器去拼全加器是理解这段逻辑的最直观方式先用半加器算a b再把它输出的和与cin做第二次半加最后把两次产生的进位做或运算。module full_adder ( input wire a, input wire b, input wire cin, output wire sum, output wire cout ); wire s1, c1, c2; half_adder u0 ( .a (a), .b (b), .sum (s1), .carry (c1) ); half_adder u1 ( .a (s1), .b (cin), .sum (sum), .carry (c2) ); assign cout c1 | c2; endmodule这种写法最大的好处是把“位之间的关系”显式地表达出来后面配合generate可以级联成任意位宽。初学者经常会在这里纠结为什么不用行为级代码直接写sum a b cin行为级当然可以在综合里最终也会被综合成同一个功能。但如果你要控制结构、要走进位链的布线路径门级或者数据流级的写法才具备可迁移性。2. 多位加法器怎么选进位处理方式决定性能2.1 行波进位加法器直觉正确延迟随位宽线性增长把多个全加器首尾相接低位的cout接到高位的cin就得到了行波进位加法器。名字里的“行波”很形象进位像水波一样从最低位一路传到最高位。module ripple_carry_adder #( parameter WIDTH 8 )( input wire [WIDTH-1:0] a_i, input wire [WIDTH-1:0] b_i, input wire carry_in_i, output wire [WIDTH-1:0] sum_o, output wire carry_out_o ); wire [WIDTH:0] carry; assign carry[0] carry_in_i; genvar i; generate for (i 0; i WIDTH; i i 1) begin : gen_full_adder full_adder fa_unit ( .a (a_i[i]), .b (b_i[i]), .cin (carry[i]), .sum (sum_o[i]), .cout(carry[i1]) ); end endgenerate assign carry_out_o carry[WIDTH]; endmodule这个结构最大的优点是简单、可读性强。缺点是进位链延迟很明显一次8bit加法最高位的“最终结果”要等所有低位的进位依次确定。在硬件里每个全加器从cin到cout至少有2级门延迟8bit就是16级位宽翻倍延迟翻倍。放到工程里如果时钟频率要求高这个结构往往成为时序路径上的瓶颈。实际综合时绝大多数FPGA工具并不会老老实实按这个组合逻辑展开。它们会识别出这是加法器结构然后映射到芯片自带的专用进位链上。所以你在FPGA上综合行波进位加法器速度可能比想象中好。但在ASIC或者教学仿真里去按门级展开模型估算延迟还是能看到线性增长的规律。2.2 超前进位加法器用并行方式消除等待行波进位的问题在于进位要“逐级传播”。超前进位加法器的思路是提前把每一位的进位生成条件算出来让高位的进位不需要等低位实际算出结果。定义两个中间信号生成信号g a b当a和b都为1时这一位一定产生进位。传播信号p a | b当a和b中至少有一个为1时如果低位有进位进来这一位会把进位继续向上传。那么第i1位的进位可以写成c[i1] g[i] | (p[i] c[i])把c[i]继续展开就能得到只用最低位进位计算出来的值。这就是超前进位的核心。Verilog里可以直接写成 generate 循环module cla_adder #( parameter WIDTH 4 )( input wire [WIDTH-1:0] a, input wire [WIDTH-1:0] b, input wire c_in, output wire [WIDTH-1:0] sum, output wire c_out ); wire [WIDTH-1:0] g a b; wire [WIDTH-1:0] p a | b; wire [WIDTH:0] c; assign c[0] c_in; genvar i; generate for (i 0; i WIDTH; i i 1) begin : gen_cla assign c[i1] g[i] | (p[i] c[i]); assign sum[i] a[i] ^ b[i] ^ c[i]; end endgenerate assign c_out c[WIDTH]; endmodule这里有个需要理解的坑上面的代码只是把进位表达式用生成逻辑展开了但它本质上仍然是一级一级推导的c[i1]综合器能不能把它优化成真正的“超前进位”结构取决于综合工具。真正的CLA会在代码里通过括号手动展开c[4] g[3] | (p[3] g[2]) | ...。但是现代综合工具对加法器的优化能力很强手写展开反而不一定最好所以绝大多数时候我们只需要理解原理不一定非要手搓一个“教科书版CLA”。2.3 工程里更常见的折中流水线加法器如果目标是高吞吐而不是让单次加法特别快还有一个思路把加法拆成几个流水级中间插入寄存器。比如64bit的加法不直接在一个周期内做完而是先算低32bit、存下来再算高32bit周期数变多但每一个周期能接受新的输入整体吞吐率上去了。流水线不是加法器本身的逻辑变快而是用面积换吞吐。综合视角下你会看到关键路径长度不变但它从“一次加法必须走完”变成了“一个周期只走一部分”。这种思路在做DSP、图像处理、高性能计算时很常见。实际选型时可以画一张简单的延迟和吞吐对比表结构单次延迟循环周期数吞吐率硬件开销行波进位低到中1中等低超前进位较低1较高中高流水线加法器高N高中高延迟和吞吐是两回事这一点很多初学者会搞混。行波进位在“单个加法完成时间”上可能反而不占优势但它在简单组合逻辑场景下足够用。超前进位在关键路径上更友好代价是逻辑更复杂。流水线则适合需要连续输入流数据的场景。3. 可直接复用的完整代码与Testbench3.1 参数化加法器模块使用注意事项把位宽留成参数是RTL的基本要求。前面给出的ripple_carry_adder已经用parameter WIDTH实现了参数化用的时候只需要例化并传入参数ripple_carry_adder #( .WIDTH(16) ) u_adder_16b ( .a_i (a_16), .b_i (b_16), .carry_in_i(cin), .sum_o (sum_16), .carry_out_o(cout) );重点说一下generate块里的命名begin : gen_full_adder不是可有可无的如果你在generate循环里例化了模块并且给这个模块传递carry[i]这种数组那么综合和仿真工具会要求你给generate块一个名字以便在GUI或者网表中定位故障。如果不写名字有些工具能以genblk自动命名但定位问题的时候会让你抓狂。还有一个容易踩的点genvar循环里不能直接把i当作信号去连接没有位宽搭配的端口。比如全加器的a是1bit你写.a(a[i])没问题但如果a_i本身高位在前你要注意循环的方向是i0对应最低位a_i[0]。很多人习惯用手写数字编号时从低位开始这里保持一致就好。3.2 Testbench设计要点与覆盖思路没有Testbench的加法器模块等于没有验收。写加法器Testbench的核心目标不是跑通一次而是尽量覆盖边界两个输入都是0结果0且进位0。输入最大值0xF...F两个加数相加后溢出进位为1。进位输入本身为1验证低位进位能被正确传递。随机组合利用参考模型expected a b cin对比。下面这个Testbench使用了穷举方式对WIDTH4来说a、b各有16种取值再加cin2种共512种情况完全穷举也不慢timescale 1ns / 1ps module tb_adder; parameter WIDTH 4; reg [WIDTH-1:0] a_tb; reg [WIDTH-1:0] b_tb; reg cin_tb; wire [WIDTH-1:0] sum_tb; wire cout_tb; reg [WIDTH:0] expected; integer i, j, k; ripple_carry_adder #( .WIDTH(WIDTH) ) dut ( .a_i (a_tb), .b_i (b_tb), .carry_in_i (cin_tb), .sum_o (sum_tb), .carry_out_o (cout_tb) ); initial begin for (i 0; i (1 WIDTH); i i 1) begin for (j 0; j (1 WIDTH); j j 1) begin for (k 0; k 2; k k 1) begin a_tb i; b_tb j; cin_tb k; #10; expected i j k; if ({cout_tb, sum_tb} ! expected) begin $display(Error: a%h b%h cin%b sum%h cout%b expected%h, a_tb, b_tb, cin_tb, sum_tb, cout_tb, expected); $fatal; end end end end $display(All tests passed.); $finish; end endmodule这里的{cout_tb, sum_tb}是位拼接把1bit的进位输出和4bit的和拼成一个5bit变量方便和expected做整体对比。注意我用的是!而不是!。在仿真中如果出现X态!在某些情况下会被判定为假导致测试“误通过”!则会把X态视为不匹配更容易暴露问题。仿真完成后除了看“All tests passed”还建议打开波形把最高位的进位路径手动拉出来看一下。加法器这种模块逻辑仿真的结果强烈依赖时序和进位关系光看打印日志不够直观。4. 综合、验证与代码风格里的坑4.1 阻塞赋值与非阻塞赋值不是随便选的加法器本身是组合逻辑assign语句里不存在阻塞和非阻塞的问题。但如果你把加法器包在一个带时钟的模块里比如让sum寄存一拍输出就涉及到底用always (posedge clk)里的写法。低层全加器模块继续用assign顶层模块做打拍时用非阻塞赋值是最不容易出错的组合。module adder_reg #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] a_i, input wire [WIDTH-1:0] b_i, output reg [WIDTH-1:0] sum_o ); wire [WIDTH-1:0] sum_w; ripple_carry_adder #( .WIDTH(WIDTH) ) u_adder ( .a_i(a_i), .b_i(b_i), .carry_in_i(1b0), .sum_o(sum_w), .carry_out_o() ); always (posedge clk or negedge rst_n) begin if (!rst_n) sum_o {WIDTH{1b0}}; else sum_o sum_w; end endmodule有一个很常见的仿真复用陷阱如果完全在always块里写sum a b仿真时你会发现sum比预期晚一个时钟周期才变化。这是正常现象因为非阻塞赋值本来就是把“此刻计算好的值”在下一个时钟沿才更新。但如果你本来只是想要组合输出却顺手用了always和那在时序里就会出现额外一拍这会让你的调试方向跑偏。4.2 加法器综合时最容易被忽略的位宽位宽不对是加法器相关的第一号低级错误。两个8bit数相加最大是255 255 510这个结果需要9bit才能放下。如果你声明sum_o也为8bit仿真工具不会直接报错而是按截断处理溢出被丢弃。在RTL里显式把输出位宽扩展成WIDTH1是在做数据通路设计时就要想清楚的事。如果需要无符号扩展可以直接在赋值前拼接assign extended_sum {1b0, a_i} {1b0, b_i};这样即使两个操作数都是8bit相加结果也会保留在9bit空间里。另一种做法是让模块本身多出一个carry_out也就是前面代码里那个carry_out_o。二者表达的是同一件事但外面拿结果时要明确你到底是看完整和值还是只看截断后的低8位4.3 仿真波形里出现X态和毛刺怎么查组合逻辑加法器的毛刺本质上是不同输入端通过不同路径到达输出端的时间不一致造成的。逻辑仿真中很难精确看到门延迟除非你加了延迟注释。实际操作中更常见的是X态。X态出现的典型场景有三个输入没复位直接参与组合计算。位宽不匹配比如用未初始化的寄存器参与加法。generate循环里连错了位导致某些bit没有被驱动。遇到X态第一反应不是去改代码而是先看仿真波形。单独拉出a_i、b_i、carry_in_i和各级carry如果某个中间信号一直是X那就说明它没有被赋值。再往前追一级直到找到源头。加法器这种层级结构顺着例化树追信号是最高效的方式。5. 常见问题与排查技巧实录5.1 仿真时结果总是差一个时钟周期这个问题的场景通常是你在模块内部写了always (posedge clk)结果把组合逻辑和时序逻辑混在一起了。加法器如果是纯组合逻辑就不应该加时钟你只是在Testbench里每隔10ns改变输入组合逻辑的输出要等到下一次输入变化才会变而不是等一个时钟沿。如果输出确实要打拍那Testbench就也要按同样的时钟时序去采样。比如你在时钟上升沿后#2读取输入时钟周期为10ns那么读取窗口通常要放在输入稳定之后。不要用“等到输出等于某值时再采样”的写法那样只能验证仿真的静态结果不能验证时序。5.2 Testbench全绿色但波形里进位链没有变化这种情况多半是参考模型和DUT使用了同一个表达式。比如你在Testbench里写了expected i j kDUT内部也用了IPC综合的加法器实现那么两者即使有结构问题也可能“共同错”。更好的做法是至少给一组手工计算的真值。比如验0xF 0x1应该等于0x10进位是1验0x7 0x8 1应该等于0x10进位是1。这些边界用例能明显提高验证的有效性。对于加法器全穷举当然最理想但如果位宽超过8bit穷举就不太现实了建议采用随机测试 定向边界测试。5.3 generate 循环中的命名和位序问题前面代码里我写了generate for (i 0; i WIDTH; i i 1) begin : gen_full_adder。这里i0对应的端口是a_i[0]不是a_i[WIDTH-1]。如果位序写反加法结果完全错误。这类问题在仿真里不容易发现因为只是结果差了一个值而且不是每个测试用例都会触发错误但边界用例会立刻暴露出来。另外如果多个generate块用了同一个信号名但分别位于不同模块层级工具会按层次路径定位不会重名报错。但是代码可读性会变差。我习惯在例化名中加入gen_前缀比如gen_full_adder这样看到波形时能快速判断这是第几位。5.4 我怎么快速定位“进位链路”上的问题最直接的办法是在模块里引出一根测试总线把中间进位链都拉到顶层端口上。对于行波进位加法器可以加几个assign debug_carry carry;的输出。这个做法在Debug时很高效但交付给其他人的时候记得要把这些调试端口删掉或改为ifdef包裹。如果觉得改端口麻烦也可以在Testbench里用层次化引用直接看内部信号比如initial $monitor(%h, dut.carry);层次化引用在仿真里很自由但综合工具不会理会它。所以它只适合验证不适合做“最终代码”。最后分享一个我自己常用的验证思路不要只盯着sum的结果加法器有两条关键路径一条是a/b到sum的异或路径另一条是cin到cout的进位路径。前者验证的是逻辑门级后者验证的是位间传播。只要把两条路径分别用波形拆开看加法器的绝大部分实现问题都能快速定位。写代码前多花五分钟想清楚进位结构比事后翻一百行波形更有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑