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

发布时间:2026/10/15 11:24:52
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 1b0) begin if (second_pulse) begin if (sec_l 4d9) begin sec_l 0; if (sec_h 4d5) begin sec_h 0; if (min_l 4d9) begin min_l 0; if (min_h 4d5) begin min_h 0; if (hour_l 4d9) begin hour_l 0; // 小时十位到2且个位到3即23点归零 if (hour_h 4d2 hour_l 4d3) begin hour_h 0; end else begin hour_h hour_h 1; end end else if (hour_h 4d2 hour_l 4d3) 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 4d9) begin min_l 0; if (min_h 4d5) begin min_h 0; if (hour_l 4d9) begin hour_l 0; if (hour_h 4d2 hour_l 4d3) begin hour_h 0; hour_l 0; end else begin hour_h hour_h 1; end end else if (hour_h 4d2 hour_l 4d3) 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) 2d0: begin sel 4b1110; seg seg_code(bcd[3:0]); end 2d1: begin sel 4b1101; seg seg_code(bcd[7:4]); end 2d2: begin sel 4b1011; seg seg_code(bcd[11:8]); end 2d3: begin sel 4b0111; seg seg_code(bcd[15:12]); end endcase end // 共阴极数码管段码表a段对应seg[0] function [7:0] seg_code; input [3:0] num; case (num) 4d0: seg_code 8b0011_1111; 4d1: seg_code 8b0000_0110; 4d2: seg_code 8b0101_1011; 4d3: seg_code 8b0100_1111; 4d4: seg_code 8b0110_0110; 4d5: seg_code 8b0110_1101; 4d6: seg_code 8b0111_1101; 4d7: seg_code 8b0000_0111; 4d8: seg_code 8b0111_1111; 4d9: seg_code 8b0110_1111; default: seg_code 8b0000_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 1b0) begin key_pulse 1b1; end else begin key_pulse 1b0; end end endmodule消抖的思路是检测到电平变化后开始计数如果计满20ms电平没有再次变化说明按键已经稳定此时输出一个脉冲。这个方案的抗干扰能力比简单的延时取反要强因为它能过滤掉中间的电平跳变。参数DEBOUNCE_MS设成20常见的机械按键抖动时间不会超过这个值设太大会让按键响应变肉设太小的消抖效果不彻底。需要注意按键的触发沿是按下时还是释放时。上面代码在按下沿输出脉冲也就是按键刚被按下、电平从高跳到低并且稳定后触发。实际调时操作里我一般建议在释放沿触发因为用户的手可能有轻微颤抖释放沿触发能减少误触。实现方式很简单把最后判断改成key_prev 1b1表示电平从低回到高且稳定后输出脉冲。4.3 贪睡功能和闹钟输出让闹铃不要只会一直响“带闹铃等功能”里的“等功能”三个字能在很多方向做扩展。常见的是给闹钟加贪睡模式闹铃响了之后如果检测到贪睡按键蜂鸣器停5分钟再响。这个逻辑拆开看其实就是闹钟比较模块输出一个暂停信号把比较结果屏蔽一段时间。用计数器实现比较简单收到贪睡键后启动一个5分钟倒计时倒计时期间把alarm_pulse拉低结束再恢复。另一个常见扩展是让闹铃输出到蜂鸣器之前加一个分频产生某种节奏比如响一秒停一秒而不是一直发出刺耳的连续音。这些扩展都不需要改动已有模块的接口只要在顶层加几个小模块就行这也体现出模块化架构的好处。5. 上板调试避坑仿真和实测之间隔了这5个常见问题5.1 仿真里秒脉冲永不翻转的位宽问题仿真波形里second_pulse一直拉不高或者计数器卡在某个值不动首先怀疑分频模块的计数器位宽。手动算位宽容易出低级错误比如50MHz分频CNT_MAX是49_999_999这个数大于2^253355万小于2^266711万需要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和仿真波形里分步确认再也不会把时间耗在黑匣子调试上。希望这些经验能帮你少熬几个夜一次就跑通。本文还有配套的精品资源点击获取