FPGA实战:从4位全加器到数码管显示的完整系统搭建
你有没有试过在FPGA开发板上点亮一个数码管,结果发现数字一直在“跳”?或者明明输入了5 + 6,却只看到B而不是期望的11?这些问题背后,其实是数字系统设计中几个关键环节的协同问题——运算、转换、显示与时序控制。
今天我们就来解决这个经典难题:如何在一个资源有限的FPGA平台上,实现两个4位二进制数相加,并将结果以十进制形式稳定地显示在4位七段数码管上。整个过程不仅涉及基础组合逻辑的设计,还融合了时序控制和人机交互技巧,是初学者迈向真正“系统级”FPGA开发的第一步。
一、为什么选4位全加器?它不只是“加法那么简单”
在所有数字电路教学中,加法器几乎是第一个登场的模块。但你知道吗?哪怕是最简单的串行进位结构,也能揭示现代处理器ALU的核心思想。
全加器的本质:三位一体的逻辑单元
一个1位全加器(Full Adder)要处理三个输入:两个操作数a、b和低位进位cin,输出当前位的和sum以及向高位的进位cout。它的真值表看起来复杂,但其实可以用两句话概括:
- 本位和 = 三数异或
- 进位 = 至少有两个为1
用Verilog写出来就是:
assign sum = a ^ b ^ cin; assign cout = (a & b) | (cin & (a ^ b));别小看这两行代码——它们构成了整个算术系统的基石。你可以把它想象成一个“投票机制”:只有当至少两人同意(即有两个或以上输入为1),才会产生进位。
构建4位加法器:模块化才是工程之道
直接写一个4级串联的逻辑表达式当然可以,但那样既难读又无法复用。真正的做法是自底向上、模块化构建:
module adder_4bit( input [3:0] A, input [3:0] B, input Cin, output [3:0] Sum, output Cout ); wire [3: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(carry[3])); assign Cout = carry[3]; endmodule这种设计方式有三大好处:
1.可测试性强:每个full_adder都能单独仿真验证;
2.易于扩展:想要8位?复制粘贴再接四级就行;
3.便于调试:一旦出错,可以直接定位到某一级。
⚠️ 提醒:虽然串行进位(Ripple Carry)结构延迟较高(信号像波纹一样逐级传递),但在4位宽度下影响极小,完全满足教学和低速应用需求。对于更高性能场景,才需要考虑超前进位(Carry Lookahead)等优化方案。
二、怎么让计算结果显示出来?数码管动态扫描揭秘
FPGA的强大在于“你能定义一切”,但最终用户看到的往往只是一个亮着的数字。那么问题来了:如何用最少的IO口驱动多位数码管?
答案就是——动态扫描(Dynamic Scanning)。
数码管是怎么工作的?
常见的4位共阳极七段数码管内部结构如下:
| 引脚 | 功能 |
|---|---|
| a~g | 控制各段亮灭 |
| dp | 小数点 |
| D1~D4 | 位选线(高电平选通对应位) |
但注意!如果你把所有段都连在一起(称为“段总线”),而每位有自己的位选线,那总共只需要7 + 4 = 11根IO,而不是28根!
这就引出了动态扫描的核心思想:分时复用 + 视觉暂留。
扫描原理:像轮班一样点亮每一位
假设我们要显示 “1234”:
- 第1个时刻 → 选通第1位 → 输出‘1’的段码
- 第2个时刻 → 选通第2位 → 输出‘2’的段码
- ……
- 循环往复,每2ms切换一次
只要刷新频率高于50Hz(即每秒刷新50次以上),人眼就感觉不到闪烁,仿佛四位同时亮着。
✅ 经验法则:推荐扫描频率在100Hz ~ 1kHz之间。太低会闪,太高则亮度下降且增加功耗。
实现难点:时序必须稳如老狗
下面这段代码看似简单,实则藏着不少坑:
reg [15:0] counter; reg [1:0] current_digit; always @(posedge clk) begin if (counter >= CNT_MAX) begin counter <= 0; current_digit <= current_digit + 1; end else begin counter <= counter + 1; end end其中CNT_MAX = (50_000_000 / 1000 / 4) - 1 = 12499,意味着每12500个时钟周期(250μs)切换一位,在50MHz主频下刚好实现1kHz总刷新率。
这里的关键是:
- 计数器不能溢出回滚,否则可能导致某一位长时间不亮;
-current_digit必须严格按0→1→2→3循环,避免跳位或漏位。
段码译码:别忘了极性匹配!
很多初学者烧了半天,发现数码管要么全亮、要么全灭,原因往往出在段码极性没搞清。
比如共阳极数码管:要让某段亮,就得给它低电平!
所以你的译码函数输出后还得取反:
function [6:0] seg_decoder; input [3:0] bcd; case(bcd) 4'h0: seg_decoder = 7'b0000001; // a~g 4'h1: seg_decoder = 7'b1001111; ... default: seg_decoder = 7'b1111111; // 全灭 endcase endfunction然后在外面加上~seg_decoder(...)才能正确驱动共阳管。
💡 秘籍:建议在项目开头明确定义宏或参数说明硬件类型,例如:
verilog `define COMMON_ANODE
后续逻辑根据该定义自动调整输出极性,提升代码移植性。
三、从二进制到十进制:结果可视化前的最后一关
你以为Sum[3:0]直接送进数码管就能显示十进制?大错特错!
比如A=7,B=8, 结果是15(二进制1111),没问题;但如果A=9,B=7, 结果是16,已经超出4位表示范围,变成0000加一个进位……这显然不是我们想要的“16”。
因此必须做一件事:二进制转BCD(Binary to BCD)
如何拆分出“十位”和“个位”?
最简单的方法是对结果进行判断和分解:
wire [4:0] total = {Cout, Sum}; // 最大为 1 + 15 = 16 wire [3:0] tens, ones; // 查表法转换 always @(*) begin case(total) 5'd0: {tens, ones} = 8'd00; 5'd1: {tens, ones} = 8'd01; ... 5'd10: {tens, ones} = 8'd10; 5'd11: {tens, ones} = 8'd11; ... 5'd16: {tens, ones} = 8'd16; default: {tens, ones} = 8'd00; endcase end虽然用了case语句显得不够优雅,但对于仅17种可能的结果来说,这是最快、最可靠的方案。
更高级的做法是使用“双dabble算法”或状态机实现自动转换,适合更大位宽场景。
数据打包:喂给显示控制器
最终我们将十位和个位拼成一个16位数据,高位补零:
wire [15:0] display_data = {12'd0, tens, ones};然后接入前面写的seg_display_controller模块,就能在数码管上看到正确的十进制结果了。
四、常见坑点与调试秘籍
别以为写了代码就能一次成功。以下是我在带学生做这个实验时总结的五大高频问题:
❌ 问题1:数码管闪烁严重
原因:扫描频率太低(<50Hz)
解法:检查计数器上限是否设置合理,确保每位显示时间 ≤ 5ms
❌ 问题2:数字重影或串位
原因:位选信号未及时关闭,导致前后两位同时部分点亮
解法:在case分支中明确写出每一位的选择,不要用移位生成digit_sel
❌ 问题3:显示乱码(如‘5’显示成‘6’)
原因:段码顺序与实际接线不符(比如FPGA引脚分配错了)
解法:逐段测试,确认a~g对应关系,必要时修改seg_decoder输出顺序
❌ 问题4:进位丢失,超过15的结果变0
原因:未将Cout纳入总数计算
解法:务必使用5位总和{Cout, Sum}进行BCD转换
❌ 问题5:亮度不均,某一位特别暗
原因:该位占空比偏低或驱动能力不足
解法:检查current_digit是否均匀递增;若负载大,可在位选线上加三极管或专用驱动芯片
五、这个设计能走多远?
别小看这个“小学生计算器”级别的项目,它实际上涵盖了FPGA开发中的多个核心技能点:
| 技术点 | 对应实践 |
|---|---|
| 组合逻辑设计 | 全加器、译码器 |
| 时序逻辑与时钟管理 | 动态扫描计数器 |
| 模块化与接口封装 | 子模块实例化、端口映射 |
| 数据格式转换 | Binary → BCD |
| 硬件适配与引脚约束 | 段码/位选引脚绑定 |
| 系统集成与调试 | 多模块联调、现象分析 |
更重要的是,这套架构具有很强的延展性:
- 想做减法?把
B取反再加1即可(补码运算) - 想支持负数显示?引入符号位并扩展显示逻辑
- 想升级成简易CPU?把这个加法器放进ALU,加上寄存器和控制单元就行了
写在最后:动手才是最好的学习
技术文档看得再多,不如亲自在开发板上跑一遍。当你亲手拨动开关,看着数码管缓缓亮起“11”的那一刻,那种成就感,是任何理论都无法替代的。
下次如果你遇到类似“我写的逻辑没错,为啥结果不对?”的问题,不妨停下来问自己三个问题:
- 我的数据路径完整吗?(有没有漏掉进位?)
- 我的时序稳定吗?(扫描节奏对不对?)
- 我的硬件匹配了吗?(共阳还是共阴?引脚接对了吗?)
这三个问题答清楚了,90%的bug都能迎刃而解。
如果你正在学习FPGA,欢迎把你的实现截图或遇到的问题发在评论区,我们一起debug,一起进步。