ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Verilog数字时钟设计:从分频到闹铃的FPGA实现与避坑指南

Verilog数字时钟设计:从分频到闹铃的FPGA实现与避坑指南

简介:这份资源是面向FPGA初学者与数字电路学习者的Verilog时钟项目工程包,围绕时钟、闹铃、报时与按键去抖等实用功能展开,适合作为HDL入门练手或课程设计参考。压缩包共101个文件,约3.83MB,以Quartus工程文件为主,包含cdb、hdb、ddb、rdb等编译数据库与报告文件,以及qpf、qsf工程配置、sof与pof下载文件、v源文件、rpt报告和readme说明,完整保留了从源码到综合、布局布线的工程痕迹。已有1527人学习下载。读者可从中获得可综合的Verilog时钟模块实现思路,理解秒、分、时计数器与进位逻辑,掌握闹铃比较器、整点报时触发以及按键去抖状态机的写法,并借助工程文件与报告复盘综合结果,加深对FPGA可编程逻辑与同步设计的理解。

1. 从一块“走不准”的闹铃板子说起:Verilog 数字时钟到底能干什么

前阵子帮一个做 FPGA 的朋友看他的课程设计,一块开发板上跑着数码管时钟,能显示时分秒,也能设闹铃,但问题一堆:按键按下去要等半秒才反应,闹铃响了关不掉,跑一晚上能快出十几秒。他问我是不是代码写错了,我看了下,其实不是“错”,是典型的数字时钟设计里几个关键点没处理干净——按键消抖、跨时钟域同步、BCD 计数进位、闹铃比较逻辑,任何一个环节偷懒,板子就会用各种玄学现象教你做人。

这份 Verilog 时钟(带闹铃等功能)资源,就是围绕这类需求做的完整实现:以 FPGA 为平台,用纯 Verilog 描述一个可综合的数字时钟系统,核心功能包括时分秒计数与显示、按键校时、闹铃设置与触发、整点报时等。它适合正在做数字逻辑课程设计、FPGA 入门练手,或者想找一个“麻雀虽小五脏俱全”的时序逻辑参考工程的人。你不需要先精通 Verilog,但至少要能看懂 always 块和状态机,否则后面调分频和消抖时会比较痛苦。下面我按“先搞清楚它怎么搭起来,再动手改,最后避开那几个必踩的坑”的顺序拆一遍。

2. 拆开看架构:分频、计数、显示、闹铃四条线怎么接

2.1 系统时钟从哪来:分频链与使能脉冲的取舍

FPGA 开发板上一般有一个固定的晶振,常见是 50MHz。时钟模块要产生 1Hz 的秒信号,最直接的做法是写一个分频计数器,把 50MHz 分到 1Hz。但这里有个选型问题:你是分出一个 1Hz 的时钟信号去驱动后续逻辑,还是分出一个 1Hz 的使能脉冲,让后续逻辑仍然跑在 50MHz 时钟下、只在使能有效时动作?

我一般推荐后者。原因是 FPGA 内部对时钟网络有专门布线资源,随便用逻辑分频出来的信号当时钟,容易产生毛刺和时序问题,综合工具也会报一堆警告。用使能脉冲的方式,整个设计只有一个时钟域,时序分析干净得多。常见做法是:

// 50MHz -> 1Hz 使能脉冲,cnt 计到 50_000_000-1 时拉高一个周期 module clk_enable ( input wire clk, // 50MHz 系统时钟 input wire rst_n, // 低电平复位 output reg en_1hz // 1Hz 使能,宽度一个 clk 周期 ); reg [25:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; en_1hz <= 1'b0; end else if (cnt == 26'd49_999_999) begin cnt <= 26'd0; en_1hz <= 1'b1; end else begin cnt <= cnt + 1'b1; en_1hz <= 1'b0; end end endmodule

