☰
FPGA Verilog数字钟设计全攻略:分频、计数、动态扫描与校时
2026/10/15 16:58:25 网站建设 项目流程

简介:一份基于Verilog的FPGA数字钟设计资源,面向FPGA初学者、数字逻辑课程实验者以及准备参加电子设计竞赛的学生,完整演示在Quartus II环境下实现计时、跑表、闹钟与时间调整功能的工程流程。整个资源包仅7.82MB,共145个文件,核心包含Quartus工程与配置文件(qpf/qsf)、Verilog硬件描述源码、仿真波形(vwf)、编译综合报告(rpt)、下载配置文件(sof)等。从工程建立、代码编写到综合、仿真、烧写均有对应文件,目录结构清晰,打开工程即可按步骤复现。目前已有400余人学习,相关模块设计完整,注释详细,覆盖分频计数、数码管显示驱动、按键消抖交互、时间寄存器更新以及闹钟比较触发等关键逻辑,可帮助读者直观理解时序逻辑设计、模块划分和FPGA开发流程。压缩包内还保留了编译过程产生的报告与临时文件,便于排查工程配置问题,适合课程设计、毕业设计或自学练习,也是掌握Verilog语法和Quartus工具链的上手实例。

1. 一个跑在硅片上的时钟,凭什么比单片机里的更值得做

第一次在开发板上看到自己写的Verilog数字钟精准走秒时,那种感觉和用单片机点个LED完全不一样。同样是计时,MCU靠中断累加、靠库函数调用,而FPGA上的数字钟是从晶振的每一次振荡里硬生生分频出来的,秒脉冲的每一次跳变都对应着电路里真实的触发器翻转。这个项目在高校实验室和培训班里火了十几年不是没道理:它麻雀虽小,却完整覆盖了时序逻辑设计、分频电路、状态机、动态扫描显示和按键交互——做完它,你才算真正摸到了数字电路设计的门槛。

这篇文章就围绕“FPGA+Verilog数字钟”这条主线展开:从架构怎么划分、分频系数怎么算,到跑马灯式的扫描显示怎么写、按键消抖有哪些野路子,再到仿真和板级调试里最容易翻车的时钟竞争、复位时序和引脚约束问题。无论你是准备交课程设计,还是想给自己攒一个实用的桌面小时钟,照着这个方案走,踩坑概率能少一半。先说明白一点:数字钟的难点从来不在“计时”,而在如何让显示稳定、按键不飘、上电不出毛刺,这些才是区分新手和熟手的地方。

2. 先画架构:分频、计时、显示三件事拆开做

2.1 为什么把模块拆成三个而不是写在一个 always 块里

很多初学者拿到数字钟需求后,第一反应是写一个大always块:既做分频,又做秒计数,还得顺手把数码管扫描给做了。这类写法在仿真里看着能跑,一上板子就出各种奇怪问题:显示闪烁、按键反应迟钝、偶尔秒跳两下。原因很简单——FPGA是并行硬件,你把它当成串行C语言写,综合器报一堆警告还算好的,时序约束过不了才头疼。

我一般会把数字钟拆成三个独立模块:clk_div做分频,把开发板上的50MHz或100MHz系统时钟分出1Hz秒脉冲和1kHz扫描脉冲;counter负责时、分、秒的计数与校时逻辑;display接收计数值并完成数码管段码译码与位选扫描。模块之间通过明确的端口信号连接,顶层只做例化和连线。这样做的好处有三点:每个模块单独仿真好测;改分频系数不影响计数逻辑;多人协作或者复用之前的IP时,接口清晰不容易改坏。

一个实用的顶层端口定义大致长这样:

