从加法器到数码管:在FPGA上实现一个“看得见”的4位计算器
你有没有过这样的经历?写了一堆Verilog代码,烧进FPGA板子后,逻辑看似正确,但就是不知道内部数据到底对不对。没有输出,就像黑盒运行——这正是初学者常遇到的困境。
今天,我们来做点“看得见”的事情:用FPGA搭建一个4位全加器,把两个二进制数相加的结果,实时显示在一个七段数码管上。整个过程不依赖电脑串口、也不靠逻辑分析仪,只靠一排拨码开关输入,一眼就能看到计算结果。
这个项目虽小,却完整走通了信号输入 → 算术运算 → 数据译码 → 物理输出的全流程。它是数字系统设计的“Hello World”,也是理解硬件行为本质的最佳入口。
为什么是4位全加器?
别看它简单,全加器可是现代处理器算术单元(ALU)的起点。哪怕是最强大的CPU,其加法功能也始于这样一个个小小的“1位全加器”模块。
我们要做的4位全加器,能完成如下任务:
- 输入:两个4位二进制数 A[3:0] 和 B[3:0]
- 外加一个进位输入 Cin
- 输出:4位和值 Sum[3:0] + 最终进位 Cout
数学上就是:
Sum = A + B + Cin (模16)
结构上,它由四个1位全加器级联而成,采用串行进位(Ripple Carry)方式连接。虽然速度不如超前进位加法器,但胜在结构清晰、易于理解和实现,非常适合教学与入门实践。
搭建你的第一个组合逻辑模块:1位全加器
先来拆解最基本的单元——1位全加器。
它有三个输入:A、B、Cin
两个输出:S(和)、Cout(进位)
布尔表达式非常经典:
- S = A ⊕ B ⊕ Cin
- Cout = (A & B) | (Cin & (A ^ B))
这两个公式背后其实有直观含义:
- 和S是“奇数个1就输出1”,相当于三输入异或;
- 进位Cout发生于两种情况:AB同时为1(产生进位),或者其中一个是1且Cin也是1(传递进位)。
用Verilog写出来简洁明了:
// 1位全加器模块 module full_adder_1bit ( input A, input B, input Cin, output S, output Cout ); assign S = A ^ B ^ Cin; assign Cout = (A & B) | (Cin & (A ^ B)); endmodule这段代码使用assign语句描述纯组合逻辑,没有任何时钟参与。也就是说,只要输入变了,输出立刻响应——这就是组合电路的本质:无记忆,只看当前。
把四个小砖块砌成墙:构建4位加法器
现在我们把四个1位全加器连起来,形成4位串行进位加法器。
关键在于“进位链”的连接:低位的Cout接高位的Cin。这里我们用wire声明中间进位信号C[3:0],分别作为各级之间的桥梁。
// 4位全加器顶层模块 module adder_4bit ( input [3:0] A, input [3:0] B, input Cin, output [3:0] Sum, output Cout ); wire [3:0] C; // 内部进位线 full_adder_1bit fa0 (.A(A[0]), .B(B[0]), .Cin(Cin), .S(Sum[0]), .Cout(C[0])); full_adder_1bit fa1 (.A(A[1]), .B(B[1]), .Cin(C[0]), .S(Sum[1]), .Cout(C[1])); full_adder_1bit fa2 (.A(A[2]), .B(B[2]), .Cin(C[1]), .S(Sum[2]), .Cout(C[2])); full_adder_1bit fa3 (.A(A[3]), .B(B[3]), .Cin(C[2]), .S(Sum[3]), .Cout(Cout)); endmodule这种结构化实例化的方式,让设计层次分明。每个模块各司其职,后期若要替换成超前进位结构,只需替换顶层连接方式即可,底层原语保持不变。
⚠️ 注意:由于是组合逻辑,必须确保输入稳定后再读取输出,否则可能采样到毛刺或中间态。
如何让人“读懂”FPGA里的数据?七段数码管登场
FPGA内部的数据是冰冷的0和1。要想让人眼能识别,就得翻译成视觉符号。这时候,七段数码管就成了最直接的选择。
常见的共阳极数码管长这样:
a --- f | | b | g | --- e | | c | d | ---每一段对应一个LED。对于共阳极类型,所有阳极接VCC,阴极由FPGA控制。因此:
- FPGA输出低电平(0)→ 段点亮
- FPGA输出高电平(1)→ 段熄灭
比如要显示数字“5”,需要点亮 a、c、d、f、g,其余关闭。对应的控制字就是7'b0010010(顺序为 {a,b,c,d,e,f,g})。
于是问题来了:如何将Sum[3:0]自动转换成这7位控制信号?
答案是:BCD译码器。
写一个“翻译官”:BCD转七段译码器
我们需要一个模块,能把4位二进制输入(当作BCD码处理)映射成对应的段选信号。
考虑到4位最大是15,而数码管通常只能准确显示0~9,所以我们做个小约定:
- 若结果 ≤ 9,正常显示;
- 若 ≥ 10,则统一显示“E”表示溢出或越界。
下面是核心代码:
// BCD到七段数码管译码器(共阳极) module seg7_decoder ( input [3:0] bcd, output reg [6:0] seg // {a,b,c,d,e,f,g} ); always @(*) begin case(bcd) 4'd0: seg = 7'b1000000; // 显示 0 4'd1: seg = 7'b1111001; // 显示 1 4'd2: seg = 7'b0100100; // 显示 2 4'd3: seg = 7'b0110000; // 显示 3 4'd4: seg = 7'b0011001; // 显示 4 4'd5: seg = 7'b0010010; // 显示 5 4'd6: seg = 7'b0000010; // 显示 6 4'd7: seg = 7'b1111000; // 显示 7 4'd8: seg = 7'b0000000; // 显示 8 4'd9: seg = 7'b0010000; // 显示 9 default: seg = 7'b1000111; // 显示 E(用于10~15) endcase end endmodule这里用了经典的always @(*)组合逻辑块,配合case语句实现真值表查找。注意default分支的重要性——它保证了非法输入也能安全响应,避免综合工具生成锁存器。
✅ 小贴士:如果你的开发板是共阴极数码管,只需要把每一位取反即可。
整体系统整合:把所有模块串起来
现在我们有了三大部件:
- 输入源(拨码开关)
- 核心运算(adder_4bit)
- 输出显示(seg7_decoder + 数码管)
接下来,在顶层模块中将它们统一例化:
module top_module ( input [3:0] sw_a, // 拨码开关输入A input [3:0] sw_b, // 拨码开关输入B input cin_sw, // 进位输入开关 output [6:0] seg_out // 驱动数码管的7段信号 ); wire [3:0] sum; // 实例化4位加法器 adder_4bit u_adder ( .A(sw_a), .B(sw_b), .Cin(cin_sw), .Sum(sum), .Cout() ); // 实例化译码器 seg7_decoder u_decoder ( .bcd(sum), .seg(seg_out) ); endmodule这个顶层就像“总指挥”,把各个功能模块粘合在一起。所有接口一一对应,干净利落。
硬件配置与调试要点
光有代码还不够,还得让FPGA知道哪个信号连哪根物理引脚。你需要在XDC约束文件中添加类似内容(以Xilinx Artix-7为例):
set_property PACKAGE_PIN J15 [get_ports {sw_a[0]}] # 对应开关0 set_property PACKAGE_PIN L16 [get_ports {sw_a[1]}] # ... 其他输入依次分配 set_property PACKAGE_PIN H15 [get_ports {seg_out[0]}] # 段a set_property PACKAGE_PIN K16 [get_ports {seg_out[1]}] # 段b # ... 直至段g同时务必注意以下几点:
🔌 引脚分配准确性
必须查阅开发板原理图,确认拨码开关和数码管的实际连接引脚,否则会出现“按键无效”或“乱码”。
💡 加上限流电阻
每个数码管段建议串联220Ω ~ 1kΩ的限流电阻,防止电流过大损坏FPGA I/O或LED本身。很多开发板已内置,但仍需确认。
🧪 仿真验证不可少
在下载前,强烈建议编写Testbench进行功能仿真。例如测试组合(A=5, B=3, Cin=0)是否输出8;(7+8)是否触发“E”提示。
// testbench 片段示例 initial begin A = 4'd5; B = 4'd3; Cin = 1'b0; #10; if (Sum === 4'd8) $display("Pass: 5+3=8"); end常见坑点与解决秘籍
❌ 问题1:数码管显示乱码或全暗
- 排查方向:检查是否搞错了共阳/共阴类型;
- 对策:尝试将译码输出全部取反再观察。
❌ 问题2:按下某个键,数值跳变不止
- 原因:机械开关存在抖动;
- 对策:加入去抖模块(可用计数器延时消抖),或改用按键+同步寄存器采样。
❌ 问题3:明明输入是6+3=9,却显示“E”
- 真相:可能是引脚接错导致某一位始终为高;
- 技巧:先单独测试译码器,输入固定值看能否正常显示0~9。
❌ 问题4:仿真通过,板级失败
- 重点怀疑对象:XDC引脚约束错误、电源异常、下载模式设置不当;
- 建议:逐级隔离测试,先验证输入,再验证中间信号,最后看输出。
可以怎么继续玩?扩展思路推荐
别以为这只是个“玩具项目”。它的骨架足够灵活,可以轻松升级为更实用的功能模块:
✅ 双数码管显示(0~15)
把Sum[3:0]拆分为十位和个位,用动态扫描方式驱动两位数码管,真正显示十六进制结果。
✅ 添加减法功能
引入一个op_sel信号,当为1时执行减法(通过补码实现),变成简易ALU。
✅ 自动累加器
加入时钟和寄存器,实现每隔1秒自动+1,做成计数器或定时提醒装置。
✅ 结果存储与回显
加一个简单的RAM模块,记录最近几次运算结果,支持翻页查看。
✅ 接入真实传感器
后续可结合ADC模块读取温度、电压等模拟量,再用数码管显示数字化结果。
写在最后:最小单元中的工程哲学
这个项目从头到尾没用一行高级语言,也没有操作系统介入。但它完整展现了嵌入式系统的灵魂:
输入感知 → 数据处理 → 物理反馈
我们从最基础的门电路出发,一步步构建出具有实际交互能力的数字系统。每一个assign语句都在定义硬件行为,每一根连线都承载着信号流动的真实路径。
更重要的是,当你拨动开关、看到数码管亮起那一刻,你会真切感受到:硬件不是抽象的概念,而是可以触摸的逻辑世界。
也许有一天你会设计CPU、做图像处理、跑AI推理。但请记得,一切伟大的工程,往往都始于这样一个小小的加法器。
正如那句话所说:从最小单元出发,构建无限可能。
如果你正在学习FPGA,不妨今晚就动手试试。点亮第一个“看得见”的结果,也许就是你成为硬件工程师的真正起点。