news 2026/10/15 16:58:16

FPGA数字钟设计详解:从时钟使能到动态扫描的Verilog实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA数字钟设计详解:从时钟使能到动态扫描的Verilog实战

简介:使用Verilog语言在FPGA上开发的数字钟完整工程,面向有基础数字电路概念、希望上手FPGA开发的初学者或相关课程学习者。设计覆盖计时、跑表、闹钟和时间调整等常用功能,包含时钟分频、计时计数、显示驱动、按键防抖与闹钟比较等模块,源码注释详细,便于理解每条逻辑。压缩包共145个文件,整体约7.82MB,以Quartus工程文件为主,既有v/vhd源码,也有qpf/qsf工程配置、rpt/qmsg编译报告、ddb/qdb中间数据库以及仿真波形vwf/vcd,按目录结构可还原工程并追踪编译综合流程;文件类型也表明,资源不仅给出最终代码,还保留编译过程报告与仿真数据,适合复盘排错。配合详细注释和完整文件,读者可对照学习分频、状态机、模块例化等Verilog实践,也可在此基础上扩展出整点报时或秒表显示等个人功能。目前已有416人学习下载,可作为数字电路课程设计或FPGA入门的参考资料。

1. 数字钟在 FPGA 上到底难在哪

一块入门级 FPGA 开发板,加上六位数码管,做一个 Verilog 数字钟,听起来就是个入门练手项目。但真正动手你会发现:秒计数走两分钟就跳变、数码管有残影、按键按一下时间跳两个数、上电瞬间分钟位直接变成 39。这些问题的根子不在代码量,而在你对「时钟」和「使能」这两个概念的理解。数字钟的核心不是计数,而是把 50MHz 系统时钟安全地变成 1Hz 的秒脉冲,再把这个秒脉冲当作「使能」而不是「时钟」送给计数器。这篇文章就按这个主线,把计数框架、扫描显示、按键校时、跨时钟同步一条龙讲透,附上可以直接落地的 Verilog 代码和我在调试中踩过的几个坑。

2. 计数框架:先定模型,再写代码

2.1 用 BCD 计数还是二进制计数

数字钟的显示对象是六个数码管,每个管子显示 0-9。最直观的写法是直接把总秒数存成一个整数,然后用除法和取模拆出时分秒。这在仿真里完全没问题,但综合到 FPGA 上就麻烦——除法器会被映射成一组查找表或迭代减法结构,组合逻辑路径极长,时序报告一片红。更关键的是,数字钟的显示规则是 24 小时制、60 进制、逢十进一,这和 BCD 码天然对应。

我一般直接定义六个 4 位寄存器,分别存秒个位、秒十位、分个位、分十位、时个位、时十位:

reg [3:0] sec_unit, sec_dec; reg [3:0] min_unit, min_dec; reg [3:0] hour_unit, hour_dec;

每位的计数上限由显示规则决定:秒个位逢九进一,秒十位逢五进一,分钟同理,小时十位在 24 小时制下逢二清零。这种写法牺牲了几个触发器,换来的是显示驱动代码几乎为零——直接把寄存器的值连到数码管译码逻辑上就行,不需要任何算数运算。做复位、校时、快调的时候也只需要针对某一位寄存器操作,调试时看仿真波形一目了然。

这里有个新手常犯的错误:把秒十位写成if (sec_dec == 5) sec_dec <= 0;,但是忘了秒个位归零的时机。正确的进位关系不是「秒十位到了 5 就清零」,而是「秒个位从 9 跳 0 的那一拍,同时把秒十位加一」。也就是低位满十产生的进位脉冲,才是高位的计数使能。这个思路贯穿整个计数器设计。

2.2 50MHz 到 1Hz:用使能信号,不要用分频时钟

很多入门教程会教你用计数器分频出一个 1Hz 的时钟,然后把这个时钟接到秒计数器的posedge上。这在仿真里跑得通,但上板大概率出问题:分频时钟是全局时钟网络的「外人」,走的是普通布线资源,抖动和偏斜都不可控;而且多个模块如果都用分频时钟,跨时钟域的麻烦会接踵而至。正确做法是保留原始 50MHz 时钟,把分频结果做成一个宽度为一个时钟周期的脉冲使能。

