FPGA数字钟设计:从Verilog时序逻辑到LCD显示驱动完整实战
2026/9/9 4:23:40 网站建设 项目流程

简介:一套完整的基于Verilog的多功能数字钟设计项目,面向FPGA学习者与电子设计爱好者,基于Cyclone II FPGA实现了自动计时、手动校时、倒计时、闹钟提醒、整点报时与LCD显示等实用功能。项目围绕时钟生成、计数器累加、时间显示、控制逻辑与用户接口等核心模块展开,涵盖12/24小时制转换、按键控制等细节,完整呈现从RTL代码编写到仿真验证的FPGA开发流程。压缩包共计69个文件,主要包括Verilog源码(.v)、Quartus工程文件(.qsf/.qpf)、仿真波形(.vwf)、FPGA配置比特流(.sof/.pof)以及Word设计报告(.doc)等,整体大小约2.12MB,目录结构清晰便于按模块检索。已有8527人学习下载,参考价值高。源码逐行注释,配套报告从系统概述、Verilog代码分模块讲解到综合布局布线、硬件测试均有完整梳理,适合课程设计、电子竞赛或自学FPGA数字钟开发的读者直接复用。 每个学FPGA的人,大概率都绕不开数字钟这个项目。我当年调这块板子的时候也踩了不少坑,现在回头看,Verilog数字钟确实是一个把时序逻辑、模块化设计、外设驱动全串起来的好例子。这个项目做的事情很明确:用FPGA实现一个完整的数字钟系统,支持自动计时、手动校时、倒计时、整点报时,并且通过LCD屏幕实时显示当前时分秒。网上类似的代码很多,但大部分要么功能残缺,要么注释简陋,根本没法直接拿来学。这次分享的这个版本,我把完整的源代码、逐行注释和配套的Word报告都整理好了,无论你是刚开始学Verilog语言入门教程的学生,还是在实验室里做课程设计的本科生,这个项目都能帮你把“写代码”和“调板子”这两件事彻底打通。

我自己在写这套代码的时候,最深的感受是:数字钟看起来简单,但真正要把它做稳定、做完整,涉及到的细节一点不比一个中型项目少。时钟分频怎么设计、按键消抖怎么做、LCD1602的时序怎么匹配、报时逻辑怎么不产生毛刺,每一个环节都有讲究。下面我会从系统设计、核心模块实现、仿真调试到问题排查,把整个项目的完整思路和实操过程拆开来讲。

1. 系统设计方案与模块划分

1.1 功能需求解析

先明确这个项目到底要做什么。自动计时是基础功能,也就是秒、分、时按照60进制和24进制正常递增;手动校时是必备的交互功能,毕竟任何时钟都不可能永远准确,必须允许用户通过按键调整时间;倒计时则是从预设时间开始递减,到零后停止并给出提示;整点报时要求在每小时整点触发蜂鸣器或指示灯提示;LCD显示则是把当前时间实时展示在屏幕上。

这五个功能单独看都不算难,但组合在一起,就需要一个清晰的设计思路。我建议先不要急着写代码,而是先把整个系统的数据流和控制流画出来。我的做法是:顶层模块负责例化所有子模块,子模块之间通过使能信号和进位信号进行通信,数据在模块之间单向流动。这种“高内聚、低耦合”的设计方式,让你后续无论是单独仿真某个模块,还是排查某个功能失效的问题,都会轻松很多。

1.2 顶层模块架构

整个项目的顶层模块我命名为top_digital_clock,内部例化了六个子模块:

模块名功能输入信号输出信号
clk_div时钟分频:50MHz转1Hz和1kHzclk, rst_nclk_1hz, clk_1khz
counter_60秒/分计数器clk_1hz, rst_n, enq_bcd, carry
counter_24小时计数器clk_1hz, rst_n, enq_bcd, carry
debounce按键消抖模块clk_1khz, key_inkey_pulse
ctrl_unit模式控制与校时状态机clk_1hz, key_pulse, switchmode, adj_en
lcd_driverLCD1602显示驱动clk_1khz, data_inlcd_data, lcd_rs, lcd_rw, lcd_en

