news 2026/9/16 1:51:45

基于FPGA的闹钟系统设计:Verilog时序与状态机实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于FPGA的闹钟系统设计:Verilog时序与状态机实战

简介:基于FPGA的闹钟系统设计完整工程包,适用于数字逻辑、EDA技术等课程设计场景,帮助学习者完成带闹钟功能的24小时计时器。设计包含七段数码管显示、按键输入、时间设置与闹钟比较、扬声器驱动等模块,覆盖从RTL编码到上板验证的完整流程。压缩包共122个文件,以Verilog源码(.v)为核心,辅以Xilinx约束文件(.xdc)、Vivado工程文件(.xpr)、综合布线报告(.rpt)及可下载比特流(.bit),并包含自动仿真/综合脚本(.bat/.tcl)与工程日志,整体仅1.48MB,便于快速下载与本地复现。已有546人学习,适合正在做同类课程设计或想理解FPGA计时器实现细节的读者。资源提供课程设计一手资料,可直接用Vivado打开工程查看层次化设计,对照源码理解按键消抖、时间计数、闹钟匹配发声等关键逻辑,也可参考其中的约束与引脚分配快速迁移到自己的板卡上。

1. 基于 FPGA 的闹钟系统设计:一个把时序写进硅片的最小完整项目

很多 FPGA 入门教程会把「闹钟」当成计时器来写,以为只要计数器能数到 60 就完成了。实际上,一个能调时间、能定闹、能响铃的基于 FPGA 的闹钟系统设计,至少同时牵扯四条技术线:分频计数、按键消抖、BCD 进位、数码管动态扫描。这四条线单独拿出来都是数字电路基础,合在一起才是一份合格的 RTL 练手项目,也正好覆盖了 FPGA 开发中最常被问到的「计数器怎么写、状态机怎么跳、组合逻辑怎么不产生毛刺」这类问题。对于有 5 年以上经验的工程师,这个项目真正的价值在于提醒你:跨时钟域处理、复位释放、使能脉冲的时序关系,在 50MHz 下怎么做才不留下隐患。下文会直接给出可综合的 Verilog 代码、参数表和上板排错手段。

2. 时间基准与按键输入:分频计数、同步器和消抖的 RTL 实现

2.1 分频到 1 秒:单周期 tick 比方波可靠

基于 FPGA 的闹钟系统设计首先要回答一个基础问题:1 秒从哪里来。开发板通常提供 50MHz 或 100MHz 的板载晶振,FPGA 内部不可能直接生成另一个独立时钟,只能通过分频计数产生秒脉冲。这里要刻意区分「秒脉冲」和「秒方波」:方波是占空比 50% 的信号,适合驱动蜂鸣器;而推进时间计数器的应该是单周期宽的 tick 脉冲。原因是计数器在 tick 的上升沿只加 1,如果用方波直接当时钟,频率源和设计逻辑会混在一起,综合后的时钟树也更容易出时序问题。

module clk_div #( parameter integer DIV = 50_000_000 )( input wire clk, input wire rst_n, output reg tick ); reg [$clog2(DIV)-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; tick <= 1'b0; end else if (cnt == DIV - 1) begin cnt <= 0; tick <= 1'b1; end else begin cnt <= cnt + 1; tick <= 1'b0; end end endmodule

这段代码用$clog2(DIV)自动计算计数器位宽,DIV 为 50_000_000 时位宽是 26,正好覆盖 0 到 49_999_999。cnt 计数到 DIV-1 时拉高一个时钟周期的 tick,其他时间都保持为 0。关键参数是 DIV:它必须等于目标频率与系统频率的比值减一,而不是直接写 1 秒。如果板载时钟是 27MHz,DIV 就改成 27_000_000;如果还想让闹钟支持毫秒级校时,可以再例化一个 DIV 为 50_000 的模块得到 1ms 脉冲,上一级计数值按 1000 累加。这里要注意,tick 是高电平有效的使能信号,后续所有计数器都应该用if (tick) q <= q + 1的方式推进,而不是把 tick 接在时钟引脚上。

2.2 按键不是理想开关:两级同步器与计数消抖

机械按键在按下和释放的瞬间会产生长达几毫秒到几十毫秒的抖动,采样结果会是一串不稳定的电平跳变。如果直接把按键接到状态机的跳转条件上,一次按击可能触发多次设置操作。而且按键信号相对系统时钟是异步输入,第一级寄存器采样时存在亚稳态窗口。常见做法是先做两级同步器消除亚稳态传播,再用一个计数器要求新电平稳定保持一段时间后才认为按键状态真正改变。

