ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Verilog FPGA实现带闹铃时钟:模块划分分频消抖与调试避坑指南

Verilog FPGA实现带闹铃时钟:模块划分分频消抖与调试避坑指南

简介:一份用于FPGA与Verilog进阶学习的时钟设计工程,面向有基础数字电路概念、希望上手完整项目的开发者,覆盖时钟计数、闹铃触发、整点报时以及按键去抖等常用逻辑。压缩包共101个文件,包含.v源代码与Quartus工程配置文件(qsf/qpf),以及编译生成的sof、pof下载文件、rpt报告、db信息文件等,整体大小3.83MB,便于直接打开工程查看设计层次与综合结果。已有1526人学习下载。通过该资源可了解时钟分频与进位计数在Verilog中的实现方式,掌握基于比较器的闹钟条件检测思路,并学习机械按键输入的去抖处理技巧。建议结合注释或工程结构逐一追踪各模块,适合作为课堂实验、课程设计或自学练手的参考工程。

1. 用Verilog做一台带闹铃的时钟:动手前先想清楚的关键点

很多人第一次接到“用Verilog写一个时钟,带闹铃等功能”这个需求时,第一反应是“不就一个计数器嘛”。真做起来才发现,一个能上板跑、能调时、能闹铃的Verilog时钟,一半以上的工作量花在了按键消抖、模块联调和显示扫描上。把分频、计数、显示、按键、闹钟比较堆在一个always块里写,仿真勉强能过,一上板就各种翻车。

这篇笔记按我实际做过的方案来拆:先是顶层架构怎么切,再给分频、BCD计数、动态扫描、闹钟比较和按键消抖的核心代码,最后把容易踩的坑集中列出来。目标是让你照着这个思路,用一块常见的FPGA开发板和一套EDA工具就能跑通,而不是只停留在仿真波形好看的阶段。

2. 先把架构立住:时钟和闹铃这六个模块各管一件事

2.1 为什么不能把所有逻辑写进一个大always块

见过不少同学把秒计数、分计数、时计数、闹钟比较全写在同一个always块里,结果代码超过两百行,改一个功能就要重新理一遍时序。这种写法最大的问题是:每个模块的时钟域和触发条件全搅在一起,仿真时看不出问题,综合后时序报告一拉全是违例。

我一般会按“单一职责”把整个设计拆成六个模块:分频模块负责把开发板上的高频时钟变成1Hz秒脉冲;时间计数模块负责秒、分、时的递增和归零;显示模块把BCD时间值转成数码管段码并扫描输出;按键处理模块负责消抖和调时状态切换;闹钟模块负责设定值比较和闹铃输出;顶层模块把前面五个实例化并连线。

这样拆的好处很明显:每个模块的测试可以单独写testbench跑,坏了哪一块直接定位到对应文件,不用在整个工程里翻来翻去。而且综合之后的资源占用和时序收敛都会好很多。下面这张表是我习惯的模块划分,端口方向以顶层视角为准。

模块名主要端口职责
clk_divclk_in, rst_n, second_pulse分频产生1Hz秒脉冲
time_cntsecond_pulse, adjust_set, adjust_value秒/分/时计数与调时
bcd_displaytime_bcd, clk_in, rst_n, seg, sel动态扫描数码管显示
key_debouncekey_in, clk_in, key_out按键消抖,输出单周期脉冲
alarm_cmptime_bcd, alarm_set, alarm_en, alarm_out时间比较,触发闹铃
topclk_in, rst_n, key, seg, sel, buzzer顶层连线,对外接口

每个模块的边界一定要清晰。比如时间计数模块只知道秒脉冲和调时信号,不关心按键怎么消抖;闹钟模块只知道当前时间和闹钟设定值,不关心时间怎么来的。边界清晰之后,你甚至可以先把闹钟模块架空,用一个手动拨码开关模拟时间跑通,这在实际调试时很有用。

2.2 对时、调时、闹钟设定三条路径要分开

“带闹铃等功能”意味着不止要显示时间,还要能对时和设闹钟。这三条路径如果在代码里不分开,最终一定会在某个边界条件上出问题。

我习惯的做法是:定义一个两位的按键状态寄存器mode[1:0],00表示正常运行,01表示对时(调整小时和分钟),10表示设置闹钟。按键模块只负责把原始按键变成干净的脉冲,具体这个脉冲是“切换模式”还是“数值+1”,由顶层根据当前模式来决定。这样每条路径的输入输出都很明确,也不容易被后续功能干扰。

