简介:这是一份面向FPGA开发的Verilog学习资源,重点围绕4位超前进位加法器以及如何用4位加法器级联成16位加法器,系统讲解数字逻辑设计方法。资源基于Vivado工具完成工程搭建、逻辑综合与仿真验证,适合初学者熟悉硬件描述语言开发流程,也适合进阶者复习进位链优化思路。压缩包内共432个文件,总大小约1.68MB,内容涵盖Verilog源码、Xilinx工程文件、批处理与Tcl运行脚本、综合报告和仿真日志等,各类文件分工明确,目录结构清晰,便于按模块查阅。工程源码分别实现了4位CLA和16位级联加法器,测试工程包含仿真激励,可帮助读者核对超前进位生成函数、传播函数以及最终进位结果是否正确。同时,通过波形仿真可观察进位级联过程。已有2775人学习或下载,打开即可复用或修改,适合作为课程设计、实验报告或FPGA入门实践的完整基础模板。
1. 为什么4bit超前进位加法器值得手写一遍
行波进位加法器每产生一位进位都要等前一位算完,16bit行波的最坏路径从bit0一路串到bit15,这在数字IC笔试和FPGA面试里几乎是必问的题:怎么把这条进位链砍短。超前进位加法器(Carry Look-Ahead Adder)把进位从“逐级传递”改成“按公式并行展开”,4bit块内用3级左右的门同时算出C1到C4,再把4bit块拼成16bit后,关键路径与“等16次进位”无关。这个题目正好覆盖组合逻辑推导、时序估算和模块例化三项基本功,无论目标是设计岗还是验证岗,手写一遍都比背公式收获更大。
2. 4bit超前进位加法器的逻辑推导与Verilog实现
2.1 用G和P把全加器拆成“生成”与“传播”
对于一个全加器,输入是A、B、Cin,输出和S与进位Cout。把真值表化简后得到两个等价式:
S = A ^ B ^ Cin
Cout = (A & B) | (A & Cin) | (B & Cin)
如果只盯着Cout看,可以把它拆成一条与Cin无关的路径和一条依赖Cin的路径。令G = A & B,P = A ^ B,Cout变成:
Cout = G | (P & Cin)
当A和B同时为1时,G=1,本bit无论Cin是0还是1都会向高位产生进位,所以G叫生成信号(generate)。当A和B不同时,G=0,若P=1,高位的进位等于Cin原样透传,所以P叫传播信号(propagate)。S也可以借助P写得更短:S = P ^ Cin。
把加法和进位拆成两组中间信号后,原来“后一位要等前一位Cout”的递归链条,变成了可以逐层展开的布尔表达式。这是超前进位加法器和行波进位加法器在结构上最大的区别。
2.2 展开C1到C4:把串行进位换成并行表达式
4bit CLA 的接口我用下列信号:输入A[3:0]、B[3:0]、Cin,输出S[3:0]、Cout。对每一位,定义G[i] = A[i] & B[i],P[i] = A[i] ^ B[i]。从最低位开始展开进位:
C[0] = Cin
C[1] = G[0] | (P[0] & C[0])
C[2] = G[1] | (P[1] & G[0]) | (P[1] & P[0] & C[0])
C[3] = G[2] | (P[2] & G[1]) | (P[2] & P[1] & G[0]) | (P[2] & P[1] & P[0] & C[0])
C[4] = G[3] | (P[3] & G[2]) | (P[3] & P[2] & G[1]) | (P[3] & P[2] & P[1] & G[0]) | (P[3] & P[2] & P[1] & P[0] & C[0])
C[1]到C[4]没有任何一项依赖C[1]、C[2]、C[3]自身的计算结果,等式右边全部来自输入和C[0]。也就是说,这4个进位可以同时算出来,等待时间只等于与或门深度,而不是4次串行加法。对应关系如下表:
| 进位信号 | 最长项 | 需要等待的进位 |
|---|---|---|
| C[1] | P0 & C0 | C0 |
| C[2] | P1 & P0 & C0 | C0 |
| C[3] | P2 & P1 & P0 & C0 | C0 |
| C[4] | P3 & P2 & P1 & P0 & C0 | C0 |
每个进位都只依赖C0,这就是“超前进位”中“超前”二字的来源。位宽继续增大时,C[n]最长的与项会变成n+1个输入,门扇入迅速变大,所以工程上很少做8bit以上的单级CLA,4bit是常见粒度。
2.3 可直接落地的4bit超前进位加法器Verilog代码
下面是我在项目里常用的写法,模块同时输出Gg和Pg,为后面16bit扩展留好接口:
module cla4 ( input [3:0] a, input [3:0] b, input cin, output [3:0] s, output cout, output Gg, // 组生成:本4bit块独立产生进位 output Pg // 组传播:本4bit块把进位透传下去 ); wire [3:0] g = a & b; wire [3:0] p = a ^ b; wire [3:0] c; assign c[0] = cin; assign c[1] = g[0] | (p[0] & cin); assign c[2] = g[1] | (p[1] & g[0]) | (p[1] & p[0] & cin); assign c[3] = g[2] | (p[2] & g[1]) | (p[2] & p[1] & g[0]) | (p[2] & p[1] & p[0] & cin); assign Gg = g[3] | (p[3] & g[2]) | (p[3] & p[2] & g[1]) | (p[3] & p[2] & p[1] & g[0]); assign Pg = p[3] & p[2] & p[1] & p[0]; assign cout = Gg | (Pg & cin); assign s = p ^ c; endmodule这段代码的核心是三个部分。第一部分用两条wire位向量算出每一位的G和P,g和p只依赖a、b,与进位无关。第二部分用assign并行展开C[1]到C[3],每个表达式都直接写完整,不引入中间进位变量。第三部分把C[4]拆成Gg与Pg两个输出,Gg代表“本组自己产生进位”的所有条件,Pg代表“组内的4位全部允许进位透传”的条件,因此cout = Gg | (Pg & cin)。
如果只做4bit加法器,Gg和Pg不接任何信号即可,综合器会优化掉;如果做16bit,这两个输出会在下一级CLA里参与组间进位计算。S用p ^ c而不是直接用加法运算符,目的是保持CLA的结构:和位本来就等于P[i] ^ C[i],写成p ^ c能让综合器识别出异或树,避免把整个模块重新推断成行波进位加法器。
提示:只要输入组合没变化,这组assign的综合结果就是一张纯组合逻辑表。不要在always块里用case把所有2^32输入组合枚举出来,那样综合器反而可能因为case分支太多而重新生成串行进位链。
3. 用4bit加法器搭建16bit加法器:行波级联与两级超前进位
3.1 为什么16bit不直接写16位展开式
理论上可以把C[0]到C[16]全部展开,C[16]的最长与项会包含17个输入,综合到FPGA的6输入LUT上需要拆成多级LUT,时钟频率不升反降。更常见的做法是把位宽切成4bit小块,块内复用cla4,块与块之间再单独设计进位网络。基于上一节的cla4,16bit加法器自然有两种拼法:组间行波级联和组间超前进位。
3.2 方案一:4个4bit加法器行波级联
第一种接法最简单,把上一级的cout接到下一级的cin,依次连接四个cla4:
module cla16_ripple ( input [15:0] a, b, input cin, output [15:0] s, output cout ); wire [2:0] c; // C4, C8, C12 cla4 u0 (.a(a[3:0]), .b(b[3:0]), .cin(cin), .s(s[3:0]), .cout(c[0]), .Gg(), .Pg()); cla4 u1 (.a(a[7:4]), .b(b[7:4]), .cin(c[0]), .s(s[7:4]), .cout(c[1]), .Gg(), .Pg()); cla4 u2 (.a(a[11:8]), .b(b[11:8]), .cin(c[1]), .s(s[11:8]), .cout(c[2]), .Gg(), .Pg()); cla4 u3 (.a(a[15:12]), .b(b[15:12]), .cin(c[2]), .s(s[15:12]), .cout(cout), .Gg(), .Pg()); endmodule四个例化语句把16bit切成四段,a[3:0]、b[3:0]属于第一组,以此类推。块内部仍然是超前进位,所以真正串行的只有c[0](C4)到c[2](C12)三段组间进位。这种方式功能完全正确,代码量最小,但组间进位没有并行化,关键路径约等于4个cla4的延迟串联,16bit相对4bit的优势只体现在每段内部。
例化时把Gg和Pg显式留空,是为了避免输出悬空的编译告警;在实际工程里如果时序约束允许,也可以把Gg/Pg引出到顶层做观测。
3.3 方案二:组间超前进位,两级CLA架构
第二种方案把四个cla4的Gg、Pg收集起来,再用一组与C[1]~C[4]结构相同的表达式同时算组间进位。这样整个16bit只有两级CLA:第一级在四个块内并行算各自的G/P和低位进位,第二级同时生成C4、C8、C12、C16。
module cla16 ( input [15:0] a, b, input cin, output [15:0] s, output cout ); wire [2:0] C; // C4, C8, C12 wire [3:0] G, P; // 四个块的组生成/组传播 cla4 u0 (.a(a[3:0]), .b(b[3:0]), .cin(cin), .s(s[3:0]), .Gg(G[0]), .Pg(P[0])); cla4 u1 (.a(a[7:4]), .b(b[7:4]), .cin(C[0]), .s(s[7:4]), .Gg(G[1]), .Pg(P[1])); cla4 u2 (.a(a[11:8]), .b(b[11:8]), .cin(C[1]), .s(s[11:8]), .Gg(G[2]), .Pg(P[2])); cla4 u3 (.a(a[15:12]), .b(b[15:12]), .cin(C[2]), .s(s[15:12]), .Gg(G[3]), .Pg(P[3])); assign C[0] = G[0] | (P[0] & cin); assign C[1] = G[1] | (P[1] & G[0]) | (P[1] & P[0] & cin); assign C[2] = G[2] | (P[2] & G[1]) | (P[2] & P[1] & G[0]) | (P[2] & P[1] & P[0] & cin); assign cout = G[3] | (P[3] & G[2]) | (P[3] & P[2] & G[1]) | (P[3] & P[2] & P[1] & G[0]) | (P[3] & P[2] & P[1] & P[0] & cin); endmoduleu0的cin直接接顶层cin,u1到u3的cin分别接C[0]、C[1]、C[2],这四个C由第二级CLA同时产生。u0到u3的cout端口在这里不需要连接,因为每组的最终进位已经由assign的直接表达式算出来了。cla4内部计算自己的cout所用的逻辑与C[0]等表达式重复,综合时会被等价合并,不会让面积变成两倍。
从时序上看,16bit CLA的关键路径不再横跨16位,而是先经过第一级cla4算出G/P,再经过第二级CLA算出C[2],最后进入u3内部从cin到S[15]的末级路径。与组间行波相比,进位等待从“三段串行”变成“一段并行加末段组内”,在综合报告里能明显看到路径长度差异。两种方案的取舍如下表:
| 对比项 | 组间行波级联 | 两级超前进位 |
|---|---|---|
| 组间进位方式 | C4→C8→C12串行 | 第二级CLA并行 |
| 关键路径 | 约4个cla4串行 | 约2个cla4等效深度 |
| 额外逻辑 | 无 | 第二级CLA的与或门 |
| 代码量 | 4条例化 | 加约10行assign |
| 适用场景 | 面积优先、教学演示 | 时序优先、ALU/DSP数据通路 |
如果继续扩展到64bit,两级还不够:需要把四个16bit块再次收集G/P,组成第三级CLA。位宽每乘4就多一级,关键路径按级数增长而不是按位宽增长,这正是超前进位结构在高位宽加法器里的价值。
4. 16bit超前进位加法器的testbench设计与仿真验证
4.1 自校验testbench:边界用例加5000组随机激励
验证16bit加法器不能只靠看波形。下面这个testbench同时做三件事:对全0、全1等边界输入做定点检查;产生5000组随机输入;用无符号加法表达式算出期望值并自动比对。
`timescale 1ns / 1ps module tb_cla16; reg [15:0] a, b; reg cin; wire [15:0] s; wire cout; cla16 dut ( .a(a), .b(b), .cin(cin), .s(s), .cout(cout) ); reg [16:0] expect; integer i; initial begin $dumpfile("cla16_tb.vcd"); $dumpvars(0, tb_cla16); a = 16'h0000; b = 16'h0000; cin = 1'b0; #10; if ({cout, s} !== 17'h00000) begin $display("FAIL case0: 0+0+0"); $finish; end a = 16'hFFFF; b = 16'h0000; cin = 1'b1; #10; if ({cout, s} !== 17'h10000) begin $display("FAIL case1: FFFF+0+1"); $finish; end a = 16'hFFFF; b = 16'hFFFF; cin = 1'b1; #10; if ({cout, s} !== 17'h1FFFF) begin $display("FAIL case2: FFFF+FFFF+1"); $finish; end for (i = 0; i < 5000; i = i + 1) begin a = $random; b = $random; cin = $random & 1; #5; expect = {1'b0, a} + {1'b0, b} + cin; if ({cout, s} !== expect) begin $display("MISMATCH: t=%0t a=%h b=%h cin=%b s=%h cout=%b", $time, a, b, cin, s, cout); $finish; end end $display("ALL TESTS PASSED"); $finish; end endmodule三个边界用例分别覆盖“全0进位0”、“最大数加1”和“溢出到第17位”三种情况。{cout, s}拼接成17bit与expect比较,避免只比较s漏掉cout。随机循环里$random & 1取随机数的最低位作为cin,保证cin只有0和1两个取值。expect = {1'b0, a} + {1'b0, b} + cin把两个16bit输入显式扩展成17bit,防止出现位宽截断。
把所有用例写成自动比较而不是手动观察,是组合逻辑验证的底线;伪随机序列在同一仿真器下可重复,出现MISMATCH时第一次报错的位置就是最小复现用例。
4.2 使用iverilog和GTKWave跑仿真的具体命令
在VSCode终端或任意shell里直接执行:
iverilog -s tb_cla16 -o cla16_tb.vvp cla16.v tb_cla16.v vvp cla16_tb.vvp gtkwave cla16_tb.vcdiverilog后面的-s tb_cla16指定仿真顶层,避免cla16和cla16_ripple同时编译时顶层不明确;-o指定生成的vvp仿真文件。vvp运行仿真,因为testbench里写了$dumpfile和$dumpvars,运行目录下会生成cla16_tb.vcd。gtkwave打开波形文件查看内部信号。没有波形需求时可以省略最后一条命令,直接看ALL TESTS PASSED输出。
如果想连组间行波版一起回归,把cla16_ripple也加入编译列表,例化两个DUT后对{cout, s}做一致性比较,代码量增加不到十行,能顺带确认两种架构结果完全等价。
4.3 波形里检查两级CLA的关键信号
打开GTKWave后,把tb_cla16顶层下的C[0]、C[1]、C[2]、G[0..3]、P[0..3]加到波形窗口。重点观察一组随机输入下C[1]是否和手工计算的G[1] | (P[1]&G[0]) | ... 一致。检查点见下表:
| 信号 | 输入条件 | 期望值 |
|---|---|---|
| C[0](即C4) | a[3:0]=4'hF, b[3:0]=4'h0, cin=1 | 1 |
| C[1](即C8) | a[7:4]=4'hF, b[7:4]=4'h0, 前级进位=1 | 1 |
| cout(即C16) | a[15:12]=4'hF, b[15:12]=4'h0, C[2]=1 | 1 |
这三行分别覆盖组间进位的三级传递。如果C[0]正确而C[1]错误,问题出在第二级CLA的表达式,与组内cla4无关;如果C[0]就错,先回头查第一级的G[0]、P[0]。在Xilinx工程里也可以用ILA把C/G/P抓到片上调试,原理和看波形一样,只是采样点换成了真实时钟边沿。
5. 超前进位加法器验证技巧与三个常踩的坑
5.1 三个常踩的坑
手撕超前进位加法器时,功能无非就是那几行展开式,但下面三个坑几乎每次都有人踩到。
| 坑 | 现象 | 正确做法 |
|---|---|---|
| 和位用 a+b+cin 直接算 | 功能对,综合后回到串行进位结构 | S = P ^ C,保持CLA结构 |
| Gg/Pg展开式漏项 | 16bit扩展时只在特定组合出错 | 逐个核对与项,或用SVA断言 |
| 组合逻辑用<=赋值 | 仿真出现无关毛刺,综合前后不一致 | assign或always @*内用= |
第一个坑最常见,因为功能仿真看不出差别,综合报告里才暴露。第二个坑隐蔽在16bit场景:第一级的Gg/Pg表达式与cout共用了大部分项,如果只图省事把cout赋值给Gg而不写完整展开式,级联到高位时C12、C16会在中间某组不产生进位的情况下算错。验证时把三个手工边界用例扩成第4章的随机回归,基本能兜住。
5.2 把内部进位一起打印到回归日志里
我自己习惯在随机循环里多加一条$display,把每组输入对应的C[0]~C[2]打出来:
$display("a=%h b=%h cin=%b -> s=%h cout=%b | G=%b P=%b C=%b", a, b, cin, s, cout, dut.G, dut.P, dut.C);配合grep就能在日志里快速筛选组间进位为1的样本:
vvp cla16_tb.vvp | grep "C=1"这一行会把所有组间进位为1的样本筛出来,配合a、b的输入值验证进位预测是否符合手工展开式。回归时执行iverilog -s tb_cla16 -o cla16_tb.vvp cla16.v tb_cla16.v && vvp cla16_tb.vvp | grep -c "ALL TESTS PASSED",计数为1就是当天改动没有破坏进位链的最直接证据。
本文还有配套的精品资源,点击获取