module debounce #( parameter integer STABLE_CYCLES = 20_000 )( input wire clk, input wire rst_n, input wire btn_in, output wire btn_press ); reg btn_s0, btn_s1, btn_s2; reg [$clog2(STABLE_CYCLES)-1:0] cnt; reg stable; reg stable_d; wire changed = btn_s2 ^ stable; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin btn_s0 <= 1'b0; btn_s1 <= 1'b0; btn_s2 <= 1'b0; end else begin btn_s0 <= btn_in; btn_s1 <= btn_s0; btn_s2 <= btn_s1; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; stable <= 1'b0; end else if (changed) begin if (cnt == STABLE_CYCLES - 1) begin cnt <= 0; stable <= btn_s2; end else begin cnt <= cnt + 1; end end else begin cnt <= 0; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) stable_d <= 1'b0; else stable_d <= stable; end assign btn_press = stable && !stable_d; endmodule

这个消抖模块的输出 btn_press 不是按键原始边沿,而是「电平稳定后的上升沿脉冲」,宽度为一个时钟周期。逻辑说明分三段:前两个 always 块构成同步器,btn_s2 记录稳定后的输入;第三个 always 块在输入与稳定值不一致时启动计数,只有连续保持 STABLE_CYCLES 个周期才更新稳定值;最后一级触发器和组合逻辑产生单周期脉冲。参数 STABLE_CYCLES 的选择参考下表。

系统时钟STABLE_CYCLES实际消抖时间适用场景
50MHz20_000400us按键自带 RC 滤波或对抖动不敏感
50MHz500_00010ms常见机械轻触按键
50MHz1_000_00020ms不确定触点质量、误触率敏感

如果按键按下去是低电平有效,把btn_in取反后再接入模块。注意仿真时这套参数会导致仿真时间过长,后文第 5 章会用参数覆盖的方式把消抖时间压到几个时钟周期。

2.3 复位策略与使能对齐

分频模块和消抖模块都用异步复位、同步释放是最保守的写法。rst_n作为全局复位,必须保证释放时系统时钟已经稳定。实际开发板上如果复位键没有接 RC 延时,上电瞬间可能采样到不定态。一个稳妥做法是在顶层模块里给 rst_n 加一个专门的复位同步器,用两级寄存器把外部异步复位锁存到系统时钟域,再分发到内部逻辑。另外一个容易被忽略的问题是使能对齐:当分频模块的 tick 同时驱动多个计数器时,所有计数器必须在同一个时钟沿采样到这个脉冲,否则会出现秒、分、时之间相差一拍。RTL 里要让这些模块的代码风格完全一致,避免某些模块用组合逻辑对 tick 做二次打拍。

3. 闹钟逻辑的核心:BCD 时基计数器与状态跳转的单时钟域设计

3.1 时间显示用 BCD:进位边界和钳位条件

二进制计数器在 FPGA 里更省寄存器,但输出到数码管时需要每次做二进制到十进制的转换,既消耗组合逻辑,也不直观。基于 FPGA 的闹钟系统设计里,我习惯直接使用 BCD 码表示时分秒,每个十进制位占用 4 位寄存器,按个位和十位串联进位。秒个位计数到 9 时回 0 并向秒十位进位,秒十位计数到 5 时回 0 并向分个位进位,依次类推。小时位比较特殊:24 小时制的十位只允许到 2,且十位为 2 时个位到 4 就整体清零进入新的一天,此时不需要把进位继续向更高位传递。

module bcd_counter #( parameter integer LIMIT = 9 )( input wire clk, input wire rst_n, input wire en, output reg [3:0] q, output reg carry ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin q <= 4'd0; carry <= 1'b0; end else if (en) begin if (q == LIMIT) begin q <= 4'd0; carry <= 1'b1; end else begin q <= q + 4'd1; carry <= 1'b0; end end else begin carry <= 1'b0; end end endmodule

上述模块是一个可复用的 BCD 计数单元。en 是高电平有效的计数使能,carry 在回绕瞬间输出一个占一拍的脉冲。把秒个位例化为 LIMIT=9,秒十位 LIMIT=5,分个位 LIMIT=9,分十位 LIMIT=5,时个位 LIMIT=9,就能得到 00:00:00 到 23:59:59 的时间链。小时十位不能用固定的 LIMIT,需要在外层逻辑里单独判断:当hour_ten == 2 && hour_one == 4时,下一个秒 tick 把全部计数器清零。这里有一个容易踩的坑:如果小时十位 LIMIT 也设为 2,那么在 20 到 23 点之间小时十位会正常进位,但到 24 点时会产生一个小时十位的 carry,这个 carry 如果没有被上层处理,时间会错误地跳到 30:00。正确做法是让小时十位 LIMIT=9,由外层逻辑在 24 点触发整体复位。