这里有一个核心的设计思想我得强调一下:系统里同时存在1Hz和1kHz两路时钟,而不是把所有逻辑都跑在1Hz上。1Hz时钟用于秒脉冲计时,1kHz时钟用于按键消抖和LCD驱动。原因很简单:按键的机械抖动通常在5-20ms之间,如果用1Hz时钟去做消抖,反应太慢而且会漏检;而LCD1602的写入时序也不需要50MHz那么快,跑1kHz这个级别已经完全够用。分频系数我全部用parameter定义,这样在仿真的时候可以改小数值快速验证功能,在下板之前再改回真实的分频比,这个习惯帮助我节省了大量的仿真时间。

1.3 为什么选择Verilog方案

用Verilog做数字钟,和用单片机做数字钟是两种完全不同的思路。单片机的优势是串行执行、灵活性高,C语言写起来也快;但Verilog的核心优势在于硬件并行。数字钟里的计时、按键扫描、LCD刷新、报时判断这四件事,在FPGA里是同时进行的,模块之间靠使能信号同步,不会出现CPU那种“一个时刻只能干一件事”的瓶颈。

另外,对于学习者来说,数字钟项目是用Verilog理解“时序逻辑”的最佳载体。你会遇到always块里的阻塞赋值与非阻塞赋值区别、同步复位与异步复位选择、边沿检测、计数器模值计算等一系列基础但核心的概念。这些概念在书本上看十遍,不如在数字钟里面踩几次坑理解得深。

2. 核心模块的实现细节与实操要点

2.1 时钟分频与计数器设计

分频模块是整个系统的心脏。开发板上的晶振通常是50MHz,也就是一个周期20ns,要得到1Hz的秒脉冲,需要的分频系数是50,000,000。这个数用二进制表示大约是26位(2的25次方是33554432,2的26次方是67108864),所以至少需要26位宽的计数器。

我建议分频模块里同时输出两路时钟:一路1Hz给计时器,一路1kHz给按键和LCD。1kHz时钟可以通过把50MHz除以50,000得到,也可以用1Hz时钟再倍频,但最简单可靠的做法还是两个计数器各自分频,互不干扰。代码结构如下:

module clk_div #( parameter DIV_1HZ = 50_000_000, parameter DIV_1KHZ = 50_000 )( input wire clk, input wire rst_n, output reg clk_1hz, output reg clk_1khz ); reg [25:0] cnt_1hz; reg [15:0] cnt_1khz; // 1Hz时钟分频 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1hz <= 26'd0; clk_1hz <= 1'b0; end else if (cnt_1hz == DIV_1HZ/2 - 1) begin cnt_1hz <= 26'd0; clk_1hz <= ~clk_1hz; // 输出方波,占空比50% end else begin cnt_1hz <= cnt_1hz + 1'b1; end end // 1kHz时钟分频 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1khz <= 16'd0; clk_1khz <= 1'b0; end else if (cnt_1khz == DIV_1KHZ/2 - 1) begin cnt_1khz <= 16'd0; clk_1khz <= ~clk_1khz; end else begin cnt_1khz <= cnt_1khz + 1'b1; end end endmodule

这里有个细节值得提醒:计数器的模值是DIV_1HZ/2 - 1而不是DIV_1HZ - 1,因为我们要输出的是占空比50%的方波信号,而不是单脉冲。如果只需要每秒钟产生一个脉冲(即clk_1hz持续1个周期高电平),那计数到DIV_1HZ - 1后产生一个en_pulse就可以。在实际的项目里,我更推荐用脉冲使能信号而非分频时钟来做计时器的驱动,这样可以避免多时钟域带来的亚稳态问题。不过为了贴合大多数教材的写法,我这里还是保留了分频时钟的方案。