调时逻辑也要单独处理。比如在正常运行模式下,秒脉冲来自分频模块;在调时模式下,秒脉冲应该暂停,改由按键脉冲来驱动分或时的递增。这个切换如果直接用if-else嵌套,代码会很难读,我一般会在time_cnt模块里用两个输入信号分别表示秒脉冲和调时脉冲,内部用case语句按模式选择触发源。

3. 让时间走起来:参数化分频、BCD计数与动态显示的关键代码

3.1 参数化分频:用CLK_FREQ和TARGET_FREQ两个参数适配所有开发板

分频是最基础但也最容易写错的部分。很多教材里直接写死计数上限,比如50MHz晶振就写49_999_999,换一块100MHz晶振的板子就得整个工程搜着改。用参数化写法,一个分频模块能适配所有板子。

module clk_div #( parameter CLK_FREQ = 50_000_000, parameter TARGET_FREQ = 1 ) ( input wire clk_in, input wire rst_n, output reg pulse_out ); localparam CNT_MAX = CLK_FREQ / TARGET_FREQ - 1; // 用$clog2自动算位宽,避免手动算错导致计数器溢出 reg [$clog2(CNT_MAX)-1:0] cnt; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin cnt <= 0; pulse_out <= 0; end else if (cnt == CNT_MAX) begin cnt <= 0; pulse_out <= 1; // 只拉高一个周期 end else begin cnt <= cnt + 1; pulse_out <= 0; end end endmodule

这段代码的要点在CNT_MAX的计算和位宽推导。CNT_MAX是分频系数减一:50MHz分频到1Hz,需要计数50_000_000个时钟周期,但计数器从0开始,所以计数到49_999_999时归零。位宽用$clog2(CNT_MAX)自动计算,50MHz场景下CNT_MAX是49_999_999,$clog2结果是26,所以cnt是26位。如果手动写死25位或27位,要么溢出要么浪费寄存器。

脉冲输出只拉高一个周期,这个设计决策很重要。如果输出一直保持高电平,后面接的时间计数器在每个clk上升沿都看到高电平,就会疯狂进位。只拉高一个时钟周期,时间计数器只需要在pulse_out为1时执行加一,逻辑会非常干净。分频系数较大的时候,仿真要等很久才能看到一次脉冲,这时候可以在testbench里临时把CLK_FREQ参数改成100,先验证分频逻辑本身没问题,再改回真实参数。

3.2 BCD计数模块:六十进制和二十四进制的归零判断是核心

时间计数不能直接用二进制数,因为数码管要显示的是十进制的每一位。我习惯用BCD编码,就是每个十进制位用4bit二进制表示。秒和分的低四位是个位,高四位是十位,小时同理。这样显示模块拿到的数据直接就是四个独立的BCD数字,省掉了二进制转十进制的组合逻辑。

