Verilog实现4位串行加法器:从全加器到RCA的完整设计流程
1. 从全加器到串行加法器一个经典数字电路的Verilog实现之旅在数字电路和FPGA设计领域加法器是构成算术逻辑单元ALU乃至整个处理器最基础的模块之一。无论是做嵌入式开发、信号处理还是单纯的数字逻辑学习亲手实现一个加法器都是绕不开的“必修课”。上次我们聊了半加器算是开了个头今天咱们就深入一步用Verilog HDL来实现一个完整的4位串行加法器。你别看它“只有”4位从全加器的门级描述到模块的级联封装再到测试验证和综合实现这一整套流程走下来几乎涵盖了小型数字系统设计的全貌。我当年学FPGA的时候就是从这个例子开始才真正理解了数据流、时序和硬件描述语言之间的关系。这篇文章我就把自己踩过的坑、总结的经验以及那些教科书上不会写的细节掰开揉碎了讲给你听。2. 核心思路为什么是串行进位加法器在动手写代码之前我们得先想清楚要做什么以及为什么这么做。加法器有很多种比如超前进位加法器Carry Look-ahead Adder, CLA、选择进位加法器Carry Select Adder等它们各有优劣适用于不同的速度、面积和功耗场景。我们这里实现的“串行加法器”更准确的叫法是“行波进位加法器”Ripple Carry Adder, RCA。它的核心思想非常简单直接将多个1位的全加器Full Adder像链条一样连接起来低位全加器的进位输出Carry Out直接连接到高位全加器的进位输入Carry In。计算时进位信号像水波一样从最低位向最高位依次传递、计算。为什么先从它开始结构直观易于理解它的工作原理与我们在纸上竖式计算加法的过程完全一致非常符合直觉。对于初学者而言这是理解硬件加法最没有障碍的模型。代码简洁便于教学用Verilog描述其结构非常清晰是学习模块实例化Module Instantiation和线网Wire连接的绝佳范例。是其他高级加法器的基础CLA等快速加法器本质上是为了解决RCA进位延迟长的问题而做的优化。理解了RCA的瓶颈才能更好地 appreciate 其他方案的巧妙之处。当然它的缺点也很明显速度慢。因为高位必须等待低位的进位信号计算完成后才能开始计算所以总延迟与加法器的位数成正比。对于一个n位的RCA最坏情况下的延迟是n个全加器的延迟之和。在追求高速的场合比如CPU的ALU这通常是不可接受的。但在很多对速度要求不苛刻或者资源受限的FPGA/CPLD设计中RCA因其面积小、功耗低、设计简单的特点依然有其用武之地。我们的目标就是先把这个经典结构用Verilog实现出来并完成仿真和综合看看它在硬件上究竟是如何工作的。3. 基石构建1位全加器的门级描述万丈高楼平地起全加器就是我们的砖块。一个1位全加器有三个输入加数a、加数b、来自低位的进位ci有两个输出本位和sum、向高位的进位co。它的真值表大家应该都很熟悉了。用逻辑表达式表示就是Sum a ⊕ b ⊕ ci 三者异或Carry_out (a b) | (a ci) | (b ci) 三者两两相与再或在Verilog中我们可以用多种方式描述它行为级用always块、数据流级用assign连续赋值语句或结构级用门级原语实例化。这里采用最直观、也最能体现其电路结构的数据流级描述。// 文件名full_adder.v module full_adder ( input a, // 输入位 a input b, // 输入位 b input ci, // 进位输入 Carry-in output sum, // 和输出 Sum output co // 进位输出 Carry-out ); // 数据流建模直接使用逻辑运算符 assign sum a ^ b ^ ci; // 异或运算得到和 assign co (a b) | (a ci) | (b ci); // 根据逻辑表达式得到进位 endmodule代码细节与思考模块命名我习惯用下划线full_adder清晰易懂。原文档用的fulladder也没问题保持一致性即可。端口声明明确列出input和output。这里所有信号都是1位宽所以没有写[width-1:0]的范围。assign语句这是连续赋值语句。它意味着等式右边的任何变化都会立即反映到左边描述的是组合逻辑电路中并行的、持续的连接关系。注意这不是“执行顺序”而是“连接关系”。运算符^是按位异或是按位与|是按位或。对于1位信号按位运算与逻辑运算结果相同。注意这是最经典的门级实现。在实际的FPGA综合中综合工具可能会根据目标器件如Xilinx的LUT结构对这个逻辑进行优化和映射最终生成的电路可能不是精确的与门、或门、异或门而是用查找表LUT实现同等功能。但这不影响我们理解其逻辑本质。4. 级联与封装构建4位行波进位加法器有了全加器这个基本单元我们就可以像搭积木一样构建多位加法器了。对于4位加法器我们需要4个全加器实例并将它们的进位链串联起来。// 文件名ripple_carry_adder_4bit.v module ripple_carry_adder_4bit ( input [3:0] a, // 4位输入 a input [3:0] b, // 4位输入 b input ci, // 最低位的进位输入 output [3:0] s, // 4位和输出 output co // 最高位的进位输出/溢出标志 ); // 声明内部连线用于连接各级全加器之间的进位 wire [2:0] carry; // carry[0]连接FA0-FA1, carry[1]连接FA1-FA2, ... // 实例化第一个全加器最低位 LSB full_adder FA0 ( .a (a[0]), .b (b[0]), .ci (ci), // 使用模块的输入ci作为最低位进位 .sum (s[0]), .co (carry[0]) // 进位输出连接到内部线carry[0] ); // 实例化第二个全加器 full_adder FA1 ( .a (a[1]), .b (b[1]), .ci (carry[0]), // 进位来自前一级FA0 .sum (s[1]), .co (carry[1]) ); // 实例化第三个全加器 full_adder FA2 ( .a (a[2]), .b (b[2]), .ci (carry[1]), // 进位来自前一级FA1 .sum (s[2]), .co (carry[2]) ); // 实例化第四个全加器最高位 MSB full_adder FA3 ( .a (a[3]), .b (b[3]), .ci (carry[2]), // 进位来自前一级FA2 .sum (s[3]), .co (co) // 最高位的进位输出直接作为模块的co输出 ); endmodule关键解析与实操要点位宽定义input [3:0] a表示一个4位宽的向量a[3]是最高有效位MSBa[0]是最低有效位LSB。这是Verilog表示总线Bus的标准方式。内部连线Wirewire [2:0] carry;声明了一个3位宽的内部连线。为什么是3位因为4位加法器有3个内部进位从FA0到FA1FA1到FA2FA2到FA3。最低位的进位输入ci和最高位的进位输出co已经是模块端口不需要在内部连线中声明。模块实例化这是结构描述的核心。full_adder FA0 (...);表示创建一个名为FA0的full_adder类型的实例。括号内使用.port_name (net_name)的语法进行端口映射将当前模块的线网连接到子模块的端口上。这种按名称映射的方式非常清晰推荐始终使用。进位链的形成仔细观察端口映射FA0的.co连到了carry[0]而FA1的.ci连到了同一个carry[0]。这就构成了物理上的连接进位信号从FA0流向FA1。依此类推形成链式结构。溢出标志对于有符号数加法最高位的进位输出co并不直接等于溢出Overflow。溢出ovf的判断逻辑是ovf carry[2] ^ co;即次高位进位与最高位进位异或。原文档中将co直接作为ovf输出这实际上是将进位输出作为了一个标志更准确的模块命名可能是add4bit_with_carry_out。在严谨的算术电路中溢出标志需要单独计算。不过对于入门理解我们可以暂时接受这种简化。5. 验证与仿真用测试平台验证逻辑功能代码写完了但它对吗在硬件设计里仿真Simulation是我们的“虚拟实验室”。我们需要编写一个测试平台Testbench给设计施加激励输入并观察其响应输出。// 文件名tb_ripple_carry_adder.v timescale 1ns / 1ps // 时间单位/精度 module tb_ripple_carry_adder; // 声明与设计模块对应的信号 reg [3:0] a_tb, b_tb; reg ci_tb; wire [3:0] s_tb; wire co_tb; // 实例化待测试设计Unit Under Test, UUT ripple_carry_adder_4bit uut ( .a (a_tb), .b (b_tb), .ci (ci_tb), .s (s_tb), .co (co_tb) ); // 初始化过程块产生测试激励 initial begin // 初始化所有输入 a_tb 4b0000; b_tb 4b0000; ci_tb 1b0; #100; // 等待100个时间单位100ns让电路稳定或进行初始复位 // 测试用例1常规加法 12 10 1 23 (0b10111)进位为1 a_tb 4b1100; // 12 b_tb 4b1010; // 10 ci_tb 1b1; // 1 #100; // 等待100ns观察输出 // 期望结果s_tb 4‘b0111 (7), co_tb 1‘b1 (进位)。 因为1210123二进制10111低4位是0111进位是1。 // 测试用例2带进位的加法 10 3 1 14 (0b1110)进位为0 a_tb 4b1010; // 10 b_tb 4b0011; // 3 ci_tb 1b1; // 1 #100; // 期望s_tb 4‘b1110 (14), co_tb 0 // 测试用例3产生溢出的有符号数加法此处按无符号理解11 9 20 (0b10100)进位为1 a_tb 4b1011; // 11 b_tb 4b1001; // 9 ci_tb 1b0; #200; // 等待更长时间 // 期望s_tb 4‘b0100 (4), co_tb 1。 因为20的二进制是10100低4位是0100进位是1。 // 可以添加更多边界测试如全加111111111等 a_tb 4b1111; b_tb 4b1111; ci_tb 1b1; #100; // 期望s_tb 4‘b1111 (15), co_tb 1。 因为111111111 1_1111低4位满进位为1。 // 测试结束 #100; $finish; // 结束仿真 end // 可选将信号变化记录到日志文件或波形窗口 initial begin $dumpfile(wave.vcd); // 生成波形文件供GTKWave等工具查看 $dumpvars(0, tb_ripple_carry_adder); // 转储所有变量 end endmodule仿真操作与结果解读在Modelsim、Vivado Simulator或Icarus Verilog等工具中运行这个测试平台你会看到波形图。我们以原文档的测试用例为例进行分析0-100ns初始化为0输出s0000,co0。100-200ns输入a1100(12),b1010(10),ci1。计算12 10 1 23。二进制10111。观察波形输出s应该变为0111即23的低4位7co应该变为1即第5位的进位。注意这里s0111co1合起来是1_0111正是23。验证正确。200-300ns输入a1010(10),b0011(3),ci1。计算10 3 1 14。二进制1110。期望s1110,co0。验证正确。300-500ns输入a1011(11),b1001(9),ci0。计算11 9 0 20。二进制10100。期望s010020的低4位4co1。验证正确。如果波形显示的结果与手工计算一致那么恭喜你你的4位加法器逻辑功能是正确的实操心得仿真时一定要自己先手算预期结果再与波形对比。不要只看波形“有变化”就觉得对了。针对进位和边界情况如全1相加的测试尤为重要。另外在测试平台中适当添加$display语句打印关键时刻的输入输出值能更直观地辅助调试。6. 综合与实现从代码到实际电路仿真通过只意味着逻辑正确。我们的代码最终是要在FPGA或ASIC上变成实实在在的电路的。这一步就需要综合Synthesis和实现Implementation工具如Xilinx ISE/Vivado, Quartus等来完成。综合Synthesis工具将我们的Verilog行为描述“翻译”成目标工艺库如FPGA的LUT、触发器、进位链等的基本元件组成的网表Netlist。你可以查看综合后的“RTL Schematic”RTL级原理图它会展示出四个全加器级联的清晰结构正如我们设计的那样。关键指标查看资源利用率Utilization会报告使用了多少个LUT、触发器FF。对于这个纯组合逻辑的4位RCA主要消耗LUT资源。时序报告Timing Report这是分析性能的关键。工具会分析所有路径的延迟。关键路径Critical Path从输入到输出延迟最长的路径。对于RCA关键路径通常就是进位信号从ci传播到co的路径。路径延迟Path Delay原文档提到综合后路径延迟为8.959ns。这个延迟是工具根据器件模型如XC3S500E-5的“-5”代表速度等级和逻辑复杂度估算出来的还没有考虑布局布线带来的线延迟。实现Implementation与布局布线Place Route这一步将综合后的网表映射到FPGA芯片的具体物理资源上并连接它们。完成后你可以进行“布线后仿真”Post-Route Simulation这个仿真模型包含了真实的线延迟和器件延迟结果更接近芯片实际行为。布线后延迟原文档提到在XC3S500E-5器件上可以看到器件上的延迟。这个延迟比如可能变成10ns以上会比综合预估的延迟大因为它包含了信号在FPGA内部走线的延迟。这对于评估设计能否在要求的时钟频率下稳定工作至关重要。重要提示原文档提供的工程文件链接可能已失效。我强烈建议你不要依赖任何外部链接的源码而是根据本文的代码自己新建工程从头到尾操作一遍。这个过程本身的学习价值远大于得到一个现成的工程文件。自己敲代码、建工程、配约束、看报告遇到的问题才是你真正成长的阶梯。7. 深度优化与扩展思考一个基本的4位RCA做完了但作为工程师我们的思考不能止步于此。7.1 如何扩展为任意位宽的加法器难道要写64个实例化语句来做64位加法吗当然不用Verilog支持生成语句generate可以优雅地实现参数化位宽。module parameterized_ripple_adder #( parameter WIDTH 8 // 默认8位实例化时可修改 )( input [WIDTH-1:0] a, input [WIDTH-1:0] b, input ci, output [WIDTH-1:0] s, output co ); wire [WIDTH:0] carry; // 声明WIDTH1位宽的进位链carry[0]用作ci输入carry[WIDTH]用作co输出 assign carry[0] ci; genvar i; // 生成变量 generate for (i0; iWIDTH; ii1) begin : gen_adder full_adder FA_inst ( .a (a[i]), .b (b[i]), .ci (carry[i]), .sum (s[i]), .co (carry[i1]) ); end endgenerate assign co carry[WIDTH]; endmodule这样只需要修改WIDTH参数就能轻松得到16位、32位甚至更宽的加法器代码复用性极高。7.2 行波进位加法器的性能瓶颈与优化方向前面提到RCA的速度慢。我们来量化一下假设一个全加器从输入到co输出的延迟为T_fa那么一个N位RCA的最长延迟即关键路径延迟约为N * T_fa。当N很大时这个延迟是不可接受的。优化思路超前进位加法器CLA通过额外的逻辑并行计算出所有位的进位将延迟复杂度从O(N)降低到O(log N)。但代价是增加了电路面积和复杂度。进位选择加法器CSLA将加法器分成若干段每段同时计算“进位为0”和“进位为1”两种结果然后根据实际到来的进位选择正确的结果。这是一种用面积换速度的方法。进位保留加法器CSA与Wallace树常用于乘法器等需要多操作数加法的场景通过减少进位传播的次数来提速。使用FPGA专用进位链现代FPGA如Xilinx、Intel的器件内部都有专用的、快速的垂直进位链Carry Chain。综合工具在映射RCA时如果能正确推断并使用这些专用硬件资源其实际速度会比用普通LUT搭建快很多。在代码中确保进位逻辑被写成co (a b) | (a ci) | (b ci)这种形式有助于工具进行进位链推断。7.3 测试的完备性与自动化我们上面的测试平台是手写固定激励。对于更复杂的设计需要更系统的验证方法。随机测试使用$random生成大量随机输入并与参考模型如直接用运算符计算的结果进行比较。断言Assertion在测试平台或设计代码中插入断言语句自动检查某些条件是否永远成立。覆盖率分析检查代码覆盖率行覆盖、条件覆盖、状态机覆盖等确保测试用例充分。一个简单的随机测试片段integer i; initial begin for (i0; i1000; ii1) begin a_tb $random; b_tb $random; ci_tb $random 1b1; // 随机0或1 #10; // 使用行为级模型作为参考 expected_sum a_tb b_tb ci_tb; expected_co (expected_sum 4b1111) ? 1b1 : 1b0; if ({co_tb, s_tb} ! expected_sum) begin $display(Error at time %t: a%b, b%b, ci%b, got {co,s}%b%b, expected%b, $time, a_tb, b_tb, ci_tb, co_tb, s_tb, expected_sum); end end end8. 常见问题与调试技巧实录在实际操作中你肯定会遇到各种问题。这里分享几个我踩过的坑和解决方法。问题1仿真结果全是‘X’不定态或‘Z’高阻态。可能原因1测试平台中reg类型变量未初始化。虽然仿真器在initial块中会给它们赋值但在赋值之前它们就是‘X’。确保在initial块开头给所有驱动输入的reg变量赋初值。可能原因2设计中有组合逻辑环路Combinational Loop。检查你的assign语句或always (*)块确保没有信号不经过任何时序元件如触发器而直接依赖于自身的输出。在RCA中只要连接正确一般不会形成环路。可能原因3模块实例化时端口连接错误导致某些输入悬空表现为‘Z’。务必使用按名称映射.port_name(net_name)并仔细检查拼写。问题2综合后警告“Latch inferred”推断出了锁存器。原因在描述组合逻辑的always块中比如always (*)没有为所有可能的输入分支指定输出。综合工具为了保证功能会生成一个锁存器来“记忆”之前的值这通常不是我们想要的会浪费资源并可能引起时序问题。解决对于组合逻辑的always块确保使用if-else或case语句时有else或default分支覆盖所有情况。在我们的全加器数据流描述assign中不存在这个问题。问题3时序不满足建立/保持时间违例。原因关键路径延迟太长超过了时钟周期要求。对于大型RCA这很常见。排查查看综合或布局布线后的时序报告找到关键路径。看看路径上的逻辑级数是否过多。解决流水线Pipeline在进位链中间插入寄存器将长的组合逻辑路径打断成多个时钟周期完成。这是最常用的提速方法但会增加延迟Latency。改用更快的加法器结构如CLA。优化综合约束尝试不同的综合策略如优化面积、优化速度。检查是否使用了专用进位链查看综合报告确认工具是否将你的加法器映射到了FPGA的快速进位链上。问题4行为仿真正确但下载到板子后结果不对。可能原因1引脚约束.xdc或.ucf文件错误输入输出信号没有分配到正确的物理引脚上。可能原因2时钟约束不正确或未添加导致时序混乱。可能原因3板子上的按键抖动或开关接触不良导致输入信号不稳定。可以在设计前端添加消抖模块。调试方法使用嵌入式逻辑分析仪如Xilinx的ILAIntel的SignalTap在真实硬件上抓取信号波形与仿真波形对比。这是硬件调试的利器。从门级的全加器开始一步步构建出4位加法器再通过仿真验证、综合实现最后思考优化和扩展这正是一个完整的数字设计流程的缩影。我个人的体会是初学者最容易犯的错误是只关注代码本身而忽略了仿真验证和时序分析。一定要养成“编写-仿真-综合-查看报告-再优化”的闭环习惯。这个串行加法器项目虽小但它像一把钥匙帮你打开了用硬件描述语言进行系统设计的大门。下次我们可以试试用行为级描述来实现同样的功能或者挑战一下超前进位加法器看看如何用更多的逻辑资源来换取速度的提升。