
简介一套基于FPGA的闹钟系统设计资源面向数字电子技术、EDA或FPGA课程设计的学生也可供入门开发者学习完整项目流程。工程实现带闹钟功能的24小时计时器含4位七段数码管显示、数字键输入、TIME与ALARM按键设置以及扬声器蜂鸣提示涉及按键消抖、分频计数、模式切换、数码管扫描驱动等关键点有助于掌握状态机设计与模块化层次化编码。压缩包共122个文件资源包约1.48MB以Verilog源码.v、Xilinx约束文件.xdc、综合/实现报告.rpt及比特流配置文件.bit为主并附有工程文件.xpr、设备脚本.bat/.tcl和综合实现结果文件.dcp从源码到bitstream的完整工程链路清晰可见便于直接打开工程复现或对照修改。已有546人学习作为一手课程设计资料非常适合作为课设参考、功能扩展或上板验证的基础。1. 一个 24 小时闹钟卡在的不只是计时如果把“基于 FPGA 的闹钟系统”当成一个简单计数器综合跑通后多半会失望数码管亮度不均、按键按十次有一次失灵、时间设置状态混乱、闹钟到点不响。这个课程设计真正的复杂度不在 24 小时计时而在“人机交互”这一层——四位数码管的动态扫描要稳定刷新数字键和功能键要可靠消抖时间设置与闹钟设置的状态切换要清晰最后还要把时间比较结果翻译成扬声器的蜂鸣驱动。这篇笔记从一个可综合的 Verilog 实现出发拆开各模块的边界、参数选择和排错方法适合正在做 FPGA 课程设计或想快速上手数码管与按键交互的同学直接参考。所有代码基于 Xilinx Vivado 开发流程目标板以 EGO1 或 Basys3 这类带 4 位共阳数码管和按键的开发板为例。2. 先拆模块从按键到扬声器的数据流与时序2.1 系统级模块划分与信号约定整个闹钟系统在 RTL 层面可以分成五个独立模块时钟分频器、按键消抖与扫描、时间计数器、闹钟比较与状态控制、动态扫描显示驱动。每个模块只通过确定的端口通信这样在仿真和在线调试时能快速定位问题。顶层模块alarm_clock_top例化所有子模块对外暴露的引脚只有时钟、复位、4 位数码管段选与位选、4×4 矩阵键盘或独立按键、蜂鸣器输出。信号约定如下表信号名方向宽度含义clk_100minput1开发板系统时钟 100 MHzrst_ninput1异步复位低有效key_row / key_colinput/output4/4矩阵键盘行、列独立按键则直接接 8 个 IOseg_outoutput8七段数码管段选共阳时低电平点亮sel_outoutput4数码管位选低电平选中当前位buzzeroutput1蜂鸣器驱动高电平鸣叫2.2 时钟分频为什么不能直接数系统时钟计数器的根本问题是时间的基准。开发板一般提供 100 MHz 或 50 MHz 时钟如果直接用这个时钟去驱动second_cnt计数周期太小无法直接得到秒脉冲。最常见的做法是用一个分频计数器产生 1 kHz 的扫描时钟和 1 Hz 的秒时钟。这里有一个容易踩的坑秒时钟不要用always块生成门控时钟gated clock而是生成一个单周期脉冲sec_pulse在脉冲到来时更新时间计数器。门控时钟在 FPGA 上会引发时序约束和毛刺问题用脉冲使能的方式则干净得多。分频器设计如下以 100 MHz 输入为例parameter CLK_FREQ 100_000_000; reg [26:0] div_cnt; reg sec_pulse; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) begin div_cnt 0; sec_pulse 0; end else if (div_cnt CLK_FREQ - 1) begin div_cnt 0; sec_pulse 1; // 单周期高电平脉冲 end else begin div_cnt div_cnt 1b1; sec_pulse 0; end end参数CLK_FREQ需要与开发板实际晶振频率一致否则 1 秒脉冲的实际间隔会偏差。sec_pulse在整个周期只拉高一个时钟周期后续模块用if (sec_pulse)而不是if (clk_1s)就是为了避免给时序分析器制造多个时钟域。如果你用的是 50 MHz 板子只改CLK_FREQ即可后面的计时逻辑完全不用动。2.3 时间计数与闹钟比较的边界条件时间计数器需要处理 24 小时回绕。比较典型的设计是用两个 4 位计数器分别表示小时十位、小时个位、分钟十位、分钟个位然后在个位满 9、十位满 5、分钟满 59 等边界处逐级进位。最直接的方式是用一个以“分钟”为单位的总计数器到 1439 分钟后归零只是显示时需要拆成 BCD 码。后者更简洁但调试时不如分段计数器直观。我用的是分段 BCD 计数方式核心代码如下always (posedge clk_100m or negedge rst_n) begin if (!rst_n) begin min_ones 4d0; min_tens 4d0; hour_ones 4d2; // 初始显示 00:00这里也可以改 hour_tens 4d0; end else if (sec_pulse) begin if (min_ones 4d9) begin min_ones 0; if (min_tens 4d5) begin min_tens 0; if ({hour_tens, hour_ones} 8h23) begin hour_tens 0; hour_ones 0; end else if (hour_ones 4d9) begin hour_ones 0; hour_tens hour_tens 1b1; end else begin hour_ones hour_ones 1b1; end end else begin min_tens min_tens 1b1; end end else begin min_ones min_ones 1b1; end end end这里用了拼接运算{hour_tens, hour_ones}来判断是否到 23 点避免单独比较hour_tens 2 hour_ones 3产生额外逻辑。乘法器资源在 FPGA 里不缺但 BCD 分段进位更适合数据通路展示也方便后续把闹钟时间设置进去。闹钟比较的逻辑不再依赖连续的“比较值”而是实时比较当前时间寄存器和闹钟寄存器是否相等相等时拉高alarm_trigger并持续输出到蜂鸣器。3. 状态机控制时间设置TIME 键与 ALARM 键怎么配合3.1 四态状态机显示、设置时间、设置闹钟、查看闹钟整个系统的控制核心是一个有限状态机至少需要四个状态RUN正常走时、SET_TIME设置当前时间、SET_ALARM设置闹钟时间、VIEW_ALARM查看已设闹钟。数字键在不同状态下的含义不同在SET_TIME状态下数字键输入的是新时间的小时和分钟在RUN状态下数字键可以被忽略或者作为快速切换功能在VIEW_ALARM状态下数码管显示的是闹钟时间而不是当前时间。状态转移规则是这样的按下 TIME 键从RUN进入SET_TIME在SET_TIME中再次按 TIME 键确认新时间并回到RUN。按下 ALARM 键进入SET_ALARM或VIEW_ALARM取决于当前状态和按键时长或状态的二级区分。比较稳妥的做法是ALARM 键短按进入VIEW_ALARM再按一次进入SET_ALARM第三次按确认返回RUN。这样避免引入长按检测的额外定时逻辑。localparam RUN 2d0; localparam SET_TIME 2d1; localparam SET_ALARM 2d2; localparam VIEW_ALARM 2d3; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) state RUN; else case (state) RUN: begin if (key_time_pulse) state SET_TIME; else if (key_alarm_pulse) state VIEW_ALARM; end SET_TIME: begin if (key_time_pulse) begin state RUN; // 新时间已写入 end end VIEW_ALARM: begin if (key_alarm_pulse) state SET_ALARM; else if (key_time_pulse) state RUN; end SET_ALARM: begin if (key_alarm_pulse) begin state RUN; // 闹钟时间已锁定 end end endcase end3.2 数字键输入缓冲与时间合法性检查状态机只负责跳转数字键的具体值需要单独处理。常见的实现是用两个 4 位寄存器作为输入缓冲buf_hour_ones、buf_min_ones每次按下数字键就把旧的十位值移到十位寄存器新按下的数字存入个数位。这样连续按四次数字键就依次填充小时十位、小时个位、分钟十位、分钟个位。合法性检查容易被忽略。比如设置 25:70如果直接把四位数字写入时间寄存器会导致显示异常。在确认键按下前必须验证小时范围 0~23分钟范围 0~59。合法才写入不合法则保持原值并停留在设置状态。这个校验逻辑可以在写入时完成function legal_time; input [3:0] h_t, h_o, m_t, m_o; begin legal_time (h_t 2) (h_t ! 2 || h_o 3) (m_t 5); end endfunction注意h_t 2已经隐含小时十位最大为 2再结合h_t2时h_o3就限定了 0~23。分钟十位只要不超过 5 就合法分钟个位 0~9 天然合法。这种写法比一堆if-else简洁得多综合后的 LUT 占用也少。4. 数码管动态显示与按键消抖时序毛刺的根源4.1 动态扫描刷新率与位选时序四位七段数码管在 FPGA 上几乎都采用动态扫描驱动。所谓动态扫描就是同一时刻只点亮一位数码管依靠人眼视觉暂留效果让人以为四位同时显示。扫描频率不能太低否则会明显闪烁也不能太高否则位选切换时的拖影会降低亮度。常见做法是用 1 kHz 的扫描时钟也就是每位占用 1 ms四位数码管刷完一轮需要 4 ms刷新率 250 Hz。reg [1:0] scan_pos; reg scan_clk; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) scan_clk 0; else if (div_cnt[16:0] 0) scan_clk ~scan_clk; // 约 763 Hz取近似 end always (posedge clk_100m or negedge rst_n) begin if (!rst_n) scan_pos 2b00; else if (scan_clk) scan_pos scan_pos 1b1; end实际工程里我一般不额外生成scan_clk而是直接用自加计数器的高位作为扫描位置。例如div_cnt[16:15]作为scan_pos这样省掉一个时钟域。位选信号sel_out在组合逻辑里根据scan_pos赋值必须用case防止产生锁存器。段选数据则来自当前扫描位对应的数字。这里有一个关键点因为扫描时钟分成 1 ms 一段而按键输入或时间更新可能发生在任意时刻所以显示数据要在切换瞬间锁定。使用寄存器寄存器保持display_data在下降沿更新避免在scan_pos变化时读到一个未稳定的值。4.2 共阳数码管的编码表与低有效陷阱EGO1 和 Basys3 上的数码管通常是共阳段选信号低电平有效。同样的数字共阴和共阳的编码完全相反。以七段显示数字 0 为例共阴极编码是8h3f共阳极则是8hc0。如果直接套用网上共阴示例会导致数码管乱码。function [7:0] seg_decode; input [3:0] digit; case (digit) 4h0: seg_decode 8hc0; // 共阳 0 4h1: seg_decode 8hf9; 4h2: seg_decode 8ha4; 4h3: seg_decode 8hb0; 4h4: seg_decode 8h99; 4h5: seg_decode 8h92; 4h6: seg_decode 8h82; 4h7: seg_decode 8hf8; 4h8: seg_decode 8h80; 4h9: seg_decode 8h90; default: seg_decode 8hff; endcase endfunction这里8hff表示全灭用于小数点或不显示的数字位。如果需要在小时和分钟之间显示冒号可以用单独的 LED 或复用小数点位但要确认开发板上有没有这个资源。4.3 按键消抖10 ms 延时与边沿检测机械按键按下和释放的瞬间会产生 10~20 ms 的抖动脉冲如果直接采电平一次按下可能被识别成多次。最稳妥的消抖方法是状态机采样但课程设计里用计数器延时更直观。典型做法是检测到按下后启动一个 10 ms 的计数器期间忽略按键变化计数结束后再采样一次电平。reg [19:0] deb_cnt; reg key_clean; reg key_pulse; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) begin deb_cnt 0; key_clean 1b1; key_pulse 0; end else if (key_raw ! key_clean) begin deb_cnt 0; // 电平变化重新计时 key_clean key_raw; // 直接跟随仅用于初级消抖 end else if (deb_cnt 20d1_000_000) begin key_pulse 1; // 10 ms 后产生脉冲 end else begin deb_cnt deb_cnt 1b1; key_pulse 0; end end上面的代码是简化版真正的消抖需要在key_raw保持稳定 10 ms 后才更新key_clean。一个更严谨的写法是每 1 ms 采样一次连续 10 次相同电平才认为稳定。但资源有限时直接用 10 ms 计数器加两次采样也够用。重点是要生成“单脉冲”而不是电平信号因为状态机是基于alarm_pulse跳转而不是alarm_key电平。4.4 蜂鸣器驱动与闹钟停止条件闹钟触发后蜂鸣器不能一直响需要提供停止机制。常见做法是闹钟触发后如果检测到任意按键按下就关闭闹钟输出或者限制最大鸣响 1 分钟。我采用后者用一个alarm_cnt在触发时计数到达设定值后自动关闭。同时保留按键停止功能。reg [5:0] alarm_cnt; wire alarm_on (time_reg alarm_reg) !alarm_stopped; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) alarm_stopped 0; else if (alarm_on) begin if (sec_pulse) alarm_cnt alarm_cnt 1; if (alarm_cnt 6d60 || key_pulse) alarm_stopped 1; end endalarm_on是一个组合逻辑比较结果每秒钟判断一次即可。注意当前时间与闹钟时间相等的那一秒sec_pulse会同时驱动时间更新和闹钟判断所以要保证时间更新发生在比较之后或者在比较逻辑里使用更新前的值否则会出现一秒钟偏差。5. 管脚约束与排错从仿真到上板的关键一步5.1 编写 XDC 约束文件的关键点Vivado 工程中管脚约束是让设计跑在真实硬件上的最后一环。之前代码里所有信号都是逻辑名必须映射到实际 FPGA 引脚。EGO1 开发板的四个数码管位选、段选、按键和蜂鸣器引脚各不相同以手头板卡的约束文件为准但写法是一致的set_property PACKAGE_PIN P17 [get_ports {seg_out[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {seg_out[0]}] set_property PACKAGE_PIN P18 [get_ports {sel_out[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {sel_out[0]}]段选和位选都是多位端口XDC 里要对每一位单独约束。如果seg_out在代码里定义为output [7:0]那么约束写成seg_out[0]到seg_out[7]。位选同理。忘记约束某一位Vivado 综合时会报The port is left unconstrained但有些版本只在时序报告中警告不会阻止生成比特流上板后多余引脚悬空数码管表现为某一位不亮或混乱。5.2 仿真时容易漏掉的场景写 testbench 时很多人只验证了时间计数和状态机跳转却忽略了动态扫描的波形。动态扫描如果刷新率不够在仿真里很难看出问题因为行为仿真不涉及电气特性。正确的做法是设置足够长的仿真时间观察sel_out和seg_out的时序关系每个位选信号持续约 1 ms段选数据必须在位选拉低前建立。另一个容易漏的是异步复位。复位信号与时钟不同步时可能使计数器进入亚稳态。建议在 testbench 里把复位信号与时钟沿对齐或者在设计内部用两级同步器做异步复位同步释放。reg [1:0] rst_sync; always (posedge clk_100m) rst_sync {rst_sync[0], rst_n}; wire rst_synced rst_sync[1];5.3 上板调试三板斧bit 文件、ILA、管脚测量当行为仿真通过但上板异常时用 Integrated Logic Analyzer 观察内部信号是最快的手段。Vivado 里可以直接在综合后网表中添加 ILA 核也可以把代码里的 ILA 例化封装好ila_0 ila_inst ( .clk(clk_100m), .probe0(scan_pos), .probe1(key_pulse), .probe2(time_reg), .probe3(alarm_reg) );我在调试中发现按键消抖参数导致的按键丢失很难从 ILA 波形里看到因为触发条件本身依赖按键。更好的办法是先强制赋值一个按键信号做仿真确认状态机行为再回到板上去测。对于数码管亮度不均匀优先检查位选信号的占空比——如果某一位占空比低于其他位通常是scan_pos的赋值逻辑在特定组合下把某一位多占了一个周期。如果板子上有 LED可以把关键状态映射到 LED 上做可视化这样不用接逻辑分析仪也能快速判断状态机当前在哪个状态。比如把state输出到 4 个 LED按下 TIME 键时观察 LED 变化是否符合预期。5.4 最后的验证清单一块完整的闹钟板卡需要验证这些场景正常走时分钟进位是否正确设置 23:59 后下一分钟是否回绕为 00:00闹钟时间设置到当前时间蜂鸣器是否在一秒内响起按任意键是否停响数码管是否有明显闪烁或位串扰。如果全部通过这个课程设计基本就是满分的状态了。保存好综合后的zonghe_routed.dcp和比特流文件下次需要重新生成或调试时能省掉整个综合布局布线的时间。本文还有配套的精品资源点击获取