十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于FPGA的Verilog闹铃时钟设计:分频、消抖与状态机实战解析

基于FPGA的Verilog闹铃时钟设计:分频、消抖与状态机实战解析 简介面向FPGA初学者的Verilog时钟设计完整工程包实现小时、分钟、秒计数并集成闹铃、整点报时、按键去抖等实用功能适合学习数字电路时序控制、分频计数与状态机设计的读者。资源共101个文件压缩包3.83MB以Quartus工程文件qpf/qsf、编译配置与下载文件sof/pof为主包含一个核心Verilog源文件.v及完整项目层次报告便于直接打开工程查看代码结构。已有1525人学习下载。包内附带编译过程生成的各类数据库文件cdb/ddb等和时序分析报告可辅助理解综合、布局布线等EDA流程通过阅读源码与工程配置读者能掌握按键消抖电路设计、闹钟比较逻辑、报时信号生成以及FPGA从编写到下载验证的完整方法。 一直在做FPGA相关的东西前几天整理以前写的模块翻到一个用Verilog写的带闹铃功能的时钟工程。这玩意儿看起来简单但实际上是数字逻辑里比较典型的综合性练习分频、计数、状态机、按键消抖、显示驱动、异步信号处理一个小工程全占齐了。当时写这个是为了给一块老开发板配个实用的功能顺便练手结果一写进去发现坑一个接一个。这篇就把它拆开聊聊从模块结构到每一段代码的思路再到我踩过的那些让人头疼的问题给想自己动手写一个的人当个参考。1. 功能定下来之后时钟模块到底该怎么切分动手写Verilog之前最忌讳的就是直接开敲。时钟这种功能听起来简单但只要把闹铃加上逻辑复杂程度立马涨一截。我的做法是先列需求再定模块边界最后才动手写。我给自己定的功能清单是这样的正常走时时、分、秒24小时制用板载50MHz晶振做时钟源。时间校准三个按键分别控制时、分、秒的步进加一可以独立校准。闹铃功能设定一个目标时间到点后蜂鸣器响一分钟按任意键停止。显示输出用板上的四位数码管轮询显示时分带秒指示点闪烁。功能定完之后模块就自然分出来了。分频模块assign产生秒脉冲、时间计数模块时/分/秒计数器、按键处理模块debounce边沿捕获、闹铃比较模块、数码管显示驱动模块扫描译码。每个模块独立成文件接口明确这样无论是仿真还是后期改参数都很方便。我见过不少新手上来就把所有逻辑写在一个模块里几百行代码堆在一起状态稍微多一点就乱套。你要是只做作业交差还行但凡想仿真看波形想加功能立刻就会明白模块化有多重要。具体我用的模块划分方式如下// 顶层例化关系伪代码 top_clock ├── clk_div_1s // 50MHz - 1Hz ├── time_counter // 时分秒计数 ├── key_debounce // 按键消抖 ├── alarm_compare // 闹铃触发 └── seg_driver // 数码管扫描每个模块就干一件事模块之间的信号连接全部在顶层完成。这样改闹铃逻辑的时候根本不用碰显示部分排错的时候也可以直接在Modelsim里单独给某个子模块激励效率高很多。2. 秒脉冲生成这块分频方案没你想的那么随意秒脉冲是整个时钟的心脏它不准后面全白搭。常见的做法就是把50MHz系统时钟通过计数器分频得到1Hz使能信号。这里有个很重要的细节到底是产生一个占空比50%的1Hz时钟信号还是产生一个宽度为一个系统时钟周期的1Hz脉冲使能这个选择直接影响后续时序设计的走向。我当时最开始用的是第一种方案——直接分频出1Hz方波。结果就踩了大坑这种信号如果直接当时钟用在FPGA里会形成门控时钟或者派生时钟。综合工具会警告你这里有clock gating问题时序约束不好做更重要的是在布局布线之后容易出现毛刺。比如分频计数器翻转的那一瞬间如果组合逻辑的路径有延迟输出的时钟沿就可能抖。对于时钟这种要求稳定的功能来说这是不可接受的。后来我查了些资料也问了有经验的人发现正规做法是用计数器产生一个时钟周期的脉冲作为使能信号。也就是说寄存器在这一个周期里加一其余时间保持不动。reg [25:0] cnt; reg tick_1s; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt 26d0; tick_1s 1b0; end else if (cnt 26d49_999_999) begin cnt 26d0; tick_1s 1b1; // 仅维持一个clk周期的高电平 end else begin cnt cnt 1b1; tick_1s 1b0; end end这样时钟系统里始终只有一个全局时钟50MHz1Hz只是作为数据通路上的使能信号综合后就是普通的同步逻辑不存在时钟分频带来的那些麻烦。这一点理解了之后整个时钟的设计思路就清晰了。不过按这个写法如果板子的晶振是50MHz计数到49,999,999就是从0到49999999一共50000000个周期正好一秒。换块板子晶振频率变成别的值改这个数字就行。建议用参数parameter CLK_FREQ 50_000_000; localparam CNT_MAX CLK_FREQ - 1;这样可读性和可维护性都上来了。3. 时分秒计数器和进位判断一天之中的边界条件处理有了稳定的秒脉冲接下来就是计数逻辑。这模块单独看很直观但真正写的时候容易被“进位”和“清零”的条件搞乱。我写的时分秒计数器思路是秒计数器每到59就等待下一个tick回零同时给分钟一个进位脉冲分钟同理到59后再进位给小时小时到23后归零。常见的写法有两种一种是用组合逻辑判断计数到最大值然后每个使能脉冲加一另一种是直接用阻塞赋值写教科书式的计数器。在这里我强烈建议用非阻塞赋值并且把使能组合到一个always块里。always (posedge clk or negedge rst_n) begin if (!rst_n) begin sec 6d0; min 6d0; hour 5d0; end else if (tick_1s) begin if (sec 6d59) begin sec 6d0; if (min 6d59) begin min 6d0; if (hour 5d23) hour 5d0; else hour hour 1b1; end else min min 1b1; end else sec sec 1b1; end end这种嵌套if实现的是时间的自然进位逻辑上一环扣一环。有同事看到这段代码问过我为什么分钟加一不写在sec59里面而是写在sec59 min59里面因为分钟要等“秒从59到0”这一瞬间才加一如果只判断min59就直接加那就会在秒还没走完60秒的时候分钟就变了。另外需要注意这一点仿真的时候你要是把tick_1s和clk完全对齐计数器的变化会发生在时钟上升沿。但是如果干扰导致tick_1s的脉冲宽度大于一个时钟周期计数器可能在一个周期内加两次时序就乱了。所以tick_1s信号的时序约束要处理好这也是为什么秒脉冲必须严格控制在单个时钟周期宽度内。小时的位宽我也单独定义成5位因为它最大能到235位二进制可以表示0~31不用扩展。分钟和秒都是0~596位足够。4. 闹铃判断逻辑不能只比对时刻相等你得设计触发状态闹铃是整个项目里最有意思的部分。要是把“当前时间和闹铃时间相等就响铃”直接写出来必然出问题。为什么因为秒脉冲是一秒一个当前时间等于闹铃时间时这个相等条件会持续保持一整秒蜂鸣器会一直响——你可能觉得这没毛病但蜂鸣器响的是连续高电平又长又吵。而且更关键的是如果你只写了相等就响不写停止条件那它会一直响到天荒地老。我设计的闹铃需求是到点响铃一分钟响铃期间按键可关闭。所以不能简单比对相等还要引入一个alarm_flag状态位。触发条件有两个当前时间和闹铃时间完全相同。alarm_flag还没被置1防止重复触发。一旦触发alarm_flag拉高蜂鸣器开始响。此后即便时间还在闹铃时刻比如秒还没走完也不会再次触发。等一分钟之后用另一个计数器计时alarm_flag自动清零闹铃状态结束。reg alarm_flag; reg [5:0] ring_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin alarm_flag 1b0; ring_cnt 6d0; end else if (key_stop) begin alarm_flag 1b0; ring_cnt 6d0; end else if (alarm_flag ring_cnt 6d59) begin alarm_flag 1b0; ring_cnt 6d0; end else if ({hour, min, sec} {alarm_hour, alarm_min, 6d0} !alarm_flag) begin alarm_flag 1b1; ring_cnt 6d0; end else if (alarm_flag) begin ring_cnt ring_cnt 1b1; end end这段代码有一个关键细节比较时秒数用的是6d0而不是sec。也就是秒走到0的那一刻触发闹铃。如果写成sec 0那和{hour,min,sec}全等于比较是等效的。但为什么不直接用等号比{hour,min,sec} {alarm_hour, alarm_min, sec}这里涉及一个“比较瞬间”的问题因为sec0时整个状态会保持一个tick周期所以只要在这个周期内条件为真flag就会拉高不会有漏触发。触发条件里加上!alarm_flag还有一个隐藏用途防止用户在设置闹铃时把闹铃时间设成“当前正在走的这一秒”导致触发瞬间又把flag置上出现在时钟校准过程中闹铃误响的问题。实际使用中这种误触发的概率不小加一个flag就能挡掉一大半。蜂鸣器输出我控制成2kHz方波——单纯的直流高电平驱动蜂鸣器不太响用一定频率的方波声音更清晰。我在蜂鸣器驱动模块里用计数器生成一个2kHz的脉冲只有alarm_flag为高时才输出该脉冲reg [14:0] beep_cnt; reg beep_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) beep_cnt 15d0; else if (alarm_flag) begin if (beep_cnt 15d24999) begin beep_cnt 15d0; beep_reg ~beep_reg; end else beep_cnt beep_cnt 1b1; end else begin beep_cnt 15d0; beep_reg 1b0; end end2kHz方波用来驱动无源蜂鸣器效果刚刚好功率消耗也不大。5. 按键消抖与边沿检测很多人的时钟“跳数”“连加”就是栽在这按键是这个项目里最容易翻车的地方。机械按键按下的时候触点会抖动持续时间通常有5~20毫秒。你要是直接把按键电平接进计数器一次按下可能被判成好几次时间就会一下子加了好几秒。此坑不解决校准功能基本没法用。消抖的思路是检测到电平变化之后等上一段时间如果电平稳定了再认为是有效按下。我用的方案是20ms延时确认在50MHz下也就是计数一百万个周期。reg [19:0] cnt_delay; reg key_in_d1, key_in_d2; wire key_press; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_in_d1 1b1; key_in_d2 1b1; cnt_delay 20d0; end else begin key_in_d1 key_in; key_in_d2 key_in_d1; if (key_in_d2 ! key_in_d1) cnt_delay 20d0; else if (cnt_delay 20d1_000_000) cnt_delay cnt_delay 1b1; end end assign key_press (key_in_d2 1b0 key_in_d1 1b1 cnt_delay 20d1_000_000);这段逻辑里又出现了一个关键点检测到电平变化就重新计时如果抖动导致电平来回跳计时器就会反复清零直到信号真正稳定下来。这样可以保证只有稳定的低电平按键按下才会在计时满后产生一次key_press脉冲。按键消抖里另一个容易忽略的问题是“加一”动作是电平触发还是边沿触发。很多人消抖做完了但体现在计数上还是“按下后连续加”或者“按下没反应”就是因为把key_press直接接到了使能上而不是边沿检测。我上面的代码在最后用了一个组合的边沿检测key_in_d1从1变0且计数满后才生成脉冲所以一次按键只有一拍的key_press高电平计数器每按一次只加一不会连加。我们来看仿真波形按下key_in经过20ms计时key_press输出一个单时钟周期的高脉冲然后计数器在这个脉冲到来时加一。整个流程在ModelSim里看得非常清楚这也是仿真比上板调试方便的地方。还有一个经验按键释放的时候同样会有抖动。如果设计里没做释放检测用户按完松开时计数器可能又加了一次。我那会儿就因为这个被坑了一把每次设时间总多走一秒。后来我在状态机里加了“释放检测”只在按键从0变1且稳定后再允许下一次按键检测。简单来说就是增加了一个空态等待让按键状态机在按键释放前不响应任何新的按下。6. 数码管显示驱动扫描刷新和动态显示里的防闪烁策略显示部分我用的是四位共阴数码管驱动方式采用动态扫描。动态扫描的原理就是人眼视觉暂留让四位数码管轮流点亮只要刷新频率够高看起来就好像四位数同时在亮。扫描的实现思路一个2位计数器驱动case语句选择当前点亮的位选同时把对应的数值送给段选。典型刷新频率是1kHz也就是每位停留1ms四轮一个完整循环4ms。低于500Hz会明显闪烁高于2kHz会出现亮度不均或者驱动芯片跟不上。实际我用的是1ms换一位也就是1kHz/位。reg [1:0] scan_cnt; reg [6:0] seg_data; reg [3:0] display_num; always (posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt 2d0; else if (scan_en) scan_cnt scan_cnt 1b1; end always (*) begin case (scan_cnt) 2d0: begin display_num hour / 10; seg_sel 4b1110; end 2d1: begin display_num hour % 10; seg_sel 4b1101; end 2d2: begin display_num min / 10; seg_sel 4b1011; end 2d3: begin display_num min % 10; seg_sel 4b0111; end endcase end这里有个细节必须注意位选和段选不能同时变化。如果扫描到下一路的同时段选数据也变了数码管在切换瞬间可能出现短暂的“拖影”——上一位的数据残影显示在下一位上。解决方法是把段选数据打一拍或者先变化位选等稳定后再更新段选。我当时在数据变更路径上加了时序约束实际效果是拖影几乎看不见了。另外我这个显示部分只显示了时分秒没有单独显示。但我留了一个额外的小功能每秒切换小数点位让DP引脚以1Hz频率闪烁这样能直观看出时钟还在走。这个功能只需把tick_1s信号对一位寄存器做异或reg dp; always (posedge clk or negedge rst_n) if (!rst_n) dp 1b0; else if (tick_1s) dp ~dp;这样秒点每秒闪一次一眼就知道系统没死。7. 校准功能和状态切换用状态机来管理时间设定逻辑时间校准和闹铃设定本质上是一模一样的操作逻辑选中某个字段时或分或秒按键加一到上限回零。区别只是操作的目标寄存器不同。所以我用了一个简单的状态机来管理工作模式而不是给每个模式写一套独立逻辑。状态定义IDLE正常走时。TIME_SET_MIN校准分钟。TIME_SET_HOUR校准小时。ALM_SET_MIN设置闹铃分钟。ALM_SET_HOUR设置闹铃小时。五个状态两个按键模式切换键、加一键。模式切换键每按一次状态就沿着IDLE - TIME_SET_MIN - TIME_SET_HOUR - ALM_SET_MIN - ALM_SET_HOUR - IDLE循环走。加一键在当前状态下对相应字段做加一操作。always (posedge clk or negedge rst_n) begin if (!rst_n) mode 2d0; else if (key_mode_press) mode (mode 4d4) ? 4d0 : mode 1b1; end每个模式下key_add_press的判断路径不同。这种设计的好处是逻辑统一、扩展容易。比如你要加个秒校准加个状态就行不用改动其他模块的代码。不过这状态机的图我就不画了文字描述已经足够清晰。实际上硬件描述语言里状态机写多了你会有一种感觉状态本身不复杂复杂的是不同状态之间的边界条件和切换优先级。比如“在设置状态时闹铃还能不能触发”我的答案是设置过程中不允许触发闹铃避免用户正对时间呢突然响铃手忙脚乱按错键。实现方法很简单alarm_compare模块只在IDLE状态下才工作其他状态下把alarm_flag强制拉低。8. 按键释放检测一个让时钟“走时准确”但让设置难受的小坑前面提到按键释放检测这里单独展开讲一下因为这个坑太典型了。如果你只做按下检测不做释放检测那么在用户按住按键不放的时候key_press不会持续产生因为消抖逻辑只在跳变瞬间产生一个脉冲这个行为是正确的。问题出在用户手抖或者按键质量差的时候按下过程中信号在高低电平间跳变每跳一次就会触发一次消抖计时一旦某次跳变后稳定时间达到20ms就会产生一次误触发的key_press。这个情况真实发生之后的表现就是校准时按一下加一结果时间加了两甚至三。我在排查的时候看示波器按键按下时电平波形确实在来回跳。最终解决方法是加“释放检测”状态机检测到按下降沿之后进入待释放状态除非检测到按键释放且稳定否则不再产生加一操作。reg key_released; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_released 1b1; end else if (key_in_d2 1b1) key_released 1b1; else if (key_press key_released) begin key_released 1b0; end end这个方法本质上把“能触发下一次加一”的条件从“电平变化”改成了“完整地释放了一次”。虽然带来一个副作用如果你一直按住不放它永远不会再次加一但这在正常使用习惯里反而是对的。总结来说按键处理看起来是小功能但直接决定了整个时钟的“可用性”。逻辑写得再华丽按键一按跳三下整体体验就是废的。所以我在这个项目里花了最多时间调的就是按键部分也是推荐大家重点仿真验证的部分。9. 综合与上板验证时序约束和实测中出现的问题代码写完之后就是综合、布局布线、下载上板。这个过程也有不少值得说的地方。首先是综合工具的选择。我用的Vivado如果你用Quartus或者更老的ISE操作上大同小异。但有一个共通点你需要在约束文件里指定主时钟频率。50MHz的时钟周期是20ns约束这么写create_clock -period 20.000 -name clk_50m [get_ports clk]不加这个约束工具默认按理想时钟综合布局布线出来的时序报告没有参考意义。我见过有人上板后发现数码管闪烁不均匀、蜂鸣器声音沙哑最后查出是时钟约束没写工具把时钟频率猜错了布局布线路径长度不满足时序要求寄存器采样出现亚稳态。时序收敛之后就是上板实测。我这边实测下来有几个现象值得提数码管在切换瞬间轻微拖影这是因为段选数据在扫描位切换瞬间没有稳定。后面我把段选数据多打了一拍拖影消失。蜂鸣器声音偏小换了一个三极管驱动之后声音大小恢复正常FPGA引脚直接驱动蜂鸣器的驱动能力有限。晶振走时误差跑了一晚上快了两秒左右这是因为板载晶振本身有几十ppm的频率误差对时钟应用来说在可接受范围内。如果对精度要求高就得用外部RTC芯片或者温补晶振不过那不是这个项目的范围了。另外我还测试了在设置闹铃分钟时跨过整点的情况。比如当前23:58你把闹铃设成00:00然后在23:59进入设置模式并保持住那么到00:00时闹铃会不会响按我前面的设计设置模式下alarm_compare不工作所以不会响。如果你希望这种情况下也响就得把alarm_compare的使能条件改成“时间在走就算”这不是对错问题而是需求定义问题。这个边界我想了很久才定下来因为用户的直觉是“我设了闹铃它就该响”但工程上的安全逻辑是“我在操作它就别闹”。10. 再往下走这个设计还能怎么扩展时钟这个项目做完之后最直接的价值是熟悉了FPGA开发从需求到上板的完整回路。但如果你想让这个项目变成一份更有说服力的作品下面几个方向都可以直接往现有框架上叠加日期模块月、日、星期注意闰年判断用一个大计数器记录从基准日期到当前日期的天数然后反推星期。逻辑不难但边界条件多很适合练手。改I2C接口的RTC芯片如果你想让时钟断电后还能走时外部挂一个PCF8563或者DS3231。I2C的Verilog实现本身就是一个有价值的子模块可以单独拿出来写篇文章。加温湿度传感器显示数码管四位显示不过来可以加个按键切换显示模式或者换LCD屏幕。改跑仿真直接出报告写一个完备的testbench随机模拟按键抖动验证按键处理模块在各种恶劣条件下的行为这个训练很能锻炼验证思维。我个人更推荐做倒数计时器模式或者秒表模式因为它们需要引入更复杂的状态控制和暂停/继续逻辑而且日常使用频率高做完可以直接放桌面上用。这个项目的代码本身不复杂但它教会我的事情比代码多得多模块划分的粒度怎么把握状态机的状态定义怎么防止边界互相干扰仿真和上板的差异在哪里以及如何在一个“小功能”里做出稳定可靠的行为。如果你也在写类似的练习项目希望这篇对你有点用。本文还有配套的精品资源点击获取
返回列表