3.2 闹钟状态机:set、inc、stop 的输入仲裁

闹钟的按键操作需要一套状态机来管理。常见做法是三个按键:set 键在「正常显示、设置小时、设置分钟、闹钟响铃」这几个状态之间切换,inc 键在设置状态下累加对应字段,stop 键在响铃时停止闹钟。所有键的按键脉冲都由第 2 章的 debounce 模块产生,因此状态机不必关心抖动细节,只处理干净的边沿脉冲。

module alarm_fsm ( input wire clk, input wire rst_n, input wire btn_set, input wire btn_inc, input wire btn_stop, input wire alarm_match, input wire alarm_en, output reg [1:0] state, output reg cfg_hour, output reg cfg_min, output reg ring ); localparam S_NORMAL = 2'd0; localparam S_SET_HOUR = 2'd1; localparam S_SET_MIN = 2'd2; localparam S_ALARM_RING = 2'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_NORMAL; ring <= 1'b0; cfg_hour<= 1'b0; cfg_min <= 1'b0; end else begin case (state) S_NORMAL: begin cfg_hour <= 1'b0; cfg_min <= 1'b0; if (btn_set) state <= S_SET_HOUR; else if (alarm_match && alarm_en) begin state <= S_ALARM_RING; ring <= 1'b1; end end S_SET_HOUR: begin cfg_hour <= 1'b1; if (btn_inc) // 此时应该在时钟模块中把 hour_one + 1 state <= S_SET_HOUR; else if (btn_set) state <= S_SET_MIN; end S_SET_MIN: begin cfg_hour <= 1'b0; cfg_min <= 1'b1; if (btn_inc) state <= S_SET_MIN; else if (btn_set) state <= S_NORMAL; end S_ALARM_RING: begin if (btn_stop) begin state <= S_NORMAL; ring <= 1'b0; end end default: state <= S_NORMAL; endcase end end endmodule

这段状态机把模式信号 cfg_hour 和 cfg_min 输出给时钟计数模块。时钟模块在 cfg_hour 为高时,把每个 inc 脉冲当作一次小时增加来处理;cfg_min 为高时,把 inc 脉冲当作分钟增加。跳转关系是直线式的:正常态按 set 进入设置小时,再按 set 进入设置分钟,第三次 set 返回正常。这个流程很符合物理按键操作直觉,也便于数码管上高亮显示当前正在设置的字段。alarm_match 来自比较器:当设定的闹钟时间与当前时分秒完全相等时拉高。alarm_en 是闹钟总开关,可以由另一个拨码开关或长按 set 键控制。响铃状态里用 ring 信号驱动蜂鸣器,而不是直接让蜂鸣器持续响到耗尽电源,stop 键和超时自动关闭都可以通过这个状态退出。

3.3 设置状态下的越界保护

在设置小时和分钟时,inc 脉冲不能无限制地让 BCD 叠加。小时设置建议用一个小型状态逻辑:当 cfg_hour 有效且收到 inc 脉冲时,判断当前 hour 值,如果到了 23 就回 0,否则加 1。分钟设置类似,但要多判断一次个位和十位的联合值。这个保护逻辑可以写在顶层模块里,也可以用一段always块独立实现。一个经验是不要在状态机内部直接修改时间计数值,而是把 inc 转化为inc_hour_pulseinc_min_pulse,让时间计数模块统一处理。这样做的好处是,所有影响时间数值的逻辑只有一个数据源,避免状态机的组合逻辑路径直接连到多个 BCD 计数器的置位端。

4. 数码管动态显示与顶层互联:把计数结果送到引脚

4.1 六位数码管动态扫描的时序指标

时间显示用六个七段数码管,位选信号依次点亮每一位,段选信号同时送出该位的字形编码。人眼存在视觉暂留,只要每位刷新频率不低于 60Hz,扫描看起来就是连续稳定的。六位扫描时,每一位的扫描周期可以设置在 1ms 到 2ms 之间,这样整体刷新率在 80Hz 到 160Hz 之间,既能避免闪烁,又不会因为切换过快导致段选信号还没稳定就被采样。扫描计数器用系统时钟计数产生,分频目标一般取 1k 到 4k。注意位选信号之间需要插入一个空拍或者利用掩码,防止切换瞬间出现「幽灵位」显示上一个位的残影。

