简介:面向FPGA初学者与硬件设计入门者的Verilog数字钟完整工程资源,基于Quartus II 13.0开发环境设计,涵盖计时、跑表、闹钟、时间调整四大核心功能,并以模块化方式呈现分频、按键消抖、显示驱动、时间递增与闹钟比较等设计思路,适合课程设计、毕业设计或自学参考。资源包共145个文件,压缩后仅7.82MB,主要包含Verilog源文件、工程配置文件(.qpf/.qsf)、仿真波形文件(.vwf)、编译报告(.rpt)、FPGA下载文件(.sof)以及详细注释文本等,目录结构清晰,便于直接打开、编译与学习。目前已有416人学习使用。代码注释非常详细,从外部晶振分频得到秒脉冲,到计时模块的时间递增、显示驱动编码、按键输入与功能切换、闹钟触发等均有明确说明,可帮助读者系统理解FPGA时序逻辑设计的完整流程,并在此基础上继续扩展倒计时、整点报时、日期显示等更高阶功能,特别适合课程设计或自学者参考。 做FPGA入门,我自己带新人时最常推荐的一个完整小项目就是数字钟。原因很简单,它把Verilog开发里最核心的那几件事全都串了起来:计数器的分频、进位的边界处理、按键消抖、数码管动态扫描,再加上一个完整的仿真验证流程,一套走下来,基本就摸清了FPGA开发从RTL设计到板级调试的整个套路。这篇文章就把整个数字钟项目从思路拆解、代码实现到仿真踩坑一次性讲透,适合刚学完Verilog基础语法、想真正在开发板上跑通一个工程的同学参考。
1. 先理清整体思路:数字钟到底在做哪几件事
1.1 系统拆解:时基、计时、显示三路并行
数字钟表面上看就是一个“计时+显示”的小东西,但落到FPGA里,其实是三路并行在跑:一是时基生成,把板上晶振的频率分频出1Hz的秒脉冲,以及数码管动态扫描需要的1kHz刷新信号;二是计时进位,秒、分、时三个计数器按60、60、24的进制依次进位;三是显示扫描,把当前计数值通过七段数码管显示出来。
这三部分听起来是串联的——秒脉冲驱动秒计数,秒计数进位到分,分再进位到时,最后把时分秒的值送显示。但在代码组织上,它们是三个相对独立的模块,通过“使能信号”和“数据总线”连接。这种模块化拆解是数字系统设计的基本功,以后做串口、图像处理、甚至完整的SoC,都是同样的思路:先拆功能,再定接口,最后逐个实现。
还有一点值得提前说:如果后面想加闹钟、秒表、整点报时,基本不用改动核心计时链路,只需要在外面加判断逻辑和输出设备就行。这就是拆模块带来的好处。
1.2 方案选型:为什么用计数器而不是PLL
有同学会问,FPGA开发板上明明有PLL/MMCM这类时钟管理IP,分频直接用IP不就行了吗?为什么还要自己用Verilog写计数器?
我的答案很直接:入门阶段,计数器分频能把“数脉冲”这件事的本质看清楚,而且数字钟这种应用对时钟质量要求很低,1Hz和1kHz信号根本不需要PLL的精确相位和低抖动特性。PLL更适合高速场景,比如DDR读写、高速串行收发器,这些才需要真正的低抖动、多相时钟。用计数器生成慢速使能信号,不仅逻辑透明,还方便仿真时改参数加速验证,是更适合学习阶段的做法。
但这里有一个关键的工程决策:不要把分频器输出的“慢时钟”直接当成时钟信号去驱动模块。正确做法是产生一个“脉冲使能”信号——它只在全局时钟(比如50MHz)的一个周期内拉高,后面的计数器靠这个使能来判断“下一秒到了没”。这样整个设计始终处于单时钟域,时序约束简单,仿真也不会因为多时钟域出现各种莫名其妙的亚稳态问题。这是整个项目里我认为最重要的一点。
2. 核心模块实现:把“秒”这个基本单位弄准
2.1 分频器:分频系数计算与parameter化写法
先算一笔账。假设开发板晶振是50MHz,也就是一秒内有50000000个时钟周期。如果我要产生1Hz的脉冲使能,分频系数就是50000000;如果给数码管扫描用1kHz信号,分频系数就是50000000 ÷ 1000 = 50000。计数器从0数到“分频系数减1”就完成一个周期。
分频模块我习惯写成带参数的通用模块,这样换板子、改频率的时候不用动逻辑:
module clk_div #( parameter CLK_FREQ = 50_000_000, parameter OUT_FREQ = 1 )( input wire clk, input wire rst_n, output reg pulse ); // 分频系数为 CLK_FREQ/OUT_FREQ,计数器计到 CNT_MAX 时输出一个脉冲 localparam CNT_MAX = CLK_FREQ / OUT_FREQ - 1; reg [$clog2(CNT_MAX + 1) - 1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 'd0; pulse <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 'd0; pulse <= 1'b1; // 只持续一个时钟周期 end else begin cnt <= cnt + 1'b1; pulse <= 1'b0; end end endmodule这个模块有两个细节值得注意。一是用$clog2自动计算计数器位宽,避免手动估算出错。比如50MHz分频到1Hz,CNT_MAX是49999999,位宽需要26位,手写[25:0]没问题,但换参数时容易忘记改,用$clog2一劳永逸。二是输出的是单周期脉冲,不是50%占空比的方波,这是为了配合后面的计数器做“条件加一”,而不是制造新的时钟域。
2.2 六十进制与二十四进制计数器:BCD方式最省心
分钟和秒钟都是“0到59”的六十进制,小时要按“0到23”的二十四进制处理。这里我推荐用BCD计数,也就是把每一位十进制数分别用4位寄存器保存:秒个位sec_low、秒十位sec_high、分个位min_low、分十位min_high、时个位hour_low、时十位hour_high。
为什么不用纯二进制然后再做除法转BCD?因为Verilog的除法在综合时会消耗大量逻辑资源,而且可读性差。用BCD计数虽然代码多几行,但显示模块可以直接拿各位的值去查数码管段码表,逻辑非常直观。
秒计数模块的核心写法如下:
reg [3:0] sec_low, sec_high; wire sec_low_end = (sec_low == 4'd9); wire sec_high_end = (sec_low_end && sec_high == 4'd5); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_low <= 4'd0; end else if (en_1s) begin if (sec_low == 4'd9) sec_low <= 4'd0; else sec_low <= sec_low + 1'b1; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_high <= 4'd0; end else if (en_1s && sec_low_end) begin if (sec_high == 4'd5) sec_high <= 4'd0; else sec_high <= sec_high + 1'b1; end end这里的进位逻辑要注意:sec_high加一的时机是“秒个位正好从9变0”的那个时钟沿,也就是en_1s && sec_low_end。这样个位清零和十位加一发生在同一拍,不会出现多打一拍或者丢一拍的边界问题。分钟模块和秒模块结构完全一样,只是把en_1s换成en_1min,这个使能来自秒模块的sec_high_end && sec_low_end && en_1s。
小时的模块稍微特殊一点,因为24小时制的终点是23,不是29。判断清零的条件要写成hour_high == 4'd2 && hour_low == 4'd3。我曾经见过有同学照搬分钟的写法,判断hour_low == 9 && hour_high == 2才清零,结果19点之后直接跳到00而不是20,这就是边界条件没想清楚。
3. 按键与显示:数字钟的人机交互
3.1 按键消抖:机械抖动不消除,一次按下当十次
开发板上的按键基本都是机械开关,按下和释放的瞬间会产生5到20毫秒不等的抖动。如果直接把按键信号接进计数器做调时,一次按下可能触发十几次进位,体验非常糟糕。
消抖的思路是“延时确认”:检测到按键电平变化后,开始计数,如果经过一段稳定时间电平保持不变,才认为按键真的被按下。在50MHz时钟下,10毫秒对应50万个周期,比前面分频器的计数还要大,所以这种消抖本质上又是一个计数器,只是不是循环计数,而是“检测到变化就计数,一旦电平翻转就清零重来”。
按键输入也需要做跨时钟域处理。按键信号相对FPGA全局时钟是异步的,直接采会有亚稳态风险。我的习惯是先把按键打两拍同步,再做消抖和边沿检测:
reg key_sync0, key_sync1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 <= 1'b1; key_sync1 <= 1'b1; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; end end打两拍之后,再判断key_sync1的上升沿或下降沿。消抖计数器在电平不稳时不停清零复位,只有电平稳定时间足够,才输出一个单周期的按键有效脉冲。同时我建议按键释放也要消抖,否则松开时的一个小毛刺可能再次触发一次“按下”,表现为松开瞬间数字又跳了一下。
3.2 数码管动态扫描:人眼余晖下的技术活
数字钟用6位数码管显示时分秒,分别是小时两位、分钟两位、秒钟两位,中间两个小点做分隔。动态扫描的原理是:在任何一个瞬间,只点亮其中一位数码管,循环轮流点亮6位,刷新频率足够高时,人眼会因为视觉暂留而感觉所有位同时都在亮。
扫描频率的选择很关键。每一位刷新时间一般取1毫秒左右,6位一轮就是6毫秒,大约166Hz的刷新率,肉眼看起来非常稳定,几乎没有闪烁感。如果每一位时间超过3毫秒,拍照时就会看到明显的条纹闪烁;如果太快,又可能出现相邻位串扰,显示亮度不均。
具体实现上,需要一个扫描计数器,在0到5之间循环,每个值对应一位数码管的位选信号。同时把时分秒的各位数据按扫描位置送给段选译码电路:
reg [2:0] scan_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt <= 3'd0; else if (en_1k) scan_cnt <= scan_cnt + 1'b1; end reg [3:0] current_digit; always @(*) begin case (scan_cnt) 3'd0: current_digit = sec_low; 3'd1: current_digit = sec_high; 3'd2: current_digit = min_low; 3'd3: current_digit = min_high; 3'd4: current_digit = hour_low; default: current_digit = hour_high; endcase end然后current_digit再经过一个七段译码模块输出segments。这里有个坑必须单独说:动态扫描时,位选切换和段选数据切换不能在同一时刻完成。如果上一秒位选还在第3位,段选数据已经是第4位的数字了,那么第3位会在瞬间显示第4位的数字,肉眼看到的就是重影或残影。
解决方法是“先换位选,再给段选”,或者干脆在切换瞬间先让所有段选熄灭,等位选稳定后再把数据送上去。我习惯实现三段式:先关闭显示,再更新位选和数据,最后使能显示,这样时序最安全。上板后如果看到重影,优先检查这一步,通常改完立刻就好了。
3.3 稍加扩展:从数字钟到多功能表
这个项目做完之后,扩展空间其实很大。比如在秒计数到59时检测一下,输出一个脉冲拉高蜂鸣器,就能实现整点报时。再比如加一个状态寄存器,用按键切换“正常计时”和“调时模式”,调时模式下按一次按键对应位加一,就是带校时功能的数字钟。这些扩展不会改动核心分频和计数模块,只在外围加逻辑,对理解“模块复用”非常有帮助。
4. 仿真和板级踩坑:先看波形再上板
4.1 testbench怎么写:复位、时钟与激励
很多人学Verilog时容易跳过一个关键技能:自己写testbench。数字钟这种多模块联动的项目,不仿真直接上板,出问题很难定位——到底是分频没出来,还是进位逻辑错了,还是显示扫描时序有问题,靠眼睛瞪代码效率太低,在Modelsim或者Vivado自带的仿真器里看波形一眼就能分辨。
一个基础testbench的框架如下:
`timescale 1ns/1ps module tb_top; reg clk; reg rst_n; reg key_hour, key_min; wire [6:0] seg; wire [5:0] sel; // 20ns周期,即50MHz always #10 clk = ~clk; initial begin clk = 0; rst_n = 0; key_hour = 1'b1; key_min = 1'b1; #100; rst_n = 1; // 给一段运行时间,让秒脉冲产生几个周期 #5_000_000; $stop; end top_digital_clock u_top ( .clk (clk), .rst_n (rst_n), .key_hour (key_hour), .key_min (key_min), .seg (seg), .sel (sel) ); endmodulealways #10 clk = ~clk是仿真里生成50MHz时钟最常用的写法,#100之后释放复位,给寄存器一段确定初值的时间。没有复位信号时,所有reg的初值都是X,波形上全是红色,所以复位那段一定不能省。
4.2 大分频参数使仿真太慢:用参数覆盖加速
这是数字钟仿真里最容易让人崩溃的一个问题。真实板子上分频系数是50000000,仿真器要完整跑完50M个周期才能看到第一个秒脉冲,足够把仿真器等到天荒地老。解决办法是把分频模块设计成可参数的,然后在testbench里用小的参数覆盖真实参数,比如把1Hz分频的时钟频率设为10,让一个秒脉冲只等10个时钟周期:
clk_div #( .CLK_FREQ(10), .OUT_FREQ(1) ) u_clk_div_1s ( .clk (clk), .rst_n (rst_n), .pulse (en_1s) );在Vivado、Quartus和Modelsim里都支持这种参数化例化方式,比用defparam更直观,也更符合现代Verilog的书写习惯。仿真通过后,恢复真实参数综合上板,功能不受影响。这个技巧的意义在于,它让“先仿真验证逻辑正确性,再实测时序”成为可能,否则很多人会干脆跳过仿真直接上板,遇到问题又无从下手。
4.3 常见问题排查速查
我把这几年做数字钟项目遇到的典型问题整理成了一张表,按“现象→原因→解决办法”的顺序排好,遇到问题可以直接对照:
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| 仿真波形全是x | reg没初始化,initial块缺失 | 加initial给所有reg赋初值,检查复位信号 |
| 秒脉冲始终不出现 | 分频参数太大,仿真时间不够 | testbench中用参数覆盖把分频周期改小 |
| 秒正常,分钟进位不对 | 分钟使能端条件错误 | 确认分钟加一条件包含秒的十位和个位结束标志 |
| 小时跳到20后归零 | 24小时制清零条件写成29 | 清零条件改为hour_high==2 && hour_low==3 |
| 数码管重影、残影 | 位选段选切换时序没控制 | 切换瞬间先关闭显示,稳定后再更新数据 |
| 数码管闪烁 | 扫描周期太长 | 每一位刷新时间控制在1ms左右 |
| 按键按一次跳多次 | 消抖计数时间不够或没等释放 | 消抖时间至少10ms,并处理释放阶段的等待 |
| 上板时间不准 | 晶振频率与分频参数不匹配 | 确认开发板实际晶振频率,重新计算分频系数 |
最后再分享一点我个人的体会。数字钟这个项目,代码量不大,但每一步都在考验对“时序”的理解:分频的本质是计数,计数的关键是进位边界,边界的保证是在同一个时钟沿完成清零和加一。把这些想透了,后面写串口、写状态机、写总线协议,都会顺畅很多。建议拿到板子不要急着看厂商的Demo,先按自己的思路写一遍代码,仿真通过再上板,遇到问题再回来看波形,这套流程走通之后,FPGA开发就算真正入门了。
本文还有配套的精品资源,点击获取