
简介面向FPGA初学者与硬件设计入门者的Verilog数字钟完整工程资源基于Quartus II 13.0开发环境设计涵盖计时、跑表、闹钟、时间调整四大核心功能并以模块化方式呈现分频、按键消抖、显示驱动、时间递增与闹钟比较等设计思路适合课程设计、毕业设计或自学参考。资源包共145个文件压缩后仅7.82MB主要包含Verilog源文件、工程配置文件.qpf/.qsf、仿真波形文件.vwf、编译报告.rpt、FPGA下载文件.sof以及详细注释文本等目录结构清晰便于直接打开、编译与学习。目前已有416人学习使用。代码注释非常详细从外部晶振分频得到秒脉冲到计时模块的时间递增、显示驱动编码、按键输入与功能切换、闹钟触发等均有明确说明可帮助读者系统理解FPGA时序逻辑设计的完整流程并在此基础上继续扩展倒计时、整点报时、日期显示等更高阶功能特别适合课程设计或自学者参考。 做FPGA入门我自己带新人时最常推荐的一个完整小项目就是数字钟。原因很简单它把Verilog开发里最核心的那几件事全都串了起来计数器的分频、进位的边界处理、按键消抖、数码管动态扫描再加上一个完整的仿真验证流程一套走下来基本就摸清了FPGA开发从RTL设计到板级调试的整个套路。这篇文章就把整个数字钟项目从思路拆解、代码实现到仿真踩坑一次性讲透适合刚学完Verilog基础语法、想真正在开发板上跑通一个工程的同学参考。1. 先理清整体思路数字钟到底在做哪几件事1.1 系统拆解时基、计时、显示三路并行数字钟表面上看就是一个“计时显示”的小东西但落到FPGA里其实是三路并行在跑一是时基生成把板上晶振的频率分频出1Hz的秒脉冲以及数码管动态扫描需要的1kHz刷新信号二是计时进位秒、分、时三个计数器按60、60、24的进制依次进位三是显示扫描把当前计数值通过七段数码管显示出来。这三部分听起来是串联的——秒脉冲驱动秒计数秒计数进位到分分再进位到时最后把时分秒的值送显示。但在代码组织上它们是三个相对独立的模块通过“使能信号”和“数据总线”连接。这种模块化拆解是数字系统设计的基本功以后做串口、图像处理、甚至完整的SoC都是同样的思路先拆功能再定接口最后逐个实现。还有一点值得提前说如果后面想加闹钟、秒表、整点报时基本不用改动核心计时链路只需要在外面加判断逻辑和输出设备就行。这就是拆模块带来的好处。1.2 方案选型为什么用计数器而不是PLL有同学会问FPGA开发板上明明有PLL/MMCM这类时钟管理IP分频直接用IP不就行了吗为什么还要自己用Verilog写计数器我的答案很直接入门阶段计数器分频能把“数脉冲”这件事的本质看清楚而且数字钟这种应用对时钟质量要求很低1Hz和1kHz信号根本不需要PLL的精确相位和低抖动特性。PLL更适合高速场景比如DDR读写、高速串行收发器这些才需要真正的低抖动、多相时钟。用计数器生成慢速使能信号不仅逻辑透明还方便仿真时改参数加速验证是更适合学习阶段的做法。但这里有一个关键的工程决策不要把分频器输出的“慢时钟”直接当成时钟信号去驱动模块。正确做法是产生一个“脉冲使能”信号——它只在全局时钟比如50MHz的一个周期内拉高后面的计数器靠这个使能来判断“下一秒到了没”。这样整个设计始终处于单时钟域时序约束简单仿真也不会因为多时钟域出现各种莫名其妙的亚稳态问题。这是整个项目里我认为最重要的一点。2. 核心模块实现把“秒”这个基本单位弄准2.1 分频器分频系数计算与parameter化写法先算一笔账。假设开发板晶振是50MHz也就是一秒内有50000000个时钟周期。如果我要产生1Hz的脉冲使能分频系数就是50000000如果给数码管扫描用1kHz信号分频系数就是50000000 ÷ 1000 50000。计数器从0数到“分频系数减1”就完成一个周期。分频模块我习惯写成带参数的通用模块这样换板子、改频率的时候不用动逻辑module clk_div #( parameter CLK_FREQ 50_000_000, parameter OUT_FREQ 1 )( input wire clk, input wire rst_n, output reg pulse ); // 分频系数为 CLK_FREQ/OUT_FREQ计数器计到 CNT_MAX 时输出一个脉冲 localparam CNT_MAX CLK_FREQ / OUT_FREQ - 1; reg [$clog2(CNT_MAX 1) - 1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; pulse 1b0; end else if (cnt CNT_MAX) begin cnt d0; pulse 1b1; // 只持续一个时钟周期 end else begin cnt cnt 1b1; pulse 1b0; end end endmodule这个模块有两个细节值得注意。一是用$clog2自动计算计数器位宽避免手动估算出错。比如50MHz分频到1HzCNT_MAX是49999999位宽需要26位手写[25:0]没问题但换参数时容易忘记改用$clog2一劳永逸。二是输出的是单周期脉冲不是50%占空比的方波这是为了配合后面的计数器做“条件加一”而不是制造新的时钟域。2.2 六十进制与二十四进制计数器BCD方式最省心分钟和秒钟都是“0到59”的六十进制小时要按“0到23”的二十四进制处理。这里我推荐用BCD计数也就是把每一位十进制数分别用4位寄存器保存秒个位sec_low、秒十位sec_high、分个位min_low、分十位min_high、时个位hour_low、时十位hour_high。为什么不用纯二进制然后再做除法转BCD因为Verilog的除法在综合时会消耗大量逻辑资源而且可读性差。用BCD计数虽然代码多几行但显示模块可以直接拿各位的值去查数码管段码表逻辑非常直观。秒计数模块的核心写法如下reg [3:0] sec_low, sec_high; wire sec_low_end (sec_low 4d9); wire sec_high_end (sec_low_end sec_high 4d5); always (posedge clk or negedge rst_n) begin if (!rst_n) begin sec_low 4d0; end else if (en_1s) begin if (sec_low 4d9) sec_low 4d0; else sec_low sec_low 1b1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin sec_high 4d0; end else if (en_1s sec_low_end) begin if (sec_high 4d5) sec_high 4d0; else sec_high sec_high 1b1; end end这里的进位逻辑要注意sec_high加一的时机是“秒个位正好从9变0”的那个时钟沿也就是en_1s sec_low_end。这样个位清零和十位加一发生在同一拍不会出现多打一拍或者丢一拍的边界问题。分钟模块和秒模块结构完全一样只是把en_1s换成en_1min这个使能来自秒模块的sec_high_end sec_low_end en_1s。小时的模块稍微特殊一点因为24小时制的终点是23不是29。判断清零的条件要写成hour_high 4d2 hour_low 4d3。我曾经见过有同学照搬分钟的写法判断hour_low 9 hour_high 2才清零结果19点之后直接跳到00而不是20这就是边界条件没想清楚。3. 按键与显示数字钟的人机交互3.1 按键消抖机械抖动不消除一次按下当十次开发板上的按键基本都是机械开关按下和释放的瞬间会产生5到20毫秒不等的抖动。如果直接把按键信号接进计数器做调时一次按下可能触发十几次进位体验非常糟糕。消抖的思路是“延时确认”检测到按键电平变化后开始计数如果经过一段稳定时间电平保持不变才认为按键真的被按下。在50MHz时钟下10毫秒对应50万个周期比前面分频器的计数还要大所以这种消抖本质上又是一个计数器只是不是循环计数而是“检测到变化就计数一旦电平翻转就清零重来”。按键输入也需要做跨时钟域处理。按键信号相对FPGA全局时钟是异步的直接采会有亚稳态风险。我的习惯是先把按键打两拍同步再做消抖和边沿检测reg key_sync0, key_sync1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 1b1; key_sync1 1b1; end else begin key_sync0 key_in; key_sync1 key_sync0; end end打两拍之后再判断key_sync1的上升沿或下降沿。消抖计数器在电平不稳时不停清零复位只有电平稳定时间足够才输出一个单周期的按键有效脉冲。同时我建议按键释放也要消抖否则松开时的一个小毛刺可能再次触发一次“按下”表现为松开瞬间数字又跳了一下。3.2 数码管动态扫描人眼余晖下的技术活数字钟用6位数码管显示时分秒分别是小时两位、分钟两位、秒钟两位中间两个小点做分隔。动态扫描的原理是在任何一个瞬间只点亮其中一位数码管循环轮流点亮6位刷新频率足够高时人眼会因为视觉暂留而感觉所有位同时都在亮。扫描频率的选择很关键。每一位刷新时间一般取1毫秒左右6位一轮就是6毫秒大约166Hz的刷新率肉眼看起来非常稳定几乎没有闪烁感。如果每一位时间超过3毫秒拍照时就会看到明显的条纹闪烁如果太快又可能出现相邻位串扰显示亮度不均。具体实现上需要一个扫描计数器在0到5之间循环每个值对应一位数码管的位选信号。同时把时分秒的各位数据按扫描位置送给段选译码电路reg [2:0] scan_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt 3d0; else if (en_1k) scan_cnt scan_cnt 1b1; end reg [3:0] current_digit; always (*) begin case (scan_cnt) 3d0: current_digit sec_low; 3d1: current_digit sec_high; 3d2: current_digit min_low; 3d3: current_digit min_high; 3d4: current_digit hour_low; default: current_digit hour_high; endcase end然后current_digit再经过一个七段译码模块输出segments。这里有个坑必须单独说动态扫描时位选切换和段选数据切换不能在同一时刻完成。如果上一秒位选还在第3位段选数据已经是第4位的数字了那么第3位会在瞬间显示第4位的数字肉眼看到的就是重影或残影。解决方法是“先换位选再给段选”或者干脆在切换瞬间先让所有段选熄灭等位选稳定后再把数据送上去。我习惯实现三段式先关闭显示再更新位选和数据最后使能显示这样时序最安全。上板后如果看到重影优先检查这一步通常改完立刻就好了。3.3 稍加扩展从数字钟到多功能表这个项目做完之后扩展空间其实很大。比如在秒计数到59时检测一下输出一个脉冲拉高蜂鸣器就能实现整点报时。再比如加一个状态寄存器用按键切换“正常计时”和“调时模式”调时模式下按一次按键对应位加一就是带校时功能的数字钟。这些扩展不会改动核心分频和计数模块只在外围加逻辑对理解“模块复用”非常有帮助。4. 仿真和板级踩坑先看波形再上板4.1 testbench怎么写复位、时钟与激励很多人学Verilog时容易跳过一个关键技能自己写testbench。数字钟这种多模块联动的项目不仿真直接上板出问题很难定位——到底是分频没出来还是进位逻辑错了还是显示扫描时序有问题靠眼睛瞪代码效率太低在Modelsim或者Vivado自带的仿真器里看波形一眼就能分辨。一个基础testbench的框架如下timescale 1ns/1ps module tb_top; reg clk; reg rst_n; reg key_hour, key_min; wire [6:0] seg; wire [5:0] sel; // 20ns周期即50MHz always #10 clk ~clk; initial begin clk 0; rst_n 0; key_hour 1b1; key_min 1b1; #100; rst_n 1; // 给一段运行时间让秒脉冲产生几个周期 #5_000_000; $stop; end top_digital_clock u_top ( .clk (clk), .rst_n (rst_n), .key_hour (key_hour), .key_min (key_min), .seg (seg), .sel (sel) ); endmodulealways #10 clk ~clk是仿真里生成50MHz时钟最常用的写法#100之后释放复位给寄存器一段确定初值的时间。没有复位信号时所有reg的初值都是X波形上全是红色所以复位那段一定不能省。4.2 大分频参数使仿真太慢用参数覆盖加速这是数字钟仿真里最容易让人崩溃的一个问题。真实板子上分频系数是50000000仿真器要完整跑完50M个周期才能看到第一个秒脉冲足够把仿真器等到天荒地老。解决办法是把分频模块设计成可参数的然后在testbench里用小的参数覆盖真实参数比如把1Hz分频的时钟频率设为10让一个秒脉冲只等10个时钟周期clk_div #( .CLK_FREQ(10), .OUT_FREQ(1) ) u_clk_div_1s ( .clk (clk), .rst_n (rst_n), .pulse (en_1s) );在Vivado、Quartus和Modelsim里都支持这种参数化例化方式比用defparam更直观也更符合现代Verilog的书写习惯。仿真通过后恢复真实参数综合上板功能不受影响。这个技巧的意义在于它让“先仿真验证逻辑正确性再实测时序”成为可能否则很多人会干脆跳过仿真直接上板遇到问题又无从下手。4.3 常见问题排查速查我把这几年做数字钟项目遇到的典型问题整理成了一张表按“现象→原因→解决办法”的顺序排好遇到问题可以直接对照现象可能原因排查/解决办法仿真波形全是xreg没初始化initial块缺失加initial给所有reg赋初值检查复位信号秒脉冲始终不出现分频参数太大仿真时间不够testbench中用参数覆盖把分频周期改小秒正常分钟进位不对分钟使能端条件错误确认分钟加一条件包含秒的十位和个位结束标志小时跳到20后归零24小时制清零条件写成29清零条件改为hour_high2 hour_low3数码管重影、残影位选段选切换时序没控制切换瞬间先关闭显示稳定后再更新数据数码管闪烁扫描周期太长每一位刷新时间控制在1ms左右按键按一次跳多次消抖计数时间不够或没等释放消抖时间至少10ms并处理释放阶段的等待上板时间不准晶振频率与分频参数不匹配确认开发板实际晶振频率重新计算分频系数最后再分享一点我个人的体会。数字钟这个项目代码量不大但每一步都在考验对“时序”的理解分频的本质是计数计数的关键是进位边界边界的保证是在同一个时钟沿完成清零和加一。把这些想透了后面写串口、写状态机、写总线协议都会顺畅很多。建议拿到板子不要急着看厂商的Demo先按自己的思路写一遍代码仿真通过再上板遇到问题再回来看波形这套流程走通之后FPGA开发就算真正入门了。本文还有配套的精品资源点击获取