module seg_driver #( parameter integer CLK_HZ = 50_000_000, parameter integer SCAN_HZ = 1000 )( input wire clk, input wire rst_n, input wire [23:0] bcd_data, input wire dot_en, output reg [5:0] an, output reg [7:0] seg ); localparam integer CNT_MAX = CLK_HZ / SCAN_HZ - 1; reg [$clog2(CNT_MAX)-1:0] cnt; reg [2:0] pos; reg [3:0] bcd; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; pos <= 0; end else begin if (cnt == CNT_MAX) begin cnt <= 0; pos <= pos + 1; end else begin cnt <= cnt + 1; end end end always @(*) begin case (pos) 3'd0: begin an = 6'b111110; bcd = bcd_data[3:0]; end 3'd1: begin an = 6'b111101; bcd = bcd_data[7:4]; end 3'd2: begin an = 6'b111011; bcd = bcd_data[11:8]; end 3'd3: begin an = 6'b110111; bcd = bcd_data[15:12]; end 3'd4: begin an = 6'b101111; bcd = bcd_data[19:16]; end 3'd5: begin an = 6'b011111; bcd = bcd_data[23:20]; end default: begin an = 6'b111111; bcd = 4'd0; end endcase end always @(*) begin case (bcd) 4'h0: seg = 8'h3F; 4'h1: seg = 8'h06; 4'h2: seg = 8'h5B; 4'h3: seg = 8'h4F; 4'h4: seg = 8'h66; 4'h5: seg = 8'h6D; 4'h6: seg = 8'h7D; 4'h7: seg = 8'h07; 4'h8: seg = 8'h7F; 4'h9: seg = 8'h6F; default: seg = 8'h00; endcase end endmodule

上述代码采用共阴极数码管引脚假设,段码高电平点亮。an是位选,低电平选中对应位,例如6'b111110表示第 0 位被选中。bcd_data[3:0]是秒个位,[7:4]是秒十位,[11:8]是分个位,依次类推,最后[23:20]是小时十位。扫描计数器每 1ms 切换一次 pos,完整六位一个周期是 6ms,刷新率 166Hz。如果开发板是共阳极数码管,把seg改成~seg,位选改成高电平选中即可。dot_en 用来控制小数点,本设计里可以让秒十位与分钟之间的点在每秒 tick 时翻转,实现一个独立的秒闪烁指示。

4.2 段码表与极性处理

上面代码里的七段码表是最容易出错的区域。常见的开发板有两种接法:共阴极数码管段选输出高电平点亮,共阳极数码管段选输出低电平点亮。同一个数字 0 在共阴极下是8'h3F,在共阳极下则要按位取反变成8'hC0。更麻烦的是有些 PCB 布局会把段选顺序打乱,比如 a 段接在数据的 bit1 而不是 bit0,这时段码表必须按实际原理图重新排列。我一般会在拿到开发板工程后先写一个小测试:让其中一个数码管依次点亮 a 到 dp 八段,肉眼确认引脚顺序,再回来修改段码表。这一步如果跳过,后续显示内容会全是乱码,而且排错成本极高。

4.3 顶层例化与 IO 规划

顶层模块把分频、消抖、BCD 计数器、状态机、数码管驱动串在一起。例化时最需要注意的是各模块之间的使能信号命名不能混淆:分频模块输出的秒 tick 送入秒个位 bcd_counter 的 en,秒个位的 carry 送入秒十位的 en,这样一个脉冲链就完成了。顶层同时还要把闹钟时间寄存器和当前时间寄存器做一次比较,比较结果直接给状态机的 alarm_match。

对于 FPGA 项目来说,管脚约束至少要确认三件事:系统时钟引脚对应开发板上的哪个 BANK,按键输入是否有上拉电阻,数码管位选和段选是否有驱动芯片。如果数码管段选电流较大,建议查看板载三极管或驱动芯片型号,FPGA 的 IO 不能直接长时间承受大电流。FPGA 工程师和 PCB 工程师之间最常见的交互就是这里:原理图上段选线和位选线的网络命名是否与约束文件里的端口名一一对应,网络顺序是否一致。这个闹钟系统虽然规模不大,但它把「RTL 端口、约束文件、PCB 网络」三者对齐了一遍,后续做大项目时这类经验会直接帮你缩短 debug 周期。

5. 上板前用仿真把时间走到 00:00,再用三个动作排查硬件

5.1 仿真时覆盖消抖参数

基于 FPGA 的闹钟系统设计到了上板前的最后关卡,最容易被卡住的反而是仿真时间太长。消抖模块默认 STABLE_CYCLES 是 50 万,在 testbench 里要等 10ms 才会产生一个按键脉冲,而验证一次完整的「小时设置 + 闹钟触发」流程往往要模拟好几分钟的虚拟时间,仿真器实际跑起来可能几分钟到几十分钟。我习惯在实例化 DUT 时直接用参数覆盖把消抖时间压到 4 个时钟周期:

