Verilog加法器设计全攻略:从RTL代码到FPGA综合优化
发布时间:2026/10/14 18:21:11 锦皓数字建站

最近在做数字逻辑课程的实验有个学弟问我加法器到底怎么用 Verilog 写才规范是不是直接写assign sum a b;就够了。这问题其实很有意思。加法器是数字电路的基石从最简单的半加器到 ALU 里的关键路径几乎每个数字系统都离不开它。但很多人初学 Verilog 时要么只会在仿真里拉波形看结果要么就是背代码模板根本不清楚为什么加法器要分那么多种结构也不明白手写 RTL 和综合器自动优化之间的关系。这篇内容就围绕“用 Verilog 实现加法器”这件事把设计思路、代码实现、测试验证、综合优化这些环节完整走一遍。不管你是刚开始学 FPGA 的学生还是想夯实数字设计基础、准备面试的开发者都可以参考这里的思路和代码。我会把每个关键选择背后的原因讲透也会把实操中容易踩的坑一并列出来。1. 加法器方案选型先搞清楚你要哪种“加法器”很多新手拿到“实现加法器”这个需求第一反应是写行为级代码a b仿真一跑通就觉得完事了。实际上加法器的选择是数字设计里一个典型的“面积与速度权衡”问题。你在决定怎么写之前得先想清楚几个问题加法的位宽是多少时钟频率要求多高是放在组合逻辑路径里还是时序逻辑里是追求资源最省还是追求延迟最低1.1 半加器与全加器整个加法器体系的地基半加器是最简单的计算单元只处理两个 1bit 输入不考虑来自低位的进位。它的逻辑就两条和S A xor B也就是两个输入不一样时结果为 1。进位输出Cout A B两个输入同时为 1 时才产生进位。全加器则多了一个进位输入Cin表达式稍微复杂一点S A xor B xor CinCout A B | Cin (A xor B)注意第二项Cin (A xor B)它表示“低位进位进来的时候如果 A、B 恰好有一个为 1就会继续向高位进位”。很多人在这里会直接写A B | Cin A | Cin B功能上也对但综合出来的电路会多几个 LUT查找表。数据流写法用A xor B复用中间结果门数更少延迟也更有优势。1.2 N 位加法器的三种主流结构多位加法器不是简单地把全加器并联关键在于进位怎么传递。根据进位处理方式不同主流结构有三种行波进位加法器Ripple Carry Adder, RCA这是最直观的结构把 N 个全加器串联低位的进位输出接到高位的进位输入。优点是结构规整、布线简单、面积最小缺点是进位是逐级传播的最坏情况下信号要从最低位一路“滚”到最高位延迟和位宽成正比。8 位还好如果是 64 位关键路径就会非常长。超前进位加法器Carry Lookahead Adder, CLA用生成函数 G 和传播函数 P 提前并行计算每一位的进位不等低位慢慢传过来。延迟大幅降低四级、五级就能完成 32 位甚至 64 位的加法但逻辑复杂度、布线资源也随之翻倍。进位选择加法器Carry Select Adder这是工程上非常实用的折中方案把加法器分成若干组每组同时计算 Cin0 和 Cin1 两种情况的结果然后用一个多路选择器根据实际进位快速挑出最终结果。在 FPGA 上如果手工搭LUT 消耗会比较大但在高性能加法器 IP 里经常出现。直接说结论的话学 FPGA、做入门设计行波进位足够理解高性能处理器和面试题超前进位必须掌握。实际项目里你真正常见的是直接写a b让综合工具自动选择最优结构——这个我后面细讲。2. 手写基础代码从 1bit 全加器到参数化行波进位加法器这一节我们写实际可以跑通的 Verilog 代码。我的建议是先用数据流建模把 1bit 全加器写出来再通过 generate 循环生成 N 位行波进位加法器。这不仅是练手更是理解硬件生成逻辑的好办法。2.1 1bit 全加器的数据流实现module full_adder ( input wire a, input wire b, input wire cin, output wire s, output wire cout ); assign s a ^ b ^ cin; assign cout (a b) | (cin (a ^ b)); endmodule这段代码用的是assign连续赋值属于数据流建模。写组合逻辑时我强烈建议优先用这种风格逻辑清晰、可读性好、不容易引入多余的锁存器。等位宽变大、逻辑变复杂之后再考虑用always (*)加上case或if的行为级写法。补充一个细节cout的逻辑式里出现了两次a ^ b有些仿真器不会自动合并同一个表达式如果你担心逻辑重复计算可以先定义一根中间线wire axb a ^ b; assign s axb ^ cin; assign cout (a b) | (cin axb);综合工具通常会自动优化但这样写对阅读代码的人更友好。2.2 参数化行波进位加法器用 generate 批量实例化有了全加器接下来用参数化方式生成任意位宽的行波进位加法器。这里用到parameter、genvar和generate三个关键字module ripple_carry_adder #(parameter WIDTH 8) ( input wire [WIDTH-1:0] a, input wire [WIDTH-1:0] b, input wire cin, output wire [WIDTH-1:0] sum, output wire cout ); wire [WIDTH:0] carry; assign carry[0] cin; genvar i; generate for (i 0; i WIDTH; i i 1) begin : gen_adder full_adder u_full_adder ( .a (a[i]), .b (b[i]), .cin (carry[i]), .s (sum[i]), .cout (carry[i1]) ); end endgenerate assign cout carry[WIDTH]; endmodule几个关键点必须讲清楚carry被定义成[WIDTH:0]比输入多一位就是为了把最终进位导出来不然最高位的进位会悄悄丢掉。这就是位宽设计最常见的错误源头。begin : gen_adder这个标签不是随便写的。在 generate 循环里必须有块名否则后续想通过层次引用访问某个具体全加器实例都会很麻烦而且在某些综合工具里不带标签的 generate 循环也可能报错。assign carry[0] cin;这一步不能省。行波进位链条的起点就是外部进来的进位直接连到第一个全加器的cin上。2.3 关于模块实例化的两个容易踩的坑实例化全加器的时候有人喜欢用端口位置连接而不是名字连接比如full_adder u1(a[0], b[0], carry[0], sum[0], carry[1]);。在外部时靠位置顺序一旦修改模块端口顺序所有调用点全错。我吃过一次大亏一个学长封装的全加器把cin和cout换来换去项目里几十处实例化全部要改。所以建议所有实例化都用端口名连接。另一个坑是三目运算符和逻辑短路。有人写assign cout cin ? (a | b) : (a b);逻辑上等价于全加器进位表达式但综合出的电路延迟和面积和标准写法有差异。实际测试里使用异或复用的版本在延迟上通常更优因为这样只有一个 MUX 级的额外延迟而三目运算版本会引入条件选择逻辑。如果不确定哪种写法更好直接交给综合器去优化功能上等价就行。3. 性能优化进阶超前进位加法器的推导与 Verilog 实现行波进位最大的问题是进位链串行传播——第 15 位的sum必须等第 0 位的进位一路传导过来才能稳定。我在做某图像处理芯片预研时遇到过 32 位加法器串在关键路径上时钟频率始终上不去的状况换成超前进位结构后整个路径延迟降低了将近一半。所以这部分值得认真讲。3.1 生成函数 G 与传播函数 P 是怎么来的先定义两个中间信号这是理解 CLA 的钥匙G_i A_i B_i当 A 和 B 都为 1 时不管低位有没有进位这一位一定会向高位产生进位叫生成函数Generate。P_i A_i | B_i当 A 和 B 至少一个为 1 时如果低位进位是 1这一位就能把进位继续传上去叫传播函数Propagate。于是第 i 位的进位可以写成C[i1] G_i | (P_i C[i])这个式子非常有内涵。它表示进位要么在本位直接生成要么从低位传播上来。把它一层层展开C[1] G0 | (P0 C0) C[2] G1 | (P1 C1) G1 | (P1 G0) | (P1 P0 C0) C[3] G2 | (P2 G1) | (P2 P1 G0) | (P2 P1 P0 C0)看出来了吗高位进位不再依赖逐级等待而是通过展开的逻辑并行计算只依赖最原始的C0和所有输入的 G、P 信号。这就是超前进位的核心思想。3.2 一个 4 位 CLA 的实现代码4 位是最常见的粒度因为逻辑复杂度适中代码可读性好module cla4_adder ( input wire [3:0] a, input wire [3:0] b, input wire cin, output wire [3:0] sum, output wire cout ); wire [3:0] g a b; wire [3:0] p a | b; wire [4:0] c; assign c[0] cin; assign c[1] g[0] | (p[0] c[0]); assign c[2] g[1] | (p[1] g[0]) | (p[1] p[0] c[0]); assign c[3] g[2] | (p[2] g[1]) | (p[2] p[1] g[0]) | (p[2] p[1] p[0] c[0]); assign 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]); assign sum a ^ b ^ c[3:0]; assign cout c[4]; endmodule注意这里的p用的是a | b而不是a ^ b。在 CLA 结构里P A | B已经足够用于进位传递而且综合面积更小。如果直接用异或也行逻辑功能不受影响但没必要多消耗一部分逻辑门。和行波进位加法器的实现里a ^ b被复用于和输出sum不同这里sum独立用a ^ b ^ c计算所以p可以用更省面积的或门。3.3 扩展到更大位宽时树形结构与分组策略CLA 有竞争也有隐患位宽越大C[N]的表达式项数爆炸增长。4 位还能手写16 位的手写展开就是灾难。工程上更常用的做法组内超前进位、组间行波进位。比如 16 位分成 4 组每组内用 4 位 CLA组间用行波方式传递C[4]、C[8]、C[12]。这种设计在延迟和面积折中上非常经典面试中经常被问。完全并行化。每一级都引入 CLA 逻辑形成真正的树形并行进位延迟最低但资源开销极其惊人通常只有在服务器级 CPU 算数单元里才会看到。进位选择加法器。每组同时算cin0和cin1两路结果用多路选择器快速挑出一个延迟介于 RCA 和完全 CLA 之间华为某研发团队在浮点单元里就大量使用这种结构。所以做实际项目时不需要有“我一定要手写 CLA”的执念。先确认综合工具把综合成了什么结构再针对关键路径做优化。有些时候你手写的 CLA 反而不如工具自动生成的进位链快。4. Testbench 怎么写验证加法器功能的完整流程写代码只完成了 60% 工作真正让你敢“上板”的是验证。verilog 里没有像软件那样方便的assert所以测试平台怎么写直接决定你调试效率。4.1 先做一个覆盖所有输入组合的穷举验证对于 8 位加法器A、B 各有 256 个取值Cin 有 2 个总共 131072 种组合用循环遍历完全跑得动。仿真的本质就是撒一把输入看输出和期望值是否一致。以下是一段可以直接用的 testbenchtimescale 1ns / 1ps module tb_adder; parameter WIDTH 8; reg [WIDTH-1:0] a; reg [WIDTH-1:0] b; reg cin; wire [WIDTH-1:0] sum; wire cout; integer i, j, k, errors; reg [WIDTH:0] expected; ripple_carry_adder #(.WIDTH(WIDTH)) uut ( .a(a), .b(b), .cin(cin), .sum(sum), .cout(cout) ); initial begin errors 0; 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 i; b j; cin k[0]; #10; expected {1b0, a} {1b0, b} {1b0, cin}; if ({cout, sum} ! expected) begin errors errors 1; $display(ERROR: a%0d b%0d cin%b sum%0d cout%b expected%0d, a, b, cin, sum, cout, expected); end end end end if (errors 0) $display(All %0d test cases passed., (1 WIDTH) * (1 WIDTH) * 2); else $display(Total errors: %0d, errors); $finish; end endmodule这段代码有几个细节值得说。第一expected必须比a、b多一位否则abcin的最高位进位会被截断验证结果永远对不上。第二赋值给expected时我用了拼接位操作这样能明确告诉仿真器这是一个WIDTH1位的无符号运算而不是让位宽隐式扩展可以避免无符号数传播导致的一堆警告。4.2 用 $monitor 或自动对比代替人肉看波形初学者最常干的事情是打开仿真器盯着波形图逐条核对。8 位全遍历几千个组合还好32 位全遍历就完全不现实。所以代码里加入自动对比非常关键。$display只打印错误全部通过时只打印成功信息。配合以下这句还能在仿真过程中随时观察关键信号// 可选每次输入变化后打印一次仿真状态 // $monitor(a%0d b%0d cin%b sum%0d cout%b, a, b, cin, sum, cout);我个人的调试习惯是穷举验证只开$display报错做大型模块联调时再用$monitor观察连续数据流的时序变化。不要两条都开那会刷屏刷到怀疑人生。4.3 时序控制与仿真中的常见坑#10表示等待 10 个时间单位。在纯组合逻辑验证里它给信号一个稳定窗口。但注意#10是仿真延迟不是综合延迟综合工具会直接忽略它。有些新手以为#10能提高电路速度那是完全理解错了仿真延迟只影响仿真器的事件调度不影响任何硬件特征。另一个坑是测试平台里给a、b、cin赋值的方式。程序块里的begin...end是顺序执行的仿真器在同一个时间片内处理所有#10之后的赋值。如果你在循环里不写#10直接连续改a、b、cin由于组合逻辑的输出会立即跟随输入变化你实际上看不到任何中间状态验证结果也会乱。所以每个输入向量后要留出充足的#时间。5. 综合落地FPGA 上的资源与性能观察仿真跑通了接下来是把 RTL 代码放到 FPGA 上综合、实现。这是很多自学同学的薄弱环节因为软件仿真顺畅不代表硬件资源利用合理。5.1 综合器是如何“翻译”加法器的现代的 FPGA 综合工具比如 Vivado 或 Quartus遇到a b时会自动推断出硬件结构。它们通常不直接生成你想象中的裸和门阵列而是映射到 FPGA 内部的专用进位链比如 Xilinx 的 CARRY4 原语或通用 LUT 上。Xilinx 7 系列 FPGA 中一个 CARRY4 可以实现 4 位超前进位链效率远高于用 LUT 搭出来的 RCA。所以你会遇到一个反直觉的现象手写 8 位 CLA 的综合结果延迟可能比直接写a b还差。原因在于工具对标准加法运算的优化算法成熟度远高于它对任意组合逻辑的优化。这不是说手写 CLA 没用——在非常特定的位宽、特定约束下手工定制结构依然能压制工具但前提是你得会看综合报告、时序报告。5.2 综合报告怎么看从面积和时序两个维度判断综合并实现之后一定要打开报告看三个指标LUT 与 FF 消耗。如果综合工具推断出加法器使用的是进位链资源LUT 计数往往不高。如果手写结构把进位逻辑拆到了普通 LUT 里LUT 数量会明显上涨。不同结构的资源消耗差别从几倍到几十倍都可能出现。关键路径延迟。在时序报告中找到从输入到输出的最大路径延迟。对加法器来说行波进位结构的关键路径通常就是从cin到最后一位sum的进位链这条路径上经过的 LUT 越多延迟越大。时钟频率。如果你的加法器在一个always (posedge clk)块里综合器会以时钟频率约束为基准做布局布线。8 位加法器随便跑32 位加法器在 300MHz 以上的时钟下就可能变红。看到红不要慌用上一节的进位选择或分层 CLA 结构重构或者直接降低位宽分组。5.3 直接写还是手写 RTL我的建议做项目时我强烈建议默认直接写让综合器去做优化。理由有三点工具优化算法远比你手写成熟尤其在 FPGA 上专用进位链的布线位置和延迟特征只有工具最清楚。手写代码可读性差团队交接成本高而且 generator 展开的大量实例化会让网表文件巨大。你手写的结构在综合后很可能被工具重新映射最终效果未必是你脑海里想象的电路。那什么时候值得手写答案是你在做芯片设计、做算法定制 IP或者你的位宽和结构需求极其特殊比如需要饱和加法、进位保存加法时。在这些场景下手写 RTL 的价值在于把设计意图显式表达给后端工具避免它做出“次优”的选择。真正进过项目后你会明白加法器优化的优先级其实没有想象中那么高。性能瓶颈往往出在存储读写、跨时钟域同步、总线仲裁等更复杂的逻辑上。加法器的核心意义在于让你理解数字电路的基础模型这个基础打牢了后面分析复杂模块才不费劲。6. 常见错误与排查经验我自己踩过的那些坑最后这部分分享一些实操中总结的调试技巧很多都是教科书里不会单独讲但实际一踩就浪费一整天的细节。6.1 信号位宽不匹配导致“carry 被截断”写加法器最容易犯的错误就是位宽不够。比如定义output [WIDTH-1:0] sum却忘了把最终进位cout保留结果在仿真中两个数相加溢出时最高位进位被静默丢弃。这种情况在单一模块里可能不明显一旦级联到下一个模块数据怎么都对不上。排查方法先在 testbench 里把{cout, sum}打印出来看它是否等于你期望的完整结果。如果比期望少一位那就说明位宽定义有问题。习惯上我会把所有加法结果的信号都定义为比输入宽一位从根源上杜绝问题。6.2 阻塞赋值与非阻塞赋值混用行为级代码的经典雷区如果加法逻辑写在时钟驱动的always块里必须用非阻塞赋值。写组合逻辑的always (*)块里则用阻塞赋值。混用的话轻则仿真出现中间态重则综合出不期望的锁存器。我见过一个同学在always (posedge clk)里写sum a b;仿真波形看着没问题但综合后的网表出现了几个多余的触发器排查了很久才发现是赋值风格的问题。建议在写加法器这类简单功能时就坚持“组合逻辑用数据流建模时序逻辑用非阻塞赋值”。这样最安全。6.3 generate 循环里的常见报错与陷阱用generate做参数化设计时有两个问题高频出现。第一循环变量genvar i不能在initial或always内部声明只能出现在模块级或 generate 块内。第二generate 循环里实例化模块时块名必须唯一否则会报重名。有时为了省事不写块名Vivado 会默认生成名字但后续代码中引用的层次路径非常难记建议养成写块名的习惯。6.4 仿真通过、上板不跑综合器和仿真器看待代码的方式不同一个非常典型的现象testbench 遍历所有输入结果全对但烧到板子上就是有些数加不对。这通常是两个原因造成的。硬件外部没有对输入信号做同步处理按键或拨码开关存在抖动导致采到的a[3]瞬间不是理想电平。你的加法器用了always (*)且敏感列表漏写了信号仿真器未必报错综合器却推断出了错误的结构。第二种情况发生的概率比想象中高。我调试某总线译码逻辑时就遇到过一次敏感列表漏掉cs_n仿真结果千奇百怪但在波形上又看不出明显规律。后来总结出一套检查口诀组合逻辑必须写always (*)永远别手动列敏感信号列表连续赋值assign不需要敏感列表会自动跟踪右端信号变化。7. 一些真实的项目体会说点题外话我在某图像处理项目的实战中发现加法器的“理论结构”和“工程结构”完全是两回事。当时需要给一个 64 位数据通路做累加理论分析觉得行波进位会拖垮性能凭证 CLA 方案能追上时序。结果综合之后工具自动在 FPGA 进位链上实现了 64 位加法延迟比我手写的 CLA 还低了 20%而且 LUT 消耗少了将近一半。从那以后我调整了自己的设计原则先在代码里写标准的a b、a b cin先用工具跑一次综合报告确认关键路径在哪里再针对性地做改进。除非你是纯粹为了学习原理否则别一上来就手写各种进位结构。另外还有一个实测有效的技巧如果多个加法器要共享一组输入可以考虑把它们融合成一次加法或优化成进位保存加法器结构而不是让工具生成多个独立加法器。做 DSP 算法中的乘累加时这种融合能明显节约资源。最后再提醒一句加法器是很基础的组件但越基础的东西越需要深挖。你如果能把半加器、行波进位、超前进位这三层都自己写一遍、验证一遍、综合一遍数字电路设计的地基就算打得很扎实了。之后学乘法器、除法器、浮点运算很多思想都是一脉相承的。动手试一下别光看跑一次仿真比读十篇文章都有用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。