简介:一份基于Verilog的FPGA数字钟完整工程,面向FPGA初学者与数字逻辑课程设计人群,展示在Quartus II 13.0环境下从代码编写到电路实现的全流程。工程包含计时、跑表、闹钟与时间调整等完整功能模块,代码注释详细,便于对照学习时序逻辑设计、分频思想以及按键输入处理等核心知识点。压缩包共145个文件,大小7.82MB,以.qpf/.qsf工程配置、.v/.vhd源码、.vwf仿真波形、.sof下载配置文件以及.txt说明文档为主,附带大量编译与仿真中间文件,便于追踪工程任务。已有416人学习使用。借助这套工程,可掌握用Verilog描述硬件行为的方法,理解数字钟的模块划分与整体集成,获得一套可直接下载到FPGA开发板运行的参考设计,配合工程内的仿真激励与DO脚本,能完整复现分析、综合、下载流程,适合课设参考、自学练手或作为后续扩展基础。 数字钟这个项目,很多人的第一反应是“不就是几个计数器嘛”。我见过不少新手直接打开Vivado,在一个top.v里把分频、计时、显示全部写完,仿真跑通就觉得自己掌握了FPGA。结果一到上板,各种问题全出来了——要么时钟乱掉,要么数码管闪烁拖影,要么按键按十次才灵一次。我甚至面试时遇到好几个简历写着“FPGA数字钟”的同学,却答不上来为什么不能把分频后的1Hz信号直接当时钟用。
这个项目看着基础,但它把FPGA开发里最核心的几块全串起来了:时钟域处理、计数器设计、状态机编写、动态扫描显示、按键消抖、仿真验证、引脚约束与上板调试。把它认认真真做完一遍,比刷十份入门教程都值。这篇文章我就把自己做这个项目时踩过的坑、总结的经验、最终采用的代码结构完整写出来,希望能帮你少走点弯路。
1. 数字钟这种“练手项目”,为什么值得掰开揉碎做一遍
1.1 需求不大,考点不少
很多人低估了数字钟的复杂度。表面上它只需要显示时分秒,但你想让它真正达到“能用”的程度,功能需求一列出来其实不少:
- 正常走时:秒、分、时三个量连续递增,60秒进1分,60分进1小时,24小时翻转。
- 时间校准:至少需要“小时+”“分钟+”“秒清零”之类的按键操作,否则时间错了只能干瞪眼。
- 显示稳定:六位数码管不能闪烁、不能拖影,刷新频率要合适。
- 可靠按键:机械按键必须消除抖动和亚稳态,否则按一次可能跳好几个数。
这些需求涉及到的FPGA基础技能包括:时钟分频与时钟使能的概念、同步计数器设计、多模块协作与接口定义、有限状态机(FSM)、动态扫描显示算法、异步信号同步化处理、testbench仿真验证、时序约束和引脚分配。
换句话说,一个数字钟把FPGA入门阶段绝大部分知识点都覆盖了。你把这个项目吃透,后面再去看UART、SPI、I2C这类通信协议,会顺畅得多,因为大家本质上都在做同一件事——用Verilog描述硬件行为,让数据在正确的时钟节拍下流动。
1.2 我的开发环境与板卡选择
做这个项目用什么板子其实不挑,但为了让你能对照复现,我交代一下我的环境:
- 芯片:Xilinx Artix-7系列(具体型号不重要),系统时钟100MHz。
- 开发工具:Vivado 2020.2以上版本,仿真直接用Vivado自带的XSim。
- 外设:六位共阳数码管、三个独立按键(我用的是“设置”“加”“减”三个键)、一个复位按键。
- 语言:Verilog-2001,顶层模块用ANSI风格端口声明。
如果你用的是Altera/Intel的Quartus,或者国产高云、紫光同创的IDE,核心代码完全可以直接搬,只需要根据各自工具的引脚约束格式改一下约束文件。
2. 顶层设计:先把时钟域、模块边界和数据流理顺
2.1 一个关键认知:1Hz脉冲是“使能”,不是“时钟”
这是整个项目里最重要的一个设计决策,99%的新手会在这里犯错。
很多人的第一版代码是这样写的:
// 错误示范:把分频后的信号当时钟用 reg clk_1hz; always @(posedge clk) begin if (cnt == 50_000_000 - 1) begin cnt <= 0; clk_1hz <= ~clk_1hz; end end always @(posedge clk_1hz) begin // 计时逻辑 end表面上看没问题,仿真也能出波形。但到了综合实现阶段,工具会报一堆时序告警,因为你在FPGA内部生成了一个新的时钟域clk_1hz,这个时钟是通过普通逻辑翻转出来的,不是经过专用时钟资源(BUFG/MMCM)驱动的。更麻烦的是,这个内部时钟的偏斜(skew)和抖动(jitter)无法保证,在时序收敛时很难满足约束,板子跑起来偶尔会出现“秒跳两下”这种诡异现象。
正确的做法是:保持全系统只有一个时钟,也就是板级晶振的100MHz。分频器输出的不是时钟,而是一个“每秒钟拉高一个时钟周期”的脉冲使能信号。
// 正确示范:输出脉冲使能,而不是翻转时钟 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; en_1hz <= 1'b0; end else if (cnt == 27'd99_999_999) begin cnt <= 0; en_1hz <= 1'b1; // 只拉高一个周期 end else begin cnt <= cnt + 1'b1; en_1hz <= 1'b0; end end这个en_1hz信号怎么用?它只作为计数器加1的“闸门条件”,在always块里写if (en_1hz) sec <= sec + 1;,而所有逻辑依然在posedge clk下工作。这样整个设计就只有一个时钟域,综合实现干净利落,时序约束也好写。
2.2 模块怎么切,接口怎么定
我最终把设计分成了四个模块,每个模块职责单一,方便单独仿真和复用:
| 模块名 | 职责 | 关键输入 | 关键输出 |
|---|---|---|---|
clk_div | 生成秒脉冲使能、毫秒扫描使能等 | 100MHz系统时钟 | en_1hz,en_scan |
key_proc | 按键同步、消抖、模式状态机 | 三个按键原始信号 | key_press,key_plus,key_minus |
timer_core | 时分秒的模60/模24计数 | 秒脉冲使能、校时命令 | hour_bcd,min_bcd,sec_bcd |
seg_disp | BCD码转段选、动态扫描 | 三个BCD时间数据 | seg_data,an_sel |
这个划分不是随便定的,核心原则是:每个模块只依赖“使能信号”驱动,模块之间通过握手信号或使能信号传递信息,不直接传递时钟。这样就算后面你打算在系统里加一个整点报时的蜂鸣器,或者加一个LCD显示,也只需要新写一个模块,接上对应的使能信号和数据接口就行,不会牵一发动全身。
3. 核心RTL实现:分频、计时进位链与动态扫描的细节
3.1 分频器代码与位宽计算
分频器是整个系统的时间基准,代码本身不复杂,但位宽和常量的计算很讲究。上面已经给了秒脉冲的片段,我再补充一下扫描使能的设计。
数码管动态扫描需要一个1kHz左右的刷新率,也就是每1ms切换一位数码管。100MHz时钟要数100000个周期才到1ms,100000需要17位二进制位($clog2(100000) = 17$)。我在代码里用本地参数定义分频数,方便调整:
module clk_div #( parameter SYS_CLK_HZ = 100_000_000 )( input wire clk, input wire rst_n, output reg en_1hz, output reg en_scan ); localparam CNT1_W = $clog2(SYS_CLK_HZ); localparam CNT1_TOP = SYS_CLK_HZ - 1; reg [CNT1_W-1:0] cnt_1s; reg [16:0] cnt_scan; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1s <= 0; en_1hz <= 1'b0; end else if (cnt_1s == CNT1_TOP) begin cnt_1s <= 0; en_1hz <= 1'b1; end else begin cnt_1s <= cnt_1s + 1'b1; en_1hz <= 1'b0; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_scan <= 0; en_scan <= 1'b0; end else if (cnt_scan == 17'd99_999) begin cnt_scan <= 0; en_scan <= 1'b1; end else begin cnt_scan <= cnt_scan + 1'b1; en_scan <= 1'b0; end end endmodule这里有个细节值得说:$clog2是Verilog-2001里的系统函数,用来计算位宽非常方便,$clog2(100_000_000)的结果是27,也就是说秒计数器需要27位的寄存器。很多人偷懒写reg [31:0] cnt;,也不是不行,但综合出来会白白浪费寄存器资源,养成精确计算位宽的习惯对做FPGA很有帮助。
3.2 模60/模24计数器的统一写法
时分秒三个计数器逻辑相似,只是模数不同。秒和分都是模60的,小时是模24的,完全可以用一个带参数化的模块生成两个实例,而不是复制三份代码。我推荐用二进制计数,显示的时候再转BCD,这样进位逻辑更自然。
module timer_unit #( parameter MOD_VALUE = 60, parameter WIDTH = 7 // 60用7位,24用5位,统一7位也行 )( input wire clk, input wire rst_n, input wire en_in, // 进位使能 input wire [WIDTH-1:0] load_data, input wire load_en, output reg [WIDTH-1:0] cnt, output reg en_out // 输出进位 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; en_out <= 1'b0; end else if (load_en) begin cnt <= load_data; en_out <= 1'b0; end else if (en_in) begin if (cnt == MOD_VALUE - 1) begin cnt <= 0; en_out <= 1'b1; // 只在溢出那拍拉高 end else begin cnt <= cnt + 1'b1; en_out <= 1'b0; end end else begin en_out <= 1'b0; end end endmodule顶层实例化的时候,把秒模块的en_out接到分模块的en_in,分模块的en_out接到时模块的en_in,一条进位链就串起来了。这条链的好处是每个计数器只有在收到进位使能时才动作,不会因为使能信号持续拉高而出现“一秒加两下”的bug。
为什么我选择“二进制计数+显示时转BCD”,而不是直接在计数器里用BCD(个位和十位分别计)?因为在硬件里,BCD计数的进位判断比较复杂,个位到9之后要同时完成“个位清零、十位加一”两个操作,代码写起来啰嗦还容易出错。二进制计数天然适配加法器,模60的判断也只是一个比较器的事,最后的位数转换用查找表解决,很划算。
3.3 动态扫描与消影细节
六位共阳数码管的动态扫描原理不复杂:同一时刻只点亮一位,轮询点亮六位,利用视觉暂留让人眼以为六位同时亮。关键在扫描频率和消影处理。
扫描频率不宜太低,低于50Hz人眼就能感知闪烁;也不宜过高,过高会占用太多CPU逻辑资源(好吧,其实FPGA不在乎,但过高没有实际意义)。我用的1kHz扫描频率,每1ms切换一位,一个完整循环6ms,刷新率约167Hz,实测下来非常稳定,完全无闪烁。
真正坑人的是“消影”问题。扫描切换的过程中,位选信号如果先切换而段选数据还没准备好,数码管会在切换的瞬间短暂显示“上一位”的段码,表现出来就是轻微的重影或拖影。解决办法是:先关闭所有位选(全部拉高,共阳管高电平灭),再更新段选数据,最后才打开当前位的位选。在组合逻辑和时序逻辑之间做好这个先后顺序:
// 段选数据用组合逻辑按扫描计数选通 wire [6:0] seg_cur = seg_rom[disp_buf[scan_cnt]]; // 位选信号采用独热码,且用寄存器打一拍输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) an_sel <= 6'b111111; else if (en_scan) begin an_sel <= ~(6'd1 << scan_cnt); // 共阳,0选中 end end至于seg_data,我遵循“更新段选之后再选通位选”的原则,实际工程里还需要根据你的硬件是共阳还是共阴来取反段码表。我板子上的共阳数码管,0对应段码11000000(即0xC0),在Verilog里写成二进制7'b1000000加一个高有效段选。具体的段码表用case或者always @(*)实现即可,这个不贴完整代码了,书和网络上一抓一大把,重点是你得明白共阳和共阴的码表是反的。
4. 按键处理与状态机:整个系统最脆弱的一环
4.1 从亚稳态到同步器
按键处理在所有FPGA项目里都是最容易出问题的模块,因为按键是外部输入的异步信号,随时可能跳变。直接把这个信号用到always @(posedge clk)里,寄存器的时序建立/保持时间就可能被违反,触发器中采到的不确定电平就是亚稳态。亚稳态可能会导致你的模块采到一个既不是0也不是1的“电压”,后续逻辑跟着全部乱套。
最基础的处理是“打两拍”,也就是串两个触发器同步。我自己踩过的坑是:只打了约束文件中异步信号的同步器,却忘了把原始按键信号隔离。后来我专门写了一个key_sync模块,把三个按键统一处理后才接进状态机。
4.2 四态消抖状态机
机械按键按下和释放时,簧片会来回弹跳几毫秒到几十毫秒。如果不去抖,一次按下会产生好几跳边沿,校时的时候按一次“加1”可能直接把小时加了3到5个。
我用的消抖算法是标准四态状态机,状态定义如下:
S_IDLE:空闲,等待按键按下。S_PRESS_DEB:检测到按下,开始消抖计数(比如连续采样到1ms低电平才确认按下)。S_PRESSED:按键已确认按下,输出一个单周期的“按下事件”,等待按键释放。S_RELEASE_DEB:释放消抖,连续采样到1ms高电平才回到空闲。
这个状态机每1ms执行一次采样判断,按键事件只在S_PRESSED状态刚进入时输出一个脉冲。由于机械抖动最长也就10ms左右,我用1ms的采样周期配合连续两次相同电平的确认条件,实测效果很好,没有出现误触发。
有个经验是:消抖计数的时间常数不要写死在代码里。你可以用参数化定义DEB_MS,方便在不同板子、不同按键上调整。有些便宜的按键弹跳时间能到20ms,如果你的消抖窗口只有1ms,那就等于没消。
4.3 校时模式与加减逻辑
有了干净的按键事件,剩下的就是模式切换逻辑了。我的设计是三个按键:“设置”键在“正常显示→时校准→分校准→秒清零→正常显示”之间循环;“加”和“减”键在当前模式下对对应字段进行加减。
模式状态机每次切换时,需要把timer_core的load_en拉起来,把当前显示值作为预设值加载到计数器里。这里容易踩的坑是:加载数据用的是 BCD 码,计数器内部是二进制。所以我加了一个 BCD 转二进制的模块,先把显示用的 BCD 值变成二进制,再送入加载端口。你可千万别跳过去做“BCD直连二进制”的事情,那样校时后时间会彻底乱掉。
校时操作需要秒清零吗?我一开始没有加“秒清零”功能,后来实际使用时发现,校准时间时秒位很可能不是0,这时候要让秒自动归零。因此在模式状态机里加了一路“秒清零”事件,和“小时设置”“分钟设置”互斥。这个小功能让我省了很多事,虽然题目里不要求,但做产品化的数字钟,这几乎是刚需。
5. 仿真、综合与上板:三个最容易翻车的环节
5.1 testbench怎么组织,才能快速验证功能
数字钟的仿真难点在于时间尺度太大。如果你的 testbench 里老老实实等100_000_000个周期才验证一次秒计数,那仿真一次要跑几分钟到十几分钟,效率太低。我的办法是用参数化覆盖(defparam)把分频数缩小,用一个TCK_TIME宏控制仿真时间尺度。
具体做法:在 testbench 里例化clk_div时,不再用默认的SYS_CLK_HZ,而用defparam u_div.SYS_CLK_HZ = 100;,这样原本需要1秒才产生的秒脉冲,在仿真里1微秒就产生了。再用一个task封装按键操作,模拟两个 20ms 的高/低电平状态来测试消抖逻辑。
testbench 里一定要用$display打印关键节点的变化,比如每次秒脉冲到来时打印当前时分秒。我之前调试时只看波形,眼睛都要瞎了,后来加了一行$display("%0t : seconds = %0d", $time, sec_cnt);,问题立刻定位。仿真时数据结构和现实差别太大,指望肉眼是看不出来的。
5.2 综合与实现阶段的三个经典坑
第一个坑就是我在2.1节说的:内部生成时钟。如果你把分频后的信号当always @(posedge clk_1hz)使用,Vivado 在综合后时序报告里会非常难看,而且大概率会有CLOCK_DEDICATED_ROUTE的告警,说明某个信号本应走时钟网络却走了通用布线,功能在最坏温度和电压下是不保证的。
第二个坑是复位信号的极性。我板子上的按键复位是高电平有效的,而大多数IP核约定的是低电平复位rst_n。如果你接触过一个工程后来又换了板子,极有可能被这个小小的极性坑掉半天时间。我的经验是:在代码里把所有按键输入都过一遍取反assign rst_n = ~key_rst;,后续模块全部统一用rst_n。
第三个坑是综合工具优化掉了“没用的”寄存器。当你把仿真波形调出来一切正常,上板却发现功能不对,检查综合报告,常常会发现计数器的很多寄存器被优化掉了。原因多半是en_1hz或者某位信号没有正确连接到输出,综合工具认为这些逻辑没有输出负载,就给你优化掉了。排查思路很直接:检查综合后的 Schematic,看计数器是否还完整存在。
5.3 引脚约束与调试技巧
引脚约束文件(.xdc)是上板前必须认真对待的东西。我给别人看工程时,最常看到的问题是不约束时钟引脚和复位引脚——直接让工具自动布局布线。这在你自己的验证板上可能没问题,但一旦到了正式项目,时钟引脚不约束意味着你不指定set_property PACKAGE_PIN和IOSTANDARD,工具可能给你分到一个完全不对的物理管脚。
数字钟的约束其实很简单,大概长这样:
set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] create_clock -period 10.000 -name sys_clk [get_ports clk] set_property PACKAGE_PIN N15 [get_ports {key[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {key[0]}] set_property INPUT_PULLUP true [get_ports {key[0]}]注意按键我建议在约束里开INPUT_PULLUP,这样可以省掉板子上的外部上拉电阻,并且直接让按键按下产生低电平,逻辑上更统一。
调试时还有一个非常有用的办法:用Vivado的ILA(集成逻辑分析仪)观察en_1hz和计数器的变化。不用所有信号都引到外部管脚去接示波器,在综合属性里给目标信号添加(* mark_debug = "true" *),综合后打开硬件管理器就能实时抓波形。我调试时就是靠ILA发现了我3.2节说的“en_out没有及时拉低”的问题,从而修正了进位链时序。
最后再分享一个体会
把数字钟从“跑通仿真”做到“上板稳定运行”,我做了一遍之后才真正理解为什么前辈总是强调“时序逻辑的输入信号要么是时钟,要么是使能,别混着用”。这个项目虽然只是FPGA入门的“hello world”,但认真对待的话,它完全够你练完从代码编写、仿真验证、综合实现到板级调试的完整闭环。做完之后建议你再挑战一下加一个整点报时或者闹钟功能,那又会逼着你去思考优先级、共时钟域协作和模块复用——到那时候,你就算真正摸到FPGA设计的门槛了。
本文还有配套的精品资源,点击获取