reg [31:0] clk_cnt; reg one_sec_pulse; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 32'd0; one_sec_pulse <= 1'b0; end else if (clk_cnt == 32'd49_999_999) begin clk_cnt <= 32'd0; one_sec_pulse <= 1'b1; // 只拉高一个周期 end else begin clk_cnt <= clk_cnt + 1'b1; one_sec_pulse <= 1'b0; end end

参数说明:49_999_999对应 50MHz 晶振下的 1 秒周期,因为计数器从 0 数到 49_999_999 共 50_000_000 个周期。如果板子是 27MHz 晶振,这个值改成26_999_999;如果是 12MHz,改成11_999_999。one_sec_pulse高电平只持续一个 50MHz 时钟周期,之后计数器的秒个位在这个脉冲为高时加一。这样做的好处是:显示刷新、按键消抖、蜂鸣器报时全部可以继续用 50MHz 时钟,所有逻辑处于同一个时钟域,时序分析干干净净。

秒计数器的使能写法如下:

always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) sec_unit <= 4'd0; else if (one_sec_pulse) begin if (sec_unit == 4'd9) begin sec_unit <= 4'd0; sec_dec <= (sec_dec == 4'd5) ? 4'd0 : sec_dec + 1'b1; end else sec_unit <= sec_unit + 1'b1; end end

注意sec_dec的进位和清零写在了sec_unit == 9的分支里,而不是单独一个if (sec_dec == 5)。这是为了保证「满 60 进 1」发生在同一条时序路径上。分钟和小时的计数器结构完全一致,只是把触发使能从one_sec_pulse换成sec_unit == 9 && sec_dec == 5产生的进位条件。小时位稍微特殊:hour_unit == 9时小时十位加一,hour_dec == 2 && hour_unit == 3时整个小时域清零,模拟 24 点归零。

3. 显示驱动:六位数码管的动态扫描

3.1 位选与段选:扫描时序的 Verilog 实现

六位数码管如果每个管子独占 8 根数据线,要占 48 个引脚,入门板子根本不够用。所以用动态扫描:同一时刻只有一位数码管点亮,轮流点亮六位,利用人眼视觉暂留实现「同时亮」的错觉。扫描频率一般取 1kHz 左右——每位管子每秒被点亮约 160 次,不会有闪烁感,也不会因为切换太快导致驱动芯片来不及响应。

reg [2:0] scan_cnt; reg [5:0] sel_reg; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 3'd0; sel_reg <= 6'b111110; // 初始点亮最右边一位 end else if (scan_cnt == 3'd4) begin scan_cnt <= 3'd0; sel_reg <= {sel_reg[4:0], sel_reg[5]}; // 循环移位 end else scan_cnt <= scan_cnt + 1'b1; end

扫描计数器的分频系数3'd4配合 50MHz 时钟,每位管子点亮时间是5 / 50MHz = 100ns,六位一轮是 600ns,扫描频率约 1.67MHz——这太快了,数码管的寄生电容还没来得及充满就切走了,实际亮度会很低。这个参数得改大,常见做法是用一个scan_clk_div寄存器把扫描时钟分到 1kHz 左右。上面的代码只是为了展示循环移位的写法,实际工程里我会把扫描频率单独做成参数:

localparam SCAN_DIV = 16'd50_000; // 50MHz / 50000 = 1kHz

每一位管子在点亮期间,段选数据要稳定。段选数据来自当前选中的那一位寄存器的值,查表得到七段码。这里的关键是「当前选中的位」和「送往数码管的段码」必须是对应的——sel_reg最低位对应最右边的管子,那么取数逻辑就要根据sel_reg判断当前是哪一位,然后从sec_unit、sec_dec……一路选到hour_dec。

3.2 消影处理:先关位选,再换段码

动态扫描最常见的画面上问题是「幻影」:某一位数码管上隐约能看到隔壁一位的数字,尤其是 8 这个全亮的数字。原因是位选切换的一瞬间,段选数据还停留在上一个管子的数据上,新管子被点亮的那几十纳秒里,拿到的是错误的段码,视觉上就是一条残影。

解决办法是给位选和段选的更新加上顺序约束。常见做法是:先关掉所有位选(全部置为无效电平),再更新段选数据,最后打开新的位选。用 Verilog 表达就是:段选寄存器用组合逻辑读出,但位选寄存器的更新和段选寄存器的更新发生在不同的 always 块里,并且段选数据在sel_reg变化前先锁存好。

标准做法是加一个寄存器,把段选数据打一拍:

reg [7:0] seg_reg; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) seg_reg <= 8'h00; else case (sel_reg) 6'b111110: seg_reg <= seg_data(sec_unit); // 查表 6'b111101: seg_reg <= seg_data(sec_dec); // 其余位依次类推 default: seg_reg <= 8'h00; endcase end

