简介:一套基于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 <= 1'b0; end else begin if (cnt == DIV_N - 1) begin cnt <= 0; tick <= 1'b1; end else begin cnt <= cnt + 1'b1; tick <= 1'b0; 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 + 1'b1; end end else begin ones <= ones + 1'b1; 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 + 1'b1; end always @(*) begin case (scan_pos) 2'd0: sel = 4'b1110; 2'd1: sel = 4'b1101; 2'd2: sel = 4'b1011; 2'd3: sel = 4'b0111; default: sel = 4'b1111; endcase end // 下面是段码表与扫描位置的组合选择,对应共阳极数码管 always @(*) begin case (scan_pos) 2'd0: seg = seg_map(sec_ones); 2'd1: seg = seg_map(sec_tens); 2'd2: seg = seg_map(min_ones); 2'd3: seg = seg_map(min_tens); endcase end function [7:0] seg_map; input [3:0] digit; begin case (digit) 4'h0: seg_map = 8'hC0; 4'h1: seg_map = 8'hF9; 4'h2: seg_map = 8'hA4; 4'h3: seg_map = 8'hB0; 4'h4: seg_map = 8'h99; 4'h5: seg_map = 8'h92; 4'h6: seg_map = 8'h82; 4'h7: seg_map = 8'hF8; 4'h8: seg_map = 8'h80; 4'h9: seg_map = 8'h90; default: seg_map = 8'hFF; 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输出 | 对应显示内容 |
|---|---|---|
| 2'd0 | 4'b1110 | 秒个位 |
| 2'd1 | 4'b1101 | 秒十位 |
| 2'd2 | 4'b1011 | 分个位 |
| 2'd3 | 4'b0111 | 分十位 |
扫描顺序不需要和视觉从左到右一致,只要保持循环稳定即可。如果你还需要显示小时,就把位选扩展到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 <= 1'b0; alarm_out <= 1'b0; 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 <= 1'b1; key_r2 <= 1'b1; 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 <= 1'b1; key_pressed <= 1'b0; end else begin if (key_clean != key_stable) begin cnt <= cnt + 1'b1; if (cnt == 20'd1_000_000) begin cnt <= 0; key_stable <= key_clean; if (key_clean == 1'b0) key_pressed <= 1'b1; else key_pressed <= 1'b0; 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 = 3'd0; localparam SET_HOUR = 3'd1; localparam SET_MINUTE = 3'd2; localparam ALARM = 3'd3; 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状态机,建议使用两段式:第一段同步状态寄存器,第二段组合逻辑计算下一状态并输出。这样写出来的代码更容易被综合工具优化,也方便在仿真中定位是跳转条件不满足还是组合逻辑漏了分支。
| 状态名 | 状态编码 | 跳转条件 | 主要输出 |
|---|---|---|---|
| IDLE | 000 | key_set 或 alarm_out | 显示当前时间 |
| SET_HOUR | 001 | key_ok | 显示小时并等待调节 |
| SET_MINUTE | 010 | key_ok | 显示分钟并等待调节 |
| ALARM | 011 | key_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,重点看WNS(Worst 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 == 1'b1,再把当前时间和闹钟设定时间调到一致,抓到的波形会清晰显示match从0到1和alarm_out是否产生了单周期脉冲。此时如果alarm_out一直为0,问题大概率在比较器模块的连线,查一下顶层是不是把cur_m_tens和alm_m_tens接到同一个信号上。
最后留一个具体的检查脚本:用virtue或verdi看到波形后,验证三个关键点。第一,分频器输出tick_1hz在50MHz下翻转周期精确为500ms低、500ms高;第二,秒计数器的十位在个位等于9之后的下一个en高电平期间进位,而不是提前进位;第三,闹钟比较器只在分秒相等时刻产生一个周期脉冲,不会持续高。这三点验证完,这个数字闹钟的时序逻辑部分就算真正吃透了。
本文还有配套的精品资源,点击获取