这段代码里cnt的位宽要算够:50_000_000 需要 26 位(2^26 = 67_108_864)。参数说明上,如果你板子晶振不是 50MHz,把比较值改成频率-1即可。en_1hz只拉高一个时钟周期,后续计数器用它当“允许走一秒”的开关。这样写的好处是,后面所有逻辑都挂在同一个clk上,不会出现“这个模块用 1Hz 时钟、那个模块用 50MHz”的跨时钟域麻烦。

2.2 时分秒计数:BCD 还是二进制,进位怎么处理

时间计数有两种常见写法:一种是用二进制计数器记总秒数,显示时再转 BCD;另一种是直接维护六个 BCD 位(秒个位、秒十位、分个位……)。对于数码管显示的场景,我更倾向直接维护 BCD,因为省掉了每次显示前的除法转换,逻辑直观,调试时在仿真波形里一眼就能看出“现在是 12:34:56”。

核心是一个秒计数模块,在en_1hz有效时递增:

// 秒计数:00~59 循环,产生分进位 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_lo <= 4'd0; sec_hi <= 4'd0; carry_min <= 1'b0; end else if (en_1hz) begin if (sec_lo == 4'd9) begin sec_lo <= 4'd0; if (sec_hi == 4'd5) begin sec_hi <= 4'd0; carry_min <= 1'b1; // 59->00,向分进位 end else begin sec_hi <= sec_hi + 1'b1; carry_min <= 1'b0; end end else begin sec_lo <= sec_lo + 1'b1; carry_min <= 1'b0; end end else begin carry_min <= 1'b0; end end

这里的关键是进位脉冲carry_min只维持一个周期,分计数模块看到它才加一。分、时的逻辑同理,只是时的范围是 00~23,判断条件变成“个位到 9 且十位到 2 时归零”。参数上要注意:en_1hz和carry_min不能同时被两个 always 块驱动,进位链要单向传递,否则综合出多驱动,板子行为不可预测。

2.3 数码管动态扫描:位选与段选的时序配合

时间值有了,要显示到数码管上。多位数码管一般是动态扫描:轮流点亮每一位,靠人眼视觉暂留形成“同时亮”的效果。扫描频率通常取 1kHz 左右,太低会闪,太高则亮度不足。常见做法是用一个计数器分出一个扫描使能,再配合位选信号切换。

// 扫描计数器:50MHz 分到约 1kHz 换位 reg [15:0] scan_cnt; reg [2:0] scan_sel; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 16'd0; scan_sel <= 3'd0; end else if (scan_cnt == 16'd49_999) begin scan_cnt <= 16'd0; scan_sel <= scan_sel + 1'b1; // 6 位数码管,0~5 循环 end else begin scan_cnt <= scan_cnt + 1'b1; end end

scan_sel用来选择当前点亮哪一位,同时决定段选输出哪一段码。段码表一般用 case 语句实现,把 0~9 对应的七段编码列出来。这里容易翻车的地方是位选和段选的更新时机:如果位选变了、段选还没跟上,会出现“鬼影”——某一位短暂显示上一位的数字。解决办法是在同一个时钟沿更新位选和段选,或者在位选切换后插入一个消隐周期。

2.4 闹铃比较:相等判断还是“到点触发”

闹铃功能的本质是把当前时间和设定的闹铃时间做比较。最直觉的写法是if (hour == alm_hour && min == alm_min),相等就拉高闹铃输出。但这样有个问题:如果闹铃时间设成当前时间,它会一直响,因为条件持续成立。更合理的做法是做一个“到点触发”的边沿检测——当比较结果从“不相等”变成“相等”的那一刻,产生一个脉冲去置位闹铃标志,标志再由按键清除。

// 闹铃触发:比较结果上升沿产生一个周期脉冲 reg match_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) match_d <= 1'b0; else match_d <= time_match; // time_match 是组合比较结果 end wire alm_trigger = time_match & ~match_d; // 上升沿

alm_trigger拿去置位一个alm_flag寄存器,蜂鸣器输出就看这个 flag。清除只能靠按键,这样闹铃响后不会自己停,符合真实闹钟的行为。参数上,闹铃时分的存储用两组 BCD 寄存器,设置模式下由按键修改,正常模式下只读。