秒计数器的核心是一个模60计数器。我强烈建议这里直接用BCD码计数,也就是用两个4位寄存器分别表示十位和个位,而不是用一个6位二进制数去计数。原因很简单:你的LCD显示模块需要的是十位和个位分离的BCD码,如果计数器用二进制,后面还得写一个二进制转BCD的模块,白白增加代码量和出错概率。

// 模60计数器(秒/分通用),BCD输出 module counter_60 ( input wire clk, input wire rst_n, input wire en, output reg [3:0] q_high, // 十位 output reg [3:0] q_low, // 个位 output reg carry ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin q_high <= 4'd0; q_low <= 4'd0; carry <= 1'b0; end else if (en) begin if (q_low == 4'd9) begin q_low <= 4'd0; if (q_high == 4'd5) begin q_high <= 4'd0; carry <= 1'b1; // 触发上一级进位 end else begin q_high <= q_high + 1'b1; end end else begin q_low <= q_low + 1'b1; carry <= 1'b0; end end end endmodule

秒的进位接分计数器的使能端,分的进位接小时计数器的使能端,小时计数器是模24,到23:59:59后清零。你会发现整个计时链路其实就是一个“使能链”,这种级联方式简洁清楚,每段逻辑都可以单独仿真验证。

2.2 按键消抖与校时逻辑

按键消抖是很多新手容易忽略的部分。机械按键在按下和释放的瞬间,电平会来回抖动几十次,持续大概5-20ms。如果直接用这个信号去做校时,最典型的现象就是“按一下,时间跳了好几分钟”。

消抖的思路有很多:硬件上可以并联RC滤波电容,最简单;软件上可以用延时采样。我的做法是用1kHz时钟对按键信号进行连续采样,只有检测到连续20个周期(即20ms)都为稳定的低电平,才认为按键真正按下。然后再做一个上升沿检测,把持续的电平信号转换成一个单周期的脉冲信号,这个脉冲用来驱动状态机跳转再合适不过。

// 按键消抖 + 上升沿脉冲生成 module debounce ( input wire clk_1khz, input wire rst_n, input wire key_in, output reg key_pulse ); reg [4:0] cnt; reg key_reg, key_prev; always @(posedge clk_1khz or negedge rst_n) begin if (!rst_n) begin cnt <= 5'd0; key_reg <= 1'b1; end else if (key_in == 1'b0) begin // 按键按下,开始计数 if (cnt == 5'd20) key_reg <= 1'b0; // 稳定低电平 else cnt <= cnt + 1'b1; end else begin cnt <= 5'd0; key_reg <= 1'b1; end end always @(posedge clk_1khz or negedge rst_n) begin if (!rst_n) begin key_prev <= 1'b1; key_pulse <= 1'b0; end else begin key_prev <= key_reg; key_pulse <= key_reg & ~key_prev; // 下降沿检测 end end endmodule

校时逻辑用了一个简单的状态机,包含四个状态:RUN(正常运行)、ADJ_HOUR(校小时)、ADJ_MIN(校分钟)、ADJ_SEC(校秒)。按键一用于切换状态,每按一次状态就往后跳一格,从RUN按到ADJ_SEC再按回RUN;按键二用于在被选中的单位上加一。这里还有一个很实用的乘法:如果长按按键二超过1秒,时间应该连续递增,这个功能可以根据项目的难度要求决定要不要加。

2.3 整点报时与倒计时实现

整点报时本质上是一个信号判断问题。最简单的逻辑:当分钟计数器为0且秒计数器为0时,输出一个高电平脉冲,驱动蜂鸣器响。但要注意,“秒为0”这个条件在1Hz时钟下会持续整整一秒钟,如果直接拉高蜂鸣器,声音会持续1秒,这其实也合理,传统钟表的整点报时也就响个几秒。

