手把手教你用 Vivado 2018.3 实现 FPGA 数字钟设计
你有没有试过从零开始,在一块FPGA开发板上“造”出一个能走时的数字钟?这不仅是个炫技项目,更是掌握数字系统设计核心能力的关键一步。今天,我们就以Vivado 2018.3为平台,带你一步步实现一个完整的数字钟系统——从代码编写、仿真验证到最终烧录上板,全过程实战演练。
这个项目看似简单,实则麻雀虽小五脏俱全:它涵盖了时钟分频、同步计数、状态控制、动态扫描显示等典型数字电路技术,是初学者通往高级FPGA开发的必经之路。更重要的是,整个流程完全基于 Xilinx 官方推荐的设计方法学,让你在动手中真正理解现代数字系统是如何被“构建”出来的。
为什么选择数字钟作为入门项目?
在嵌入式和FPGA教学中,LED流水灯之后最常见的就是数字钟了。但别小看它——相比单纯的IO翻转,数字钟涉及多个模块协同工作,要求严格的时序配合与信号完整性处理。
而使用Vivado 2018.3这个经典版本进行开发,有以下几个优势:
- 稳定性高,兼容大多数主流开发板(如 Basys3、Nexys4 DDR);
- 支持完整的 RTL 到比特流流程,适合学习全流程开发;
- GUI界面清晰,调试工具丰富,便于观察波形和资源占用;
- 社区资料丰富,遇到问题容易找到解决方案。
我们选用 Verilog HDL 语言完成设计,所有模块均采用同步时序风格,确保可综合性和跨平台移植性。
整体架构怎么搭?先画张脑图!
要做一个数字钟,脑子里得先有个“系统框图”。我们可以把整个系统拆解成三个核心模块:
- 时钟分频器:把50MHz的板载晶振变成精准的1Hz秒脉冲;
- 时间计数器:实现秒→分→时的自动递增与进位;
- 数码管驱动:将当前时间实时显示出来。
这三个模块通过顶层文件连接在一起,形成一个自顶向下的模块化结构。这种设计方式不仅逻辑清晰,也方便后期扩展功能(比如加个闹钟或调时模式)。
接下来,我们逐个击破每个模块的实现细节。
模块一:如何从50MHz得到1Hz?揭秘时钟分频
FPGA开发板通常提供50MHz或100MHz的有源晶振作为主时钟源。但我们做数字钟需要的是每秒跳一次的“秒信号”,也就是1Hz。这就需要用到计数型分频器。
分频原理一句话讲清:
要生成1Hz信号,就在50MHz下计数到25,000,000次后翻转输出电平,这样高低各占一半周期,正好是1Hz方波。
下面是关键代码实现:
module clk_divider( input clk_50m, input rst_n, output reg clk_1s ); parameter DIV_CNT = 25_000_000; reg [24:0] count; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin count <= 0; clk_1s <= 0; end else if (count >= DIV_CNT - 1) begin count <= 0; clk_1s <= ~clk_1s; end else begin count <= count + 1; end end endmodule📌重点说明:
- 使用
always @(posedge clk_50m)实现上升沿触发,符合同步设计规范; - 复位采用异步低电平复位(
negedge rst_n),保证上电可靠初始化; - 输出
clk_1s是通过取反自身实现翻转,避免额外逻辑延迟; - 若你的开发板是100MHz,记得把
DIV_CNT改为50,000,000。
💡小贴士:虽然这个分频器足够稳定,但如果想进一步提升鲁棒性,可以考虑对clk_1s做两级寄存器同步,尤其是在跨时钟域传递时。
模块二:时间怎么走?写一个会“进位”的计数器
有了1Hz的秒脉冲,就可以让时间“动起来”了。我们需要一个支持24小时制的计数逻辑,满足以下规则:
- 秒从 0 到 59,满60归零并给分进位;
- 分从 0 到 59,满60归零并给时进位;
- 时从 0 到 23,满24归零。
同时,我们还想加入调时功能:按下一个按键进入设置模式,再按另一个键手动加一秒(实际应用中可通过消抖后单脉冲触发)。
以下是完整实现:
module counter_time( input clk_1s, input rst_n, input set_mode, input add_en, output reg [7:0] sec, output reg [7:0] min, output reg [7:0] hour ); always @(posedge clk_1s or negedge rst_n) begin if (!rst_n) begin sec <= 8'h00; min <= 8'h00; hour <= 8'h00; end else if (set_mode && add_en) begin // 设置模式下允许手动调节 if (sec < 8'h59) sec <= sec + 1'b1; else sec <= 8'h00; end else if (!set_mode) begin // 正常计时模式 if (sec < 8'h59) begin sec <= sec + 1'b1; end else begin sec <= 8'h00; if (min < 8'h59) min <= min + 1'b1; else begin min <= 8'h00; if (hour < 8'h23) hour <= hour + 1'b1; else hour <= 8'h00; end end end end endmodule📌编码技巧:
- 使用BCD码格式存储时间(例如
8'h23表示23点),高位四位表示十位,低位四位表示个位,方便后续送显; - 所有赋值使用非阻塞赋值
<=,防止锁存器意外生成; add_en建议来自消抖后的单脉冲信号,避免长按导致连加。
⚠️常见坑点:
如果忘记在else if中排除set_mode的情况,可能会导致在调时时仍然继续自动计时,造成混乱。一定要注意条件互斥!
模块三:六位数码管怎么亮?深入动态扫描机制
现在时间数据已经有了,怎么把它显示出来?大多数开发板都配有6位共阳或共阴数码管。我们采用动态扫描的方式来驱动它们。
动态扫描的本质是什么?
利用人眼视觉暂留效应,快速轮流点亮每一位数码管,只要刷新率超过100Hz,看起来就像是同时亮着的。
我们设定一个约1kHz的扫描时钟(可用50MHz再次分频获得),每1ms切换一位,依次显示“时-分-秒”的六个数字。
下面是驱动模块的核心代码:
module seg_display( input clk, input rst_n, input [7:0] data[5:0], // 输入六位BCD数据 output reg [7:0] seg_data, // 段码输出 a~g, dp output reg [5:0] sel // 位选输出(低有效) ); reg [2:0] current_digit; reg [15:0] cnt; // 扫描时钟分频:50MHz → 1kHz always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; current_digit <= 0; end else begin if (cnt >= 49999) begin // 50,000周期 = 1ms @50MHz cnt <= 0; current_digit <= current_digit + 1'b1; end else begin cnt <= cnt + 1'b1; end end end // 查表法生成段码(共阴极示例) always @(*) begin case (data[current_digit][3:0]) // 只取低4位 4'h0: seg_data = 8'b1100_0000; 4'h1: seg_data = 8'b1111_1001; 4'h2: seg_data = 8'b1010_0100; 4'h3: seg_data = 8'b1011_0000; 4'h4: seg_data = 8'b1001_1001; 4'h5: seg_data = 8'b1001_0010; 4'h6: seg_data = 8'b1000_0010; 4'h7: seg_data = 8'b1111_1000; 4'h8: seg_data = 8'b1000_0000; 4'h9: seg_data = 8'b1001_0000; default: seg_data = 8'b1100_0000; endcase end // 位选控制:当前位低电平有效 always @(posedge clk or negedge rst_n) begin if (!rst_n) sel <= 6'b111111; else sel <= ~(6'b1 << current_digit); // 左移定位当前位 end endmodule📌关键点解析:
- 段码根据
current_digit对应的数据查表输出; sel使用左移操作实现轮询,每次只有一位为低(共阴极需外部译码注意极性);- 扫描频率 ≈ 1kHz(每1ms切一次),远高于人眼感知阈值,无闪烁感;
- 若使用共阳数码管,需将段码取反,并调整XDC约束。
💡优化建议:为了减少组合逻辑延迟,可以把段码表封装成 ROM 或使用casez提升匹配效率。
把所有模块串起来:顶层顶层设计实战
现在三个核心模块都完成了,下一步就是在顶层模块中把它们实例化并连接起来。
module digital_clock_top( input sys_clk_50m, input rst_n, input key_set, input key_add, output [7:0] seg_data, output [5:0] seg_sel ); wire clk_1s; wire [7:0] time_sec, time_min, time_hour; // 分频模块 clk_divider u_div ( .clk_50m(sys_clk_50m), .rst_n(rst_n), .clk_1s(clk_1s) ); // 计时模块 counter_time u_counter ( .clk_1s(clk_1s), .rst_n(rst_n), .set_mode(key_set), .add_en(key_add), .sec(time_sec), .min(time_min), .hour(time_hour) ); // 拆分时间数据为六位BCD数组 wire [7:0] display_data[5:0]; assign display_data[5] = {4'h0, time_hour[7:4]}; // 十位小时 assign display_data[4] = {4'h0, time_hour[3:0]}; // 个位小时 assign display_data[3] = {4'h0, time_min[7:4]}; assign display_data[2] = {4'h0, time_min[3:0]}; assign display_data[1] = {4'h0, time_sec[7:4]}; assign display_data[0] = {4'h0, time_sec[3:0]}; // 显示模块 seg_display u_disp ( .clk(sys_clk_50m), .rst_n(rst_n), .data(display_data), .seg_data(seg_data), .sel(seg_sel) ); endmodule📌连接要点:
- 所有子模块共享同一个
rst_n和主时钟sys_clk_50m; - 时间数据通过中间wire传递,保持类型一致;
- BCD拆分时注意高位补零,避免未定义状态。
上手第一步:Vivado 2018.3 操作全流程
光写代码还不够,还得让它跑起来!以下是基于 Vivado 2018.3 的标准开发流程:
1. 创建新工程
打开 Vivado 2018.3 → “Create Project” → 选择RTL Project→ 跳过添加源文件 → 选择目标器件(如 XC7A35T for Basys3)。
2. 添加设计文件
右键Design Sources→ “Add Sources” → 选择之前写的.v文件(clk_divider.v,counter_time.v,seg_display.v,top_module.v)。
3. 设置顶层模块
右键digital_clock_top→ “Set as Top”。
4. 添加引脚约束文件(.xdc)
新建一个 XDC 文件,绑定物理引脚。以下是以 Basys3 开发板为例的配置:
## Clock set_property PACKAGE_PIN W5 [get_ports sys_clk_50m] set_property IOSTANDARD LVCMOS33 [get_ports sys_clk_50m] ## Reset Button (active low) set_property PACKAGE_PIN U18 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] ## Keys set_property PACKAGE_PIN T17 [get_ports key_set] set_property IOSTANDARD LVCMOS33 [get_ports key_set] set_property PACKAGE_PIN T18 [get_ports key_add] set_property IOSTANDARD LVCMOS33 [get_ports key_add] ## Segment Outputs [a~dp] set_property PACKAGE_PIN L16 [get_ports {seg_data[0]}] # a set_property PACKAGE_PIN M13 [get_ports {seg_data[1]}] # b ... set_property PACKAGE_PIN H15 [get_ports {seg_data[7]}] # dp ## Digit Selects (anodes, active low) set_property PACKAGE_PIN J17 [get_ports {seg_sel[0]}] # rightmost set_property PACKAGE_PIN H17 [get_ports {seg_sel[1]}] ... set_property PACKAGE_PIN K14 [get_ports {seg_sel[5]}] # leftmost⚠️ 引脚编号请根据你使用的开发板手册严格核对!
5. 综合与实现
点击左侧 Flow Navigator 中的:
-Run Synthesis→ 查看警告是否合理;
-Run Implementation→ 检查布局布线结果;
-Generate Bitstream→ 生成.bit文件。
6. 下载到FPGA
连接开发板 → 打开 Hardware Manager → Auto Connect → Program Device → 选择生成的比特流文件 → 烧录!
几秒钟后,数码管上就会显示出跳动的时间啦!
遇到问题怎么办?这些坑我替你踩过了
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 数码管不亮 | 引脚接错 / 极性反了 | 检查XDC中seg_data和sel是否对应共阴/共阳 |
| 显示闪烁严重 | 扫描频率太低 | 提高至≥100Hz,建议1kHz左右 |
| 时间不准 | 分频系数错误 | 重新计算DIV_CNT = F_in / 2 |
| 按键调时不灵 | 未消抖 | 加入按键消抖模块或RC滤波电路 |
| 编译报错“latch inferred” | 条件分支不完整 | 检查if-else是否全覆盖 |
💡最佳实践提醒:
- 所有时序逻辑统一使用
always @(posedge clk); - 关键信号命名要有意义(如
clk_1s,rst_n); - 在综合前务必运行行为仿真(Behavioral Simulation),用 Testbench 验证逻辑正确性;
- 利用 Vivado 的 Schematic 查看综合后的网表结构,确认没有意外锁存器。
还能怎么升级?给你的数字钟加点料
完成了基础功能之后,不妨尝试以下扩展方向:
✅增加闹钟功能:添加比较器,当时间匹配时驱动蜂鸣器;
✅引入RTC芯片:通过I²C挂接 DS3231,实现断电走时;
✅加入PS端交互:若使用 Zynq 平台,可在 ARM 上跑 Linux 实现时间设置界面;
✅使用 HLS 尝试C语言建模:用 Vivado HLS 快速原型验证算法逻辑;
✅添加贪睡功能:长按某键暂停响铃,9分钟后再提醒。
每一次迭代,都是向复杂系统设计迈进的一步。
如果你正在学习 FPGA,那么这个数字钟项目绝对值得花几个晚上认真打磨。它不只是“让数码管亮起来”那么简单,而是教会你如何思考系统的层次结构、如何管理时序、如何排查硬件问题。
当你亲眼看到自己写的代码变成了真实世界中滴答前行的时间,那种成就感,只有亲手做过的人才懂。
你现在准备好打开 Vivado,新建第一个工程了吗?欢迎在评论区分享你的实现过程和遇到的问题,我们一起解决!