资讯动态

FPGA数字闹钟设计:从分频器到状态机的完整工程实践

发布时间:2026/9/13 6:12:26 来源:尧图企业网站定制
简介一套基于FPGA的简易数字闹钟设计工程采用Verilog语言在Vivado环境下完成并以EGo1开发板为硬件载体适合FPGA初学者、电子类专业学生或数字电路设计入门者。项目覆盖了数字钟常用的计时计数、时间比较、闹钟触发及按键输入等核心逻辑可以帮助读者建立起从需求分析、代码编写、仿真验证到板级调试的完整设计流程。资源包共249个文件大小25.36MB包含Verilog源码.v、Vivado工程配置.xpr、引脚约束文件.xdc、综合与实现报告.rpt以及用于自动编译、仿真、运行的批处理和Tcl脚本另有仿真波形与bit流文件便于直接学习和验证。目前已有1528人学习下载。通过这些文件读者既能查看模块划分也能按脚本复现编译与综合过程适合作为课程设计或FPGA入门练习的参考素材。1. 数字闹钟在FPGA上到底卡在哪很多刚接触FPGA开发的人会以为数字闹钟无非是写个计数器、到时间响铃但真正拿到EGo1开发板上把这套逻辑跑通就会发现真正的麻烦全在边角上50MHz外部时钟怎么变成精确的1Hz秒脉冲、时分秒用二进制还是BCD表示、七段数码管动态扫描的刷新率怎么选、按键按下瞬间怎么消抖、闹钟触发后会不会因为比较逻辑不当而连续响上几十次。这一个小项目几乎包含了时序逻辑设计、状态机、约束和硬件调试的完整链路非常适合作为FPGA入门的第二个或第三个工程——比流水灯有信息量又不至于一上来就碰DDR或PCIE。下面这套基于Vivado和Verilog的工程除了可以直接下载到EGo1跑起来更值得拆开看的是每个模块的边界条件和设计取舍。2. 用Verilog把秒脉冲、时基计数器和七段数码管先立起来数字闹钟的底层是时间基准。EGo1板载晶振一般是50MHz也就是说FPGA内部每个时钟周期是20ns拿它去直接计数到86400次是没意义的因为秒本身需要先被“切”出来。这一步做不好后面所有比较都是空中楼阁。2.1 分频器从50MHz到1Hz的两种写法常见做法有两种直接写一个计数器产生周期为1s的使能脉冲或者用Vivado的Clocking Wizard生成一个1Hz时钟。对于这种简单闹钟我更建议用计数器产生tick使能而不是真正去生成1Hz时钟。原因在于多时钟域会带来跨时钟域和时序约束的额外负担而一个高电平持续一个周期的tick脉冲可以用在使能端逻辑简单且不会破坏原有主时钟域。module clk_div #( parameter DIV_N 50_000_000 )( input wire clk, input wire rst_n, output reg tick ); reg [25:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; tick 1b0; end else begin if (cnt DIV_N - 1) begin cnt 0; tick 1b1; end else begin cnt cnt 1b1; tick 1b0; end end end endmodule这个模块的参数DIV_N决定了分频系数50MHz下取50_000_000每隔一整个秒周期拉高一次tick。代码里使用cnt DIV_N - 1作为归零条件避免了用异步复位计数器保证输出脉冲宽度恰好是单时钟周期。之所以不直接在分频器内部让cnt自减到0是因为自增计数配合比较在当前FPGA架构上综合出的资源更少进位链更短时序收敛也更容易。如果你用的板子晶振是100MHz把参数改成DIV_N 100_000_000即可。2.2 时基计数器BCD还是二进制这里决定后边比较器好不好写有了秒脉冲tick下一步是把它累加成秒值。这里需要做一个关键选择直接用二进制计数到59后再进位还是用BCD码每位数独立0~9计数。从资源角度讲二进制计数更省只有6位就能数到59但显示的时候必须做二进制到BCD的转换。反过来用BCD码存储时分秒每个数码管显示位直接对应4bit可读性好代码不会藏逻辑而且后面闹钟比较器只需要按位比较。对于FPGA入门项目我会优先选BCD因为你的瓶颈不在那几十个LUT而在整个设计思路是否清晰。module bcd_counter ( input wire clk, input wire rst_n, input wire en, input wire clear, output reg [3:0] tens, output reg [3:0] ones, output wire carry ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin tens 0; ones 0; end else if (clear) begin tens 0; ones 0; end else if (en) begin if (ones 9) begin ones 0; if (tens 5) begin tens 0; end else begin tens tens 1b1; end end else begin ones ones 1b1; end end end assign carry (en tens 5 ones 9); endmodule这个模块实现的是模60计数器的单个数字部分。en信号来自上一级的tick或者低位的进位clear用于复位当前计数值carry在高位需要进一的时候拉高。参数化扩展也很直观分钟计数器再放一个这样的实例秒计数器的carry接分钟计数器的en。注意这里的carry是纯组合逻辑输出在后续模块中要避免直接用来驱动寄存器最好在下一个时钟沿打一拍否则可能出现组合环路隐患。对于时计数器模24的判断条件与分秒不同tens 2 ones 3时归零即可。2.2.1 时钟、复位与使能信号的处理这个工程的复位我统一用了低电平异步复位rst_n按键复位在EGo1板上通常会接到一个独立按键上。使能链路是tick - 秒 - 分 - 时每一级en都来自上一级的进位这样做的好处是单时钟域内所有寄存器都工作在同一个时钟沿时序分析时不需要设置set_false_path。如果你在仿真里发现秒计数器和预期差一拍先检查是不是把tick直接接成了时钟。这里必须强调tick只能作为en绝对不能作为posedge否则综合工具会识别出多个时钟源给后续约束制造很大麻烦。2.3 七段数码管驱动动态扫描的刷新率与毛刺EGo1开发板上的数码管多半是共阳极段码低电平有效。动态扫描的核心是轮流选中位选同时把当前位的段码送到数据线上。刷新率不能太低人眼能明显感觉到闪烁也不要高到让三极管开关跟不上常见做法是让扫描频率落在800Hz到1kHz之间。module seg_driver ( input wire clk, input wire rst_n, input wire [3:0] sec_ones, input wire [3:0] sec_tens, input wire [3:0] min_ones, input wire [3:0] min_tens, output reg [3:0] sel, output reg [7:0] seg ); reg [15:0] scan_cnt; wire [1:0] scan_pos scan_cnt[15:14]; always (posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt 0; else scan_cnt scan_cnt 1b1; end always (*) begin case (scan_pos) 2d0: sel 4b1110; 2d1: sel 4b1101; 2d2: sel 4b1011; 2d3: sel 4b0111; default: sel 4b1111; endcase end // 下面是段码表与扫描位置的组合选择对应共阳极数码管 always (*) begin case (scan_pos) 2d0: seg seg_map(sec_ones); 2d1: seg seg_map(sec_tens); 2d2: seg seg_map(min_ones); 2d3: seg seg_map(min_tens); endcase end function [7:0] seg_map; input [3:0] digit; begin case (digit) 4h0: seg_map 8hC0; 4h1: seg_map 8hF9; 4h2: seg_map 8hA4; 4h3: seg_map 8hB0; 4h4: seg_map 8h99; 4h5: seg_map 8h92; 4h6: seg_map 8h82; 4h7: seg_map 8hF8; 4h8: seg_map 8h80; 4h9: seg_map 8h90; default: seg_map 8hFF; endcase end endfunction endmodule这个驱动模块把scan_cnt的高两位作为扫描位置4ms轮换一次。位选信号使用独热码的高有效段码seg_map查表输出。扫描频率由scan_cnt的高两位决定如果主频是50MHz则每位停留13.1ms整个周期约52.4ms刷新率约19Hz明显偏低。实际设计时我会把计数器高位换成scan_cnt[13:12]让每位约0.82ms刷新率约1.2kHz这样既不会闪烁也不会给段驱动带来开关损耗。注意sel和seg都是组合逻辑寄存器输出的组合结果在时序约束中通常不需要额外约束因为它们是从寄存器直接输出的。真正要小心的是如果scan_cnt的宽度不够高两位翻转频率过高数码管会产生重影。另一个细节是段码表应尽可能写成function这样综合时能复用否则4个扫描位置重复写4遍大case语句代码会膨胀到难以维护。扫描位sel输出对应显示内容2d04b1110秒个位2d14b1101秒十位2d24b1011分个位2d34b0111分十位扫描顺序不需要和视觉从左到右一致只要保持循环稳定即可。如果你还需要显示小时就把位选扩展到6位扫描计数器高3位对应6个位置多余位置空着。3. 闹钟比较器、按键消抖与状态机的实现边界时间显示正常之后闹钟功能的差异主要体现在比较器、按键输入和状态控制上。这一章最容易翻车的地方是比较器只在某一时刻匹配导致闹钟响铃只维持一个周期或者按键消抖没有配合状态机出现一次按键触发多次切换。3.1 比较器当前时间和闹钟设定值怎么比才不会误触发闹钟通常只需要比较“时”和“分”秒值不参与比较。如果current_minutes和alarm_minutes相等且current_hours也相等那么理论上每秒钟都会满足一次条件。实际上应该用边沿检测的方式产生触发信号而不是直接拉高buzzer输出。module alarm_compare ( input wire clk, input wire rst_n, input wire [3:0] cur_h_tens, cur_h_ones, input wire [3:0] cur_m_tens, cur_m_ones, input wire [3:0] alm_h_tens, alm_h_ones, input wire [3:0] alm_m_tens, alm_m_ones, output reg alarm_out ); reg match_d; // 前一拍匹配结果 wire match (cur_h_tens alm_h_tens) (cur_h_ones alm_h_ones) (cur_m_tens alm_m_tens) (cur_m_ones alm_m_ones); always (posedge clk or negedge rst_n) begin if (!rst_n) begin match_d 1b0; alarm_out 1b0; end else begin match_d match; // 只在条件从0变1时拉高一个时钟周期 alarm_out match ~match_d; end end endmodule这段代码先把当前时分与设定时分组合成一个match比较然后存入match_d用match ~match_d生成只持续一个周期的高电平脉冲。这样闹钟不会因为分钟数一直相等而持续响而是每重新进入匹配条件一次才触发一次。如果你希望闹钟响铃持续多秒可以在外面加一个定时器在alarm_out出现后启动倒计时例如响30秒后自动关闭。需要注意这里比较的是BCD码的各位不是整个字节所以不需要把cur_h_tens拼成8位再比较按位比较可以在同一个always块里直接使用且更直观。3.2 按键输入消抖用计数器还是用状态机按键是机械的按下和释放都会有大约5ms到20ms的抖动。最简单的消抖是延时采样但如果是像闹钟这种需要频繁设置的模式建议写一个带有cnt和稳定标志位的消抖模块。module debounce ( input wire clk, input wire rst_n, input wire key_in, output reg key_pressed ); reg [19:0] cnt; reg key_r1, key_r2, key_stable; wire key_clean; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_r1 1b1; key_r2 1b1; end else begin key_r1 key_in; key_r2 key_r1; end end assign key_clean key_r2 key_in; // 低电平表示按下这里做下降沿检测 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; key_stable 1b1; key_pressed 1b0; end else begin if (key_clean ! key_stable) begin cnt cnt 1b1; if (cnt 20d1_000_000) begin cnt 0; key_stable key_clean; if (key_clean 1b0) key_pressed 1b1; else key_pressed 1b0; end end else begin cnt 0; end end end endmodule这段代码用key_r1 - key_r2做简单的双触发器同步防止亚稳态传播。key_clean是同步后的输入当key_clean与稳定状态不一致时计数连续1ms抖动结束后才更新key_stable。key_pressed会维持一个周期方便状态机里使用。1ms对应50MHz下的1_000_000个时钟周期这里参数需要根据实际板载时钟调整。如果你发现自己按键经常误触发把那1ms放大到5ms也会有帮助但在cnt宽度不够时要注意溢出。真正细心的设计还会在状态机里增加一个按键释放判断实现“只有释放后再按下才有效”这样可以避免长按导致反复切换。3.3 控制逻辑设置模式、运行模式与闹钟响铃的状态切换闹钟的状态比单纯计时多了一层用户需要切换到设置模式、选择调整小时还是分钟然后确认退出。状态机比一堆if/else更适合表达这种互斥行为。localparam IDLE 3d0; localparam SET_HOUR 3d1; localparam SET_MINUTE 3d2; localparam ALARM 3d3; reg [2:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin next_state state; case (state) IDLE: begin if (key_set) next_state SET_HOUR; else if (alarm_out) next_state ALARM; end SET_HOUR: begin if (key_ok) next_state SET_MINUTE; end SET_MINUTE: begin if (key_ok) next_state IDLE; end ALARM: begin if (key_stop) next_state IDLE; end endcase end状态编码用了3位独热吗没有这里用了普通二进制编码因为总共只有4个状态独热多出来的寄存器对FPGA资源影响不大而普通编码方便在ILA波形里直接看状态值。在这个状态机里key_set进入设置小时key_ok从小时间接跳到分钟设置再次按key_ok回到正常计时期间当前时间仍然在跑。需要单独说明的是如果用户只设置了小时没有设置分钟就退出整个传入的闹钟设定值应当有一个默认值或上次记忆值否则会触发一个未知比较。3.3.1 状态机的编码方式如果在Vivado综合报告里看到大量LUT被用于状态跳转适当使用(* FSM_ENCODING one-hot *)综合属性会提升频率但这个小设计没有必要。更多时候你碰到的问题是key_set和key_ok来自不同按键多个按键同时按下时状态机可能跳两个状态这需要在按键处理模块增加优先级判断。我的做法是在状态机的组合逻辑里先判断key_set再判断key_ok并把这两个信号设置为单周期脉冲不满足的就是设计意图里不允许的输入。状态机之外还要注意数码管动态扫描和状态机运行在同一个时钟域不要在状态跳转的高电平周期里插入阻塞赋值否则会出现状态不能完整跑完的情况。对于简单的4状态机建议使用两段式第一段同步状态寄存器第二段组合逻辑计算下一状态并输出。这样写出来的代码更容易被综合工具优化也方便在仿真中定位是跳转条件不满足还是组合逻辑漏了分支。状态名状态编码跳转条件主要输出IDLE000key_set 或 alarm_out显示当前时间SET_HOUR001key_ok显示小时并等待调节SET_MINUTE010key_ok显示分钟并等待调节ALARM011key_stop控制响铃输出4. 在Vivado里跑仿真、加约束和生成bitstream这个工程包里有compile.bat、elaborate.bat、simulate.bat和runme.bat说明设计不是用Vivado GUI一步一步点的而是走了命令行批处理流程。这种方式非常适合反复修改RTL后自动回归仿真也是fpga开发在工程化阶段必须会的手段。4.1 用elaborate.bat / simulate.bat 跑行为仿真Vivado自带xsim仿真器命令行流程是先xvlog解析Verilog文件再xelab对顶层进行elaborate最后xsim运行仿真。批处理脚本里通常会定义项目名和文件列表。xvlog -i ../rtl -i ../sim -f filelist.f xelab top_tb -timescale 1ns/1ps -debug typical -s sim_snapshot xsim sim_snapshot -tclbatch run.tclxvlog的-i指定搜索路径这个工程把RTL和testbench分开放filelist.f里逐行写clk_div.v、bcd_counter.v等文件。xelab的-timescale决定仿真时间精度-s sim_snapshot给快照起名后续xsim打开这个快照并执行run.tcl。run.tcl里常见命令是添加所有内部信号到波形然后run 100ms这一步能直接看到秒脉冲是否能翻转到下一级。如果你用simulate.bat发现仿真直接退出且无波形大概率是xvlog -i参数指向的RTL路径不对或者testbench里用了未被综合的仿真专用语句比如$display但顶层模块没有initial块。还有一点在命令行批处理里跑仿真时不要用绝对路径否则换台电脑就要改脚本用相对路径并保证从工程根目录调用才能稳定复现。4.2 引脚约束EGo1开发板上的时钟、按键、数码管引脚仿真通过之后硬件实现的关键是XDC约束文件。EGo1开发板不同版本引脚定义有差异这里给出一种常见配置set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN F4 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n] set_property PACKAGE_PIN C1 [get_ports state_key] set_property IOSTANDARD LVCMOS33 [get_ports state_key] set_property PACKAGE_PIN R1 [get_ports seg[0]] set_property IOSTANDARD LVCMOS33 [get_ports seg[0]]时钟引脚一般会直接接在板上有源晶振的输出上E3这个位置在部分EGo1板卡上是全局时钟引脚用PACKAGE_PIN约束以后Vivado会自动把该引脚连接到全局时钟缓冲器。按键和段码是普通IO配置为LVCMOS33即可。要注意如果段码在板载三极管驱动后是低电平有效XDC里不需要额外设置属性段码本身只是电平逻辑。约束文件是所有工程里最容易出现“合不上”的地方。如果你下载bit后数码管乱码第一件事不是改RTL而是检查位选和段码是否有约束冲突。如果两个引脚被分配到同一个物理管脚Vivado综合时直接报错这种情况查代码里有没有多个模块同时驱动同一个信号。还有一个隐蔽问题某些EGo1板卡上多组IO共用bank的供电电压如果VCCIO设错了所有输出都会异常现象是bit下载成功但板子上灯微亮。4.3 时序分析与常见警告处理综合后打开report_timing_summary重点看WNSWorst Negative Slack。对50MHz时钟来说WNS为正表示满足时序要求。如果出现负的setup slack最直接的办法是检查组合逻辑是不是在关键路径上过深比如比较器里同时比较四个BCD数位这类逻辑在低速设计中一般没问题但在较大的设计里就得考虑插入流水线寄存器。4.3.1 多周期路径与时钟域在闹钟工程里分频器产生的tick不是时钟所以不需要对tick设置多周期路径。但如果你用了Clocking Wizard生成了多个时钟Vivado会自动识别时钟组默认情况下跨时钟域路径会被设为不要求的时序关系。这时你需要明确set_clock_groups -asynchronous否则时序报告会大量出现无意义的红色路径。另外千万别把key_in这样的外部异步信号直接用在时序逻辑的条件里一定要经过双触发器同步否则上板后偶尔会出现状态机跳到非法状态。警告类型常见原因建议处理[Synth 8-3332] undefined input端口声明与例化不匹配检查模块端口名和连接[Synth 8-3812] port not connected位选或段码悬空按实际需求连接或置0[Timing 38-191] no clock defined分频输出被误当时钟将分频输出改为使能信号5. 硬件下载后的调试验证看波形不如看现象先把bit文件用runme.bat下载到EGo1板上。这里给一条更合适的验证链路不直接设闹钟而是用一个按键快进时间。通常在测试用代码里加一个key_fast信号按下时让秒计数器的使能变成1kHz而不是1Hz这样等待几秒钟就能看到分针走动比盯着秒表看真实时间效率高得多。wire tick_cur; assign tick_cur key_fast ? tick_fast : tick_1hz;tick_1hz是正常秒脉冲tick_fast可以由分频器参数改为50_000生成也就是1kHz。把tick_cur接入秒计数器的en拨到快调模式几秒钟分钟会自动进位这时观察数码管和闹钟状态比较器是否在匹配后触发响铃。这样能快速覆盖“小时跳变”“分钟归零”“闹钟触发”三个关键行为。如果快进模式下数码管出现闪烁把scan_cnt的高位改窄提升扫描频率。如果响铃没有触发则使用Vivado的ILA核抓内部信号。在工程里添加ILA后重新综合布局布线设触发条件为alarm_out 1b1再把当前时间和闹钟设定时间调到一致抓到的波形会清晰显示match从0到1和alarm_out是否产生了单周期脉冲。此时如果alarm_out一直为0问题大概率在比较器模块的连线查一下顶层是不是把cur_m_tens和alm_m_tens接到同一个信号上。最后留一个具体的检查脚本用virtue或verdi看到波形后验证三个关键点。第一分频器输出tick_1hz在50MHz下翻转周期精确为500ms低、500ms高第二秒计数器的十位在个位等于9之后的下一个en高电平期间进位而不是提前进位第三闹钟比较器只在分秒相等时刻产生一个周期脉冲不会持续高。这三点验证完这个数字闹钟的时序逻辑部分就算真正吃透了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价