如果你刚接触FPGA,十有八九会被推荐的第一个正经实验不是流水灯,而是加法器设计。山东大学FPGA课程实验一就把加法器放在第一关,这个安排看起来简单,但背后藏着一整套数字电路的基本功:真值表、组合逻辑、Verilog语法、testbench仿真,再到最终上板的管脚约束。我当年也是从这块板子一路踩坑过来的,后来带过几届学弟,发现大家遇到的坑几乎一模一样。这篇就把这个实验从头到尾拆开讲明白,包括代码怎么写、仿真怎么调、上板为什么不亮,希望能帮你少走点弯路。
1. 实验背景与核心思路
1.1 为什么第一课选择加法器
加法器是数字系统最基础的运算单元,CPU里的ALU、计数器的累加逻辑、地址生成器,说到底都离不开它。学校里很多课程设计会用交通灯、电子琴这类花哨题目,但山东大学FPGA课程偏偏选加法器当开胃菜,我觉得原因很实在:加法器能让你在一节课内同时接触到,模块端口怎么定义、组合逻辑怎么写、仿真激励怎么给,以及最重要的——“电路不是程序”这个思维转换。
很多同学是学过C语言再来学FPGA的,上手就喜欢写for循环、写变量自加,总想用软件思维去写硬件。加法器这个小东西没有时序状态,没有复杂状态机,正好把你逼到一个最朴素的问题上:怎么用语言描述一条信号从输入到输出的传播路径。理解了这个,后面学流水灯、状态机、FIFO都会顺很多。
1.2 从真值表到Verilog:三种实现思路对比
我给这个实验分了三个层次,你们可以根据自己的进度选择做哪一种。
第一种是逻辑门级描述。把加减法退化成最原始的与门、或门、异或门,完全照着手绘电路图去例化门电路。这种方法最繁琐,但最接近硬件本质,能让你看清每一位是如何独立运算的。
第二种是数据流描述。用assign语句配合+运算符,一行代码解决。这种方法看起来是“作弊”,但它体现了Verilog作为硬件描述语言的抽象能力:你描述的是“期望什么结果”,工具会自动推断出加法器电路。
第三种是行为级描述。在always块里用case或者if列出所有输入组合,相当于把真值表直接翻译成代码。这种写法对逻辑综合工具最友好,是工业界最常用的方式。
我建议你们三种都试一遍,然后对比综合后的RTL视图。你会发现工具生成的电路和你手绘的门级结构几乎一样,那种“原来代码真的会变成电路”的感觉,比刷十遍理论课都有用。
2. 设计原理与代码实现
2.1 半加器和全加器的逻辑拆解
在动手写代码前,先得把底层的铁律搞清楚。一个二进制加法,不考虑来自低位的进位时,就是半加器。设两个输入为a和b,输出为和sum、进位cout,真值表如下:
| a | b | sum | cout |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 1 |
从真值表能直接写出逻辑表达式:sum = a ^ b,cout = a & b。异或门就是“只看两个数是否不同”,与门就是“两个都为1时才产生进位”。
但实际做多位加法时,每一位还要考虑来自低位的进位cin,这就是全加器。全加器有三个输入a、b、cin,输出sum和cout。注意这里的cin是“carry in”,代表来自低位的进位输入。表达式变成:
sum = a ^ b ^ cincout = (a & b) | (cin & (a ^ b))
很多人问为什么要写成这样,其实就是穷举8种输入状态化简的结果。你可以用卡诺图验证,也可以直接理解为:只要a、b同为1,必然产生进位;如果a和b只有一个为1,但低位的进位是1,那也会产生进位并传给高位。
2.2 多位加法器的两种经典写法
掌握了单bit全加器,多位加法就是把它复制多份。最直观的方式叫行波进位加法器(Ripple Carry Adder,RCA),把每个全加器的cout接到下一位的cin。第一位cin接地(0),最高位cout作为整个加法器的溢出标志。这种结构简单,缺点是高位要等低位的进位逐级传上来,位宽越大延迟越高。
另一种叫超前进位加法器(Carry Lookahead Adder,CLA),它通过逻辑电路预先算出每一位的进位,不需要等待逐级传递,速度更快但电路更复杂。对于课程实验,RCA已经足够,而且能让你更清楚进位的传播关系。
不过真的用Verilog实现时,大多数人不会自己展开门级电路,而是直接写:
assign {cout, sum} = a + b + cin;
这里的{cout, sum}意思是将进位和和的各位拼接成一个大的向量。位宽要匹配,比如a和b是4位,sum就是4位,cout是1位,拼接后就是5位,可以容纳最大结果15+15+1=31,也就是5位二进制。这一行代码综合后,工具会自动帮你生成一个优化的加法器电路,可能是行波进位,也可能是超前进位,取决于综合策略。
2.3 完整Verilog代码与注释
下面我给出一个4位加法器的完整代码,包含门级描述和行为级两种写法,你们可以对照学习。先看行为级,这是最常写的:
module adder4 ( input wire [3:0] a, // 加数1 input wire [3:0] b, // 加数2 input wire ci, // 进位输入 output wire [3:0] sum, // 和的低4位 output wire co // 进位输出 ); assign {co, sum} = a + b + ci; // 拼接进位输出与和 endmodule再看门级实现,用全加器级联。先写一个1位全加器模块,再在顶层例化4次:
// 1位全加器 module fulladder ( input wire a, b, cin, output wire sum, cout ); wire s1; assign s1 = a ^ b; assign sum = s1 ^ cin; assign cout = (a & b) | (cin & s1); endmodule // 4位行波进位加法器 module adder4_rca ( input wire [3:0] a, b, input wire ci, output wire [3:0] sum, output wire co ); wire c1, c2, c3; // 中间进位 fulladder fa0 ( .a(a[0]), .b(b[0]), .cin(ci), .sum(sum[0]), .cout(c1) ); fulladder fa1 ( .a(a[1]), .b(b[1]), .cin(c1), .sum(sum[1]), .cout(c2) ); fulladder fa2 ( .a(a[2]), .b(b[2]), .cin(c2), .sum(sum[2]), .cout(c3) ); fulladder fa3 ( .a(a[3]), .b(b[3]), .cin(c3), .sum(sum[3]), .cout(co) ); endmodule门级实现的价值在于,你能在仿真波形里肉眼看到进位c1、c2、c3是如何像多米诺骨牌一样逐位传递的。如果你用的是Vivado,综合后打开Schematic视图,能直接看到四个全加器串联的结构,这一步强烈建议做,对理解硬件很有帮助。
3. 仿真验证与上板
3.1 用ModelSim或Vivado自带仿真搭建testbench
写好设计代码后,下一步就是验证。很多同学拿到板子就直接烧,结果LED乱闪,又不知道哪里出了问题。正确的流程是先仿真。仿真工具可以用ModelSim,或者直接用Vivado/Quartus自带的仿真器。我以Vivado为例,新建工程后添加上面写的adder4模块,然后创建一个testbench文件,也叫激励文件:
`timescale 1ns/1ps module tb_adder4; reg [3:0] a; reg [3:0] b; reg ci; wire [3:0] sum; wire co; adder4 dut ( .a(a), .b(b), .ci(ci), .sum(sum), .co(co) ); initial begin a = 4'd0; b = 4'd0; ci = 1'b0; #10; // 测试1:0 + 0 = 0 a = 4'd0; b = 4'd0; ci = 1'b0; #10; // 测试2:5 + 3 = 8, 无进位 a = 4'd5; b = 4'd3; ci = 1'b0; #10; // 测试3:15 + 1 = 16, 进位为1,和的低4位为0 a = 4'd15; b = 4'd1; ci = 1'b0; #10; // 测试4:7 + 4 + 1 = 12 a = 4'd7; b = 4'd4; ci = 1'b1; #10; $display("a=%d b=%d ci=%d sum=%d co=%d", a, b, ci, sum, co); $finish; end initial begin $monitor("time=%0t a=%b b=%b ci=%b sum=%b co=%b", $time, a, b, ci, sum, co); end endmodule这个testbench里用了initial块,它在仿真开始后按顺序执行。#10表示延时10个时间单位,用$monitor可以实时打印所有信号变化,方便在控制台核对。运行仿真后,在波形窗口里添加a、b、ci、sum、co这几个信号,就能看到每一组输入下的输出。
3.2 波形分析和常见仿真陷阱
仿真波形看着简单,但有几个坑是大家很容易踩的。
第一个坑是没有把testbench加入仿真。在Vivado里,你要在Sources面板里把tb_adder4设为仿真顶层,不是把adder4设为顶层。很多同学在Simulation里点了Run后,发现波形窗口空白,多半是没设置对。
第二个坑是信号显示为X态。X表示不定态,常见原因是变量没有初始化。比如testbench里的reg变量默认值是X,如果没在initial块里赋值就直接#10,仿真出来的sum就是X。所有参与激励的reg变量,在仿真开始前必须赋值,这和我们写C语言时的“变量要初始化”一个道理。
第三个坑是波形窗口里信号看不全。因为有些信号被综合优化掉了,比如中间进位c1、c2、c3,在综合后仿真里可能不存在。如果你是在行为级仿真(也叫前仿真)里,这些内部net是能看到的。建议做功能仿真时用RTL仿真,不要用综合后仿真,否则你会被一堆缩写弄得头大。
第四个坑更隐蔽:测试用例覆盖不全。我见过很多同学只测了“3+5”和“7+2”,以为仿真过了就没问题了,结果上板后按到15+1,进位co直接崩溃。加法器最重要的边界测试就是所有位全为1时再加1,比如4位加法器的15+1、15+15+1,这些极端情况一定都要测到。
3.3 上板验证时如何观察结果(LED/数码管)
仿真通过后,终于可以烧录到板子上。实验板的操作方式是:把a和b对应的4位拨码开关拨到某个值,按下输入按键模拟cin(或者固定接地),然后观察LED显示sum和co。在我的板上,8个LED中前4个显示sum,第5个显示co。具体管脚分配要参考你的板卡原理图,在Vivado里新建约束文件(.xdc),把a端口绑定到拨码开关,sum和co绑定到LED。
这里有个小建议:如果板子有数码管,不妨把结果用两个数码管显示成十进制数。虽然实验只要求二进制LED,但数码管能直观看到“13+6=19”,比二进制直观得多。做法是把sum和co再转成BCD码,用两个数码管扫描显示。这个扩展会用到七段译码器和动态扫描技术,刚好是很多FPGA课程下一个实验的内容,提前练练不吃亏。
引脚约束的语法很简单,比如:
set_property PACKAGE_PIN J15 [get_ports {a[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {a[0]}]每个信号都要有对应的PACKAGE_PIN和IOSTANDARD,具体引脚编号以你的板卡原理图为准。这里最容易犯的错误是引脚看错,明明约束了a[0]对应的按键,板子却映射到了另一个按键。我当年烧完波形怎么拨开关都没反应,最后发现是原理图上的网络名和xdc里的引脚对不上,折腾了整整一晚上。所以拿到板卡的第一步永远是看原理图,确认外设连接到FPGA的哪个Bank、哪个Pin,别想当然。
4. 常见问题与调试心得
4.1 仿真结果正确但上板不亮怎么回事
如果你仿真波形完美,但一烧到板子上LED死活不亮,先别急着怀疑人生。按照优先级排查这三件事。
第一,检查管脚约束是否生效。在Vivado里打开Implementation后的Device视图,看看你约束的引脚是否真的连接到了LED和开关。有时你约束了引脚但忘了保存约束文件,工具会警告“忽略未约束的I/O”,但很多同学不看警告,直接生成bitstream,结果所有端口都被工具自动分配到了任意位置。
第二,检查输入信号的消抖问题。拨码开关本质是机械触点,按下去或拨动时会有几毫秒的抖动,信号在0和1之间来回跳。你仿真时用的是理想的阶跃信号,但真实电路里抖动会让加法器连续计算多次,LED看起来就是乱跳,或偶尔不变化。解决方法是给输入加个简单的消抖电路,或者用按键而非拨码开关时,配合一个计数器做消抖。
第三,检查时钟是否被使用。我们这次实验的sum输出是组合逻辑,不依赖时钟,所以上板时如果不加时钟也能看到结果,只要输入稳定输出就稳定。但如果你在扩展里加了寄存器,或者用数码管动态扫描,那必须给设计提供正确的时钟信号。初学者最常犯的错就是忘记给板子的时钟引脚添加约束,导致时序逻辑完全没反应。注意,实验中如果使用always @(posedge clk)这种写法,就必须把clk约束到板载晶振的引脚上。
4.2 进位链延迟与组合逻辑竞争
实验做完后,我建议你问问自己一个问题:为什么4位行波进位加法器不能跑很高的时钟频率?这就要说到进位链延迟了。每个全加器从输入到产生进位输出,大约经过两个门延迟。4位加法器的进位要依次经过4个全加器,总延迟是串联的。时钟周期必须大于这个延迟,否则结果还没稳定下来,下一拍就到了,就会采样到错误数据。这在你后面学到时序约束和静态时序分析时会反复遇到。
还有一个有趣的坑:组合逻辑的输出会存在毛刺。比如两个输入从“3+5”变成“4+4”,各位信号变化的时刻可能不完全相同,导致sum先短暂变成无效值,再稳定到8。如果你的LED直接接在组合逻辑输出上,你肉眼可能看不见,但用示波器测会发现毛刺。如果后续电路里有寄存器采样,就可能在毛刺瞬间采到错误值。解决方法是让输出经过一级寄存器打拍,把组合逻辑的结果在时钟沿到来前锁存住,这样毛刺就不会传播到下一级。
这个知识点看似超纲,但考试面试很喜欢问。很多公司招聘FPGA工程师,必然会问“组合逻辑和时序逻辑的区别”,以及“如何消除毛刺”。从这个实验里能顺便弄明白这些问题,性价比很高。
4.3 初次使用FPGA开发板的3个建议
如果你是完全没用过FPGA板卡的小白,第一次上板前请记住下面三点,都是我用眼泪换来的。
第一,先跑一遍板卡自带的DEMO。不管是黑金、正点原子还是Altera的板子,出厂都会内置几个演示工程,比如流水灯、数码管秒表。先下载官方demo,确认硬件和下载线没问题,再开始自己的设计。我见过有同学第一次烧录就把下载器驱动装错了,折腾了三个小时,最后才发现是驱动没装对,原厂demo直接能跑。
第二,养成编译后看警告的习惯。综合和实现阶段,工具会输出很多警告,比如“inferred latch”、“unused input”等。初学阶段看到warning不用慌,但要花时间看懂每条警告在说什么。很多发诡异的bug,根源就在这些被忽略的警告里。比如“signal sum is not used”可能就是你的端口连接错了,sum压根没驱动到LED上。
第三,用拨码开关做输入时,一定要加消抖吗?我的答案是拨码开关可以不消抖,但按键必须消抖。因为拨码开关拨到位后基本就稳定了,抖动发生在拨的过程中,如果你在拨完后再看LED,看到的已经是稳定结果。但按键按下和松开时的抖动会持续几十毫秒,如果你的电路检测下降沿,可能会检测到多次。所以建议所有输入按键都加一个简单的20ms计数器消抖,这个技巧后面做交通灯、电子琴时都能用上。
关于这个加法器实验,最后再分享一个我觉得特别有用的操作:在完成基本要求后,试着把加法器改成减法器。补码减法其实很简单,a - b等价于a + (~b + 1),也就是把b按位取反再加1。你只需要在原加法器的b输入前加一排异或门,再控制cin为1,就能实现减法。这个改动看起来不大,但能帮你把二进制补码、进位、借位这些概念一次性串起来。做完之后你会发现,CPU里加减法其实就是这么回事,整个数字系统就像一个精致的拼图,而加法器就是那块最核心的起点。