注意这个 always 块用的是sel_reg的当前值,但seg_reg更新完之后的下一拍,sel_reg才切换。这样当新一位管子被点亮时,段选总线上的数据已经稳定成了新值。代价是多了一个寄存器的延迟,但消除了竞态。上板验证时你会发现,加上这一拍之后,此前那种「8 旁边跟着半个 0」的残影直接消失。这个「先锁存、再切换、后点亮」的顺序是数码管驱动的通用心法,做产品级的 LED 点阵屏驱动时同样适用。

4. 按键校时与快调:消抖、边沿检测与同步

4.1 消抖与单脉冲:一次按下只触发一次

数字钟必然有校时按键:一个切到秒/分/时选择位,一个加一。机械按键按下和松开的瞬间,触点会弹跳几十毫秒,如果不做消抖,一次按下会被计数器当成十几次触发,时间直接飞掉。消抖的思路不是「等它稳定」,而是「确认它稳定」——检测到电平变化后,等 20ms 再读一次,如果还是新电平,才认为是一次有效按下。

reg [19:0] debounce_cnt; reg key_reg, key_stable; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin debounce_cnt <= 20'd0; key_reg <= 1'b1; // 假设按键默认高电平 key_stable <= 1'b1; end else begin key_reg <= key_in; if (key_reg != key_in) begin debounce_cnt <= 20'd0; // 电平在跳,计数器清零 end else if (debounce_cnt == 20'd999_999) begin key_stable <= key_in; // 稳定超过 20ms,采样 end else begin debounce_cnt <= debounce_cnt + 1'b1; end end end

这段代码的核心逻辑是:按键电平每变化一次,计数器就清零重新计时;只有连续 20ms 没变化,才把当前电平采进来。20'd999_999对应 50MHz 下的 20ms。采进来之后,还要做一个下降沿检测——因为我们需要的是「按下」这个动作,而不是「电平为低」这个状态:

wire key_press = (key_stable_prev == 1'b1) && (key_stable == 1'b0);

每次key_press拉高一个周期,后面的校时逻辑就根据当前选择位,把对应的寄存器加一。如果你做的是「按住连续快调」,那就不能只看边沿,得隔一段时间自动触发一次,这个后面讲。

4.2 跨时钟域:50MHz 下的按键信号,怎么安全地进入分频系统

按键消抖做完之后,key_stable仍然是一个异步信号——它来自人的手指,和 50MHz 时钟没有任何相位关系。直接把它送进 always 块的条件里,综合工具会报亚稳态警告。虽然数字钟这类低速率应用实际翻车概率不高,但凡是吃这碗饭的,都会养成打两拍的习惯。

更稳妥的做法是三级同步器加边沿检测,把异步信号彻底变成 50MHz 域里的单脉冲:

reg [2:0] sync_ffs; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) sync_ffs <= 3'b111; else begin sync_ffs[0] <= key_in; sync_ffs[1] <= sync_ffs[0]; sync_ffs[2] <= sync_ffs[1]; end end assign key_sync = sync_ffs[2];

sync_ffs[0]采样的是原始异步信号,可能亚稳态;sync_ffs[1]采样的是sync_ffs[0],此时亚稳态大概率已经收敛;sync_ffs[2]给足了两拍的收敛时间,用它做逻辑判断基本稳了。第三级不是必须的,但成本就三个触发器,我习惯加上,后面接边沿检测时能进一步降低误触发概率。

这里有个容易被忽略的坑:按键的消抖计数器和校时逻辑如果用的不是同一个时钟域——比如你把扫描时钟单独分频出来用了——那消抖后的信号送进扫描域之前,还得再做一次同步。常见做法是全程只用 50MHz 一个时钟,所有分频都做成使能脉冲,这能从根本上避开大量跨时钟问题。数字钟这个体量,完全没有必要引入第二个时钟域。

5. 避坑清单:数字钟最常见的五个翻车现场

5.1 现象一:秒计数在 59 跳到 00 时,低位出现毛刺,分钟偶发多加一分钟

现象:仿真波形正常,上板跑着跑着,分钟位偶尔比预期快一分钟。原因:sec_unit == 9和sec_dec == 5这两个条件同时成立时,秒十位清零和低位置零发生在同一个 always 块里的同一条赋值路径,但如果你把分钟进位写成了独立判断if (sec_unit == 9 && sec_dec == 5)且这个条件在秒位更新之前判断,综合后可能产生一条组合逻辑路径上的毛刺。解决:把分钟进位直接合并进秒计数器的更新分支,做成「先改秒,再让秒的最终值决定分钟」的级联使能——低位模块输出进位脉冲,高位模块消费脉冲,而不是高位模块自己组合判断低位状态。

