拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于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 1b0; end else if (cnt DIV - 1) begin cnt 0; tick 1b1; end else begin cnt cnt 1; tick 1b0; end end endmodule这段代码用$clog2(DIV)自动计算计数器位宽DIV 为 50_000_000 时位宽是 26正好覆盖 0 到 49_999_999。cnt 计数到 DIV-1 时拉高一个时钟周期的 tick其他时间都保持为 0。关键参数是 DIV它必须等于目标频率与系统频率的比值减一而不是直接写 1 秒。如果板载时钟是 27MHzDIV 就改成 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 1b0; btn_s1 1b0; btn_s2 1b0; 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 1b0; 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 1b0; 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 4d0; carry 1b0; end else if (en) begin if (q LIMIT) begin q 4d0; carry 1b1; end else begin q q 4d1; carry 1b0; end end else begin carry 1b0; end end endmodule上述模块是一个可复用的 BCD 计数单元。en 是高电平有效的计数使能carry 在回绕瞬间输出一个占一拍的脉冲。把秒个位例化为 LIMIT9秒十位 LIMIT5分个位 LIMIT9分十位 LIMIT5时个位 LIMIT9就能得到 00:00:00 到 23:59:59 的时间链。小时十位不能用固定的 LIMIT需要在外层逻辑里单独判断当hour_ten 2 hour_one 4时下一个秒 tick 把全部计数器清零。这里有一个容易踩的坑如果小时十位 LIMIT 也设为 2那么在 20 到 23 点之间小时十位会正常进位但到 24 点时会产生一个小时十位的 carry这个 carry 如果没有被上层处理时间会错误地跳到 30:00。正确做法是让小时十位 LIMIT9由外层逻辑在 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 2d0; localparam S_SET_HOUR 2d1; localparam S_SET_MIN 2d2; localparam S_ALARM_RING 2d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_NORMAL; ring 1b0; cfg_hour 1b0; cfg_min 1b0; end else begin case (state) S_NORMAL: begin cfg_hour 1b0; cfg_min 1b0; if (btn_set) state S_SET_HOUR; else if (alarm_match alarm_en) begin state S_ALARM_RING; ring 1b1; end end S_SET_HOUR: begin cfg_hour 1b1; 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 1b0; cfg_min 1b1; 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 1b0; 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_pulse和inc_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) 3d0: begin an 6b111110; bcd bcd_data[3:0]; end 3d1: begin an 6b111101; bcd bcd_data[7:4]; end 3d2: begin an 6b111011; bcd bcd_data[11:8]; end 3d3: begin an 6b110111; bcd bcd_data[15:12]; end 3d4: begin an 6b101111; bcd bcd_data[19:16]; end 3d5: begin an 6b011111; bcd bcd_data[23:20]; end default: begin an 6b111111; bcd 4d0; end endcase end always (*) begin case (bcd) 4h0: seg 8h3F; 4h1: seg 8h06; 4h2: seg 8h5B; 4h3: seg 8h4F; 4h4: seg 8h66; 4h5: seg 8h6D; 4h6: seg 8h7D; 4h7: seg 8h07; 4h8: seg 8h7F; 4h9: seg 8h6F; default: seg 8h00; endcase end endmodule上述代码采用共阴极数码管引脚假设段码高电平点亮。an是位选低电平选中对应位例如6b111110表示第 0 位被选中。bcd_data[3:0]是秒个位[7:4]是秒十位[11:8]是分个位依次类推最后[23:20]是小时十位。扫描计数器每 1ms 切换一次 pos完整六位一个周期是 6ms刷新率 166Hz。如果开发板是共阳极数码管把seg改成~seg位选改成高电平选中即可。dot_en 用来控制小数点本设计里可以让秒十位与分钟之间的点在每秒 tick 时翻转实现一个独立的秒闪烁指示。4.2 段码表与极性处理上面代码里的七段码表是最容易出错的区域。常见的开发板有两种接法共阴极数码管段选输出高电平点亮共阳极数码管段选输出低电平点亮。同一个数字 0 在共阴极下是8h3F在共阳极下则要按位取反变成8hC0。更麻烦的是有些 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 对时法把秒脉冲校准后剩下的事情就是信任综合工具把工程烧进芯片等它在设定的时刻响铃。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门