FPGA数字钟设计:从Verilog时序逻辑到LCD显示驱动完整实战

发布时间:2026/9/9 4:23:42
FPGA数字钟设计:从Verilog时序逻辑到LCD显示驱动完整实战 简介一套完整的基于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_1khzcounter_60秒/分计数器clk_1hz, rst_n, enq_bcd, carrycounter_24小时计数器clk_1hz, rst_n, enq_bcd, carrydebounce按键消抖模块clk_1khz, key_inkey_pulsectrl_unit模式控制与校时状态机clk_1hz, key_pulse, switchmode, adj_enlcd_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次方是335544322的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 26d0; clk_1hz 1b0; end else if (cnt_1hz DIV_1HZ/2 - 1) begin cnt_1hz 26d0; clk_1hz ~clk_1hz; // 输出方波占空比50% end else begin cnt_1hz cnt_1hz 1b1; end end // 1kHz时钟分频 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1khz 16d0; clk_1khz 1b0; end else if (cnt_1khz DIV_1KHZ/2 - 1) begin cnt_1khz 16d0; clk_1khz ~clk_1khz; end else begin cnt_1khz cnt_1khz 1b1; 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 4d0; q_low 4d0; carry 1b0; end else if (en) begin if (q_low 4d9) begin q_low 4d0; if (q_high 4d5) begin q_high 4d0; carry 1b1; // 触发上一级进位 end else begin q_high q_high 1b1; end end else begin q_low q_low 1b1; carry 1b0; 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 5d0; key_reg 1b1; end else if (key_in 1b0) begin // 按键按下开始计数 if (cnt 5d20) key_reg 1b0; // 稳定低电平 else cnt cnt 1b1; end else begin cnt 5d0; key_reg 1b1; end end always (posedge clk_1khz or negedge rst_n) begin if (!rst_n) begin key_prev 1b1; key_pulse 1b0; 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 4d5 sec_q_low 4d9 min_q_high 4d5 min_q_low 4d9;这个条件和秒计数器加一到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 3d0, S_INIT2 3d1, S_INIT3 3d2, S_FUNC_SET 3d3, S_DISP_ON 3d4, S_CLR 3d5, S_ENTRY_MODE 3d6, S_IDLE 3d7;显示内容方面第一行显示“HH:MM:SS”格式的当前时间第二行可以显示倒计时的剩余时间或者模式状态提示。因为内部计数器已经是BCD码所以显示时只需要把十位和个位分别映射到对应的ASCII码即可ASCII BCD 8h30。这个映射关系虽然基础但不少初学者就是在这里卡住——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变0q_high加1变1看起来没问题但carry信号的高电平持续了整整一个秒时钟周期。如果直接用这个carry去驱动分计数器的使能端分计数器会在整个秒时钟周期内都处于使能状态虽然由于时钟沿只在每一拍到来时才采一次数据实际影响不大但严格来说这种做法不干净。更好的做法是让carry只维持一个时钟周期的高电平脉冲这个在代码里写成carry (q_low 4d9 q_high 4d5) ? 1b1 : 1b0;即可然后在下一拍组合逻辑会自动拉低从而形成一个单周期脉冲。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]这里要特别提醒时钟引脚最好接在开发板上标有MRCC或SRCC的专用时钟引脚上不要随意接到普通的IO引脚。因为FPGA内部的时钟树需要通过专用引脚进入才能保证所有时序逻辑共享同一时钟域乱接的话虽然仿真没问题上板后时序收敛基本过不了严重的会直接导致逻辑乱跳。另外LCD数据线在Vivado里如果分配到了差分引脚对比如P和N成对出现的引脚会触发编译警告甚至报错选引脚的时候注意避开。配套的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报告还是日后自己回看都会省很多力气。这套带详细注释的完整工程我建议大家拿到手之后不要只是编译下载看个亮灯就结束而是对照着本文的设计思路把每一个模块的代码重写一遍。写一遍比读十遍的收获都要大。本文还有配套的精品资源点击获取