简介:这份资源面向电子信息、自动化等专业学生及FPGA初学者,提供一套基于VHDL语言、在FPGA上实现多功能数字闹钟的完整设计文档。内容围绕EDA技术展开,涵盖FPGA查找表原理、VHDL开发流程,以及时间存储、计时、闹钟设置、比较器、显示与用户交互等模块的建模思路,并给出从需求分析、系统划分、仿真验证到综合布局布线、下载测试的完整实现路径,源代码经Quartus仿真综合后下载至GW48系列开发板验证,计时、校时与定时闹钟功能均运行稳定。压缩包内为1个doc文档,约2.71MB,以论文形式系统梳理设计原理与开发过程,目录结构清晰,便于按章节查阅。目前已有832人学习下载,适合作为数字系统设计课程实践、EDA实验或同类项目开发的参考范例。
1. 基于FPGA的闹钟系统设计:为什么它成了数字逻辑课的“分水岭项目”
很多人第一次接触 FPGA,都是从跑马灯、数码管计数开始的,这些实验做起来顺风顺水,可一旦进入“基于FPGA的闹钟系统设计”这个题目,进度条就卡住了。原因不复杂:闹钟系统同时踩中了分频、计数、状态机、按键消抖、时间比较、显示驱动这几个核心模块,任何一个环节时序没对齐,现象就是数码管乱跳、按键没反应、到点不响。它不像跑马灯那样改一个参数就能看到效果,而是要求你把多个时钟域和状态迁移串成一条完整的链路。
这篇文章面向的是正在做课程设计、准备电赛或者想用 FPGA 做一个可演示小系统的从业者。我会按“先立住原理、再动手复现”的顺序,把分频器、BCD 计数、按键边沿检测、闹钟比较逻辑、数码管动态扫描这几个模块拆开讲,给出可综合的 Verilog 代码和参数设置方法,最后落到调试技巧和常见翻车点上。读完你应该能自己搭出一套能走时、能设闹钟、到点有输出的完整系统,而不是只停留在仿真波形好看的阶段。
2. 系统架构与时钟分频:把 50MHz 变成 1Hz 的那条链路
2.1 为什么闹钟系统必须先解决分频问题
FPGA 开发板上通常只有一个高频晶振,常见是 50MHz 或 100MHz。闹钟需要的最小时间单位是 1 秒,也就是 1Hz。从 50MHz 到 1Hz,分频比是 50,000,000。这个数字看起来简单,但直接用一个 26 位计数器数到 25,000,000 再翻转,是大多数人的第一反应。问题在于:如果你后续还要做数码管动态扫描,扫描频率通常在 1kHz 左右,也需要从同一个高频时钟分出来。两个分频器如果各自独立计数,综合工具会分别生成两套寄存器,资源浪费不说,关键是两个时钟域之间没有固定的相位关系,显示刷新和秒计数可能在某些时刻同时翻转,导致数码管出现瞬间闪烁。
我一般会采用“主计数器 + 多路比较”的结构:用一个自由运行的 26 位计数器从 0 数到 49,999,999 后归零,然后用比较器分别产生 1Hz、1kHz 等使能信号。这样所有时序逻辑都挂在同一个 50MHz 时钟下,属于同步设计,不存在跨时钟域问题。
// 50MHz 主时钟分频,产生 1Hz 和 1kHz 使能信号 module clk_divider ( input wire clk_50m, // 50MHz 板载时钟 input wire rst_n, // 低电平复位 output reg en_1hz, // 1Hz 使能,高电平持续一个时钟周期 output reg en_1khz // 1kHz 使能,用于数码管扫描 ); reg [25:0] cnt_main; // 26 位计数器,最大 67,108,863 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) cnt_main <= 26'd0; else if (cnt_main == 26'd49_999_999) cnt_main <= 26'd0; else cnt_main <= cnt_main + 1'b1; end // 1Hz 使能:每 50,000,000 个时钟周期拉高一次 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) en_1hz <= 1'b0; else if (cnt_main == 26'd49_999_999) en_1hz <= 1'b1; else en_1hz <= 1'b0; end // 1kHz 使能:每 50,000 个时钟周期拉高一次 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) en_1khz <= 1'b0; else if (cnt_main % 26'd50_000 == 26'd49_999) en_1khz <= 1'b1; else en_1khz <= 1'b0; end endmodule这段代码里cnt_main是唯一的自由计数器,en_1hz和en_1khz都是单周期脉冲。注意en_1khz的判断条件用了取模运算,综合工具会自动优化成比较逻辑,但如果你用的是资源很紧的小容量 FPGA,可以改成两个独立的比较值来省掉除法器。参数方面,49_999_999对应 50MHz 时钟下的 1 秒周期,如果你的板子是 100MHz,这个值要改成99_999_999,同时en_1khz的比较值改成99_999。这是最容易翻车的地方:换了板子没改分频参数,结果走时快一倍或慢一半。
2.2 秒、分、时计数器的 BCD 实现与进位边界
有了 1Hz 使能,接下来就是计时。很多人习惯用二进制计数器,到 59 进位,显示的时候再转 BCD。这样做逻辑简单,但显示模块需要额外的二进制转 BCD 电路,而且转换过程有组合逻辑延迟,在高速扫描时容易出毛刺。更稳妥的做法是直接用 BCD 计数器:个位和十位各用 4 位寄存器,个位到 9 且十位到 5 时产生进位。
// 秒计数器,BCD 编码,范围 00-59 module sec_counter ( input wire clk, input wire rst_n, input wire en_1hz, // 来自分频器的秒使能 output reg [3:0] sec_ones, // 秒个位 output reg [3:0] sec_tens, // 秒十位 output reg carry_min // 分进位脉冲 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_ones <= 4'd0; sec_tens <= 4'd0; carry_min <= 1'b0; end else if (en_1hz) begin if (sec_ones == 4'd9) begin sec_ones <= 4'd0; if (sec_tens == 4'd5) begin sec_tens <= 4'd0; carry_min <= 1'b1; // 59 秒后进位 end else begin sec_tens <= sec_tens + 1'b1; carry_min <= 1'b0; end end else begin sec_ones <= sec_ones + 1'b1; carry_min <= 1'b0; end end else carry_min <= 1'b0; end endmodule这里的关键参数是sec_ones == 4'd9和sec_tens == 4'd5,分别对应个位满 9 和十位满 5。分和时的计数器结构完全一样,只是进位条件不同:分是 59 进 1,时是 23 进 1。注意carry_min只在秒从 59 跳到 00 的那个时钟周期拉高,分计数器用这个脉冲作为使能,而不是用 1Hz 使能直接驱动。这样做的原因是:如果分计数器也用 1Hz 使能,它会在每个秒脉冲都判断一次,虽然逻辑上没错,但多了一级不必要的判断,而且进位脉冲的宽度更干净,便于级联。
时计数器的边界是 23,判断条件是hour_tens == 2 && hour_ones == 3。这里有个容易忽略的点:如果闹钟要支持 24 小时制,时计数器到 23 后归零;如果要支持 12 小时制,还需要一个 AM/PM 标志位。我建议课程设计阶段先用 24 小时制,减少状态变量。
3. 按键消抖与状态机:设置模式怎么切、光标怎么移
3.1 机械按键的抖动窗口与消抖计数器参数
闹钟系统至少需要三个按键:模式键、加键、减键。机械按键在按下和释放瞬间会产生 5ms 到 20ms 的抖动,如果直接拿按键信号去触发状态机,一次按下可能被识别成多次。常见的消抖方法是:用一个 20ms 的计数器,只有当按键电平稳定超过 20ms 才认为有效。
// 按键消抖模块,稳定时间 20ms module debounce ( input wire clk, // 50MHz input wire rst_n, input wire key_in, // 原始按键输入,低电平有效 output reg key_press // 消抖后的按键脉冲,高电平一个周期 ); reg [19:0] cnt_deb; // 20 位计数器,20ms 对应 1,000,000 个周期 reg key_sync0, key_sync1; // 两级同步寄存器 reg key_stable; // 稳定后的按键电平 // 两级同步,消除亚稳态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 <= 1'b1; key_sync1 <= 1'b1; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; end end // 消抖计数 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_deb <= 20'd0; key_stable <= 1'b1; end else if (key_sync1 != key_stable) begin if (cnt_deb == 20'd999_999) begin key_stable <= key_sync1; cnt_deb <= 20'd0; end else cnt_deb <= cnt_deb + 1'b1; end else cnt_deb <= 20'd0; end // 输出按下瞬间的单周期脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) key_press <= 1'b0; else if (key_stable == 1'b0 && key_sync1 == 1'b0) key_press <= 1'b1; else key_press <= 1'b0; end endmodule参数20'd999_999对应 50MHz 下的 20ms。如果你的时钟是 100MHz,这个值要改成1_999_999。两级同步寄存器key_sync0和key_sync1是必须的,因为按键信号相对于 FPGA 内部时钟是异步的,不加同步会引入亚稳态,表现为偶尔按键失灵。key_press只在按键稳定按下后的第一个周期拉高,这样状态机每按一次只收到一个脉冲。
3.2 设置模式的状态迁移与光标闪烁逻辑
闹钟系统通常有三种模式:正常走时、设置时间、设置闹钟。用一个三段式状态机来管理模式切换,模式键在三个状态之间循环。设置模式下,加键和减键修改当前光标选中的位,光标通过另一个按键或模式键长按来移动。
// 闹钟主状态机 module alarm_fsm ( input wire clk, input wire rst_n, input wire key_mode, // 模式键脉冲 input wire key_inc, // 加键脉冲 input wire key_dec, // 减键脉冲 input wire en_1hz, output reg [1:0] mode, // 00 正常,01 设时间,10 设闹钟 output reg [2:0] cursor // 光标位置:0-5 对应时分秒的六位 ); // 状态编码 localparam MODE_NORMAL = 2'b00; localparam MODE_SET_TIME = 2'b01; localparam MODE_SET_ALARM = 2'b10; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin mode <= MODE_NORMAL; cursor <= 3'd0; end else begin if (key_mode) begin case (mode) MODE_NORMAL: mode <= MODE_SET_TIME; MODE_SET_TIME: mode <= MODE_SET_ALARM; MODE_SET_ALARM: mode <= MODE_NORMAL; default: mode <= MODE_NORMAL; endcase cursor <= 3'd0; // 切换模式时光标归零 end else if (mode != MODE_NORMAL) begin if (key_inc) cursor <= (cursor == 3'd5) ? 3'd0 : cursor + 1'b1; else if (key_dec) cursor <= (cursor == 3'd0) ? 3'd5 : cursor - 1'b1; end end end endmodule这里cursor的范围是 0 到 5,对应时十位、时个位、分十位、分个位、秒十位、秒个位。加键和减键在设置模式下移动光标,而不是直接修改数值。修改数值的逻辑放在另一个模块里,根据cursor的值决定改哪个寄存器。这种分离的好处是状态机只负责模式切换和光标移动,数值修改是纯组合逻辑,调试时容易定位问题。
光标闪烁用 1Hz 使能来控制:当en_1hz为高时,当前光标对应的数码管位熄灭,否则正常显示。这样人眼看到的就是 1Hz 的闪烁效果。注意闪烁不能太快,否则看起来像常亮;也不能太慢,否则用户以为没选中。1Hz 到 2Hz 是比较合适的范围。
4. 闹钟比较与输出驱动:到点响铃的判定条件和蜂鸣器波形
4.1 时间比较器的位宽对齐与相等判断
闹钟比较逻辑看起来简单:当前时间等于闹钟时间就输出响铃。但实际做的时候,很多人会在这里翻车。原因是当前时间和闹钟时间都是 BCD 编码的六位寄存器,比较的时候要逐位比较,而不是把六位拼成一个二进制数再比。如果你把 BCD 码当成二进制数比较,比如 59 秒的 BCD 是0101_1001,二进制值是 89,而 60 秒的 BCD 是0110_0000,二进制值是 96,虽然 59 小于 60,但 BCD 的二进制解释会出错。
// 闹钟比较模块 module alarm_compare ( input wire [3:0] cur_hour_tens, input wire [3:0] cur_hour_ones, input wire [3:0] cur_min_tens, input wire [3:0] cur_min_ones, input wire [3:0] cur_sec_tens, input wire [3:0] cur_sec_ones, input wire [3:0] alm_hour_tens, input wire [3:0] alm_hour_ones, input wire [3:0] alm_min_tens, input wire [3:0] alm_min_ones, input wire alarm_en, // 闹钟使能开关 output reg alarm_match // 匹配输出 ); always @(*) begin if (alarm_en && cur_hour_tens == alm_hour_tens && cur_hour_ones == alm_hour_ones && cur_min_tens == alm_min_tens && cur_min_ones == alm_min_ones && cur_sec_tens == 4'd0 && cur_sec_ones == 4'd0) begin alarm_match = 1'b1; end else alarm_match = 1'b0; end endmodule注意这里秒的比较条件是cur_sec_tens == 0 && cur_sec_ones == 0,也就是只在整分时刻触发一次。如果你把秒也纳入比较,闹钟会在那一分钟内持续响,而不是响一声就停。实际产品中闹钟通常响 30 秒到 1 分钟,所以可以用一个计数器在alarm_match拉高后开始计时,计到 30 秒后自动关闭。alarm_en是一个使能开关,用户可以通过按键打开或关闭闹钟,避免每天到点都响。
4.2 蜂鸣器驱动波形与占空比参数
蜂鸣器分有源和无源两种。有源蜂鸣器给高电平就响,驱动简单;无源蜂鸣器需要方波驱动,频率决定音调。FPGA 的 IO 口驱动能力有限,通常要加一个三极管或 MOS 管来驱动蜂鸣器。如果直接用 IO 口驱动,电流可能超过 20mA,长期工作会损坏 IO。
// 无源蜂鸣器驱动,输出 2kHz 方波 module buzzer_driver ( input wire clk, // 50MHz input wire rst_n, input wire alarm_match, // 闹钟匹配信号 output reg buzzer_out // 蜂鸣器输出 ); reg [14:0] cnt_buzz; // 15 位计数器,2kHz 半周期 12,500 个时钟 reg alarm_active; // 响铃状态 // 响铃持续 30 秒 reg [24:0] cnt_duration; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin alarm_active <= 1'b0; cnt_duration <= 25'd0; end else if (alarm_match) begin alarm_active <= 1'b1; cnt_duration <= 25'd0; end else if (alarm_active) begin if (cnt_duration == 25'd1_499_999_999) // 30 秒 alarm_active <= 1'b0; else cnt_duration <= cnt_duration + 1'b1; end end // 2kHz 方波生成 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_buzz <= 15'd0; buzzer_out <= 1'b0; end else if (alarm_active) begin if (cnt_buzz == 15'd12_499) begin cnt_buzz <= 15'd0; buzzer_out <= ~buzzer_out; end else cnt_buzz <= cnt_buzz + 1'b1; end else begin cnt_buzz <= 15'd0; buzzer_out <= 1'b0; end end endmodule参数15'd12_499对应 50MHz 下 2kHz 方波的半周期。2kHz 是比较常见的蜂鸣器音调,听起来不刺耳。25'd1_499_999_999对应 30 秒的响铃时长,如果你觉得太长,可以改成25'd499_999_999(10 秒)。注意cnt_duration的位宽要够,30 秒在 50MHz 下需要 31 位,这里用 25 位只能计到约 0.67 秒,实际综合时会溢出。正确的位宽是$clog2(50_000_000 * 30),约 31 位。我在第一次做的时候就是位宽给少了,结果响铃时间完全不对,查了半天才发现是计数器溢出。
5. 数码管动态扫描与显示驱动:六位数码管怎么不闪烁
5.1 动态扫描的刷新率与消隐处理
六位数码管如果采用静态驱动,需要 6 组段选信号,IO 口占用太多。动态扫描是更常见的做法:用 1kHz 使能依次选通每一位,段选信号共用。刷新率是 1kHz / 6 ≈ 166Hz,人眼不会感到闪烁。但如果你把扫描频率降到 100Hz,刷新率变成 16.7Hz,就会明显看到闪烁。
// 六位数码管动态扫描 module seg_scan ( input wire clk, input wire rst_n, input wire en_1khz, // 1kHz 扫描使能 input wire [3:0] digit0, // 秒个位 input wire [3:0] digit1, // 秒十位 input wire [3:0] digit2, // 分个位 input wire [3:0] digit3, // 分十位 input wire [3:0] digit4, // 时个位 input wire [3:0] digit5, // 时十位 input wire [2:0] cursor, // 光标位置 input wire blink, // 闪烁使能 output reg [7:0] seg_data, // 段选,共阴极 output reg [5:0] seg_sel // 位选,低电平有效 ); reg [2:0] scan_idx; always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_idx <= 3'd0; else if (en_1khz) scan_idx <= (scan_idx == 3'd5) ? 3'd0 : scan_idx + 1'b1; end // 位选译码 always @(*) begin case (scan_idx) 3'd0: seg_sel = 6'b111110; 3'd1: seg_sel = 6'b111101; 3'd2: seg_sel = 6'b111011; 3'd3: seg_sel = 6'b110111; 3'd4: seg_sel = 6'b101111; 3'd5: seg_sel = 6'b011111; default: seg_sel = 6'b111111; endcase end // 段选译码,带光标闪烁 reg [3:0] cur_digit; always @(*) begin case (scan_idx) 3'd0: cur_digit = digit0; 3'd1: cur_digit = digit1; 3'd2: cur_digit = digit2; 3'd3: cur_digit = digit3; 3'd4: cur_digit = digit4; 3'd5: cur_digit = digit5; default: cur_digit = 4'd0; endcase end always @(*) begin // 光标闪烁:如果当前位是光标位且 blink 为高,则熄灭 if (blink && scan_idx == cursor) seg_data = 8'h00; else begin case (cur_digit) 4'd0: seg_data = 8'h3F; 4'd1: seg_data = 8'h06; 4'd2: seg_data = 8'h5B; 4'd3: seg_data = 8'h4F; 4'd4: seg_data = 8'h66; 4'd5: seg_data = 8'h6D; 4'd6: seg_data = 8'h7D; 4'd7: seg_data = 8'h07; 4'd8: seg_data = 8'h7F; 4'd9: seg_data = 8'h6F; default: seg_data = 8'h00; endcase end end endmodule段选码8'h3F到8'h6F是共阴极数码管的 0 到 9 编码。如果你用的是共阳极数码管,这些值要取反。seg_sel是低电平有效的位选信号,6'b111110表示选通第 0 位。注意在切换位选的时候,段选信号也要同步切换,否则会出现“鬼影”——上一位的段码短暂地显示在下一位上。解决办法是在位选切换的那个周期把段选清零,或者用寄存器打一拍。我一般会在en_1khz拉高时先关掉所有位选,下一个周期再打开新的位选,这样虽然损失了一点亮度,但显示干净。
5.2 光标闪烁与设置模式的显示区分
设置模式下,当前光标位要以 1Hz 闪烁,其他位常亮。闪烁信号blink直接来自分频器的en_1hz,但要注意en_1hz是单周期脉冲,不能直接当电平用。正确的做法是用一个 T 触发器把en_1hz转成 0.5Hz 的方波,或者用计数器分频。这里为了简单,我假设blink已经是一个 1Hz 的方波信号。
另外,设置时间模式和设置闹钟模式在显示上要有区分。常见做法是:设置时间时,数码管显示当前时间;设置闹钟时,数码管显示闹钟时间。这需要在顶层模块里用一个多路选择器,根据mode信号选择显示当前时间还是闹钟时间。如果不做区分,用户设置闹钟时看到的是当前时间,会以为按键没生效。
6. 避坑与排查:仿真过了上板不跑,先查这五个地方
6.1 现象:数码管显示乱码或全亮
原因通常是段选码极性搞反了。共阴极和共阳极的段选码是反的,如果你拿共阴极的码去驱动共阳极数码管,显示的就是乱码。解决方法是查一下开发板原理图,确认数码管是共阴还是共阳。另一个原因是位选信号没有消隐,切换位的时候上一位的段码串到下一位,看起来像乱码。解决办法是在位选切换时插入一个周期的全灭状态。
6.2 现象:按键按一次跳多位
这是消抖没做好。机械按键的抖动窗口通常在 10ms 左右,如果你的消抖计数器只数了 1ms,抖动还没结束就被识别成多次按下。把消抖时间加到 20ms 通常能解决。另一个可能是按键脉冲没有做边沿检测,key_press在按键持续按下时一直为高,状态机每个时钟周期都收到一次“按下”。检查key_press的逻辑,确保它只在按键稳定后的第一个周期拉高。
6.3 现象:走时速度不对,快一倍或慢一半
分频参数和板载时钟不匹配。50MHz 的板子用了 100MHz 的分频值,走时就会慢一半;反过来则快一倍。确认开发板晶振频率,然后重新计算分频计数器。另外,如果你用了 PLL 倍频或分频,实际时钟频率可能和标称值不同,用示波器测一下 PLL 输出,或者用一个已知频率的信号源验证。
6.4 现象:闹钟到点不响
先查alarm_en是否打开。很多人在设置闹钟后忘了打开使能开关,导致比较逻辑被屏蔽。然后查比较条件:秒的比较是不是00,如果写成了cur_sec == alm_sec,而闹钟的秒没有设置,可能永远不匹配。最后查蜂鸣器驱动:有源蜂鸣器给高电平就响,无源蜂鸣器需要方波。如果你用方波驱动有源蜂鸣器,它只会发出很轻的“嗒嗒”声,而不是持续响。
6.5 现象:综合报时序违例,上板不稳定
50MHz 时钟下,组合逻辑路径太长会导致建立时间违例。常见的长路径是 BCD 计数器的进位链和数码管译码器。解决办法是在进位链中间插入寄存器,或者把数码管译码改成流水线结构。另一个可能是跨时钟域信号没有同步,比如按键信号直接进了状态机。加两级同步寄存器通常能解决。如果时序违例不严重,可以尝试降低时钟频率,但这不是长久之计。
7. 进阶技巧:用参数化模块和仿真测试把调试时间砍一半
7.1 参数化设计:让同一套代码适配不同板子
前面每个模块都硬编码了分频参数,换一块板子就要改好几个文件。更工程化的做法是用parameter把时钟频率和分频比抽出来,在顶层模块统一传入。
module clk_divider #( parameter CLK_FREQ = 50_000_000, // 输入时钟频率 parameter EN_1HZ_DIV = CLK_FREQ, // 1Hz 分频比 parameter EN_1KHZ_DIV = CLK_FREQ / 1000 // 1kHz 分频比 )( input wire clk, input wire rst_n, output reg en_1hz, output reg en_1khz ); reg [31:0] cnt_main; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt_main <= 32'd0; else if (cnt_main == EN_1HZ_DIV - 1) cnt_main <= 32'd0; else cnt_main <= cnt_main + 1'b1; end always @(posedge clk or negedge rst_n) begin if (!rst_n) en_1hz <= 1'b0; else en_1hz <= (cnt_main == EN_1HZ_DIV - 1); end always @(posedge clk or negedge rst_n) begin if (!rst_n) en_1khz <= 1'b0; else en_1khz <= (cnt_main % EN_1KHZ_DIV == EN_1KHZ_DIV - 1); end endmodule这样换板子的时候只需要在顶层改CLK_FREQ一个参数,所有分频值自动计算。注意EN_1KHZ_DIV用了除法,综合工具会在编译时算出常数,不会消耗运行时资源。如果你的综合工具不支持参数化除法,可以手动算好传入。
7.2 仿真测试:用 testbench 覆盖 59 秒到 00 秒的进位
上板调试很耗时,很多逻辑错误其实可以在仿真阶段发现。写一个 testbench,把时钟频率降低到 1kHz,这样 1 秒的仿真只需要 1000 个周期,几毫秒就能跑完 24 小时的计时。
`timescale 1ns / 1ps module tb_alarm; reg clk; reg rst_n; wire en_1hz, en_1khz; // 用 1kHz 时钟仿真,分频参数相应缩小 clk_divider #( .CLK_FREQ(1000), .EN_1HZ_DIV(1000), .EN_1KHZ_DIV(1) ) u_div ( .clk(clk), .rst_n(rst_n), .en_1hz(en_1hz), .en_1khz(en_1khz) ); // 时钟生成 initial begin clk = 0; forever #500 clk = ~clk; // 1kHz end // 测试序列 initial begin rst_n = 0; #2000 rst_n = 1; // 等待 59 秒,观察进位 wait (en_1hz); repeat (59) @(posedge en_1hz); #1000; // 检查秒计数器是否归零,分是否进位 // 这里可以加断言或打印语句 $display("Time: %t, sec_ones=%d, sec_tens=%d", $time, u_sec.sec_ones, u_sec.sec_tens); #10000 $finish; end endmodule这个 testbench 把分频参数缩小到 1/50000,仿真速度提升了几万倍。你可以用$display打印关键信号,或者用波形查看器观察进位时刻。重点检查 59 秒到 00 秒的跳变、23 时到 00 时的跳变、以及闹钟匹配时刻的蜂鸣器输出。仿真通过后再上板,能省掉大量抓波形的时间。
7.3 一个具体技巧:用 SignalTap 抓按键消抖后的脉冲
如果你的开发板支持嵌入式逻辑分析仪(比如某公司的 SignalTap),一定要用起来。把key_press、en_1hz、alarm_match这几个信号加到观察列表里,设置触发条件为key_press上升沿。这样按键一按下,逻辑分析仪就抓到波形,你能直接看到消抖后的脉冲宽度、状态机的迁移过程、以及闹钟匹配时的信号变化。比用 LED 调试直观得多。
我自己的习惯是:仿真跑通基本功能,上板后用 SignalTap 抓一次完整操作流程,确认按键、状态机、显示、蜂鸣器四个环节的时序都对得上。如果某个环节没反应,就往前一级查,直到找到断点。这套流程走下来,一个闹钟系统从写代码到稳定运行,通常两天内能搞定。希望帮到你。
本文还有配套的精品资源,点击获取