从半加器到超前进位加法器:Verilog实现与FPGA设计实践 📅 发布时间:2026/8/26 4:52:27 👁 浏览次数: 1. 项目概述从加法器开始的FPGA逻辑设计之旅刚接触FPGA和Verilog的朋友可能觉得一上来就要搞懂复杂的时序、状态机或者高速接口有点无从下手。我的建议是别急着跑先学会稳稳地走。而“走”的第一步往往就是从最基础、最核心的组合逻辑电路开始搭建。加法器就是这个绝佳的起点。它不仅是计算机算术逻辑单元ALU的基石更是你理解硬件描述语言HDL如何描述硬件行为、如何被综合成实际电路的一把钥匙。今天我就结合自己踩过的坑和总结的经验带大家用Verilog实现几种常见的加法器从最简单的半加器、全加器到工程中更实用的行波进位加法器RCA、超前进位加法器CLA。我们不光要写出能仿真的代码更要弄明白代码背后的硬件结构是什么不同的实现方式在速度、面积上有什么权衡。如果你手头有像Xilinx的Basys3、Altera/Intel的DE10-Standard或者国产的EGO1、黑金AX系列等开发板完全可以跟着步骤做一遍用实践巩固理论。2. 加法器的核心原理与硬件映射在写第一行Verilog代码之前我们必须搞清楚一个根本问题我们写的不是“程序”而是“电路描述”。软件思维里c a b是一条瞬间执行的指令。但在硬件世界里这个“加”的动作需要由实实在在的门电路与、或、非、异或等连接而成信号需要时间从输入端传播到输出端。Verilog的作用就是用一种文本形式精确地描述这些门电路如何连接以及信号之间的逻辑关系。2.1 从布尔代数到门电路半加器与全加器的本质我们从最小的单元开始。半加器Half Adder用于计算两个1位二进制数的和它有两个输入A, B两个输出和Sum进位Cout。其真值表非常简单A0, B0 - Sum0, Cout0A0, B1 - Sum1, Cout0A1, B0 - Sum1, Cout0A1, B1 - Sum0, Cout1观察一下你会发现Sum 的输出逻辑其实就是 A 和 B 的“异或”XOR而Cout 的输出逻辑是 A 和 B 的“与”AND。所以一个半加器本质上就是一个异或门加一个与门。在Verilog里我们可以直接用^和运算符来描述。那么全加器Full Adder呢现实中的加法不可能只加一位当处理多位数字时低位产生的进位必须参与到高位的运算中。全加器就是为解决这个问题而生的它有三个输入A, B, 来自低位的进位Cin输出依然是和Sum与向高位的进位Cout。其逻辑表达式稍微复杂一点Sum A ^ B ^ Cin三个输入进行异或Cout (A B) | (A Cin) | (B Cin)任意两个输入同时为1则产生进位你可以把它理解为一个半加器计算A和B的初步和与进位再加上一个半加器将初步和与Cin相加的组合并用或门合并进位。理解这个布尔表达式是写出正确Verilog代码的基础。注意很多新手会试图用软件的顺序执行思维去理解Sum A ^ B ^ Cin担心运算顺序。在硬件中这三个异或门是同时工作的只要A、B、Cin信号稳定Sum的输出在经过一个异或门的延迟实际上是两级后就会稳定。这是硬件并发的核心思想。2.2 Verilog描述层级行为级、数据流级与门级在实现时我们可以用不同抽象层级的描述方式这直接影响综合工具生成的电路。行为级描述最高抽象层级直接使用运算符。例如assign {Cout, Sum} A B Cin;。这种方式最简洁综合工具会自动将其优化为某种加法器结构通常是性能较好的结构。对于初学者快速实现功能很棒但不利于理解底层硬件也无法精确控制电路形态。数据流级描述使用逻辑运算符直接描述布尔表达式。例如assign Sum A ^ B ^ Cin;和assign Cout (AB) | (ACin) | (BCin);。这种方式清晰地对应了门级电路是理解硬件和进行有目的优化的常用手段。门级描述直接实例化底层门原语如xor u1(Sum, A, B, Cin);和and, or等。这种方式最接近实际电路但代码冗长在现代设计中使用较少主要用于某些特殊场景或教学。对于学习而言我强烈推荐从数据流级描述开始。它在你脑海中的“逻辑表达式”和FPGA中的“查找表LUT配置”之间建立了最直观的桥梁。当你用assign语句写出表达式时你应该能立刻在脑海中勾勒出对应的门电路连接图。3. 基础单元实现半加器与全加器的Verilog代码理论说够了我们上代码。我会给出数据流级描述并附上可综合的模块代码和简单的测试平台Testbench。3.1 半加器实现// half_adder.v module half_adder ( input wire A, input wire B, output wire Sum, output wire Cout ); // 数据流描述直接对应异或门和与门 assign Sum A ^ B; // 异或逻辑得到和 assign Cout A B; // 与逻辑得到进位 endmodule这个模块定义了一个名为half_adder的电路有四个端口。assign语句是连续赋值语句意味着只要右侧的A或B发生变化左侧的Sum和Cout会立即理论上重新计算并更新。这模拟了导线连接的关系。3.2 全加器实现// full_adder.v module full_adder ( input wire A, input wire B, input wire Cin, output wire Sum, output wire Cout ); // 数据流描述基于布尔表达式 assign Sum A ^ B ^ Cin; assign Cout (A B) | (A Cin) | (B Cin); endmodule我们来写一个简单的测试文件用ModelSim或Vivado自带的仿真器看看波形。测试平台的关键是产生激励输入信号变化并观察输出。// tb_full_adder.v timescale 1ns / 1ps // 定义时间单位/精度 module tb_full_adder; reg A, B, Cin; // 输入定义为寄存器类型用于在initial块中赋值 wire Sum, Cout; // 输出定义为线网类型连接模块输出 // 实例化被测模块 full_adder u_full_adder ( .A (A), .B (B), .Cin (Cin), .Sum (Sum), .Cout(Cout) ); // 生成测试激励 initial begin // 初始化输入 A 0; B 0; Cin 0; #10; // 等待10个时间单位 // 遍历所有8种输入组合 A0; B0; Cin0; #10; A0; B0; Cin1; #10; A0; B1; Cin0; #10; A0; B1; Cin1; #10; A1; B0; Cin0; #10; A1; B0; Cin1; #10; A1; B1; Cin0; #10; A1; B1; Cin1; #10; #10 $finish; // 仿真结束 end endmodule运行仿真后你应该能看到波形图中对于每一种{A, B, Cin}的组合{Cout, Sum}的输出都符合全加器真值表。这是验证你代码逻辑正确的第一步也是最重要的一步。实操心得仿真时不要只看最后结果对不对。把波形放大观察信号变化的时刻。你会发现当输入变化后Sum和Cout并不是立即变化的而是有一个微小的延迟delta cycle或门延迟。理解这个“延迟”是后续分析时序建立时间、保持时间的基础。养成看波形、分析时序的好习惯是成为合格数字工程师的必经之路。4. 多位加法器构建行波进位与超前进位的权衡单个全加器只能算1位要计算比如两个8位向量的和我们需要把多个全加器“串联”起来。根据“串联”方式的不同就产生了性能迥异的多位加法器。4.1 行波进位加法器直观但缓慢行波进位加法器Ripple Carry Adder, RCA是最直观的构建方式把N个全加器串联低位的Cout连接到高位的Cin。// rca_4bit.v module rca_4bit ( input wire [3:0] A, input wire [3:0] B, input wire Cin, output wire [3:0] Sum, output wire Cout ); wire [2:0] carry; // 内部进位信号用于连接各全加器 full_adder fa0 (.A(A[0]), .B(B[0]), .Cin(Cin), .Sum(Sum[0]), .Cout(carry[0])); full_adder fa1 (.A(A[1]), .B(B[1]), .Cin(carry[0]), .Sum(Sum[1]), .Cout(carry[1])); full_adder fa2 (.A(A[2]), .B(B[2]), .Cin(carry[1]), .Sum(Sum[2]), .Cout(carry[2])); full_adder fa3 (.A(A[3]), .B(B[3]), .Cin(carry[2]), .Sum(Sum[3]), .Cout(Cout)); endmodule工作原理与性能瓶颈 RCA的问题在于“行波”二字。假设我们要计算A4‘b1111, B4’b0001, Cin0。最低位bit0的110产生进位carry[0]1。这个进位信号需要传递到bit1的全加器才能计算bit1的结果和产生bit2的进位如此依次传递。最坏情况下如上述例子进位信号需要从最低位一直传递到最高位。这意味着总延迟 ≈ N × 单个全加器的进位延迟。当N很大如32位、64位时这个延迟将变得不可接受成为系统性能的瓶颈。优点与适用场景优点结构简单占用逻辑资源少只有N个全加器。适用场景对速度要求不高、位宽较小如小于8位的场合或者作为更复杂加法器如进位选择加法器的组成部分。4.2 超前进位加法器用空间换时间为了打破行波进位的速度限制超前进位加法器Carry Lookahead Adder, CLA应运而生。它的核心思想是不等待低位的进位结果而是直接通过所有输入位A, B和初始进位Cin并行计算出所有位的进位。这基于一个关键观察全加器的进位输出Cout可以写成Cout G | (P Cin)。其中生成信号GenerateG A B如果本位的A和B都是1那么无论有没有低位进位本位都一定会产生进位。传播信号PropagateP A ^ B如果A和B中只有一个为1那么本位的进位输出将等于低位的进位输入Cin。即低位进位可以“穿过”这一位。对于一个4位CLA我们可以展开每一位的进位C0 G0 | (P0 Cin)C1 G1 | (P1 C0) G1 | (P1 G0) | (P1 P0 Cin)C2 G2 | (P2 C1) G2 | (P2 G1) | (P2 P1 G0) | (P2 P1 P0 Cin)C3 G3 | (P3 C2) ... 表达式更长看C1、C2、C3的表达式都只依赖于原始的A[3:0], B[3:0]和Cin我们可以用两级逻辑先并行计算所有G和P再用多输入与或门计算进位同时得到所有进位然后计算和Sum[i] P[i] ^ C[i-1]对于C[-1]即Cin。Verilog实现思路// cla_4bit.v module cla_4bit ( input [3:0] A, B, input Cin, output [3:0] Sum, output Cout ); wire [3:0] G, P; // 生成和传播信号 wire [3:0] C; // 每一位的进位C[3]即最终的Cout // 第一步并行计算所有G和P assign G A B; assign P A ^ B; // 第二步超前进位逻辑根据上述公式展开 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 C[3] 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); // 第三步计算和 assign Sum[0] P[0] ^ Cin; assign Sum[1] P[1] ^ C[0]; assign Sum[2] P[2] ^ C[1]; assign Sum[3] P[3] ^ C[2]; assign Cout C[3]; endmodule性能与代价分析速度关键路径延迟主要在于计算最高位进位如C3的那一串多输入与或门。延迟基本固定与位数N成对数关系因为可以通过多级CLA结构进一步优化远远快于RCA的线性延迟。面积需要额外的逻辑来计算G、P和复杂的进位逻辑。进位逻辑的扇入一个门的输入数量随着位数增加而急剧增大实际中需要将多位CLA分级如4位一组组间再用超前进位来降低扇入这又会增加一些逻辑。因此CLA的面积比RCA大得多。总结CLA用更多的逻辑资源面积换来了更快的速度。这是一种典型的“空间换时间”策略。注意事项上面展示的是4位基础CLA的直接实现。在实际工程中对于16位、32位加法器我们不会写出如此冗长的进位链公式会呈指数级膨胀。而是采用“分级超前进位”或“块超前进位”结构例如将4个4位CLA模块组合成一个16位加法器并额外设计一个“组超前进位单元”Block Carry Lookahead Unit来快速产生组间的进位。Vivado/Quartus等综合工具在遇到运算符时通常会自动综合出这种优化后的结构。5. 工程实践在FPGA上验证与性能分析纸上得来终觉浅我们得把代码放到FPGA里跑一跑看看综合报告才能真正理解它们的区别。5.1 综合与实现看工具如何“翻译”你的代码以Vivado为例当你分别对rca_4bit和cla_4bit进行综合Synthesis后打开综合后的原理图Schematic你会看到截然不同的电路结构。RCA原理图你会清晰地看到4个相同的子模块全加器像链条一样连接进位信号从一个模块的Cout连到下一个模块的Cin。非常直观。CLA原理图你会看到一堆LUT查找表和逻辑门它们之间的连接看起来更复杂、更“网状”没有明显的链式结构。这就是并行计算的体现。更重要的是查看综合报告中的时序和资源估算。时序报告Timing Report查看“最差负时序裕量Worst Negative Slack, WNS”。在相同的时钟约束下CLA的WNS通常会比RCA好即关键路径更短能达到的时钟频率更高。你可以尝试提高时钟频率约束直到WNS为负此时RCA会先于CLA出现时序违例。资源报告Utilization Report查看“LUT使用数量”。CLA使用的LUT数量通常会明显多于RCA因为它需要实现更复杂的组合逻辑。5.2 上板测试设计一个简单的加法器演示我们可以设计一个小的顶层模块用开发板上的开关和LED来验证加法器功能。// top_adder_test.v module top_adder_test ( input wire [3:0] sw, // 开发板上的4位拨码开关作为输入A input wire [3:0] btn, // 开发板上的4位按键作为输入B (注意防抖这里简化) input wire cin_sw, // 另一个开关作为进位输入Cin output wire [3:0] led, // 4位LED显示和Sum output wire led_cout // 另一个LED显示进位输出Cout ); // 实例化你想要测试的加法器例如CLA cla_4bit u_adder ( .A (sw), .B (btn), .Cin (cin_sw), .Sum (led), .Cout (led_cout) ); endmodule引脚约束根据你的开发板型号如Basys3的XDC文件将sw,btn,led等端口映射到实际的物理引脚。生成比特流并下载完成综合、实现、生成比特流然后下载到FPGA。手动测试拨动开关和按键观察LED的亮灭。例如设置sw4‘b0011(3)btn4’b0010(2)cin_sw0LED应该显示0101(5)进位灯不亮。设置sw4‘b1111(15)btn4’b0001(1)cin_sw0LED应该显示0000(0)进位灯亮起因为16溢出Cout1。这种简单的实物验证能极大地增强你的信心和对硬件工作方式的理解。5.3 进阶思考综合工具的优化与“”运算符在实际项目中我们几乎不会手动去写RCA或CLA的代码。我们直接写assign sum a b cin;。那么综合工具会怎么做呢现代综合工具如Vivado、Quartus非常智能它们内置了成熟的算术运算优化器。当你使用时工具会根据你的时序约束时钟频率和面积约束自动选择一个最优的加法器结构。例如如果时序要求很宽松工具可能会综合出一个面积较小的RCA或类似结构。如果时序要求很紧高频时钟工具会倾向于使用类似CLA的快速结构甚至采用更高级的算法如“进位选择加法器Carry Select Adder”或“并行前缀加法器Parallel Prefix Adder”。那么学习手动实现的意义何在理解本质明白工具在背后做了什么当出现时序问题时你才知道从何入手分析。专项优化在极端性能要求的场景下如芯片设计工程师需要手动设计定制化的加法器单元以在速度、面积、功耗之间取得最佳平衡。应对面试这是数字电路设计工程师面试的经典考题。6. 常见问题与调试技巧实录在学习和实现加法器的过程中我遇到过不少坑这里分享几个典型问题和解决方法。6.1 仿真与综合结果不一致问题描述在ModelSim里仿真波形完全正确但下载到板子上行为异常。排查思路检查未初始化的寄存器在Testbench中你用initial块给reg型信号赋了初值。但综合后这些initial语句会被忽略硬件上电时寄存器reg的状态是不确定的。如果你的设计中有依赖于初始状态的逻辑比如状态机必须在复位信号控制下进行初始化。解决方法为你的设计添加一个全局复位信号rst_n在顶层模块中用复位信号将所有寄存器初始化为确定值。always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 初始化所有寄存器 counter 0; state IDLE; end else begin // 正常逻辑 end end检查锁存器Latch的 unintentional 生成如果你在组合逻辑的always块中使用了if或case语句但没有覆盖所有分支综合工具会推断出锁存器来“记忆”未覆盖分支的值。锁存器对毛刺敏感且不易控制时序是很多诡异问题的根源。解决方法确保组合逻辑always块中所有输入信号的变化都能导致输出被明确赋值。给if加上else给case加上default。// 错误示例会生成锁存器 always (*) begin if (sel) out a; // 缺少 else当sel0时out需要保持原值因此生成锁存器 end // 正确示例 always (*) begin if (sel) out a; else out b; // 明确赋值纯组合逻辑 end检查引脚约束LED不亮可能不是因为逻辑错了而是引脚约束文件.xdc或.qsf写错了信号没有连接到正确的物理引脚。仔细核对开发板原理图。6.2 时序违例与加法器关键路径问题描述设计在低时钟频率下工作正常但一旦提高时钟约束就报告时序违例WNS为负。分析对于RCA关键路径就是那条长长的进位链。报告会指出这条路径的起点和终点。对于CLA关键路径在于计算最高位进位的多输入与或门。优化策略流水线Pipelining这是最有效的提速方法。在长的组合逻辑路径中插入寄存器将其分割成多个时钟周期完成。例如可以将一个32位加法器分成两级每级16位中间用寄存器暂存中间进位。这样虽然单个加法结果需要两个时钟周期才能输出有延迟但每个时钟周期都可以开始一次新的加法吞吐量提高并且时钟频率可以大幅提升。// 简单的两级流水线加法器示例 reg [15:0] a_low_reg, b_low_reg; reg [15:0] sum_low_reg; reg carry_mid_reg; always (posedge clk) begin // 第一级计算低16位和中间进位 {carry_mid_reg, sum_low_reg} a_low b_low; a_low_reg a_low; b_low_reg b_low; // 保存高16位输入供下一级使用 end always (posedge clk) begin // 第二级计算高16位加上中间进位 sum_high a_high_reg b_high_reg carry_mid_reg; end使用工具提供的优化指令/属性例如在Vivado中可以对某个模块或信号添加(* use_dsp48 yes *)属性尝试让工具使用DSP48 Slice来实现加法DSP Slice是FPGA中专门为算术运算优化的硬核速度非常快。检查工具是否自动推断出快速结构确保你的时序约束是合理的并且综合策略选择了“性能优先Performance”模式。工具在严格约束下会尽力优化。6.3 关于有符号数与无符号数问题wire [3:0] a, b;这样定义的加法工具默认是按无符号数处理的。如果你需要处理有符号数补码表示需要将信号声明为signed类型或者使用系统函数。wire signed [3:0] a_signed, b_signed; wire signed [4:0] sum_signed; // 结果需要扩展1位以防溢出 assign sum_signed a_signed b_signed;或者对于无符号类型但需要进行有符号运算wire [3:0] a, b; wire [4:0] sum; assign sum $signed(a) $signed(b); // 使用系统函数进行有符号运算关键点溢出处理。无符号数溢出时进位位Cout就是溢出标志。有符号数溢出判断更复杂需要看最高位和次高位的进位情况。在实际设计中必须明确你的数据格式和溢出处理策略。6.4 测试平台编写技巧一个完善的测试平台能事半功倍。自动化遍历测试对于像4位加法器这样输入空间较小256种组合的设计可以在Testbench中用循环遍历所有输入。integer i, j, k; initial begin for(i0; i16; ii1) begin for(j0; j16; jj1) begin for(k0; k2; kk1) begin A i[3:0]; B j[3:0]; Cin k[0]; #10; // 自动检查结果 if ({Cout, Sum} ! (A B Cin)) begin $display(Error at time %t: A%h, B%h, Cin%b, Got %h, Exp %h, $time, A, B, Cin, {Cout, Sum}, (ABCin)); end end end end end使用随机激励对于更复杂的设计可以使用$random或$urandom生成随机测试向量进行大量随机测试提高覆盖率。查看波形图不仅要看最终结果更要关注信号变化的时序关系特别是时钟沿和数据稳定之间的关系这对于调试时序问题至关重要。从最基础的门电路逻辑到构建出功能完备的加法器单元再到理解不同结构背后的速度与面积权衡这个过程是数字逻辑设计的缩影。我个人的体会是FPGA设计就像搭积木但比积木更讲究结构和时序。加法器是这块积木中最标准、最基础的一块。当你亲手用Verilog描述它在仿真中验证它在板卡上点亮它最后再通过综合报告分析它你对“硬件描述语言”和“数字电路”的理解就会从抽象的概念落地为具体而深刻的认识。下次当你再写下a b时你脑海中浮现的将不再只是一行代码而是一幅由LUT和进位链构成的动态电路图。这才是工程师该有的思维方式。