3. 动手改一版:按键消抖、校时状态机与闹铃设置

3.1 按键消抖:为什么你的按键要按半天才有反应

机械按键按下和松开时,触点会有几毫秒到十几毫秒的抖动,FPGA 跑在 50MHz,这个抖动会被采集成几十万个跳变。如果不消抖,按一次键可能被识别成几十次,状态机直接跑飞。常见消抖方案是:检测到按键电平变化后,启动一个计数器,持续稳定 10~20ms 才确认新状态。

// 20ms 消抖:50MHz 下计到 1_000_000 module debounce ( input wire clk, input wire rst_n, input wire key_in, // 原始按键,按下为低 output reg key_press // 消抖后的按下脉冲 ); reg [19:0] cnt; reg key_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_d <= 1'b1; key_press <= 1'b0; end else begin key_d <= key_in; if (key_d != key_in) begin cnt <= 20'd0; // 电平变化,重新计时 end else if (cnt < 20'd1_000_000) begin cnt <= cnt + 1'b1; end else if (key_d == 1'b0 && key_press == 1'b0) begin key_press <= 1'b1; // 稳定按下,输出一个脉冲 end else begin key_press <= 1'b0; end end end endmodule

参数说明:1_000_000对应 20ms,如果你觉得按键太“肉”,可以降到 500_000(10ms)。key_press只输出一个周期脉冲,方便状态机做“按一次加一”的操作。注意key_d和key_in的比较是在同一时钟域下,不要引入异步复位之外的跨时钟信号。

3.2 校时状态机:正常、设时、设分、设闹铃怎么切

时钟需要几种工作模式:正常走时、设置小时、设置分钟、设置闹铃小时、设置闹铃分钟。用一个有限状态机来管理最清晰。状态转移由按键驱动:一个“模式”键循环切换状态,一个“加”键在当前状态下递增对应值。

// 简化的模式状态机 localparam NORMAL = 3'd0, SET_HR = 3'd1, SET_MIN = 3'd2, SET_AH = 3'd3, SET_AM = 3'd4; reg [2:0] state, next_state; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= NORMAL; else state <= next_state; end always @(*) begin next_state = state; if (mode_press) begin case (state) NORMAL : next_state = SET_HR; SET_HR : next_state = SET_MIN; SET_MIN : next_state = SET_AH; SET_AH : next_state = SET_AM; SET_AM : next_state = NORMAL; default : next_state = NORMAL; endcase end end

mode_press是消抖后的模式键脉冲。在SET_HR状态下,inc_press让小时加一,到 23 归零;其他状态类似。这里有个细节:设置模式下时钟还走不走?我一般让它在设置时暂停走时,退出设置再恢复,否则你设分钟的时候秒还在跑,设完可能已经差了几秒。暂停的实现很简单,在en_1hz前面加一个门控:en_1hz_gated = en_1hz & (state == NORMAL)。

3.3 闹铃设置与蜂鸣器输出:怎么让响铃可控

闹铃的设定值存在alm_hr、alm_min两组 BCD 寄存器里,在SET_AH、SET_AM状态下由inc_press修改。比较逻辑用前面说的边沿触发方式产生alm_trigger,置位alm_flag。蜂鸣器输出可以直接接alm_flag,也可以做一个 1kHz 左右的方波驱动无源蜂鸣器,响起来音调更正常。

// 蜂鸣器:alm_flag 有效时输出约 1kHz 方波 reg [15:0] buzz_cnt; reg buzz_out; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin buzz_cnt <= 16'd0; buzz_out <= 1'b0; end else if (alm_flag) begin if (buzz_cnt == 16'd24_999) begin buzz_cnt <= 16'd0; buzz_out <= ~buzz_out; end else begin buzz_cnt <= buzz_cnt + 1'b1; end end else begin buzz_cnt <= 16'd0; buzz_out <= 1'b0; end end