5.2 现象二:数码管某一位出现拖影,尤其数字 8 换到数字 1 时最明显

现象:位选切换的瞬间,当前位能看到上一位的残留轮廓。原因:段选数据是组合逻辑直接输出的,位选切换信号和段选数据变化之间存在几十纳秒的竞争窗口。解决:把段选改成寄存器输出,先锁存当前位的数据,再切换位选。也就是第 3.2 节里的做法。检查代码时注意一个细节:如果位选和段选都接在同一个 always 块里,即便你写了sel_reg <= ...和seg_reg <= ...,它们的更新仍然在同一时刻生效,起不到错开作用——必须让段选寄存器采样的是切换前的sel_reg值。

5.3 现象三:按键「按住快调」时,每隔几次就丢一个数字

现象:短按校时正常,但长按想让时间快速跳动时,数字的增速不均匀,偶尔跳过一个数。原因:快调功能的实现通常是在按键按下期间,每隔几百毫秒自动触发一次加一。这个触发信号如果和按键边沿检测共用一条逻辑,会出现「边沿脉冲还没处理完,周期脉冲又来了」的冲突。解决:把短按和快调分成两条独立路径——短按走边沿触发,长按走周期触发;周期触发的计数器在按键稳定为低电平期间运行,触发脉冲产生后要等当前校时逻辑处理完再产生下一个。我习惯用busy信号做互斥,触发脉冲只在busy == 0时生效。

5.4 现象四:重新上电后秒位从 00 开始,但分钟位偶尔是 39 而不是 00

现象:复位后秒位归零,分钟位和小时位却是随机值,而且每次上电还不一样。原因:复位信号只清了秒计数器,分钟和小时依赖one_sec_pulse逐级向上进位,但上电瞬间sec_unit的初值如果是非零(比如 FPGA 配置过程中寄存器随机上电),它可能在复位撤销后立刻产生进位,把分钟推到一个非零值。解决:复位时把所有计数器统一清零,并且把「秒计数器的进位输出」在复位期间也强制拉低。更彻底的做法是给复位逻辑加一个上电延迟——复位撤销后等第一个秒脉冲到来之前,所有高位计数器保持清零状态。

5.5 现象五:扫描频率调高后,某一位数码管亮度明显偏低,其他位正常

现象:扫描时钟从 1kHz 调到 5kHz 后,最右边一位亮度变暗,其余五位数码管正常。原因:位选循环移位用的是{sel_reg[4:0], sel_reg[5]}这种接法,初始值6'b111110只有最低位是 0。如果某一次复位后初始值变了,或者扫描频率提高导致位选信号在数码管驱动芯片上的占空比失衡,某一根位选线对应的管子点亮时间就比其他位短。解决:先检查sel_reg初始值是否只含一个 0;然后在仿真里观察六位管子的有效点亮时间是否完全相等。扫描频率建议不要超过 2kHz,再高就得检查驱动芯片的响应时间了,低频段基本不存在这个问题。

6. 验证方法:用带检查逻辑的 Testbench 避免「看起来对」

仿真数字钟的时候,最痛苦的是跑完 24 小时逻辑才能确认进位是否正确。实际上不需要跑满,因为 24 小时制的时间边界是「小时十位从 2 跳到 0、小时个位从 3 跳到 0」的组合。验证策略是:先单独验证秒到分的进位,再把秒计数器的初值直接设成 59 分 59 秒附近,用单步使能脉冲跳过去。

下面是一个带自动检查的测试平台框架:

