资讯动态

基于FPGA的数字钟设计:从分频到闹钟的完整实现与调试

发布时间:2026/10/13 16:01:45 来源:尧图企业网站定制
1. 从一个“跑偏”的闹钟说起为什么我要用FPGA做数字钟很多人第一次接触FPGA都是从点灯、按键消抖、数码管扫描这些基础实验开始的。而“数字钟”几乎是每个FPGA学习者绕不开的综合性项目——它不像流水灯那样纯粹是IO口的排列组合也不像简单的串口收发那样只涉及单一协议。数字钟这个题目麻雀虽小五脏俱全时钟分频、计数逻辑、按键消抖、动态扫描、时间校准、闹钟比较每一个模块单拎出来都不难但要把它们塞进同一片逻辑资源里稳定跑起来还能让走时精度经得起考验这里面的门道就多了。我前后做过三版数字钟。第一版是纯计数器堆出来的功能倒是能跑但走时一天能差出好几分钟而且按键一按就乱跳第二版加了消抖和校准但数码管亮度不均晚上看还行白天基本看不清第三版也就是最终定稿的这版才算把走时精度、显示效果、操作手感都调到了一个能拿得出手的状态。这篇文章就把整个最终实现的过程拆开来讲从系统架构到每一个模块的代码思路再到调试时踩过的坑尽量说透。这个项目适合谁看如果你已经能独立写一个分频器、能看懂状态机但还没把多个模块整合成一个完整系统那这篇内容对你应该有帮助。如果你是完全零基础建议先把Verilog的时序逻辑和组合逻辑搞清楚再回来不然有些细节会看得云里雾里。整篇文章我会尽量用大白话解释关键参数会给出计算过程代码只贴核心片段重点是讲清楚“为什么这么设计”。2. 系统整体架构先想清楚数据怎么流2.1 模块划分与数据通路设计做数字钟之前我习惯先在纸上画一张数据流图。不是那种正式的框图就是随手画几个方块标上箭头想清楚信号从哪来到哪去。最终这版的架构大概是这样的顶层模块负责例化所有子模块并连接信号分频模块产生1Hz的秒脉冲和用于数码管扫描的高频时钟计时模块是核心负责秒、分、时的计数和进位按键处理模块处理三个按键模式、加、减显示驱动模块负责把时间数据转换成数码管段选和位选信号闹钟模块负责比较当前时间和设定时间并输出蜂鸣器信号。数据通路其实很直观分频模块产生的1Hz脉冲驱动计时模块计时模块的输出同时送给显示驱动和闹钟比较模块按键处理模块的输出送给计时模块用于校准。这里有一个设计决策值得说一下我把计时模块和校准逻辑放在了一起而不是让按键直接去改计时器的值。原因是如果按键直接操作计数器按下瞬间的亚稳态和抖动很容易让计数器产生毛刺甚至出现“59分跳到00分再跳回59分”这种鬼畜现象。正确的做法是按键只产生一个“加一脉冲”或“减一脉冲”计时模块在秒脉冲的节拍下统一处理这些脉冲这样所有状态变化都同步在同一个时钟域里稳定性好很多。2.2 时钟域的选择与分频策略系统时钟我用的50MHz晶振这是大多数FPGA开发板上的标配。50MHz意味着周期是20ns要得到1Hz的秒脉冲需要计数25,000,000次。这个数字用二进制表示是25位所以分频计数器至少需要25位宽。代码大概长这样reg [24:0] cnt_1hz; reg clk_1hz; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt_1hz 25d0; clk_1hz 1b0; end else if (cnt_1hz 25d24_999_999) begin cnt_1hz 25d0; clk_1hz ~clk_1hz; end else begin cnt_1hz cnt_1hz 1b1; end end这里有个细节我用的是翻转而不是产生一个周期的高脉冲。翻转的好处是占空比严格50%后续如果要用这个信号做其他逻辑时序余量更充足。但要注意翻转产生的是一个2秒周期的方波上升沿和下降沿各代表一秒。如果你需要的是每秒一个脉冲可以在翻转的基础上再取边沿或者直接改成计数到24,999,999时输出一个周期的高电平。我两种都试过实际走时精度没有明显差异但翻转法在示波器上看起来更舒服。数码管扫描用的时钟我取了1kHz也就是每1ms切换一位。这个频率是权衡的结果太低会闪太高了亮度不够而且对驱动能力要求高。1kHz意味着每位数码管每8ms被点亮一次假设8位数码管人眼的视觉暂留大概是16ms所以8ms的刷新周期完全够用。分频系数是50,000代码和上面类似就不重复贴了。注意分频计数器的位宽一定要算够。我曾经用24位计数器去分频50MHz结果计数到16,777,215就溢出了实际产生的不是1Hz而是大约2.98Hz走时快了将近三倍。这种错误编译不会报仿真也能跑但上板就是不对排查起来很费时间。2.3 为什么不用RTC芯片有人可能会问为什么不直接用DS1302这类实时时钟芯片非要自己用FPGA计数这个问题我当初也纠结过。用RTC芯片的好处是走时准、不占逻辑资源、断电还能靠纽扣电池维持。但用FPGA自己做的理由也很充分第一这个项目的核心目的是练习时序逻辑设计用RTC芯片就变成读寄存器了失去了练习的意义第二RTC芯片需要额外的IO口和通信协议对于引脚资源紧张的开发板来说不一定划算第三自己做的时钟可以通过校准逻辑微调走时速度虽然精度不如晶振级的RTC但做到一天误差几秒以内是完全可行的。实测下来用50MHz晶振分频得到的1Hz脉冲走时精度主要取决于晶振本身的精度。普通开发板上的晶振误差大概在±20ppm到±50ppm之间换算成每天误差就是1.7秒到4.3秒。这个精度对于日常看时间够用了但如果要跟标准时间对时一天下来还是能看出差别。我的做法是在计时模块里加了一个校准寄存器可以通过按键微调每秒的计数值比如把25,000,000改成24,999,990走时就会稍微快一点。这个后面讲按键处理时会详细说。3. 核心模块的代码实现与参数计算3.1 计时模块秒分时的进位逻辑计时模块是整个系统的心脏它的稳定性直接决定了数字钟能不能用。我见过不少初学者的写法是把秒、分、时写成三个独立的always块每个块里判断自己的计数是否到上限到了就清零并给下一个模块一个进位脉冲。这种写法逻辑上没错但实际综合出来可能会产生毛刺因为三个块之间的进位信号是组合逻辑产生的。我的写法是用一个always块统一处理所有计数这样所有状态变化都在同一个时钟沿下发生不会出现竞争冒险。核心代码如下always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin sec 6d0; min 6d0; hour 5d0; end else 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这段代码看起来简单但有几个地方值得注意。第一秒的位宽是6位因为0到59需要6位二进制2^664。分的位宽也是6位。时的位宽是5位因为0到23需要5位2^532。位宽给多了浪费资源给少了会溢出这个要算准。第二进位判断用的是而不是因为计数器只会递增不会跳过59所以用等于判断更精确。第三整个逻辑嵌套了三层if-else综合出来是一串优先级选择器在1Hz的时钟下时序余量巨大完全不用担心。实操心得如果你想让时钟支持12小时制只需要在显示驱动模块里做转换计时模块内部还是用24小时制。这样逻辑最简单也方便闹钟比较。我试过在计时模块里直接做12小时制结果闹钟比较的时候还要考虑上午下午麻烦得很。3.2 按键消抖状态机比延时计数更可靠按键消抖是数字钟项目里最容易出问题的地方。我第一版用的是最简单的延时计数法检测到按键按下后计数20ms如果20ms后还是按下状态就认为有效。这个方法能用但有个缺点——它只能消抖不能识别长按和短按。而且如果按键一直按着不放它会反复触发导致时间飞快地跳。最终这版我用了一个四状态的状态机空闲态、消抖态、按下态、释放态。状态转移的条件是按键输入的电平变化和计数器超时。核心思路是这样的// 状态定义 localparam IDLE 2b00; localparam DEBOUNCE 2b01; localparam PRESSED 2b10; localparam RELEASE 2b11; // 状态转移 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) begin state IDLE; cnt 20d0; end else begin case (state) IDLE: begin if (key_in 1b0) begin state DEBOUNCE; cnt 20d0; end end DEBOUNCE: begin if (cnt 20d19_999) begin if (key_in 1b0) state PRESSED; else state IDLE; end else cnt cnt 1b1; end PRESSED: begin if (key_in 1b1) state RELEASE; end RELEASE: begin if (cnt 20d19_999) begin if (key_in 1b1) state IDLE; else state PRESSED; end else cnt cnt 1b1; end endcase end end这里用的是1kHz的时钟计数20,000次就是20ms这是机械按键抖动的典型持续时间。状态机的好处是按键按下时只产生一个脉冲松开时也只产生一个脉冲不会重复触发。如果你需要长按连续加的功能可以在PRESSED状态里加一个计数器每200ms产生一个加脉冲这样按住不放就能快速调整时间。注意按键输入一定要做同步处理。如果按键信号直接来自外部引脚先经过两级触发器同步到系统时钟域否则亚稳态会让你怀疑人生。我当初省了这一步结果偶尔会出现按一下加两下的情况查了半天才发现是亚稳态导致的。3.3 数码管动态扫描亮度均匀的秘诀数码管显示我用的动态扫描方案也就是所有数码管共用段选线位选线独立控制轮流点亮每一位。这样做的好处是节省IO口8位数码管只需要8816个IO如果静态显示则需要8×864个IO。扫描的核心是位选信号的循环移位和段选信号的实时更新。位选我用了一个8位的循环移位寄存器每个扫描周期左移一位保证同一时刻只有一位数码管被点亮。段选则根据当前要显示的数字查表得到。代码大概是这样// 位选循环 always (posedge clk_1k or negedge rst_n) begin if (!rst_n) sel 8b0000_0001; else sel {sel[6:0], sel[7]}; end // 段选查表 always (*) begin case (disp_num) 4d0: seg 8b1100_0000; // 共阳极 4d1: seg 8b1111_1001; // ... 其他数字 default: seg 8b1111_1111; endcase end这里有个关键点段选信号要跟着位选信号同步更新。如果位选已经切换到下一位了段选还是上一位的数据就会出现“鬼影”——上一位的数字会短暂地显示在下一位上。解决办法是在位选切换的那个时钟沿同时更新段选数据或者用位选信号作为段选查表的索引。我用的后者把当前要显示的数字和位选信号绑定这样切换时数据自然跟着变。亮度均匀是另一个要调的地方。理论上每位点亮的时间相同亮度就应该一样。但实际中因为段选线的驱动电流有限同时点亮多个段比如数字8会比只点亮一个段比如数字1暗一些。我的做法是在段选线上串了限流电阻然后通过调整扫描频率来平衡。实测1kHz的扫描频率下亮度差异肉眼基本看不出来。实操心得如果你发现某一位特别亮或特别暗先检查那一位的位选线是不是接触不良再检查限流电阻的阻值是否一致。我遇到过一位数码管特别暗查了半天发现是那个限流电阻焊成了10kΩ其他都是1kΩ换了就好了。3.4 闹钟比较等于判断的陷阱闹钟功能看起来简单——当前时间等于设定时间就响。但实际做的时候有个坑如果用组合逻辑直接比较蜂鸣器会响一整秒因为当前时间在那一秒内是保持不变的。正确的做法是只在秒脉冲的上升沿比较一次如果相等就置位一个标志然后蜂鸣器根据这个标志发声。always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) alarm_flag 1b0; else if (hour alarm_hour min alarm_min sec 6d0) alarm_flag 1b1; else if (alarm_stop) alarm_flag 1b0; end注意比较条件里加了sec 6d0这样闹钟只在整分的时候触发一次。如果不加这个条件那一分钟内每一秒都会触发蜂鸣器会断断续续响60次。另外alarm_stop是关闭闹钟的按键信号按下后清除标志。蜂鸣器的驱动我用的是PWM方式因为无源蜂鸣器需要方波驱动才能发声。频率我取了2kHz这个频率听起来比较清脆不刺耳。PWM的占空比固定50%通过一个计数器分频得到。如果你用的是有源蜂鸣器直接给高电平就行不需要PWM。4. 调试过程中踩过的坑与排查方法4.1 走时不准从晶振到分频的逐级排查走时不准是数字钟最常见的问题。我的排查思路是从源头开始逐级验证。第一步用示波器测晶振输出确认频率是不是50MHz。如果晶振本身偏了后面怎么调都是白搭。第二步测分频模块的输出确认是不是严格的1Hz。这里有个技巧不要直接测1Hz信号因为周期太长示波器不好触发。可以测分频计数器的最高位它的翻转频率是1Hz的一半也就是0.5Hz但周期还是2秒仍然不好测。更好的办法是测一个中间分频点比如分频到1kHz用频率计测如果1kHz准了1Hz基本就准了。第三步如果分频没问题但走时还是不准那就要检查计时模块的进位逻辑。我曾经写过一个bug秒计数到59后清零但分计数没有加一结果分钟永远不走。这种逻辑错误仿真的时候很容易发现但上板后因为数码管刷新太快肉眼不一定能立刻看出来。所以仿真验证是必须的不要跳过。4.2 按键不灵敏或过于灵敏按键问题一般有两个极端要么按了没反应要么按一下跳好几下。按了没反应通常是消抖时间太长比如设了100ms人手按一下可能只有50ms就被过滤掉了。按一下跳好几下则是消抖时间太短或者没有做边沿检测。我的经验是20ms的消抖时间比较合适既能过滤机械抖动又不会漏掉正常按键。还有一个容易被忽略的问题按键的机械特性。不同品牌的按键手感不一样有的按下去很脆有的很软。如果你发现某个按键特别难按先换个按键试试不一定是代码的问题。我当初调按键调了半天最后发现是那个按键本身接触不良换了一个就好了。4.3 数码管显示乱码或闪烁显示乱码通常是段选码表写错了或者位选和段选的对应关系搞反了。排查方法很简单写一个测试模式让所有数码管轮流显示0到9看每一位是不是都能正确显示。如果某一位显示的数字不对检查那一位的段选线是不是接错了。如果所有位都显示同一个数字检查位选信号是不是没有变化。闪烁问题一般是扫描频率太低。人眼的视觉暂留大概是16ms如果扫描周期超过这个时间就会感觉到闪烁。8位数码管每位的点亮时间是扫描周期的八分之一所以扫描周期要小于16ms×8128ms对应频率要大于7.8Hz。我用的1kHz远远高于这个下限所以不会闪。如果你发现闪烁先检查扫描时钟是不是真的在跑再检查位选信号是不是在循环。4.4 常见问题速查表现象可能原因排查方法解决方法走时快或慢晶振偏差、分频计数错误测晶振频率、仿真分频模块调整校准寄存器、修正分频系数按键无反应消抖时间过长、按键损坏缩短消抖时间、更换按键改为20ms消抖、换按键按键跳变未做边沿检测、亚稳态检查状态机、加同步触发器加两级同步、改状态机显示乱码段选码表错误、接线错误测试模式逐位检查修正码表、重新接线显示闪烁扫描频率过低测扫描时钟频率提高扫描频率到1kHz闹钟不响比较条件错误、蜂鸣器驱动不足仿真比较逻辑、测蜂鸣器信号加秒脉冲同步、改用PWM驱动整机不工作复位信号错误、电源问题测复位引脚、测电源电压修正复位逻辑、检查供电避坑技巧调试的时候一定要分模块验证不要等整个系统搭好了再一起调。我的做法是每个模块单独写一个testbench仿真通过后再上板测试上板通过后再整合。这样虽然前期麻烦一点但后期排查问题会轻松很多。整合的时候如果出问题基本可以确定是模块间的连接问题而不是模块内部的逻辑问题。5. 从能跑到好用几个提升体验的细节优化5.1 时间校准的微调机制前面提到晶振有误差一天可能差几秒。我的解决办法是在计时模块里加了一个校准寄存器用来微调每秒的计数值。具体来说正常分频是计数到24,999,999产生一个秒脉冲如果发现走时偏慢就把这个值改小一点比如24,999,990这样每秒会稍微短一点走时就会变快。反之则改大。校准的粒度怎么算50MHz的时钟计数25,000,000次是0.5秒因为翻转法实际每秒对应50,000,000个时钟周期。如果改成49,999,980每秒少了20个周期误差是20/50,000,0000.4ppm换算成每天是0.034秒。这个粒度足够精细了完全可以调到日误差1秒以内。校准寄存器我留了8位可以通过按键进入校准模式后调整。调整范围是±127对应±2540个时钟周期也就是±50.8ppm覆盖了普通晶振的误差范围。5.2 显示亮度的自动调节白天和晚上的环境光差别很大固定的亮度要么白天看不清要么晚上刺眼。我加了一个光敏电阻来检测环境光然后根据光强自动调整数码管的扫描占空比。光强的时候占空比高一些亮度大光弱的时候占空比低一些亮度小。光敏电阻的阻值随光照变化我用一个分压电路把它转换成电压然后通过FPGA的ADC如果没有ADC就用比较器加PWM的方式读取。读取到的值经过简单的滤波后映射到扫描占空比的寄存器上。这个功能不是必须的但加上之后体验会好很多。5.3 整点报时与闹钟渐响整点报时是个小功能但很实用。我的实现是在分钟和秒都为0的时候让蜂鸣器短促地响一声。如果是12点或0点就响两声。这个逻辑很简单在闹钟模块里加一个判断就行。闹钟渐响是另一个提升体验的细节。普通的闹钟是一上来就最大音量容易吓一跳。我的做法是闹钟触发后蜂鸣器的PWM占空比从10%开始每隔0.5秒增加10%直到100%或者用户按下停止键。这样声音是逐渐变大的听起来舒服很多。实操心得蜂鸣器的PWM频率不要设得太低低于1kHz听起来会很沉闷也不要太高高于4kHz会变得刺耳。2kHz到3kHz是比较舒服的范围。我试过1kHz和4kHz最后还是觉得2.5kHz最好听。6. 资源占用与性能分析6.1 逻辑资源消耗整个项目在Cyclone IV系列FPGA上综合后逻辑单元LE的占用大概是这样的分频模块约50个LE计时模块约120个LE按键处理约80个LE显示驱动约150个LE闹钟模块约60个LE顶层互连约40个LE合计约500个LE。对于一款入门级FPGA通常有几千到几万个LE来说这个占用率非常低还有大量资源可以用来扩展功能。寄存器方面分频计数器用了25位计时用了66517位按键状态机用了2位加20位计数器显示驱动用了8位位选加8位段选总共大概100个寄存器左右。这个数量对于任何FPGA来说都是小菜一碟。6.2 时序余量分析系统时钟是50MHz周期20ns。综合后的静态时序分析报告显示最差路径的建立时间余量Setup Slack是15.2ns保持时间余量Hold Slack是0.3ns。建立时间余量很大说明还有很大的提速空间保持时间余量偏小但仍然是正的说明时序是收敛的。如果想跑更高的时钟频率比如100MHz建立时间余量会降到5ns左右仍然够用。但保持时间余量可能会变成负的需要调整布局布线策略或者加流水线。不过对于数字钟这个应用来说50MHz完全够用没必要追求更高的频率。6.3 功耗估算FPGA的功耗主要来自动态功耗也就是逻辑翻转消耗的功率。这个项目里翻转最频繁的是分频计数器50MHz下每20ns翻转一次。但整体逻辑规模很小所以总功耗很低。用功耗估算工具算下来核心动态功耗大概在10mW到20mW之间加上IO口的驱动功耗整机功耗不超过100mW。用USB供电就足够了不需要额外的电源。7. 后续可以继续折腾的方向这个数字钟做到这里基本功能已经很完整了。如果还想继续折腾有几个方向可以试试。第一加一个温度传感器把温度也显示在数码管上做成一个多功能时钟。第二加一个红外接收头用遥控器来调整时间比按键方便。第三加一个串口模块把时间数据发送到电脑上做一个简单的上位机显示。第四如果用带以太网口的开发板还可以做一个网络对时功能通过NTP协议从网络获取标准时间这样就不怕晶振误差了。我个人最推荐的是加温度显示因为DS18B20这类传感器是单总线协议正好可以练习一下状态机的写法而且成本很低几块钱就能买到。红外遥控也不贵但协议解析稍微复杂一点NEC协议的红外码解码需要精确的时序控制是个不错的练习。最后再分享一个小技巧如果你觉得数码管的段选码表手写太麻烦可以用一个ROM来存储码表把数字作为地址段选码作为数据。这样代码更简洁而且修改码表只需要改ROM的初始化文件不用改代码。我后来重写的时候就用了这个方法感觉清爽多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