24_999对应 50MHz 下 1kHz 半周期。alm_flag的清除由“关闹铃”按键完成,按下后alm_flag <= 0。注意alm_flag和buzz_out不要互相驱动,各管各的,否则综合会报组合环。

4. 避坑排查:仿真对、上板错的那几个经典原因

4.1 现象:仿真波形完全正确,烧到板子上数码管乱跳

原因通常是引脚约束没写对,或者数码管共阴共阳搞反了。仿真里你看到的是逻辑值,板子上还要经过引脚分配、电平标准、驱动能力这几道关。解决:先确认约束文件里时钟引脚、复位引脚、数码管段选位选引脚和原理图一致;再确认段码表是按共阴还是共阳写的,共阳要取反。拿万用表量一下段选引脚静态电平,能快速判断。

4.2 现象:按键按一次,时间跳了好几下

原因就是没做消抖,或者消抖计数器没在电平变化时清零。解决:按 3.1 的写法,检测到key_d != key_in就把计数器清零重新计时,稳定 10~20ms 才输出脉冲。另外确认按键脉冲只在一个周期内有效,状态机里用边沿检测而不是电平判断。

4.3 现象:闹铃到点不响,或者响了关不掉

不响的常见原因是比较逻辑用了组合输出直接驱动蜂鸣器,但alm_flag没被正确置位;或者闹铃时间寄存器在设置模式下被时钟走时逻辑覆盖了。关不掉则多半是清除逻辑写成了电平敏感,按键松开后 flag 又被置位。解决:触发用边沿脉冲置位 flag,清除用按键脉冲复位 flag,两者分开写,不要用同一个 always 块里的 if-else 互相干扰。

4.4 现象:跑一晚上时间快了几秒

这是分频参数和晶振实际频率的偏差问题。50MHz 是标称值,实际晶振可能有几十 ppm 的误差,一晚上累积下来就是几秒。解决:如果只是课程设计,这个精度可以接受;如果要更准,可以把分频比较值微调,或者用板上的 RTC 芯片。纯 FPGA 逻辑分频做到“一晚上不差”不太现实,心里要有这个边界。

4.5 现象:综合警告一堆,说信号被优化掉

常见于你定义了一个寄存器但从来没在输出或条件里用过,综合工具直接把它删了。解决:检查每个中间信号是否真的参与了后续逻辑,尤其是调试用的计数器和标志位。如果确实需要保留观察,可以把它接到一个空闲的 LED 或输出引脚上。

5. 进阶一点:把整点报时和闹铃做成可配置的

基础功能跑通之后,可以在这个工程上做两个小升级,既不复杂又能让设计更完整。

第一个是整点报时。思路很简单:在分计数从 59 跳到 00、且秒也归零的那一刻,产生一个报时脉冲,驱动蜂鸣器短鸣一声。实现上就是在分进位逻辑里加一个条件判断,输出一个单周期脉冲,再拿去触发一个 200ms 左右的定时器控制蜂鸣器。注意报时和闹铃可能同时触发,两个输出要做优先级或者叠加处理,别让蜂鸣器控制信号打架。

第二个是把闹铃时间做成可存储的。纯 FPGA 内部寄存器掉电就丢,如果你想让闹铃设置保留,可以外挂一片 I2C EEPROM,在设置完成后把alm_hr、alm_min写进去,上电时读回来。I2C 控制器网上有很多参考实现,移植时注意时序参数——标准模式 100kHz、快速模式 400kHz,你的分频计数器要按这个算。写 EEPROM 还有页写和写周期的等待,别连续写太密。

验证方法上,我习惯先在仿真里跑一个“加速版”:把分频比较值改小,让 1 秒变成几个时钟周期,这样几分钟就能看到时分秒进位、闹铃触发、整点报时的完整波形。确认逻辑对了,再改回真实分频上板测。这个习惯帮我省过很多次“上板等半天才发现进位写错”的时间。

从那以后我每次做时序逻辑,都强制先跑加速仿真、再上板,绝不跳过。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进