我实际做的时候用了另一个方案:把从59分59秒跳到00分00秒的那个瞬间作为报时触发点。这样只需要判断min_carry信号,也就是秒计数器产生进位信号的同一拍,让蜂鸣器输出一个持续约0.5秒的方波脉冲。关键代码如下:

// 整点报时触发条件 wire chime_trigger = sec_q_high == 4'd5 && sec_q_low == 4'd9 && min_q_high == 4'd5 && min_q_low == 4'd9;

这个条件和秒计数器加一到59秒那一个时刻对齐,然后在下一拍秒清零、分加一,报时信号刚好落在“整点跳变”的这一拍上,逻辑上非常干净。

倒计时功能稍微复杂一些,核心是一个预置数电路加递减计数器。倒计时模块需要支持用户设定初值(通过按键或拨码开关),设定完成后启动递减。为了方便LCD显示,倒计时模块同样用BCD码保存初始值和当前值。一个细节是,倒计时结束时需要产生一个完成信号,这个信号可以同样接到蜂鸣器上,用不同的鸣叫方式(比如连续响三次)和整点报时区分开。

2.4 LCD1602显示驱动

LCD1602应该是整个项目里对时序要求最高的模块了。它用的是HD44780控制器,操作模式有8位和4位两种。我推荐用4位模式,因为这样只需要占用6个FPGA引脚(4根数据线加RS、EN两根控制线),对于引脚资源紧张的学习板来说更友好。

驱动LCD的核心时序有三个阶段:初始化序列写命令写数据。初始化序列的时序要求很严格,HD44780规定了上电后必须在指定延时点发送38H(功能设置)、08H(显示关闭)、01H(清屏)、06H(进入模式设置)、0CH(显示开启)这些命令,每个命令之间还有延时要求。我建议直接用状态机来实现初始化过程,每个状态对应一条命令加一段延时。

// LCD状态机:初始化流程(4位模式) localparam S_INIT1 = 3'd0, S_INIT2 = 3'd1, S_INIT3 = 3'd2, S_FUNC_SET = 3'd3, S_DISP_ON = 3'd4, S_CLR = 3'd5, S_ENTRY_MODE = 3'd6, S_IDLE = 3'd7;

显示内容方面,第一行显示“HH:MM:SS”格式的当前时间,第二行可以显示倒计时的剩余时间或者模式状态提示。因为内部计数器已经是BCD码,所以显示时只需要把十位和个位分别映射到对应的ASCII码即可:ASCII = BCD + 8'h30。这个映射关系虽然基础,但不少初学者就是在这里卡住——LCD显示的是ASCII字符,不是二进制数本身,如果你直接把计时器的数字引脚接到LCD数据线上,屏幕上出现的一定是乱码。

注意:LCD1602的对比度调节引脚Vo通常需要接一个10k的电位器,不接的话屏幕可能全黑或者完全看不见字。这个和代码没关系,是硬件层面的问题,排查的时候别忽略。

3. ModelSim仿真与板上调试全流程

3.1 编写高效的Testbench

仿真才是Verilog开发的真正主场。很多同学写完代码直接在板上跑,发现不工作也不知道该从哪里查,原因就是跳过了仿真这一步。对于数字钟项目,我建议你在ModelSim里做的第一件事不是跑整个系统,而是单独仿真每一个子模块,确认单个模块的行为完全正确后,再集成仿真。

