1. 从两个比特开始为什么值得亲手搭一遍加法器很多人学数字电路都是从真值表、卡诺图、逻辑门一路推下来考试能拿高分但真让他画出一个能跑的加法电路脑子里是糊的。我当年也是这样直到有一次做一个小项目需要把两个四位二进制数加起来才发现自己连半加器和全加器的进位到底怎么传都没搞明白。后来我干脆花了一个周末从最基础的半加器开始把全加器、四位全加器、再到四位二进制数的补码器全部用逻辑门和Verilog各实现了一遍才算真正把这块知识焊死在脑子里。这篇内容就是那次折腾的完整记录。核心关键词是半加器、全加器、四位全加器、补码器以及它们背后的进位传播和二进制运算逻辑。我会从最基础的真值表讲起一步步推到四位全加器的级联结构再讲清楚补码器为什么可以用全加器来实现最后给出可以直接复现的Verilog代码和仿真验证方法。不管你是刚学数字电路的学生还是想复习组合逻辑的开发者都能跟着走一遍。适合谁看如果你正在学《数字逻辑》《计算机组成原理》或者准备面试被问到“怎么用逻辑门实现加法”这篇就是为你写的。如果你已经工作几年想快速回忆一下进位链和补码的硬件实现也能直接跳到对应章节。我尽量不堆公式多用生活化的类比把每个设计选择背后的“为什么”讲清楚。2. 半加器一切加法电路的起点2.1 半加器到底解决了什么问题半加器Half Adder是最简单的加法电路它只做一件事把两个一位二进制数相加输出一个和位Sum和一个进位Carry。为什么叫“半”因为它不考虑来自低位的进位输入只能处理两个输入位所以是“不完整”的加法器。先看真值表这是所有组合逻辑设计的起点ABSumCarry0000011010101101从表里能直接读出两个逻辑关系Sum 就是 A 和 B 的异或XORCarry 就是 A 和 B 的与AND。所以半加器的逻辑表达式是Sum A ⊕ BCarry A · B这个结论非常直观。你可以把二进制加法想象成十进制加法0000111011110也就是本位0进位1。异或正好描述“两个位不同时为1相同时为0”而与门描述“两个都为1时才产生进位”。2.2 用逻辑门搭出半加器实现半加器只需要两个门一个异或门和一个与门。如果你手头只有基本门异或门可以用四个与非门NAND搭出来这也是很多教材里的经典练习。具体接法用两个与非门交叉连接构成一个基本锁存结构不对那是SR锁存器。异或门的与非门实现是NAND(A, NAND(A,B)) 再 NAND 上 NAND(B, NAND(A,B))总共四个与非门。与门可以用两个与非门实现NAND 后再取反。所以一个纯与非门版本的半加器需要六个与非门。实际做实验时直接用74系列芯片里的异或门如74HC86和与门如74HC08更省事。注意半加器没有进位输入所以它只能用于最低位的加法。如果你要加多位二进制数最低位可以用半加器其余位必须用全加器。2.3 半加器的Verilog实现与仿真先给一个最简洁的Verilog描述module half_adder( input A, input B, output Sum, output Carry ); assign Sum A ^ B; assign Carry A B; endmodule仿真测试平台也很简单遍历四种输入组合module tb_half_adder; reg A, B; wire Sum, Carry; half_adder uut(.A(A), .B(B), .Sum(Sum), .Carry(Carry)); initial begin A0; B0; #10; A0; B1; #10; A1; B0; #10; A1; B1; #10; $finish; end endmodule跑一遍仿真波形应该和真值表完全一致。这一步虽然简单但建议你亲手跑一次确认工具链没问题后面全加器才能顺利推进。3. 全加器把进位串起来的关键3.1 全加器比半加器多了什么全加器Full Adder在半加器的基础上增加了一个进位输入Cin这样它就能处理来自低位的进位从而支持多位级联。它的输入是 A、B、Cin输出是 Sum 和 Cout。真值表如下ABCinSumCout0000000110010100110110010101011100111111观察 Sum 列它其实是 A、B、Cin 三个数的异或。Cout 列呢当 A、B、Cin 中有两个或三个为1时Cout 为1。所以逻辑表达式是Sum A ⊕ B ⊕ CinCout (A · B) (Cin · (A ⊕ B))这个 Cout 表达式很关键。它说明进位输出由两部分组成一部分是 A 和 B 本身都为1产生的进位另一部分是 A 和 B 中有一个为1、同时进位输入 Cin 也为1时产生的进位。用生活类比三个人投票超过半数就通过Cout 就是“通过”信号。3.2 用两个半加器加一个或门实现全加器全加器可以用两个半加器和一个或门拼出来这是最经典的结构第一个半加器把 A 和 B 相加得到 Sum1 和 Carry1。第二个半加器把 Sum1 和 Cin 相加得到最终 Sum 和 Carry2。或门把 Carry1 和 Carry2 合并成 Cout。为什么可以这样因为加法满足结合律(A B) Cin 和 A (B Cin) 结果一样。第一个半加器处理 AB第二个处理中间和与 Cin两个进位只要有一个为1最终就有进位。这种模块化设计的好处是复用半加器减少自定义逻辑。实际写Verilog时可以直接实例化两个半加器模块再加一个或门。3.3 全加器的Verilog实现与进位链分析先给行为级描述module full_adder( input A, input B, input Cin, output Sum, output Cout ); assign Sum A ^ B ^ Cin; assign Cout (A B) | (Cin (A ^ B)); endmodule再给结构化描述用两个半加器module full_adder_struct( input A, input B, input Cin, output Sum, output Cout ); wire sum1, carry1, carry2; half_adder ha1(.A(A), .B(B), .Sum(sum1), .Carry(carry1)); half_adder ha2(.A(sum1), .B(Cin), .Sum(Sum), .Carry(carry2)); assign Cout carry1 | carry2; endmodule这里要重点讲一下进位传播延迟。在全加器里Cout 依赖于 Cin而 Cin 又来自低位的 Cout。当多个全加器级联时进位会像多米诺骨牌一样从最低位一路传到最高位。这个延迟称为进位传播延迟Carry Propagation Delay是加法器速度的主要瓶颈。假设每个门的延迟是1个单位那么一个全加器的 Cout 路径大约经过两级门与门和或门所以四位级联的最坏延迟大约是4倍单级延迟。位数越多延迟越长。这也是为什么后来会出现超前进位加法器Carry Lookahead Adder的原因不过那是另一个话题了。实操心得如果你在FPGA上实现加法器综合工具通常会自动优化进位链用专用的进位逻辑资源。但如果你用分立逻辑门搭电路进位链延迟就是实打实的必须考虑。4. 四位全加器级联与工程实现4.1 级联结构的设计思路四位全加器就是把四个全加器串起来最低位LSB的 Cin 接0或者接地它的 Cout 接次低位的 Cin依次类推最高位MSB的 Cout 作为最终的进位输出。这样就能计算两个四位二进制数加上一个进位输入的和输出四位和以及一个进位。为什么最低位不直接用半加器因为半加器没有 Cin而全加器有 Cin 但可以接0功能上兼容。为了模块统一通常四位全加器全部用全加器实现最低位的 Cin 接0。这样代码更规整也方便参数化扩展到任意位宽。级联的数学表达是Sum[0] A[0] ⊕ B[0] ⊕ CinCout[0] (A[0]·B[0]) (Cin·(A[0]⊕B[0]))Sum[1] A[1] ⊕ B[1] ⊕ Cout[0]……以此类推。每一位的和都依赖于低位的进位这就是“行波进位”Ripple Carry的由来。4.2 四位全加器的Verilog实现先给结构化实现直接实例化四个全加器module ripple_carry_adder_4bit( input [3:0] A, input [3:0] B, input Cin, output [3:0] Sum, output 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再给行为级实现更简洁module adder_4bit( input [3:0] A, input [3:0] B, input Cin, output [3:0] Sum, output Cout ); wire [4:0] temp; assign temp A B Cin; assign Sum temp[3:0]; assign Cout temp[4]; endmodule行为级代码综合后工具会自动映射到合适的加法器结构。如果你只是想快速验证功能用行为级就够了。但如果你想理解底层结构建议用结构化代码跑一遍仿真观察每一位的进位传播。4.3 参数化设计从四位扩展到任意位宽实际工程中我们很少只做四位加法器。用参数化模块可以轻松扩展到任意位宽module ripple_carry_adder #(parameter WIDTH 4)( input [WIDTH-1:0] A, input [WIDTH-1:0] B, input Cin, output [WIDTH-1:0] Sum, output Cout ); wire [WIDTH:0] carry; assign carry[0] Cin; genvar i; generate for (i 0; i WIDTH; i i 1) begin : adder_loop full_adder fa( .A(A[i]), .B(B[i]), .Cin(carry[i]), .Sum(Sum[i]), .Cout(carry[i1]) ); end endgenerate assign Cout carry[WIDTH]; endmodule这个参数化写法用了 generate 块是Verilog里实现重复结构的标准做法。你可以把 WIDTH 改成8、16、32综合工具会自动展开。注意 carry 数组宽度是 WIDTH1因为要容纳最终的进位输出。注意事项行波进位加法器的延迟随位宽线性增长。如果你做32位加法器进位链会很长时序可能不满足。实际项目中综合工具通常会用超前进位或进位选择结构来优化但理解行波进位是理解所有加法器的基础。4.4 四位全加器的仿真验证仿真时建议覆盖以下场景无进位3 4 7Cout 0有进位7 9 16Sum 0Cout 1边界15 15 1 31Sum 15Cout 1随机测试用 $random 生成大量随机数和预期结果对比一个简单的测试平台module tb_adder_4bit; reg [3:0] A, B; reg Cin; wire [3:0] Sum; wire Cout; adder_4bit uut(.A(A), .B(B), .Cin(Cin), .Sum(Sum), .Cout(Cout)); integer i; initial begin for (i 0; i 100; i i 1) begin A $random; B $random; Cin $random; #10; if ({Cout, Sum} ! (A B Cin)) begin $display(Error: A%d B%d Cin%d, got %d, expected %d, A, B, Cin, {Cout, Sum}, ABCin); end end $display(Test done); $finish; end endmodule这个测试平台会自动对比硬件输出和软件计算结果任何不匹配都会打印错误。跑100组随机测试基本能覆盖大部分情况。5. 四位二进制数的补码器用加法器实现减法5.1 补码的基本概念与硬件意义补码Twos Complement是计算机表示有符号整数的标准方式。一个四位二进制数的补码就是把它的每一位取反再加1。比如 30011的补码是 1101-3。为什么这样设计因为补码让减法可以用加法来实现A - B A (-B) A (~B 1)。在硬件层面这意味着我们不需要单独的减法器电路只需要一个加法器加上取反和进位输入1就能同时支持加法和减法。这是计算机算术逻辑单元ALU设计的核心思想之一。四位二进制数的补码器输入是一个四位二进制数输出是它的补码。实现方式有两种先取反再加1。从最低位开始找到第一个1保持它和它右边的位不变左边的位全部取反。第二种方法在硬件上可以用优先编码器实现但更常见的是第一种取反加1。5.2 补码器的逻辑实现取反很简单每个位接一个非门。加1可以用一个四位全加器把取反后的结果和0001相加Cin接0。或者更巧妙的是把取反后的结果作为A输入B输入接0000Cin接1这样加法器直接输出补码。为什么Cin接1就能实现加1因为全加器的和是 A B Cin。当B0000时和就是 A Cin。Cin1就相当于加1。这样我们不需要额外的加1电路直接复用加法器的进位输入。所以补码器的结构是四个非门对输入取反。一个四位全加器A输入接取反结果B输入接0000Cin接1。输出就是补码。如果你要同时支持加法和减法可以把B输入接一个多路选择器加法时选B减法时选~B同时Cin接0或1。这就是ALU里加减法单元的基本结构。5.3 补码器的Verilog实现与验证module twos_complement_4bit( input [3:0] data_in, output [3:0] data_out ); wire [3:0] inv; assign inv ~data_in; adder_4bit uut( .A(inv), .B(4b0000), .Cin(1b1), .Sum(data_out), .Cout() ); endmodule测试时验证以下输入输出输入取反加1输出00001111000000000001111011111111001111001101110101111000100110011111000000010001注意0000的补码是0000这是补码的一个特殊性质0的补码还是0。1111-1的补码是00011也符合预期。实操心得在Verilog里取反加1可以直接写成assign data_out ~data_in 1b1;综合工具会自动优化。但如果你用结构化方式搭出来能更清楚地看到硬件资源的使用情况。我建议两种都试一遍对比综合后的电路图。5.4 从补码器到加减法器的扩展补码器本身只是一个取反加1的电路但它的真正价值在于和加法器结合构成加减法器。一个四位加减法器的结构是输入A、B以及一个控制信号Sub。当Sub0时做加法B直接进入加法器Cin0。当Sub1时做减法B取反后进入加法器Cin1。输出Sum和CoutCout可以作为溢出标志。用Verilog实现module add_sub_4bit( input [3:0] A, input [3:0] B, input Sub, output [3:0] Result, output Cout ); wire [3:0] B_operand; assign B_operand B ^ {4{Sub}}; adder_4bit uut( .A(A), .B(B_operand), .Cin(Sub), .Sum(Result), .Cout(Cout) ); endmodule这里用异或门实现条件取反当Sub1时B的每一位和1异或相当于取反当Sub0时和0异或保持不变。Cin接Sub减法时加1加法时加0。这个设计非常优雅是ALU里的经典结构。6. 常见问题与排查技巧实录6.1 仿真结果和预期不一致怎么办这是最常见的问题。排查顺序建议从底层往上层走先单独测试半加器确认异或和与门逻辑正确。再测试全加器遍历所有8种输入组合对比真值表。然后测试四位全加器用简单的数如11、23验证。最后测试补码器用0、1、-1等边界值验证。如果某一级出错问题一定在那一级或它的子模块。不要跳级调试否则会浪费时间。常见坑Verilog里运算符的位宽问题。如果你写assign Sum A B;A和B都是4位Sum也是4位那么进位会被截断。必须用5位临时变量或者显式处理进位。我见过不少人在这里翻车。6.2 进位链延迟导致时序不满足如果你在FPGA上跑高频时钟行波进位加法器可能成为关键路径。解决方法用综合工具自带的加法器IP核它们通常已经优化过。手动实现超前进位加法器减少进位传播延迟。降低时钟频率或者对加法器输出打拍。超前进位加法器的核心思想是提前计算每一位的进位而不是等低位传上来。它用生成Generate和传播Propagate信号G[i] A[i] · B[i]P[i] A[i] ⊕ B[i]C[i1] G[i] (P[i] · C[i])这样进位可以展开成只依赖输入和初始进位的表达式延迟从O(n)降到O(log n)。不过代价是电路面积增大适合对速度要求高的场景。6.3 补码器的溢出问题四位补码能表示的范围是-8到7。如果你计算的结果超出这个范围就会溢出。比如 7 1 8但四位补码里8表示为-8。溢出判断的方法是两个同号数相加结果符号变了就说明溢出。在加减法器里Cout和最高位的进位可以用来判断溢出。对于有符号数溢出标志是Cout ⊕ C[3]其中C[3]是最高位的进位输入。这个细节在ALU设计里很重要如果你要做完整的ALU必须加上溢出标志。6.4 常见问题速查表问题现象可能原因排查方法和为0但进位不对进位逻辑错误检查Cout表达式确认与门和或门连接高位结果错误进位链断裂逐位检查carry数组连接补码输出全0取反或加1失效单独测试非门和加法器仿真无输出测试平台未初始化检查initial块和$finish综合后时序违例进位链太长改用超前进位或流水线独家避坑技巧在Verilog里用$display打印中间信号是最快的调试方法。不要只盯着波形看有时候打印出来的数值更直观。另外仿真时给信号加延迟#10可以避免竞争冒险导致的假错误。7. 从逻辑门到FPGA我的实操体会把半加器、全加器、四位全加器和补码器全部走一遍最大的收获不是学会了这几个电路而是理解了“模块化”和“复用”的思想。半加器是全加器的积木全加器是四位加法器的积木加法器又是补码器和加减法器的积木。这种层层嵌套的结构在数字系统设计里无处不在。如果你手头有FPGA开发板我强烈建议你把四位全加器和补码器烧进去用拨码开关输入LED显示输出。亲眼看到拨码开关变化时LED跟着变比看仿真波形有成就感得多。我当年第一次在板子上实现加法器时发现79真的显示0并点亮进位LED那种“原来计算机就是这么算的”感觉是看多少书都换不来的。最后分享一个小技巧在写Verilog时尽量用参数化模块不要写死位宽。你今天做四位明天可能就要八位。参数化设计让你改一个数字就能扩展省去大量重复劳动。另外测试平台一定要写随机测试手工枚举只能覆盖有限情况随机测试能发现你意想不到的边界问题。这个内容后续还可以这样扩展把行波进位加法器改成超前进位加法器对比两者的时序和面积或者把补码器扩展成完整的ALU支持与、或、非、加、减、比较等操作。每一步扩展都是对数字逻辑理解的加深。