FPGA实战入门:手搓4位全加器驱动七段数码管,从逻辑到显示的完整闭环
你有没有过这样的经历?学完数字电路,知道“与或非”、懂点真值表,但一合上书就感觉像没学一样——理论和实践之间,缺的从来不是知识,而是一个看得见、摸得着的结果。
今天我们就来干一件“接地气”的事:用FPGA亲手搭建一个4位全加器,把两个二进制数相加,再通过七段数码管把结果亮出来。整个过程不调IP核、不靠现成模块,从最基础的门级逻辑开始,一步步走通“输入—计算—转换—输出”这条完整的数字系统链路。
这不仅是实验课作业,更是一次对“硬件到底怎么工作”的深度理解之旅。
为什么是“4位全加器 + 数码管”?
在嵌入式和FPGA学习中,很多人一开始就被复杂的ARM架构、DDR时序吓退。其实真正的起点,应该是这样一个简单却完整的系统:
用户输入 → 芯片运算 → 数据处理 → 物理显示
而“4位全加器 + 七段数码管”恰好完美覆盖了这个链条的所有环节:
- 输入:拨码开关设A/B两位4位二进制数
- 运算:FPGA内部实现串行进位加法器
- 转换:将5位结果(含进位)转为BCD码或Hex编码
- 输出:驱动共阳极数码管动态显示
它不像流水灯那样只是IO翻转,也不像UART通信那样涉及协议栈。它是纯组合逻辑的经典代表,没有状态机干扰,适合初学者建立清晰的因果关系认知。
更重要的是——当你看到自己写的Verilog代码真的让数码管显示出“7+8=15”时,那种成就感,比任何教材都来得真实。
全加器的本质:不只是公式,而是信号流动的路径
我们都知道全加器的表达式:
Sum = A ⊕ B ⊕ Cin Cout = (A & B) | (Cin & (A ^ B))但如果你只把它当成数学公式抄一遍,那你还没真正“看见”它的结构。
拆开看:每个信号都在讲一个故事
想象一下,你在搭积木。每一块代表一个门电路:
A ^ B是第一层异或,决定是否产生“本位可能进位”- 再跟
Cin异或一次,才是最终的Sum - 而
A & B表示“不管有没有进位,我这里肯定要出一个进位” Cin & (A ^ B)则表示“低位传来的进位,在当前位有效”
这两个条件只要满足其一,就会触发Cout向高位传递。
这就是为什么叫“全”加器——因为它考虑了所有三种输入的影响,而不是像半加器那样忽略Cin。
级联的艺术:四个全加器如何组成4位加法器?
我们将四个全加器串联起来,形成所谓的“Ripple Carry Adder(RCA)”,中文常译作“串行进位加法器”。
它的连接方式非常直观:
wire [3:0] carry; // 中间进位线 full_adder fa0 (.A(A[0]), .B(B[0]), .Cin(1'b0), .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(Cout));注意这里的Cin初始接低电平(无初始进位),然后每一级的Cout成为下一级的Cin,像波纹一样逐级推进,因此得名“ripple”。
⚠️ 延迟问题不可忽视
由于每一位必须等待前一位的Cout才能开始计算,第3位的Sum[3]实际上要等前面三级全部完成才能稳定输出。这意味着:
最高位延迟 ≈ 单个FA延迟 × 4
虽然对于4位来说影响不大,但在高速设计中这就成了瓶颈。后续你可以尝试升级为“超前进位加法器”(Carry Lookahead Adder),提前预测进位,大幅缩短关键路径。
但现在?先搞定基础版再说。
七段数码管:别小看这几个LED,它们也有“编码哲学”
你以为数码管只是七个LED拼在一起?错。它的核心在于映射规则——也就是我们常说的“段码”。
共阳极 vs 共阴极:控制逻辑完全相反
大多数开发板使用的是共阳极数码管,即所有LED的正极连在一起接到VCC,负极分别由FPGA控制。
这意味着:
✅ 要点亮某一段 → 给对应引脚输出低电平(0)
❌ 不点亮 → 输出高电平(1)
反过来如果是共阴极,则逻辑反转。
这一点一旦搞反,轻则显示错乱,重则以为自己代码写错了三天查不出bug。
段码怎么定?顺序不能乱!
假设你的数码管引脚连接如下:
seg[6] → a seg[5] → b seg[4] → c seg[3] → d seg[2] → e seg[1] → f seg[0] → g那么数字“0”需要点亮 a~f,g熄灭,对应的二进制就是:
a=0, b=0, c=0, d=0, e=0, f=0, g=1 → seg_out = 7'b1000000 ? 错!等等!这里是大坑!
如果seg[6]=a,seg[5]=b, …,seg[0]=g,那上面这个序列其实是:
| bit | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|
| 段 | a | b | c | d | e | f | g |
| 值 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
所以正确写法是:7'b1000000?不对!
No!这是典型位序误解!
实际应写作:7'b0000001?也不对!
让我们重新排布:
我们要的是:a=0, b=0, c=0, d=0, e=0, f=0, g=1
对应位宽[6:0]就是:{g,f,e,d,c,b,a}→{1,0,0,0,0,0,0}
所以正确的段码是:7'b1000000
啊?原来是对的?
没错,只有当你明确知道位定义是seg[6]=a, seg[5]=b,...,seg[0]=g时,7'b1000000才表示g=1(灭),其余为0(亮),这才构成“0”。
但这很反直觉。建议你在代码中加注释说明位分配,避免后期维护踩雷。
正确的译码器实现(共阳极)
module seg_decoder ( input [3:0] data_in, output [6:0] seg_out ); assign seg_out = (data_in == 4'd0) ? 7'b1000000 : // 0: abcdef 高亮,g灭 (data_in == 4'd1) ? 7'b1111001 : // 1: bc 高亮 (data_in == 4'd2) ? 7'b0100100 : // 2: abdeg 高亮 (data_in == 4'd3) ? 7'b0110000 : // 3: abcdg (data_in == 4'd4) ? 7'b0011001 : // 4: bcfg (data_in == 4'd5) ? 7'b0010010 : // 5: acdfg (data_in == 4'd6) ? 7'b0000010 : // 6: acdefg (data_in == 4'd7) ? 7'b1111000 : // 7: abc (data_in == 4'd8) ? 7'b0000000 : // 8: 全亮 (data_in == 4'd9) ? 7'b0010000 : // 9: abcdfg 7'b1111111; // 默认全灭 endmodule这段代码简洁高效,利用连续赋值实现查找表功能,非常适合静态显示场景。
如何显示大于9的结果?BCD拆分与双位显示
问题来了:
A = 5 (0101),B = 6 (0110),Sum = 11 (1011)
你能直接送进seg_decoder显示吗?不行。
因为data_in是4位,最大只能表示15,但我们的译码器只定义了0~9。若不做处理,10~15会进入默认分支变成全灭,或者显示成奇怪字符。
怎么办?两种思路:
方案一:支持十六进制显示(Hex Mode)
扩展译码器,支持 A-F 显示:
4'd10: seg_out = 7'b0001000; // A 4'd11: seg_out = 7'b1100000; // b(小写) 4'd12: seg_out = 7'b0101110; // C 4'd13: seg_out = 7'b1100001; // d 4'd14: seg_out = 7'b0100110; // E 4'd15: seg_out = 7'b0000110; // F这样就能直接显示b表示11,适合调试用途。
方案二:转为十进制,拆分为十位和个位
这才是工业级做法。我们需要把二进制结果(如11)拆成tens=1,ones=1,然后分别驱动两个数码管。
方法:Binary to BCD 转换(Double-Dabble算法)
这是一种经典的移位加三算法,适用于任意位宽转BCD。但对于4位输入,我们可以直接暴力查表:
wire [3:0] sum_4bit; wire cout; wire [7:0] bcd_result; // 查表法转换为两位BCD always @(*) begin case ({cout, sum_4bit}) 5'd0: bcd_result = 8'h00; 5'd1: bcd_result = 8'h01; ... 5'd10: bcd_result = 8'h10; 5'd11: bcd_result = 8'h11; ... 5'd15: bcd_result = 8'h15; default: bcd_result = 8'h00; endcase end assign tens = bcd_result[7:4]; assign ones = bcd_result[3:0];现在你有两个独立的4位BCD码,可以分别送给两个数码管。
动态扫描:多位显示的核心技术
多个数码管共享同一组段选线,靠位选信号轮流激活。这就是“动态扫描”。
基本原理
- 每次只点亮一个数码管
- 快速轮询(≥60Hz),利用人眼视觉暂留
- 避免重影的关键:消隐+同步更新
扫描控制器设计
reg [2:0] scan_cnt; wire [1:0] digit_pos = scan_cnt[2:0] < 3'd2 ? scan_cnt[2:0] : 2'd0; // 两位选择 reg [3:0] current_data; always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt <= 3'd0; else scan_cnt <= scan_cnt + 1; end // 当前显示哪一位的数据 assign current_data = (digit_pos == 2'd0) ? ones : tens; // 译码输出 wire [6:0] seg_data = seg_decoder(current_data); // 位选信号(低有效) assign digit_sel = ~(1 << digit_pos); // 假设位选低有效配合一个约1ms切换一次的计数器,即可实现流畅无闪烁显示。
实战注意事项:别让细节毁了整个项目
很多同学代码写得没问题,下载后却不亮,原因往往出在这些地方:
✅ I/O约束一定要准确
检查你的XDC文件(或QSF)是否正确绑定了物理引脚:
set_property PACKAGE_PIN J15 [get_ports {seg[0]}] # g段 set_property PACKAGE_PIN H15 [get_ports {seg[1]}] # f段 ... set_property PACKAGE_PIN G17 [get_ports {digit_sel[0]}]引脚接错一根,可能整块板子都不动。
✅ 电平匹配要确认
FPGA多数为3.3V LVCMOS,而部分数码管额定5V。虽然有些能兼容3.3V亮度运行,但最好加上电平转换芯片(如74HCT245)或使用3.3V规格的数码管。
✅ 加限流电阻!
每个段都必须串联330Ω~1kΩ电阻,防止电流过大烧坏FPGA IO。别想着省几个电阻,代价可能是整个开发板报废。
✅ 测试技巧:先用LED模拟
不确定段码对不对?先把seg_out接到开发板上的LED灯:
- 如果输入5,预期
seg_out[5:0]应该是1111001→ 对应LED亮灭模式是否匹配? - 可快速验证译码逻辑是否正确
总结:这不是结束,而是数字系统设计的起点
当你按下按钮,看着数码管从“5+3=8”跳到“7+9=16”,你会突然意识到:
我写的每一行Verilog,都在真实地操控电子的流动。
这个项目虽小,但它涵盖了:
- 组合逻辑设计思想
- 模块化编程方法
- 硬件接口控制
- 数据格式转换
- 实际调试技能
它不像AI那样炫酷,也不像GPU加速那么快,但它让你触摸到了计算机最底层的脉搏。
下一步你可以尝试:
- 加入按键去抖,支持实时加减
- 用状态机实现简易计算器
- 把结果通过UART发到PC
- 甚至做一个带乘法的ALU雏形
但请记住:
所有伟大的系统,都是从一个小小的全加器开始的。
如果你也在做类似的FPGA练习,欢迎留言交流遇到的问题。尤其是那个让人抓狂的“数码管显示错位”bug,我相信很多人都经历过……