Testbench里需要做的事情包括:生成50MHz时钟(always #10 clk = ~clk;)、模拟按键按下动作(延时后置低电平,再延时后释放)、设置好初始复位时序(先复位约100ns,再释放)。

仿真最大的痛点是时间太长。如果按真实的1Hz分频去做仿真,你要看到秒进位至少要等60个时钟周期,也就是等仿真跑到60秒,这根本不现实。我的解决方法是把分频系数参数化:

module top_digital_clock #( parameter DIV_1HZ = 50_000_000 ) (...);

仿真TB里例化时直接传小数值,比如DIV_1HZ = 10,这样秒时钟10个时钟周期就翻转一次,60秒的进位几微秒就能模拟完。仿真通过后,再在顶层例化里改回50_000_000,这一招在所有的时序项目里都非常实用。

3.2 仿真波形观察与验证

在ModelSim里跑完全编译后,重点观察这几个信号:clk_1hz是否正常翻转、sec_q_low是否为1Hz递增的BCD码、min_carry是否每60个秒脉冲拉高一次、LCD的RS和EN信号上是否出现符合时序规范的跳变。

我踩过的一个典型坑是:秒计数在q_low = 9时加1变0,q_high加1变1,看起来没问题,但carry信号的高电平持续了整整一个秒时钟周期。如果直接用这个carry去驱动分计数器的使能端,分计数器会在整个秒时钟周期内都处于使能状态,虽然由于时钟沿只在每一拍到来时才采一次数据,实际影响不大,但严格来说这种做法不干净。更好的做法是让carry只维持一个时钟周期的高电平脉冲,这个在代码里写成carry = (q_low == 4'd9 && q_high == 4'd5) ? 1'b1 : 1'b0;即可,然后在下一拍组合逻辑会自动拉低,从而形成一个单周期脉冲。

3.3 引脚分配与上板验证

仿真通过后就是综合和布局布线。不同厂家FPGA开发板的引脚定义完全不同,Altera的Quartus和Xilinx的Vivado在引脚约束的写法上也不一样。我以Vivado为例,常规写法是:

set_property PACKAGE_PIN E3 [get_ports clk] set_property PACKAGE_PIN C4 [get_ports key1] set_property PACKAGE_PIN D4 [get_ports key2] set_property PACKAGE_PIN A4 [get_ports lcd_rs]

这里要特别提醒:时钟引脚最好接在开发板上标有MRCCSRCC的专用时钟引脚上,不要随意接到普通的IO引脚。因为FPGA内部的时钟树需要通过专用引脚进入才能保证所有时序逻辑共享同一时钟域,乱接的话虽然仿真没问题,上板后时序收敛基本过不了,严重的会直接导致逻辑乱跳。

另外,LCD数据线在Vivado里如果分配到了差分引脚对(比如PN成对出现的引脚),会触发编译警告甚至报错,选引脚的时候注意避开。

配套的Word报告里,我一般会建议包含这几块内容:项目需求描述、系统框图、模块设计与关键代码解释、仿真波形截图及分析、上板调试过程记录、遇到问题和解决方案。最重要的一点是,报告里贴的代码截图和仿真波形一定要和最终版的代码保持一致,校对好再提交。很多老师会照着报告里的代码去对比工程文件,发现对不上就直接扣分。

4. 常见问题与排查技巧实录

4.1 典型问题排查速查表

这个项目我前前后后调试了很久,遇到的绝大多数问题都可以汇总成下面这张表,排查顺序基本也是按照这个从硬件到软件、从外部到内部的原则:

现象可能原因排查思路与解决方案
LCD完全无显示对比度电位器未调节;初始化时序不对先调Vo电位器;用示波器检查EN引脚是否有脉冲
LCD显示乱码数据线接错;4位模式下高4位/低4位接反;忙检测未做对照原理图核对引脚;检查初始化发送的每条命令是否严格按延时执行
时间不走分频模块输出异常;复位没有释放仿真中观察clk_1hz是否有输出;检查rst_n连接
数字跳变异常,比如直接09跳到11秒进位信号脉宽过宽,分计数器一个时钟周期内被多次使能修改进位逻辑,让carry为单周期脉冲
校时按键按一下跳好几格消抖阈值太小;按键上升沿检测写成了电平检测把消抖计数阈值加到20ms;检查key_pulse是否只有一个周期
整点不报时报时触发条件写错;蜂鸣器驱动能力不足仿真时搜索chime_trigger是否有脉冲;蜂鸣器引脚需加三极管放大电流
倒计时完成但无提示完成信号没有连接到蜂鸣器;完成信号被后续逻辑覆盖检查倒计时模块的done是否在数值为0时拉高并保持足够久

4.2 失之毫厘的隐藏陷阱

除了上表里的常见问题,还有几个经验性的小坑,值得单独拿出来说。

第一个是关于异步复位和同步释放。很多入门代码习惯直接用always @(posedge clk or negedge rst_n)的异步复位写法,这在板级上其实有隐患。如果你的按键复位在上电瞬间恰好和时钟沿非常接近,可能会导致寄存器进入亚稳态。工程上更稳妥的写法是加一个“异步复位同步释放”电路,简单说就是先用两级寄存器同步复位信号,再把同步后的复位信号给到所有模块。虽然数字钟项目对亚稳态的要求没那么苛刻,但养成这个习惯对后续做通信接口会有很大帮助。

第二个坑在蜂鸣器的驱动方式。FPGA的IO输出电流很小,通常只有几毫安,直接驱动有源蜂鸣器会发现声音很小甚至不响。正确做法是通过一个NPN三极管或者达林顿管来放大电流,并且在蜂鸣器两端并联一个续流二极管,防止关断瞬间产生的反电动势损坏IO口。我最初就是在这一块卡了半天,一直以为是代码逻辑错了,最后换了三极管驱动就好了。

第三个坑和LCD的初始化有关。不少LCD1602模块在4位模式下,初始化时必须先发三次0x03,然后再发0x02切到4位模式,这个怪异的要求是HD44780的数据手册里规定的。如果你省略了其中任何一次,LCD可能会处于一种无法正常显示的未知状态。我在编写状态机时,严格按照数据手册的时序图把初始化命令的延时做足,宁可多等不能少等,这是LCD驱动稳定工作的关键。

最后一个要提醒的是代码里的非阻塞赋值使用习惯。always时序块里一定要用<=,组合逻辑块里用=。数字钟项目里最容易出的隐蔽Bug就是:有人把组合逻辑里的阻塞赋值写进了时序逻辑块里,仿真时看似正常,但综合后的电路行为完全不一样,比如计数器同时出现了“加1”和“清零”两个操作竞争,导致数值颠倒。这个习惯如果从一开始就严格要求自己,后面写UART、SPI这类接口时能省掉大量排查时间。

4.3 从仿真到上板的调试心法

仿真通过不代表板子就能跑,这是每一个做FPGA的人都深刻体会过的事。我总结了三句话:仿真验证的是逻辑功能,上板验证的是电气特征。上板前一定检查引脚约束、时钟连接、复位电平极性;上板后遇到问题,先用串口或LED把内部信号引出来观察,不要瞎猜。数字钟这个项目有一个天然的优势——LCD显示本身就是你最好的调试工具。你可以临时把某个内部信号(比如分频器的中间计数状态)接到LCD上显示出来,看它是否按预期变化,这样排查效率比单纯用逻辑分析仪高很多。

写在最后

折腾完这个项目,我最大的体会是:数字钟不是“写一个计数器那么简单”,它是一个把数字电路基础、Verilog语法、时序设计、外设协议和调试技巧全部串起来的综合训练场。我最初写这个项目的代码时,自己也犯了不少低级错误,但正是一步步把这些坑填平,我对“硬件描述语言”这五个字理解才真正上了一个台阶。

如果你现在正在做课程设计或者准备FPGA相关的竞赛,不妨把这个数字钟项目当成一个起点。先把功能模块一个个跑通,再把按键消抖、LCD时序这些细节打磨好,最后再往里面扩展闹钟、温度显示甚至秒表功能。代码先求正确,再求优雅,注释尽量写清楚,这样无论是交Word报告还是日后自己回看,都会省很多力气。这套带详细注释的完整工程,我建议大家拿到手之后不要只是编译下载看个亮灯就结束,而是对照着本文的设计思路,把每一个模块的代码重写一遍。写一遍,比读十遍的收获都要大。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询