用Verilog在FPGA上实现数字钟:分频计数、按键校时与LCD显示全解析

📅 发布时间:2026/10/14 12:41:46
用Verilog在FPGA上实现数字钟:分频计数、按键校时与LCD显示全解析
简介基于Verilog HDL设计的多功能数字钟完整工程包面向FPGA初学者、电子设计课程学生及硬件工程师覆盖自动计时、手动校时、倒计时、闹钟及整点报时五大核心功能并在Cyclone II平台上完成综合与LCD显示驱动适合直接作为课程设计、毕业设计或自主实验的完整参考。资源共69个文件压缩包仅2.12MB包含Verilog源码、Word版设计报告、Quartus工程与配置文件qpf/qsf、仿真波形vwf、时序报告rpt、烧录文件sof/pof以及cdb、hdb、qmsg等EDA中间文件目录结构清晰便于对照FPGA设计全流程并快速定位所需内容。已有8535人浏览学习。压缩包内既有分模块的源码和详细注释也有针对PLL时钟生成、计数器构建、LCD显示接口等关键环节的说明Word报告系统梳理了系统概述、模块划分、测试验证等要点可帮助读者完整复现并灵活扩展一个实用、可扩展的数字钟系统。1. 一块板子搞定Verilog数字钟自动计时、手动校时、倒计时、整点报时和LCD显示手里只有一块基础开发板想把Verilog数字钟从分频、计时、校时一直做到LCD显示这份工程源码是我见过比较完整的参考样本。它把50MHz时钟逐级分频成1Hz秒脉冲驱动BCD计数器完成自动计时再通过按键和状态机切入手动校时附加倒计时、整点报时最后把时分秒和倒计时剩余时间显示在LCD1602上。适合还没完整做过一个“能跑起来”的时序系统的人学过Verilog基础语法知道always块和计数器但没有把模块串起来上板的经验。也适合课程设计前想快速确认结构是否合理的人。你不需要再闭门造车照着模块图把代码敲下来逐步替换参数就能在一两周内拿下整个系统。2. 自动计时核心分频器与BCD计数器怎么配合才不丢秒2.1 分频器把50MHz降到1Hz的边界参数与单脉冲输出数字钟的起点不是计时器而是一个可靠的分频器。开发板通常提供一个50MHz有源晶振这个频率太快要先降频。常见做法是设置一个25_000_000的上限计数器计数满后翻转输出电平每翻转一次就是半个周期两次翻转才能凑出1Hz。也就是说要得到1Hz的方波计数器不是计到25_000_000就输出一个脉冲而是计满后翻转电平让分频输出变成占空比50%的时钟。module clk_div_1hz( input wire clk_50m, // 板载50MHz晶振 input wire rst_n, // 低电平复位 output reg clk_1hz // 分频得到的1Hz方波 ); // 50MHz / 1Hz 50_000_000翻转一次需要25_000_000个周期 parameter CNT_MAX 25_000_000 - 1; reg [24:0] cnt; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt 25d0; clk_1hz 1b0; end else if (cnt CNT_MAX) begin cnt 25d0; clk_1hz ~clk_1hz; // 计数到顶就翻转形成50%占空比方波 end else begin cnt cnt 1b1; end end endmodule逻辑说明复位后计数器和输出都清零。每个时钟上升沿先判断复位再判断计数是否到达限定值等于CNT_MAX就清零并翻转输出电平否则连续加一。这样输出端得到一个2秒一个完整周期的方波也就是1Hz。如果晶振不是50MHz把CNT_MAX改成“晶振频率 / 2 - 1”即可。参数说明cnt是25位宽因为25_000_000的二进制表示需要25位。这里很容易写错24位寄存器最大值只有16_777_215存不下25_000_000个周期仿真时看不出来上板后秒针会以大约3倍速狂走这是最典型的翻车点。第二种常见写法是输出单周期脉冲而不是方波适用于后面的计数器使能计数达到CNT_MAX时把输出en拉高一个时钟周期后清零。代码上只需要把翻转那句换成en (cnt CNT_MAX) ? 1b1 : 1b0;。我一般会同时保留两个信号一个clk_1hz给蜂鸣器等需要连续方波的模块一个tick_1s给计时器做使能。这样避免了多级时钟驱动同一逻辑带来的时钟偏移。2.2 计数器链秒、分、时的进位设计与进位标志的时序陷阱有了1Hz的tick下一步就是BCD码计数器。选BCD而不是二进制的原因是显示模块和人机交互需要十进制如果内部用二进制加法器再转换逻辑上没错但转换模块本身容易引入组合逻辑竞争调试成本更高。既然是数字钟直接用“每位一个十进制数”的计数结构每个计数器输出就是一位BCD码。module bcd_counter( input wire clk, // 统一使用50MHz系统时钟不用分频后的时钟 input wire rst_n, input wire enable, // 每秒来一次的高电平使能单脉冲 input wire load_en, // 校时写入 input wire [3:0] load_data, output reg [3:0] q, // BCD码输出0~9 output reg carry // 进位标志供下一级使用 ); always (posedge clk or negedge rst_n) begin if (!rst_n) q 4d0; else if (load_en) q load_data; else if (enable) begin if (q 4d9) q 4d0; else q q 1b1; end end always (*) begin if (enable q 4d9) carry 1b1; else carry 1b0; end endmodule关键就在这个carry信号的产生。很多人在这一步把上一级的q 9直接接到下一级的enable或者clk这种做法在仿真里完全正常一上板就出现偶尔跳秒、分钟和秒不同步的现象。原因是门控时钟和组合逻辑使能会造成建立时间违例尤其是两级以上的级联毛刺会被下级捕获。我一般会做一个tick级联上一个计数器的carry不是直接驱动下一个计数器的clk而是连到下一个计数器的enable所有计数器共用同一个50MHz时钟。第9秒时carry拉高一个时钟周期下一秒的enable有效分钟个位加一秒个位回到0。这样就形成了“逐级使能”而非“逐级时钟”的结构。如果顶层直接拿这个组合逻辑carry去驱动下一级可能引入毛刺。最常见的处理是让下一级寄存器接收后再当使能或者顶层打一拍两者选一即可我习惯后者因为顶层可见性更强出问题好查。顶层级联的例化模式大致如下bcd_counter u_sec_l (.clk(clk_50m), .enable(tick_1s), .q(sec_l), .carry(sec_l_carry), ...); bcd_counter u_sec_h (.clk(clk_50m), .enable(sec_l_carry), .q(sec_h), .carry(sec_h_carry), ...); bcd_counter u_min_l (.clk(clk_50m), .enable(sec_h_carry), .q(min_l), .carry(min_l_carry), ...);说明使能不是持续一个秒周期而是单个时钟周期宽度所以每一级进位判断必须用enable和q9的与逻辑。时序上上一级的q在时钟沿变成9后下一拍enable才拉高再下一拍十位才加一看似延迟了两个周期但对1Hz的人眼观察没有任何影响时序收敛反而简单。时计数器跟秒、分不一样它的循环边界是23而不是逐位判断。秒和分是“个位9到0、十位5到0”的结构小时则是个位和十位联合判断十位为2且个位为3时下一个tick整体回零。如果直接复制bcd_counter到小时位时间走到12:59后再加1会变成13:00但23:59再走就变20:00半夜十二点直接翻车。我习惯给bcd_counter加一个max_value参数当q max时归零且拉高进位小时模块直接传参23秒和分模块传参59统一处理。3. 手动校时与倒计时状态机切换、按键消抖和借位处理3.1 校时状态机四态循环与按键单脉冲的配合校时的本质是把“时钟正在运行”这个默认状态打断让用户能单独修改秒、分、时。常见做法是定义四个状态正常计时、校秒、校分、校时。每按一次设置键状态向下一个状态跳转从校时按回正常计时形成一个环。localparam IDLE 2b00; localparam SET_SEC 2b01; localparam SET_MIN 2b10; localparam SET_HOUR 2b11; reg [1:0] state; reg [1:0] next_state; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin case (state) IDLE: next_state key_set_pulse ? SET_SEC : IDLE; SET_SEC: next_state key_set_pulse ? SET_MIN : SET_SEC; SET_MIN: next_state key_set_pulse ? SET_HOUR : SET_MIN; SET_HOUR: next_state key_set_pulse ? IDLE : SET_HOUR; default: next_state IDLE; endcase end逻辑说明状态切换依靠key_set_pulse这个单脉冲信号而不是按键电平。如果用按键电平直接驱动组合逻辑按住不松手状态机会一路从校时跳到正常计时这是最常见的校时功能错乱原因。参数说明key_set_pulse是按键消抖后产出的一个时钟周期宽度的高电平脉冲产生方式在3.2节细说。状态机的四态顺序也可以改成“秒-分-时-正常”作为默认本质一样但要注意状态跳变必须在消抖脉冲边沿同步不能边沿和电平混在一起。校时状态下每次按加键时分或秒加一加键同样是消抖后的脉冲。调秒的时候最好顺手把秒计数器清零这样对准整秒比较方便——现实中很少有人能用加键把秒数精确对上通常的做法是一边看着LCD一边在秒接近0时按下设置键然后直接跳过分调整。这个细节在不少参考代码里被省略。3.2 按键消抖20ms窗口为什么不能少以及边沿处理的细节按键按下时机械触点会在几毫秒到十几毫秒内反复通断直接用上升沿触发会导致一次按下被当成多次操作。消抖的常规参数是20ms检测到按键电平变化后启动一个20ms的计时器计时结束后再采样一次电平若仍保持新状态就认为按键稳定。reg [19:0] debounce_cnt; reg key_stable_prev; reg key_set_pulse; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin debounce_cnt 20d0; key_set_pulse 1b0; end else if (key_set_raw ! key_stable_prev) begin debounce_cnt 20d0; // 电平变化重新开始计数 end else if (debounce_cnt 20d999_999) begin key_stable_prev key_set_raw; key_set_pulse 1b1; // 稳定后产生单脉冲 end else begin debounce_cnt debounce_cnt 1b1; key_set_pulse 1b0; end end逻辑说明CNT 999_999对应50MHz时钟下约20ms。debounce_cnt位宽需要20位因为1_000_000的二进制表示需要20位。只要输入电平和上一个稳定值不同计数器立即清零重新计时电平保持不变的时间超过20ms后才更新稳定值并产生一个单周期脉冲。这样即使抖动在10ms内结束也判定为无效翻转。踩坑提醒有些代码在消抖后直接把稳定电平送给状态机状态机会在按键按住期间反复进入同一状态导致一次按键响应了多秒。务必把“稳定电平”和“稳定后的边沿脉冲”区分开状态机和加数逻辑只认脉冲。3.3 倒计时模块预置初值、暂停与BCD借位链的实现倒计时模块要求用户可以设定一个初值比如05:00然后每秒减一到00:00后停止并给出提示。比加计数麻烦的是借位个位从0减到9同时十位减1十位从0减到9同时百位减1。如果直接用一个二进制计数器整体减一再在显示端转换成BCD倒计时不超过65535秒时没问题一旦超过就需要更大位宽。但在BCD结构内部逐位借位能减少显示转换逻辑。module bcd_downcounter( input wire clk, input wire rst_n, input wire tick, // 1Hz使能 input wire load_en, // 加载预置初值 input wire [3:0] ones_bcd, // 个位预置值 input wire [3:0] tens_bcd, // 十位预置值 output reg [3:0] ones_q, output reg [3:0] tens_q, output reg timeout // 到零停止标志 ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin ones_q 4d0; tens_q 4d0; timeout 1b0; end else if (load_en) begin ones_q ones_bcd; tens_q tens_bcd; timeout 1b0; end else if (tick) begin if (ones_q 4d0) begin ones_q 4d9; if (tens_q 4d0) begin timeout 1b1; // 停止不再减 tens_q 4d0; end else begin tens_q tens_q - 1b1; end end else begin ones_q ones_q - 1b1; end end end endmodule逻辑说明每个tick到来先看个位。个位不是0就正常减一个位是0则回9并向十位借位若十位此时也是0说明整个倒计时已经到零timeout拉高并把十位保持0。这里用“个位回9”而不是“回10”是因为十进制下借位后个位应从9开始。参数说明这个模块只处理了两位分秒倒计时需要把十位借位继续级联。真正的实现里倒计时一般只做“分:秒”或者“时:分:秒”我习惯把整体拆成两个或三个bcd_downcounter低位模块的timeout作为高位模块的tick输入。注意timeout和tick都是单时钟周期脉冲级联时不需要再消抖。暂停功能可以直接在顶层把tick屏蔽pause1时把送入模块的tick强制置0这样比在计数器内部加pause分支更干净。timeout同时可以接到蜂鸣器实现倒计时结束后的提示音。4. LCD显示与整点报时字符拼接、驱动时序和脉冲宽度4.1 LCD1602初始化上电延迟、功能设置和忙标志的处理LCD1602是字符型液晶数据手册要求上电后至少等15ms再依次发送0x38、0x0C、0x01、0x06这些命令。实际工程里我见过很多代码只写了命令序列没有第一条延时结果下载后LCD只有背光没有字符按复位键又偶尔能亮。原理是LCD控制器内部的复位逻辑需要时间太快写命令会被丢弃。15ms这个下限不能省稳妥一点我会直接延时40ms。parameter INIT_DLY 40_000_000; // 40ms 50MHz parameter CMD_INTERVAL 5_000_000; // 命令间隔 5ms 50MHz初始化做成状态机上电后先进入延时状态计数器计满40ms再发第一条命令每条命令之间再插入5ms间隔。如果LCD的忙标志BF可用数据手册推荐每次写之前读BF但很多精简代码直接用固定延时因为读BF还要切换总线方向逻辑更复杂。我的看法固定延时在100ms的量级上足够可靠实现也简单。时序上还有一点要注意E使能引脚是高脉冲有效写数据时先把RS、RW和数据总线放好E拉高等至少几百纳秒再拉低E。综合工具通常会把这种几百纳秒的组合延迟当作正常路径不需要专门约束。真正需要卡时序的是读忙碌标志这里用不到。指令表如下自己写驱动时对着检查命令功能说明0x388位接口、双行、5x7点阵功能设置0x0C显示开、光标关、不闪烁显示控制0x01清屏光标回原点需要较长时间约1.6ms0x06写入后地址自动加一输入模式4.2 显示内容拼接BCD转ASCII与固定字符的组合LCD1602接收的是ASCII码而计时模块输出的是4位BCD。两者相差0x30也就是字符0的ASCII码是0x30。转换公式是 ascii bcd 0x30写成Verilog就是拼接wire [7:0] ascii_hour_l {4b0011, hour_l}; // 小时的个位 wire [7:0] ascii_hour_h {4b0011, hour_h}; // 小时的十位 wire [7:0] ascii_colon 8h3A; // 冒号 :逻辑说明BCD的高四位是0加0x30后高四位正好是0011所以用拼接就能完成。再把冒号和空格这些固定字符用parameter定义组成一串8位字符。LCD的DDRAM地址第一行从0x00开始第二行从0x40开始写入字符后地址自动加一所以只要按顺序把字符从0x00写进去就能显示“HH:MM:SS”。显示切换也是一个容易被忽略的点校时模式下大多数人希望被调整的字段闪烁这个功能不是LCD命令直接支持的需要程序每隔0.5秒向某个字符位置写空格或写回数字。if (flash_enable (flash_cnt 0)) lcd_data 8h20; // 空格让位熄灭 else lcd_data {4b0011, cur_bcd};这样字段就会以0.5秒周期闪烁。注意别把写LCD的代码和计时逻辑放在同一个always块里LCD写时序是几十微秒级别的操作计时是1秒级别的操作混在一起会让计数器产生毛刺。我一般把LCD写操作单独拉出来由一个写状态机统一调度。4.3 整点报时识别整点时刻与蜂鸣器单次脉冲宽度控制整点报时逻辑本身很简单当时钟走到HH:00:00时产生一个报时触发。但触发之后蜂鸣器响多久、响几次才是这个模块的难点。如果直接把秒脉冲接到蜂鸣器使能上整点时会连续响1秒听起来像错误报警如果把报时的触发信号直接连到蜂鸣器脉冲宽度只有1个时钟周期蜂鸣器几乎听不见。这两种效果我都见过。合适的做法是做一个单稳态电路检测到整点时刻输出一个高电平持续一定时间后自动拉低。对普通有源蜂鸣器400ms到500ms的单声响比较舒服。用计数器实现reg beep_en; reg [24:0] beep_cnt; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) beep_en 1b0; else if (alarm_trigger) begin beep_en 1b1; beep_cnt 25d0; end else if (beep_en) begin if (beep_cnt 25d24_999_999) begin // 约500ms beep_en 1b0; beep_cnt 25d0; end else begin beep_cnt beep_cnt 1b1; end end end逻辑说明alarm_trigger是一次性的整点脉冲它把beep_en置位并启动500ms计数计数到达24_999_99950MHz下正好500ms后beep_en自动拉低。如果想让蜂鸣器响多声可以用一个小状态机在蜂鸣器ON和OFF之间交替切换。参数说明24_999_999对应50MHz换成其他晶振频率要同步修改。整点检测的条件是分钟和秒全为0alarm_trigger必须使用单周期脉冲否则在整点附近产生抖动时会重复触发响个不停。无源蜂鸣器的情况要单独说明无源蜂鸣器需要2~4kHz的方波驱动只给直流电平不会有声音。如果板载的是无源蜂鸣器上述beep_en要作为方波振荡器的使能振荡器在beep_en有效期间输出方波。查开发板原理图确认蜂鸣器类型再决定驱动方案。5. 数字钟项目常见问题排查五个上板必踩的坑及解决办法5.1 仿真能走、上板不走分频器参数与复位极性对不上现象仿真里波形正常1Hz方波输出准确下载到开发板后秒针不走或者疯狂跳动。原因多数是分频器CNT_MAX与板载晶振不匹配或者复位信号极性反了。比如开发板是高电平复位代码写的是低电平复位rst_n就没有生效。仿真激励里把复位信号定义成和代码一致所以仿真照常走上板后真实复位电路和代码对不上。还有一个隐蔽原因计数器位宽给小了进位溢出导致分频值不对。解决第一步用parameter定义晶振频率例化时统一传入第二步确认板载复位是高有效还是低有效先做一个LED跑马灯验证复位通不通第三步把分频计数器的位宽按“晶振频率/2 - 1”的二进制位数计算少用十进制常量直接赋值。检查顺序是先看分频输出再看复位电平最后检查位宽。这类问题十有八九是复位和位宽说穿了就不是玄学。5.2 按键一次跳好几秒消抖不足与状态机连跳现象按一次加键时间加了两三秒甚至更多按校时键状态直接从正常跳到校时又跳到校分。原因按键抖动在10ms内多次触发或者消抖电路虽然正常但状态机使用的是稳定电平而不是单脉冲。使用稳定电平的典型错误是高电平持续期间状态机每个时钟周期都判定按键按下所以一次按压走了多次。若消抖本身不足例如延时只有5ms机械抖动还没结束就采到稳定值也会出现同样现象。解决把消抖窗口拉长到20ms并确保输出的是单脉冲。单脉冲逻辑要写在独立always块里用“当前稳定电平 上一个时钟周期的稳定电平 0”产生即上升沿检测。状态机只允许在脉冲沿跳变不会重复触发。同时要注意在一个状态里按住加键不应该连续加数如果需要“连加”功能应设计成“按住1秒后每100ms自动加一”主动节流。5.3 LCD只亮背光不出字符初始化时序和忙标志处理不当现象上电后LCD背光亮屏幕全黑按复位键偶尔能显示但显示内容不稳定或者显示了一串乱码。原因LCD上电后内部复位的延时没给够第一条0x38命令被丢弃后续命令也按同样节奏执行整个初始化序列失败。另一个常见原因是E使能信号没有产生足够宽的高脉冲命令虽然送上了数据线但E的建立保持时间不满足。解决初始化第一步插入40ms延时留出余量每两条命令之间再加5ms延时E信号要保证高电平持续至少1us低电平至少2us。用固定延时驱动LCD没有问题但延时计数器的位宽一定要算够我见过有人写1ms实际只有10us原因就是计数器加不到目标值。另外清屏命令0x01要求1.6ms以上的执行时间如果下一笔操作立即刷新也会出现字符错位。5.4 报时响不停或听不见脉冲宽度设计与蜂鸣器驱动方式现象整点报时要么响一长声不停要么只听到“啪”的极短一声要么声音很小。原因报时使能的宽度不对。用秒脉冲直接驱动蜂鸣器整点那一秒全程响属于宽度过大用触发信号的原始脉冲驱动宽度只有一个时钟周期蜂鸣器反应不过来如果用的是无源蜂鸣器而不是有源蜂鸣器还需要一个方波驱动信号不能用简单高低电平驱动。解决查开发板原理图确认是有源还是无源蜂鸣器。有源蜂鸣器用单稳态500ms电平驱动无源蜂鸣器需要产生一个2~4kHz方波并在报时期间持续输出方波。无源蜂鸣器只给直流电平是不会有声音的。宽度参数根据晶振频率计算后单独写成parameter方便调整。判断标准是响一声但耳朵能感觉到“一声”停下来后不再响。5.5 倒计时到零后乱码缺停止标志BCD借位断了现象倒计时走到00:00后继续走LCD上出现9、9等乱七八糟的字符或者从00:01跳到00:00后卡住但报时一直触发。原因计数器在00:00时还在接收tickBCD借位链断了——低位从0借位时高位已经变成0没有能继续借的数字逻辑产生悬空值。凡是双减计数器的设计边界条件必须单列。解决在计数器内部加timeout标志个位、十位同时为0时屏蔽tick且输出保持0。顶层例化时用“tick !timeout”作为模块使能外围再加一个stop状态。借位链的处理要按位检查个位0向十位借位时若十位是0则整体停止并明确输出归零标志而不是让借位信号一路向上传。把timeout同时接入蜂鸣器可以实现倒计时结束后的提示声音一举两得。6. 我的验证习惯先点亮LED再看片上逻辑分析仪抓波形6.1 三个先做的验证步骤新板子第一次下载我不会直接看LCD。先把分频器输出接到板载LED如果LED以1秒周期翻转说明时钟和复位都没问题。再按一下复位键观察LED是否会重新停在高电平或低电平。第二步把秒计数器的个位最低位接LED正常应该0.5秒闪一次这能确认进位链是否工作。第三步才接LCD如果显示乱至少能判断是显示驱动的问题而不是计时的问题。这是我从翻车经历里总结出来的调试顺序——直接在完整系统里调坏了都不知道该查哪。6.2 片上逻辑分析核的使用技巧遇到仿真正常、上板不正常的偶发问题最有效的工具是开发工具链自带的片上逻辑分析核。把需要观察的信号加进调试核触发条件设置成复位信号的下降沿就能抓到复位瞬间和后续几十个时钟周期的真实波形。数字钟的信号变化很慢片内存储深度不需要太大但采样时钟用50MHz采样1Hz信号边缘会有偏差我一般额外引出一个探针信号把分频器计数器的最高位引出这样能清晰看出计数有没有循环错位。把探针加进综合工程重新综合后再用调试工具观察整个过程十分钟以内。从那以后每次做完一个数字电路模块我都强制自己先跑“LED冒烟测试”再进片上逻辑分析核看关键内部信号养成这个习惯以后调试时间至少省了一半。希望帮到你。本文还有配套的精品资源点击获取