`timescale 1ns / 1ps module tb_digital_clock; reg clk_50m; reg rst_n; reg key_sel, key_add; wire [5:0] sel; wire [7:0] seg; digital_clock uut ( .clk_50m (clk_50m), .rst_n (rst_n), .key_sel (key_sel), .key_add (key_add), .sel (sel), .seg (seg) ); initial begin clk_50m = 0; forever #10 clk_50m = ~clk_50m; // 50MHz end initial begin rst_n = 0; #100 rst_n = 1; #100; // 直接把内部寄存器设到 23:59:58 force uut.sec_unit = 4'd8; force uut.sec_dec = 4'd5; force uut.min_unit = 4'd9; force uut.min_dec = 4'd5; force uut.hour_unit = 4'd9; force uut.hour_dec = 4'd2; // 手动产生一个秒脉冲,观察是否跳到 00:00:00 repeat (2) begin #1000000000; // 1 秒 $display("Time check at %0t", $time); end #100 $finish; end endmodule

这个测试平台用了force直接改写内部寄存器,跳过了漫长的计数等待。运行后在波形窗口里看hour_dec、hour_unit的翻转时机,确认它们和min_unit的清零发生在同一拍。这里注意force之后必须release,否则后面的正常计数不会执行。

上板验证阶段,我习惯把秒脉冲使能单独引到一个 LED 上——如果 LED 精确地每秒闪一次,说明分频链路没问题;如果闪烁不均匀,优先怀疑晶振频率或分频系数算错。数码管显示正确之后,再测按键:短按一次必须且只能加一,长按五秒快调速度应该稳定在一个节奏上。把这些测完再封装成 IP 或者接到更大的系统里,基本就不会出幺蛾子了。

数字钟做多了你会发现,真正值得反复打磨的不是计数本身,而是那些「看起来不重要」的边角:复位的一致性、跨时钟域的收敛、扫描时序的竞争。我早期做这块板子的时候,就是被 5.4 节那个上电随机分钟问题耗了整整一下午,最后才发现是复位只清了低位。后来养成的习惯是:任何计数器模块的复位分支里,所有寄存器必须一条<= 0清干净,宁可多写几行,也绝不在复位路径上留死角。希望这篇笔记能帮你少走这段弯路。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:58:03

定制软件开发版本更新怎么办?更新判断与售后责任说明

定制软件开发上线后&#xff0c;企业可能收到程序修复、安全升级、接口调整或新增功能的更新建议。处理时应先确认更新原因和影响范围&#xff0c;再核对责任费用、兼容条件、发布安排和验证方法。通常可以按照“确认原因—评估影响—确认费用与责任—测试并发布—验证结果—更…

作者头像 李华
网站建设 2026/10/15 16:58:02

DES五种加密模式MFC实现:源码解析与工程落地指南

简介&#xff1a;一份基于MFC实现的DES五种加密模式源码&#xff0c;面向需要理解分组密码算法和Windows界面编程的开发者&#xff0c;完整实现ECB、CBC、CFB、OFB、CTR五种工作模式&#xff0c;可用于对比不同模式在文件加密中的表现。压缩包共18个文件&#xff0c;以C头文件、…

作者头像 李华
网站建设 2026/10/15 16:58:01

C#通过COM接口实时控制AutoCAD打开关闭与生命周期管理

简介&#xff1a;本资源是一套基于C#开发的CAD文件实时控制工具&#xff0c;面向.NET开发者及工业自动化、BIM二次开发等场景下的工程技术人员&#xff0c;解决CAD文档在非交互模式下程序化打开、关闭与生命周期管理的实践难题。压缩包共31个文件&#xff0c;含9个核心C#源码文…

作者头像 李华
网站建设 2026/10/15 16:57:20

QGC地面站源码编译与二次开发实战指南

简介&#xff1a;QGC地面站源代码面向无人机开发者、QT学习者及飞控研究人员&#xff0c;提供QGroundControl完整开源工程&#xff0c;可用于远程控制、数据传输与任务规划的二次开发。资源包共2551个文件&#xff0c;约31.67MB&#xff0c;以1018个h头文件、245个cc与161个cpp…

作者头像 李华
网站建设 2026/10/15 16:56:59

FreeSWITCH对接阿里云实时语音识别NlsSdkCpp3.x模块实践

简介&#xff1a;面向需要为FreeSWITCH接入云端语音识别能力的开发者&#xff0c;这份压缩包提供了一套基于阿里云NlsSdkCpp3.X的C对接模块&#xff0c;适用于呼叫中心对话实时转写、电话会议字幕生成等场景。模块遵循FreeSWITCH模块化设计&#xff0c;通过事件驱动机制将媒体流…

作者头像 李华
网站建设 2026/10/15 16:56:37

Linux音频DAPM:跨域widget电源管理实战解析

1. 项目概述&#xff1a;从“DAPM浅析之不同域widget&#xff08;二&#xff09;”看音频电源管理的底层逻辑 “DAPM浅析之不同域widget&#xff08;二&#xff09;”这个标题&#xff0c;乍一看像是一篇嵌入式音频驱动开发的内部笔记&#xff0c;但背后藏着的是整个Linux音频子…

作者头像 李华