module time_cnt ( input wire clk, input wire rst_n, input wire second_pulse, input wire mode, // 0: 运行, 1: 调时 input wire adj_pulse, // 调时脉冲 output reg [3:0] sec_l, sec_h, output reg [3:0] min_l, min_h, output reg [3:0] hour_l, hour_h ); // 秒个位到9后归零,秒十位加1;秒十位到5后归零,分钟个位加1 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_l <= 0; sec_h <= 0; min_l <= 0; min_h <= 0; hour_l <= 0; hour_h <= 0; end else if (mode == 1'b0) begin if (second_pulse) begin if (sec_l == 4'd9) begin sec_l <= 0; if (sec_h == 4'd5) begin sec_h <= 0; if (min_l == 4'd9) begin min_l <= 0; if (min_h == 4'd5) begin min_h <= 0; if (hour_l == 4'd9) begin hour_l <= 0; // 小时十位到2且个位到3,即23点,归零 if (hour_h == 4'd2 && hour_l == 4'd3) begin hour_h <= 0; end else begin hour_h <= hour_h + 1; end end else if (hour_h == 4'd2 && hour_l == 4'd3) begin hour_h <= 0; hour_l <= 0; end else begin hour_l <= hour_l + 1; end end else begin min_h <= min_h + 1; end end else begin min_l <= min_l + 1; end end else begin sec_h <= sec_h + 1; end end else begin sec_l <= sec_l + 1; end end end else if (adj_pulse) begin // 调时模式:只调分钟或小时,秒清零,秒不进位 if (min_l == 4'd9) begin min_l <= 0; if (min_h == 4'd5) begin min_h <= 0; if (hour_l == 4'd9) begin hour_l <= 0; if (hour_h == 4'd2 && hour_l == 4'd3) begin hour_h <= 0; hour_l <= 0; end else begin hour_h <= hour_h + 1; end end else if (hour_h == 4'd2 && hour_l == 4'd3) begin hour_h <= 0; hour_l <= 0; end else begin hour_l <= hour_l + 1; end end else begin min_h <= min_h + 1; end end else begin min_l <= min_l + 1; end end end endmodule

这个模块的难点是小时进位的两条路。正常走秒脉冲时,分钟从59跳到00,小时加一,但小时自己也要处理从09到10和从19到20的个位回绕,还要处理23点后归零。调时模式下,秒钟不参与进位,直接调整分钟和小时,这就要防止在调时过程的中间状态发生误进位。注意我在分钟从59跳到00的时候判断的是hour_l是否等于9,再加小时十位是否等于2且个位是否等于3,这个判断放在小时加一的逻辑之前,是为了让23:59:59之后直接跳到00:00:00,而不是先变成24:00再在下一秒归零。

3.3 动态扫描显示:段选与位选的时序配合

四个数码管共用段选线,靠位选线轮流点亮。人眼有视觉暂留,只要每个管子每秒被刷新50次以上,看起来就是同时亮的。关键在于扫描周期不能太长也不能太短:太长会看到明显的闪烁,太短会导致位选切换时段码还没稳定就被采走,出现“重影”。

module bcd_display #( parameter SCAN_FREQ = 1000 // 每位数码管每秒刷新次数 ) ( input wire clk, input wire [15:0] bcd, // bcd[15:12]小时十位, [11:8]小时个位, [7:4]分钟十位, [3:0]分钟个位 output reg [7:0] seg, output reg [3:0] sel ); localparam SCAN_CNT = 50_000_000 / SCAN_FREQ; reg [$clog2(SCAN_CNT)-1:0] scan_cnt; reg [1:0] scan_pos; always @(posedge clk) begin if (scan_cnt == SCAN_CNT - 1) begin scan_cnt <= 0; scan_pos <= scan_pos + 1; end else begin scan_cnt <= scan_cnt + 1; end end always @(*) begin case (scan_pos) 2'd0: begin sel = 4'b1110; seg = seg_code(bcd[3:0]); end 2'd1: begin sel = 4'b1101; seg = seg_code(bcd[7:4]); end 2'd2: begin sel = 4'b1011; seg = seg_code(bcd[11:8]); end 2'd3: begin sel = 4'b0111; seg = seg_code(bcd[15:12]); end endcase end // 共阴极数码管段码表,a段对应seg[0] function [7:0] seg_code; input [3:0] num; case (num) 4'd0: seg_code = 8'b0011_1111; 4'd1: seg_code = 8'b0000_0110; 4'd2: seg_code = 8'b0101_1011; 4'd3: seg_code = 8'b0100_1111; 4'd4: seg_code = 8'b0110_0110; 4'd5: seg_code = 8'b0110_1101; 4'd6: seg_code = 8'b0111_1101; 4'd7: seg_code = 8'b0000_0111; 4'd8: seg_code = 8'b0111_1111; 4'd9: seg_code = 8'b0110_1111; default: seg_code = 8'b0000_0000; endcase endfunction endmodule

这里的关键参数是SCAN_FREQ,我习惯设成1000Hz,也就是四个位轮流扫一圈需要4ms,每位点亮1ms。这个速度下位选切换人眼完全无感,也不存在LED驱动能力不足的问题。段码表根据开发板的数码管是共阴还是共阳需要反一下,共阳极要把所有bit取反,这个细节做错了上板就是“该亮的段不亮、不该亮的段全亮”。

扫描逻辑用组合逻辑输出段码和位选,在实际工程里要注意一个隐患:如果位选和段码在同一个时钟周期内变化,硬件上可能出现短暂的段码错乱。严格的写法是让段码先用寄存器打一拍再输出,但组合逻辑写法在仿真里看不出问题,上板后轻微重影是能接受的,真正严苛的工程会改成时序逻辑输出。

4. 按下“闹钟”才算完整:闹钟比较、按键消抖和扩展功能

4.1 闹钟比较模块:只比较“时”和“分”,别把秒拉进来

闹钟设定只需要精确到分钟,所以比较的时候只要把当前的时和分与设定值做相等判断。比较时间用组合逻辑直接做等于判断就行,不需要状态机。这里有一个细节:如果闹钟允许跨天重复,那每天的同一时刻会触发一次,代码本身不用考虑日期,只要比较通过就输出一个脉冲。

module alarm_cmp ( input wire [7:0] cur_hour, // 当前小时,BCD input wire [7:0] cur_min, // 当前分钟,BCD input wire [7:0] set_hour, // 闹钟设定小时 input wire [7:0] set_min, // 闹钟设定分钟 input wire alarm_en, // 闹钟总开关 input wire clk, output reg alarm_pulse // 单周期闹钟触发脉冲 ); reg alarm_pending; always @(posedge clk) begin if (!alarm_en) begin alarm_pending <= 0; end else if (alarm_pending && (cur_min != set_min)) begin // 分钟变化后,清除闹钟待触发状态 alarm_pending <= 0; end else if (cur_hour == set_hour && cur_min == set_min) begin alarm_pending <= 1; end end // 组合逻辑输出:分钟相等时给一个脉冲 assign alarm_pulse = alarm_pending && (cur_hour == set_hour) && (cur_min == set_min); endmodule

为什么不用单纯组合逻辑做个相等判断然后直接输出?因为时间在运行,cur_min会连续保持某个值长达一分钟,如果直接拉高输出,蜂鸣器会响一分钟,而且每次进到这个值就会触发一次。加了alarm_pending状态后,闹钟触发的条件变成“检测到时间到达设定值”,而不是“当前时间等于设定值”,这样脉冲宽度是可控的。想要闹铃响得更久,可以把alarm_pulse接到一个响铃时长计数器上,让蜂鸣器响30秒或1分钟后自动停。

4.2 按键消抖:不消抖就调时间,你会被它气哭

按键按下和松开的瞬间,电平会抖动几十毫秒。如果不处理,一次按键可能被当成好几次按下。我最早做的时候偷懒没写消抖,结果按一下“加一分钟”,时间数字刷地跳了七八分钟,录像都来不及截。血泪教训告诉我,消抖模块绝不能省。

module key_debounce #( parameter DEBOUNCE_MS = 20, parameter CLK_FREQ = 50_000_000 ) ( input wire clk, input wire key_in, // 按键输入,默认高电平,按下为低 output reg key_pulse // 单周期脉冲,按下瞬间输出 ); localparam DEBOUNCE_CNT = CLK_FREQ / 1000 * DEBOUNCE_MS; reg [$clog2(DEBOUNCE_CNT)-1:0] cnt; reg key_sync1, key_sync2; // 两级同步,处理跨时钟域 reg key_prev; always @(posedge clk) begin key_sync1 <= key_in; key_sync2 <= key_sync1; end always @(posedge clk) begin if (key_sync2 != key_prev) begin // 电平变化,开始计数 cnt <= 0; key_prev <= key_sync2; end else if (cnt < DEBOUNCE_CNT) begin cnt <= cnt + 1; end end // 稳定超过20ms后,检测到按下沿输出脉冲 always @(posedge clk) begin if (cnt == DEBOUNCE_CNT && key_prev == 1'b0) begin key_pulse <= 1'b1; end else begin key_pulse <= 1'b0; end end endmodule

消抖的思路是:检测到电平变化后开始计数,如果计满20ms电平没有再次变化,说明按键已经稳定,此时输出一个脉冲。这个方案的抗干扰能力比简单的延时取反要强,因为它能过滤掉中间的电平跳变。参数DEBOUNCE_MS设成20,常见的机械按键抖动时间不会超过这个值,设太大会让按键响应变肉,设太小的消抖效果不彻底。

需要注意按键的触发沿是按下时还是释放时。上面代码在按下沿输出脉冲,也就是按键刚被按下、电平从高跳到低并且稳定后触发。实际调时操作里,我一般建议在释放沿触发,因为用户的手可能有轻微颤抖,释放沿触发能减少误触。实现方式很简单,把最后判断改成key_prev == 1'b1,表示电平从低回到高且稳定后输出脉冲。

4.3 贪睡功能和闹钟输出:让闹铃不要只会一直响

“带闹铃等功能”里的“等功能”三个字,能在很多方向做扩展。常见的是给闹钟加贪睡模式:闹铃响了之后,如果检测到贪睡按键,蜂鸣器停5分钟再响。这个逻辑拆开看其实就是闹钟比较模块输出一个暂停信号,把比较结果屏蔽一段时间。用计数器实现比较简单:收到贪睡键后启动一个5分钟倒计时,倒计时期间把alarm_pulse拉低,结束再恢复。

另一个常见扩展是让闹铃输出到蜂鸣器之前加一个分频,产生某种节奏(比如响一秒停一秒),而不是一直发出刺耳的连续音。这些扩展都不需要改动已有模块的接口,只要在顶层加几个小模块就行,这也体现出模块化架构的好处。

5. 上板调试避坑:仿真和实测之间隔了这5个常见问题

5.1 仿真里秒脉冲永不翻转的位宽问题

仿真波形里second_pulse一直拉不高,或者计数器卡在某个值不动,首先怀疑分频模块的计数器位宽。手动算位宽容易出低级错误,比如50MHz分频,CNT_MAX是49_999_999,这个数大于2^25(3355万),小于2^26(6711万),需要26位。有人惯性写成25位,仿真时计数器从0加到33,553,999就归零了,秒脉冲变成了约0.67Hz,肉眼几乎看不出区别。用代码里的$clog2自动推位宽,并把参数放在模块顶部,就能避免这类问题。

5.2 按键一次按下,时间数字连跳好几个

这个是我见过最多的翻车场景。现象是按下“加分钟”按键,分钟跳了5到10下才停,或者跳个不停。原因有两个:一是没有消抖模块,机械抖动被当成多次按下;二是消抖模块做了但输出持续高电平,而不是单周期脉冲。第一种原因直接加消抖模块解决,第二种原因要检查消抖模块的输出是否只在稳定后的一个时钟周期内拉高。如果输出一直保持高电平,后面的计数器会一直累加。

5.3 23:59:59后显示24:00而不是00:00

小时进位的归零条件写错,是BCD计数模块最容易踩的坑。现象是仿真里23:59:59之后下一秒显示24:00,再过一秒才变成00:00:00。原因在于先判断hour_l是否等于9做了归零和小时十位加一,再判断是否等于23,结果23点过后的第一个时钟周期已经进到24了,等于23的判断永远不会触发。正确的顺序是先判断当前是不是23,如果是就直接归零,不是再走正常的加一逻辑。这个顺序在代码里体现为else if分支的优先级,写的时候要特别留意。

5.4 数码管重影和闪烁同时存在

现象是显示的数字有拖影,而且整体在轻微闪烁。原因通常是扫描周期不匹配:扫描太快导致段码切换时位选还没有完全关断,旧段码串到新位选上;扫描太慢人眼能看到刷新过程。解决办法是把SCAN_FREQ参数控制在800到1500Hz之间,同时修改扫描逻辑,先把所有位选关闭,再拉高新的位选。如果你的开发板数码管驱动是三极管扩流,还要检查位选信号是否需要反相,这取决于板子的硬件设计。

5.5 综合报告出现大量Latch警告

现象是综合时警告Latch inferred,上板后某些寄存器状态不可预测。原因是组合逻辑的case或if分支没有覆盖完整,比如按键模式判断只写了00和01,忘记写default,或者某些always块在某种条件下没有给所有变量赋值。解决办法是每个case补default分支,每个if必须else齐全。用always @(*)写组合逻辑时,要保证任何一条路径下所有信号都被赋值,否则综合工具会推断出锁存器。把这些警告清零再上板,比事后查问题快得多。

6. 从能跑到好用:三个值得立刻做的工程化小技巧

第一个技巧是把所有参数集中到一个package或者define文件里。分频的时钟频率、闹钟响铃时长、消抖时间、显示扫描频率,这些参数散落在各模块顶部,改板子时要一个一个翻。我习惯建一个time_params.vh,里面define好CLK_FREQ、DEBOUNCE_MS这些常量,各模块include进来。换一块晶振不同的板子,只改一个文件,比全局搜索替换高效得多,也少些漏改的隐患。

第二个技巧是给testbench写自动校验逻辑,而不是手动看波形。仿真的秒脉冲要等很久,手动检查几百个时钟周期真的是玄学。我一般会把分频参数在testbench里调小,比如CLK_FREQ设成100,这样1秒变成了100个时钟周期,然后写一个简单的断言,检查秒计数器是否在正确时刻加一、小时是否在23:59:59后归零。断言失败就打印错误信息,跑一遍仿真能自动报告所有问题。这个习惯让我在上板前就拦住了至少一半的边界逻辑错误。

第三个技巧是给顶层模块加一个调试用的LED输出,把内部关键的秒脉冲、闹钟触发脉冲引出来。上板后看到LED闪动,就能立刻判断是分频没有起振还是计数没有跑起来,比扛着示波器去测芯片引脚方便得多。调试完这个LED可以留着,不占多少资源。这几个技巧做完,你的Verilog时钟从“仿真能过”到“上板稳定跑一天”的距离就没那么远了。

我最早做这个项目时,整整花了两天查一个“分钟进位正常但小时不进位”的怪问题,最后发现是小时模块的时钟接错了信号。那种已经是深夜、波形看了一百遍、代码检查了十遍都找不到问题的感觉,至今都记得。后来学乖了:每写完一个模块,先单独测试,再把模块间信号引到LED和仿真波形里分步确认,再也不会把时间耗在黑匣子调试上。希望这些经验能帮你少熬几个夜,一次就跑通。

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

RELATED READING

延伸阅读

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