module digital_clock ( input wire clk_50m, // 开发板系统时钟,50MHz input wire rst_n, // 低电平复位 input wire [1:0] key_adj, // 两个按键:模式切换 + 数值调整 output wire [5:0] seg_sel, // 六个数码管位选 output wire [7:0] seg_data // 段码输出,含小数点,低电平点亮 );

这个端口设计里有个容易被忽略的点:按键只接了[1:0]两个,没有单独接“加1”和“减1”。等做到校时功能时会发现,用一个按键循环切换“秒调整→分调整→时调整→正常”模式,另一个按键做加法,已经足够日常用了。省IO的同时也让状态机的层次更清楚。

2.2 选多少个开发板主频、分频系数到底怎么算

分频系数的计算是数字钟第一个真正的硬件思维训练。假设板上晶振是50MHz,那要得到1Hz的秒脉冲,就得数25,000,000个时钟周期翻转一次电平。绝大多数教程会让你用计数器计数到12,500,000翻转一次,这样输出波形高低电平各占0.5秒,正好一个周期。但放到真实项目里,这会有一个隐患:如果后续功能需要做校准或补偿,半个周期宽的脉冲在逻辑分析仪上看特征不明显。

更稳的做法是输出一个单周期脉冲:计数器计数到24,999,999时,拉高sec_tick一个时钟周期。这样得到的不是对称方波,而是一个时钟宽度的“滴答”信号,逻辑上更容易作为计数器的使能信号使用,同时也能直接接入示波器或逻辑分析仪抓取验证。两种方式各有取舍,但如果你后续想让数字钟支持“自动校准”“整点报时”,单周期脉冲方案扩展性明显更好。

分频代码的骨架长这样:

module clk_div #( parameter CNT_1S = 50_000_000 // 50MHz 时钟下 1 秒对应的周期数 )( input wire clk, input wire rst_n, output reg sec_tick // 每秒输出一个高电平脉冲 ); reg [25:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; sec_tick <= 1'b0; end else if (cnt == CNT_1S - 1) begin cnt <= 0; sec_tick <= 1'b1; // 输出单周期脉冲 end else begin cnt <= cnt + 1'b1; sec_tick <= 1'b0; end end endmodule

注意cnt的位宽:50MHz需要计数到49,999,999,二进制至少26位,写成[25:0]正好。如果板子是100MHz晶振,记得把参数改成100_000_000,同时把位宽扩到27位,否则计数器会溢出导致分频完全不正常。这个“位数不够”的坑在仿真里很难暴露,因为你仿真时通常不会跑满一秒,但一上板就会变成要么秒走很快,要么分频输出根本不动,属于典型的硬件玄学问题。

另外,很多开发板上的晶振实际输出不是整数——比如有些板子用33.333MHz或27MHz。遇到这种情况,分频系数除不尽,秒精度就会漂。这时不要硬用计数器凑整数,更靠谱的方案是用PLL把时钟倍频到整数频率后再分频,或者直接改用频率综合器IP。你现在的阶段还不需要追求铷钟级别的精度,但脑子里要绷着这根弦:数字钟的误差来源,一个是晶振本身的ppm,一个就是分频系数的舍入误差。

3. 核心逻辑动手写:计数器、状态机加动态扫描显示

3.1 秒分时计数器:从零开始搭一个不会跑飞的计时链路

计数器模块是整个设计的枢纽。它要完成的工作是:接收sec_tick脉冲,秒计数到59后归零并向分进位,分到59后向时进位,时到23后回零。设计上最需要小心的不是进位逻辑,而是复位后的初值和计数上下边界。很多新手把秒计数写成if (sec == 59) sec = 0,这在正常情况下没错,但如果在调试中你手动把秒改到了60甚至99,计数器就再也回不到正常序列了——这就是“跑飞”。

防御性写法是在每个计数值等于最大值的前一个周期就判断进位,而不是等到越界再清零。同时给每个计数器加一个同步使能信号en,只有en拉高时才会计数,方便后续接校时按键和秒脉冲。下面是一段可复用性比较好的BCD格式秒分计数写法——之所以用BCD而不是二进制,是为了让后级显示模块省掉二进制转BCD的组合逻辑网络:

module counter ( input wire clk, input wire rst_n, input wire en, // 计数使能(来自秒脉冲或按键) input wire load_en, // 校时写入使能 input wire [7:0] load_val, // 校时写入值,BCD格式 output reg [7:0] sec_bcd, output reg [7:0] min_bcd, output reg [7:0] hour_bcd, output reg carry_out // 小时回零时的进位/标志 );

在这个模块内部,你会用到两个嵌套的4位BCD计数器分别表示十位和个位。个位到9时十位加1、个位归零;十位到5且个位到9时才产生一次进位。这个“到9不是到F”的判断,恰好是很多人第一次接触BCD计数时最容易写错的地方——用always @(posedge clk)写时,如果判断条件用的是>=10而不是==9,在极端时序下会多跑一个周期。

3.2 动态扫描显示:6个数码管串行工作的带宽账

数码管显示是数字钟里最考验代码功底的部分。六个数码管不可能用6×8=48根IO直接并行驱动,一般都采用动态扫描:同一时刻只点亮一个位,以极快的速度轮询六个位,利用人眼视觉暂留让观感上“六位同时亮”。这个方案把一个时刻的IO占用从48根降到14根(6位选+8段选),代价是必须有足够高的扫描频率。

合适的扫描频率是多少?最低不能低于60Hz,否则能肉眼看出闪烁;太高又没必要——因为每组数码管的开启时间缩短,亮度会下降。我通常取1kHz左右:每个数码管点亮时间约1/6ms,六位一轮约1ms,完全覆盖视觉暂留的余量。这个频率可以直接从主时钟再分频得到,不需要单独锁相环。如果板子上数码管是低电平点亮(共阳),段码表也要取反,别拿共阴的段码直接硬套,否则显示的数字会全错或者某些段永远不亮。

动态扫描的典型写法是:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 0; seg_data <= 8'hff; // 全部段熄灭 end else if (scan_tick) begin // 1kHz 扫描使能 if (scan_cnt == 5) scan_cnt <= 0; else scan_cnt <= scan_cnt + 1'b1; end end always @(*) begin case (scan_cnt) 3'd0: begin seg_sel = 6'b111110; bcd_in = sec_bcd[3:0]; end 3'd1: begin seg_sel = 6'b111101; bcd_in = sec_bcd[7:4]; end 3'd2: begin seg_sel = 6'b111011; bcd_in = min_bcd[3:0]; end 3'd3: begin seg_sel = 6'b110111; bcd_in = min_bcd[7:4]; end 3'd4: begin seg_sel = 6'b101111; bcd_in = hour_bcd[3:0]; end 3'd5: begin seg_sel = 6'b011111; bcd_in = hour_bcd[7:4]; end default: begin seg_sel = 6'b111111; bcd_in = 4'b0000; end endcase end

这段代码里有两个细节值得说。第一,位选信号是低电平有效的,6'b111110表示选中第一个数码管,对应秒个位。第二,bcd_in在组合逻辑里被赋值后,还要过一个查表ROM(一个case语句)把它翻译成seg_data输出。这里有一个新手必踩的坑:如果位选切换和段码输出不是同一拍完成的,也就是段码还停留在上一个数字时位选已经切到下一位,那视觉效果就是数字重叠拖影。解决办法要么用寄存器打一拍对齐,要么保证段码生成是纯组合逻辑且位选切换足够快。实操中我习惯用组合逻辑输出段码,位选用寄存器同步,这样最省事也不会出鬼影。

3.3 仿真验证的基本盘:先模拟再上板

在把工程烧到板子上之前,至少要做一次行为级仿真。很多新手跳过这一步直接上板,然后出了问题只能靠板载LED瞎猜,这是在用最慢的方式调试。仿真文件里,你要做的就是产生时钟、释放复位,然后跑足够多的周期来看sec_bcd是否正常从00数到59。

`timescale 1ns / 1ps module tb_digital_clock; reg clk_50m = 0; reg rst_n = 0; wire [5:0] seg_sel; wire [7:0] seg_data; digital_clock dut ( .clk_50m(clk_50m), .rst_n(rst_n), .seg_sel(seg_sel), .seg_data(seg_data) ); always #10 clk_50m = ~clk_50m; // 50MHz 时钟 initial begin #100 rst_n = 1; #200_000_000; // 仿真跑 0.2 秒,观察进位链路是否正常 $finish; end endmodule

跑仿真时不要只盯着波形图看,重点检查三件事:复位释放后第一个sec_tick是否正常;秒从09跳到10时,个位是否清零且十位正确进位;秒从59跳到00时,分位是否加1。如果这三个点都对了,说明计数链路的核心逻辑没问题。还有一种更快的验证法:在仿真里把分频系数参数临时改成5,即5个时钟周期产生一个sec_tick,一秒的仿真时间被压缩到几百纳秒内完成。这个技巧能让你快速验证整个计数链路的进位逻辑,不用干等仿真器跑完真实的一秒(在仿真器里那会卡到你怀疑机器死机)。

4. 按键消抖与校时功能:让用户能调时间的系统才有灵魂

4.1 硬件按键的抖动与两种可靠消抖方案

一个没有任何校时功能的数字钟,只能作为纯展示品,实用性为零。但要加校时按键,就必须直面抖动问题:机械按键按下和松开的瞬间,电平会在高低之间跳变几十毫秒,如果直接把按键信号接入计数器的使能端,一次按键可能被当成三次甚至五次输入。后果就是调时间时数字狂跳,完全没法用。

最粗暴的消抖是RC低通滤波加施密特触发器,这在FPGA上没法直接用,得外接电路。我在纯逻辑方案里推荐两种:一是“延时采样法”——检测到按键电平变化后,启动一个20ms的定时器,等定时器结束再去采样按键电平,如果电平稳定则确认有效;二是“连续采样法”——用1kHz的时钟连续采样按键信号,只有当连续N次采样值相同时才认为按键状态稳定。前者代码直观,后者不会引入额外的等待延迟,响应手感更好。

4.2 调时状态机:一个按键切模式,一个按键改数值

有了消抖后的按键信号,接下去设计调时交互逻辑。我用一个2-bit状态机来管理:IDLE状态正常运行走秒;ADJ_SEC状态秒可调整;ADJ_MIN状态分可调整;ADJ_HOUR状态时可调整。key_adj[0]每触发一次,状态机按顺序循环切换;key_adj[1]在非IDLE状态下每触发一次,当前调整对象数值加1。

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin adj_state <= IDLE; end else if (key0_pulse) begin // key0 消抖后的单周期脉冲 case (adj_state) IDLE: adj_state <= ADJ_SEC; ADJ_SEC: adj_state <= ADJ_MIN; ADJ_MIN: adj_state <= ADJ_HOUR; ADJ_HOUR: adj_state <= IDLE; endcase end end

这个状态机的优劣取决于一个细节:用脉冲信号还是电平信号触发状态切换。如果直接把消抖后的电平接入状态机,松手时又会产生一次沿跳变,导致状态跳两下。所以我前面强调“单周期脉冲”——按键检测模块输出的是仅维持一个时钟周期的key0_pulse,这样每按一次只产生一个触发事件,状态机无论如何都不会多跳。

4.3 一个最容易忽略的问题:调时的时候走秒要不要停

很多教程里,调时状态下计数器仍在走秒,结果就是:你正在把分钟从23调到45,秒却还在幕后继续跑,等你调完一看,分钟数字和你设置的对不上。这是个体验极差的bug。正确做法是:进入任一调时状态后,立即将秒计数器清零并暂停计数;只有退出调时回到IDLE状态后,才重新从0开始走秒。如此,校准的时间与实际时间能以整秒对齐,误差最小。

实现上很简单:让计数器模块接收一个adj_mode信号,当它为高时让en信号强制拉低。注意,如果你正在调小时,分钟和秒都停着没问题;但如果你只调秒,那分钟和时钟也该停吗?从“校准时间”的目的看,答案是一样的——进入调时即整体暂停,这样你看到的时间就是调整后的准确时间,而不是一个边调边跑的目标。

5. 避坑指南:板级调试中的时序、复位与引脚三大关

5.1 系统复位后显示全乱或部分不亮

现象:烧录后数码管显示乱码、某一位常暗或全亮,复位键按下也不恢复。原因一般不是逻辑写错,而是复位信号释放时刻不对。FPGA上电后,时钟还没稳定时复位就撤掉了,导致部分寄存器进入亚稳态。解决:把复位信号的释放时刻滞后到时钟稳定之后,常见做法是加一个复位延时计数器——复位释放后先等1024个周期再让系统开始工作。

另外检查位选和段选的极性是否匹配。共阳数码管通常段选低电平点亮,位选低电平选中。如果你把共阳管接到了为共阴管设计的段码表上,现象就是“显示的图案完全不对”。排查方法最简单:把段码表的第一项改成只点亮a段,上板看第一位有没有亮左上角,不亮就是极性反了。

5.2 按键按下时数码管闪烁甚至复位

现象:手一碰到按键,显示就闪,严重的整个系统重启。这通常是按键没有独立消抖电路,或者按键信号没有经过同步器直接进了时钟相关逻辑。按键输入是异步信号,直接接入触发器时钟端会引发亚稳态,进而拉垮整条时钟树。解决:所有按键信号先经过两级D触发器打拍(同步器)再进消抖模块,同时确保按键信号不接入任何always @(posedge key)之类的不规范时钟用法。还有一个隐蔽点——如果开发板的按键按下是低电平有效,而你内部默认高电平有效且已经接上拉,那按下时等于直接短路到地,也会引发电源纹波导致闪烁。用手摸一下按键引脚烫不烫,基本能确定是不是这种问题。

5.3 秒走时快了或慢了,偏差稳定但不归零

现象:整机功能看起来全正常,但走一个小时慢了十几秒或者快了几秒,误差稳定单向漂移。原因几乎确定就是分频系数或晶振频率精度问题。先拿频率计或者示波器测一下开发板晶振的实际频率——有的板子标称50MHz,实测49.98MHz,累计误差就出来了。假使晶振实际频率是F_real,把CNT_1S参数改成F_real的整数部分,能基本校准。更精确的做法是加入软校准:把1Hz基准扩展为“每N秒多计数M拍”的补偿机制,但这属于进阶玩法,普通桌面时钟不需要做到这个程度。

5.4 时序约束文件没写导致显示随机闪烁

现象:仿真波形完美、上板偶尔闪、灯效不稳定,检查逻辑又找不到问题。这大概率是引脚约束里缺少时钟约束,导致综合工具不知道你的时钟频率,布局布线时走了过长的路径,建立时间违例。解决:在XDC/SDC文件里至少要有一条时钟约束:

create_clock -period 20.000 -name clk_50m [get_ports clk_50m]

20.000对应的是50MHz(周期20ns)。如果你的板子是100MHz晶振,改成10.000。很多人忽略这个文件,结果就是用默认约束跑,时序收敛靠运气。加了约束后重跑实现,大概率Vivado/Quartus会报时序警告,按提示把违规路径改一下,问题就解决了。

6. 进阶验证:把系统时钟精度量到分钟级,才算真做完

数字钟做到能走、能调只是完成了前三步,真正验证你是否理解这个系统,要看你能不能量化它的误差。最实用的验证方法不复杂:找一个标准时间源(手机或者PC时间,别用网络对时服务器,本地比对就够),让数字钟与之同步启动,跑24小时后再比对累计误差。记下两个数:秒差和漂移方向,然后计算日误差的ppm值。

假设你的板子标称50MHz,24小时慢了2秒,日误差就是2/86400≈23ppm。这个水平对于普通开发板晶振来说完全正常——消费级晶振精度通常在20到50ppm之间。如果你要求更高,替换成温补晶振或者用PLL锁定外部高精度基准,误差能降到1ppm以内,但那就不是“数字钟入门项目”的范畴了。

验证时还有一个容易被忽视的细节:测量要保证连续,不能中途断电或复位。你可以在数字钟里加一个“运行秒数”计数器(不参与显示,只用于测量),在调试时通过串口或LED灯读出累计值,精确还原开机后的总运行时间。这样即便你中途忘了记录开始时刻,也能靠内部计数反推精度。给这个小技巧取个名字叫“内部秒表法”,是做时钟类项目百试百灵的老办法。

我对数字钟这类项目有一个执念:它不需要华丽的IP核或者复杂的DSP,但它逼你养成了三个习惯——先搭架构再写代码、每段逻辑先仿真再上板、出了问题先查约束再怀疑电路。后来我做图像处理Demo、做跨平台通信系统,遇到底层时序问题时的排查思路,追根溯源都是在这个数字钟项目上练出来的。每次看到有人在群里问“为什么我的时钟在板子上不走”,我都会想起自己当年把位选线接反、干瞪眼看了半小时的糗事。希望你做完这个数字钟,也能把这几步变成肌肉记忆。要是按文章里的步骤走下来还有卡住的地方,回头检查一下五个模块的端口连线、确认一下消抖脉冲宽度,问题基本都会浮出水面。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询