简介一份基于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 1b0; end else if (cnt 27d99_999_999) begin cnt 0; en_1hz 1b1; // 只拉高一个周期 end else begin cnt cnt 1b1; en_1hz 1b0; end end这个en_1hz信号怎么用它只作为计数器加1的“闸门条件”在always块里写if (en_1hz) sec sec 1;而所有逻辑依然在posedge clk下工作。这样整个设计就只有一个时钟域综合实现干净利落时序约束也好写。2.2 模块怎么切接口怎么定我最终把设计分成了四个模块每个模块职责单一方便单独仿真和复用模块名职责关键输入关键输出clk_div生成秒脉冲使能、毫秒扫描使能等100MHz系统时钟en_1hz,en_scankey_proc按键同步、消抖、模式状态机三个按键原始信号key_press,key_plus,key_minustimer_core时分秒的模60/模24计数秒脉冲使能、校时命令hour_bcd,min_bcd,sec_bcdseg_dispBCD码转段选、动态扫描三个BCD时间数据seg_data,an_sel这个划分不是随便定的核心原则是每个模块只依赖“使能信号”驱动模块之间通过握手信号或使能信号传递信息不直接传递时钟。这样就算后面你打算在系统里加一个整点报时的蜂鸣器或者加一个LCD显示也只需要新写一个模块接上对应的使能信号和数据接口就行不会牵一发动全身。3. 核心RTL实现分频、计时进位链与动态扫描的细节3.1 分频器代码与位宽计算分频器是整个系统的时间基准代码本身不复杂但位宽和常量的计算很讲究。上面已经给了秒脉冲的片段我再补充一下扫描使能的设计。数码管动态扫描需要一个1kHz左右的刷新率也就是每1ms切换一位数码管。100MHz时钟要数100000个周期才到1ms100000需要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 1b0; end else if (cnt_1s CNT1_TOP) begin cnt_1s 0; en_1hz 1b1; end else begin cnt_1s cnt_1s 1b1; en_1hz 1b0; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_scan 0; en_scan 1b0; end else if (cnt_scan 17d99_999) begin cnt_scan 0; en_scan 1b1; end else begin cnt_scan cnt_scan 1b1; en_scan 1b0; 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 1b0; end else if (load_en) begin cnt load_data; en_out 1b0; end else if (en_in) begin if (cnt MOD_VALUE - 1) begin cnt 0; en_out 1b1; // 只在溢出那拍拉高 end else begin cnt cnt 1b1; en_out 1b0; end end else begin en_out 1b0; 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 6b111111; else if (en_scan) begin an_sel ~(6d1 scan_cnt); // 共阳0选中 end end至于seg_data我遵循“更新段选之后再选通位选”的原则实际工程里还需要根据你的硬件是共阳还是共阴来取反段码表。我板子上的共阳数码管0对应段码11000000即0xC0在Verilog里写成二进制7b1000000加一个高有效段选。具体的段码表用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设计的门槛了。本文还有配套的精品资源点击获取