debounce #( .STABLE_CYCLES(4) ) u_debounce_set ( .clk (clk), .rst_n (rst_n), .btn_in (btn_set), .btn_press (set_press) );

同理,分频模块也要在仿真例化时把 DIV 调小。可以用 10,这样每 10 个时钟周期出一个秒 tick。先验证状态机跳转和设置逻辑,再单独用默认 DIV 验证真实秒脉冲宽度。在 testbench 里把闹钟时间预设为当前时间加 2 秒,等待 alarm_match 拉高,确认 ring 信号在 stop 按下后回落。这一套跑通后,上板出现问题的概率会明显降低。

5.2 板级三查:时钟、按键、数码管

上板后如果时间不走,先不要怀疑逻辑,按三个动作排查。第一,把 clk_div 的 tick 信号引到一个空闲 LED 上,和手机秒表对 2 分钟,如果 LED 闪烁频率明显不对,说明板载时钟频率与参数 DIV 不一致,或者时钟引脚约束错误。第二,按下任意按键,观察按钮对应的 LED 或数码管是否跳变一次,如果没有反应,优先检查 debounce 模块的输入引脚是否上下拉反了,以及按键是否被复用为 FPGA 配置引脚。第三,单独用seg_driver模块循环显示 0 到 9,确认段码表和位选极性正确。第三步如果反了,显示数字会缺笔画但不至于乱跳,而位选反了则会出现多个位同时点亮或只有一位亮。

最后说一个很实用的诊断技巧:把 set 键的操作从「短按切换状态」改成「长按 2 秒进入设置」,再配合btn_press计数寄存器,可以直接在仿真里判断按键是否有粘连。保持单时钟域设计、所有按键都过同步器和消抖,这套闹钟设计在器件差异和温度变化下的行为仍然可预测。用 LED 对时法把秒脉冲校准后,剩下的事情就是信任综合工具,把工程烧进芯片,等它在设定的时刻响铃。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 1:51:41

Zynq Linux启动文件全解析:从RAR解压到SD卡跑通

简介&#xff1a;在Zynq SoC平台的Linux开发中&#xff0c;由于芯片同时集成ARM Cortex-A9与FPGA&#xff0c;驱动与硬件交互复杂&#xff0c;调试宏头文件便成为定位问题的利器&#xff1b;一份极简压缩包聚焦调试宏头文件设计&#xff0c;面向嵌入式驱动开发、系统优化及底层…

作者头像 李华
网站建设 2026/9/16 1:51:17

文本分类入门实战:基于TF-IDF与机器学习模型的完整指南

来了&#xff0c;作为带过不少NLP入门项目的从业者&#xff0c;我太清楚“LSTM调参调到头秃、结果还没传统模型高”是什么滋味了。这也是为什么我一直觉得&#xff0c;NLP-Beginner系列任务一设置的“基于机器学习的文本分类”特别适合作为第一站——它没有复杂的网络结构&…

作者头像 李华
网站建设 2026/9/16 1:51:14

DVT Eclipse保姆级安装教程:从零搭建SystemVerilog验证环境

1. 为什么验证老兵都推荐DVT Eclipse先交代下背景。我在芯片验证这行干了快十年&#xff0c;从最初用GVim写SystemVerilog&#xff0c;到后来被同事安利了DVT Eclipse&#xff0c;说实话有点“相见恨晚”的感觉。如果你平时写验证环境主要靠VSCode加插件&#xff0c;或者还在用…

作者头像 李华
网站建设 2026/9/16 1:50:16

Lamb波频散曲线:理论、Python求解与频散补偿应用

简介&#xff1a;这是一份基于MATLAB的Lamb波频散曲线计算与绘制资料&#xff0c;面向从事结构健康监测、无损检测及相关研究的工程师与科研人员。内容围绕薄板中Lamb波的传播特性&#xff0c;涵盖相速度与群速度的频散求解思路&#xff0c;并通过可视化图形直观展示波速随频率…

作者头像 李华
网站建设 2026/9/16 1:50:10

LLM应用开发实战地图:RAG、AI Agents与开源框架工程化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 1:48:26

2026精选:自带大量可商用字体的在线设计平台汇总推荐

字体版权是平面设计、新媒体配图、商业宣传中的核心刚需&#xff0c;非商用免费字体极易引发侵权纠纷&#xff0c;给个人创作者和中小商家带来损失。多数新手设计师、运营从业者难以甄别字体版权&#xff0c;也不愿付费单独购入商用字体库。2026年多款主流在线设计平台优化了商…

作者头像 李华