FPGA数字时钟设计:Verilog实现与正点原子开发板实战
1. 项目缘起从需求到板卡选型最近在带学生做FPGA的课程设计发现“数字电子时钟”这个题目真是经久不衰。它麻雀虽小五脏俱全计数器、分频、显示驱动、人机交互校时、闹钟一个不落非常适合用来检验对Verilog硬件描述语言和同步数字电路设计的掌握程度。不过很多同学在具体实现时往往会卡在一些细节上比如数码管的动态扫描时序没调好导致闪烁、按键消抖处理不当、闹钟比较逻辑有漏洞等等。这次我们选用的硬件平台是正点原子的新起点开发板。选它有几个很实在的理由首先它核心的FPGA芯片是Xilinx Artix-7系列的XC7A35T逻辑资源对于这个课程设计来说绰绰有余而且正点原子的资料和例程一向比较齐全社区支持好能省去很多底层硬件摸索的时间。其次这块板子上自带了一个8位的共阳极数码管模块正好契合我们“支持8位或6位显示”的需求——你可以用它显示“时-分-秒-毫秒”8位或者精简为“时-分-秒”6位。板载的按键、LED灯也为我们实现校时、闹钟开关及状态指示提供了现成的物理接口。所以这个项目的目标很明确在正点原子新起点开发板上用Verilog编写一个数字钟功能上要能正常走时能通过按键校准时间能设置并开关闹钟并用LED灯来指示闹钟开关状态。听起来简单但每一个环节都有值得深究的地方。下面我就结合代码和实测波形把整个设计思路、关键实现细节以及最容易踩的坑给大家掰开揉碎了讲清楚。2. 系统顶层设计与模块划分做FPGA设计最忌讳一开始就埋头写代码。合理的顶层架构是成功的一半。对于这个数字钟我们可以把它分解成几个各司其职的模块通过清晰的接口连接起来。这样不仅代码好维护调试起来也方便。整个系统的核心数据流是“时间信息”的生成、处理与显示。我设计的顶层模块比如叫top_digital_clock主要包含以下子模块时钟与复位管理模块 (clk_gen)开发板输入的是50MHz的系统时钟我们的秒、毫秒信号都需要通过对这个高频时钟分频得到。这个模块负责产生全局同步的时钟使能信号而不是直接分频出多个时钟域这是同步设计的好习惯。时间计数核心 (time_counter)这是心脏部分。它接收秒使能、毫秒使能信号维护着时、分、秒、毫秒四个计数器。同时它要处理来自“按键处理模块”的校时信号对相应的计数器进行加一操作。按键消抖与处理模块 (key_debounce)板载的机械按键存在抖动问题直接读取会导致多次误触发。这个模块需要对每个按键输入进行消抖滤波并产生干净的、持续时间一个时钟周期的脉冲信号供其他模块使用。我们至少需要三个按键模式键切换校时/闹钟设置/正常显示、加键、减键。闹钟设置与比较模块 (alarm_setting)该模块内部维护一套闹钟时间寄存器时、分。通过按键可以修改这些寄存器。它还会不断将当前时间与闹钟时间进行比较当两者完全匹配且闹钟开关开启时产生一个闹铃触发信号。数码管动态显示驱动 (seg_driver)这是最考验时序把握的模块之一。它需要将时间计数核心和闹钟设置模块输出的BCD码二进制编码的十进制数转换成共阳极数码管的段选信号并通过位选信号循环点亮8位数码管利用人眼视觉暂留实现“同时”显示。它需要处理“显示模式”正常时间、校时闪烁、闹钟设置的逻辑。闹钟开关与LED指示 (alarm_switch)通常用一个按键来控制闹钟的总开关。这个模块记录开关状态并直接驱动一个LED灯例如灯亮表示闹钟开启。这些模块在顶层通过线网wire连接。一个关键的设计原则是尽量采用同步设计所有模块都在同一个主时钟如50MHz的上升沿工作使用时钟使能来控制节奏。比如time_counter模块内部可以这样工作always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin ms_cnt 0; sec_cnt 0; // ... 其他计数器复位 end else begin // 毫秒计数 if (ms_en) begin // ms_en是1kHz的使能脉冲 if (ms_cnt 999) ms_cnt 0; else ms_cnt ms_cnt 1; end // 秒计数当毫秒计满1000时秒使能sec_en会拉高一个周期 if (sec_en) begin if (sec_cnt 59) sec_cnt 0; else sec_cnt sec_cnt 1; end // ... 分、时计数类似 end end这样做的好处是避免了多时钟域带来的亚稳态问题整个系统的时序分析也变得简单。3. 关键模块实现细节与避坑指南3.1 精准计时分频与使能信号生成很多初学者喜欢直接用分频产生的低频时钟比如1Hz去驱动计数器写成always (posedge clk_1hz)。这在简单的仿真里可能没问题但在实际FPGA中非常不推荐。因为每个这样的分频时钟都会形成一个新的时钟域需要做跨时钟域处理增加时序收敛的难度和不确定性。正确的做法是使用“使能信号”。我们生成一个50MHz的基准时钟clk_50m。然后用一个计数器产生1kHz的使能脉冲ms_en周期1ms再用另一个计数器在ms_en有效时计数每计满1000次就产生一个1Hz的使能脉冲sec_en。// 生成1ms使能脉冲 (1kHz) reg [15:0] cnt_ms; // 50_000_000 / 1000 50000需要16位计数器 always (posedge clk_50m or negedge rst_n) begin if(!rst_n) begin cnt_ms 0; ms_en 1b0; end else begin if(cnt_ms 49999) begin // 从0计数到49999共50000个周期即1ms cnt_ms 0; ms_en 1b1; // 使能脉冲高电平维持一个时钟周期 end else begin cnt_ms cnt_ms 1; ms_en 1b0; end end end // 生成1s使能脉冲 reg [9:0] cnt_sec; // 计数1000次ms_en always (posedge clk_50m or negedge rst_n) begin if(!rst_n) begin cnt_sec 0; sec_en 1b0; end else if(ms_en) begin // 只在ms_en有效时计数 if(cnt_sec 999) begin cnt_sec 0; sec_en 1b1; end else begin cnt_sec cnt_sec 1; sec_en 1b0; end end else begin sec_en 1b0; // 确保sec_en只持续一个周期 end end注意使能信号ms_en,sec_en必须是单周期脉冲。在秒计数器模块中判断if(sec_en)来增加秒值。这样所有逻辑都在clk_50m的同步下非常稳定。3.2 数码管动态扫描消除闪烁的奥秘8位数码管如果静态驱动需要8*864个IO口动态扫描只用8个段选a-g, dp和8个位选。我们的驱动模块要循环点亮每一位每位点亮一段时间比如1ms。关键在于扫描频率要足够快。常见坑点扫描频率过低导致闪烁。人眼视觉暂留时间约0.1秒。如果8位数码管扫描一轮的时间超过0.1秒比如一位持续20ms一轮就是160ms你就会明显感觉到闪烁。所以每位点亮时间建议在1-5ms之间这样扫描频率在125Hz以上基本无闪烁。另一个坑点是段选和位选的配合时序。必须在切换位选信号之前提前准备好下一位的段选数据。否则会在切换瞬间上一个位选还没关闭新的段选数据就上去了造成“鬼影”短暂显示错误内容。正确的时序是关闭当前位选置高共阳极为高电平关闭。输出下一位的段选数据。开启下一位的位选置低。保持一段时间然后循环。代码实现上可以用一个状态机或者简单的计数器来实现reg [2:0] scan_cnt; // 0-7对应8位数码管 reg [19:0] dwell_cnt; // 驻留计数器控制每位显示时间 always (posedge clk_50m or negedge rst_n) begin if(!rst_n) begin scan_cnt 0; dwell_cnt 0; seg_sel 8hFF; // 关闭所有位选 end else begin if(dwell_cnt 49999) begin // 假设每位持续1ms (50_000个周期) dwell_cnt 0; // 先关闭当前位选 seg_sel 8hFF; // 更新段选数据为下一位的数字 seg_data bcd_to_seg(data_to_display[scan_cnt]); // 然后开启下一位的位选共阳极低电平有效 seg_sel ~(1 scan_cnt); // 指向下一位 scan_cnt (scan_cnt 7) ? 0 : scan_cnt 1; end else begin dwell_cnt dwell_cnt 1; end end end // 函数 bcd_to_seg 将4位BCD码转换为7段码注意共阳极是段码低电平点亮。data_to_display是一个8x4bit的寄存器组存储了当前要显示的8位数字的BCD码。这个数组的内容需要根据显示模式正常、校时、设闹钟来更新。3.3 可靠的人机交互按键消抖与状态机按键处理是课程设计里故障的重灾区。机械按键的抖动通常持续5-20ms。我们的消抖逻辑目标是在检测到按键稳定按下比如低电平一段时间如20ms后才认为是一次有效的按键事件并输出一个单周期的脉冲。一个简单可靠的消抖模块可以这样写module key_debounce ( input wire clk, input wire rst_n, input wire key_in, // 按键输入低电平表示按下 output reg key_pulse // 消抖后的按键脉冲高电平有效一个周期 ); parameter CNT_MAX 999_999; // 20ms 50MHz (50_000_000 * 0.02 1_000_000) reg [19:0] cnt; // 足够大的计数器 reg key_in_r0, key_in_r1; // 同步打拍消除亚稳态 reg key_stable; // 稳定后的按键状态 always (posedge clk or negedge rst_n) begin if(!rst_n) begin key_in_r0 1b1; key_in_r1 1b1; cnt 0; key_stable 1b1; end else begin // 两级寄存器同步输入信号 key_in_r0 key_in; key_in_r1 key_in_r0; // 边沿检测与计数消抖 if(key_stable ! key_in_r1) begin // 检测到潜在变化 if(cnt CNT_MAX) begin // 消抖时间到 key_stable key_in_r1; // 更新稳定状态 cnt 0; end else begin cnt cnt 1; end end else begin cnt 0; // 状态稳定计数器清零 end end end // 检测稳定状态的下降沿按键按下事件产生脉冲 reg key_stable_r; always (posedge clk or negedge rst_n) begin if(!rst_n) begin key_stable_r 1b1; key_pulse 1b0; end else begin key_stable_r key_stable; // 检测下降沿之前是1未按下现在是0按下 key_pulse (~key_stable) key_stable_r; end end endmodule这个模块输出key_pulse就是一个干净的单周期脉冲可以安全地用于触发状态切换或数值加减。状态机设计我们需要一个状态机来管理整个系统的工作模式例如S_IDLE: 正常显示模式走时。S_ADJ_HOUR: 校时-小时状态此时小时位闪烁。S_ADJ_MIN: 校时-分钟状态。S_ALARM_SET_HOUR: 闹钟设置-小时状态。S_ALARM_SET_MIN: 闹钟设置-分钟状态。状态转移由“模式键”的脉冲触发。在调整状态下“加键”和“减键”脉冲用来修改对应的时间寄存器。注意在校时状态下被调整的位应该在数码管上闪烁例如以1Hz的频率熄灭这可以通过控制data_to_display数组中对应位的值正常送BCD码或送一个不显示任何数字的编码来实现。3.4 闹钟逻辑精确匹配与开关控制闹钟模块的核心是一个比较器和一个开关寄存器。reg alarm_en; // 闹钟总开关1为开 reg [5:0] alarm_hour, alarm_min; // 闹钟时间寄存器 wire alarm_trigger; // 比较逻辑必须在闹钟开启且时、分完全匹配时触发通常忽略秒和毫秒 assign alarm_trigger alarm_en (current_hour alarm_hour) (current_min alarm_min);这里有一个细节alarm_trigger会在匹配的整个一分钟内都保持高电平。这通常不是我们想要的我们可能只需要一个单脉冲的闹铃信号。可以在比较器后面加一个边沿检测电路只在current_min从alarm_min-1变成alarm_min且小时也匹配的那个瞬间产生一个脉冲。或者更简单的方法是用alarm_trigger信号去驱动一个蜂鸣器或LED并在触发后提供一个“止闹”按键将alarm_en临时关闭或置位一个“已响铃”标志位避免持续报警。闹钟开关可以直接用一个按键控制一个D触发器always (posedge clk_50m or negedge rst_n) begin if(!rst_n) begin alarm_en 1b0; end else if(key_alarm_sw_pulse) begin // 闹钟开关按键的消抖脉冲 alarm_en ~alarm_en; end end assign led_alarm alarm_en; // LED直接指示开关状态4. 系统集成、仿真与上板调试当所有模块编写完成后需要在顶层进行例化连接。务必绘制一张清晰的模块连接图确保每个信号的来源和去向都心中有数。仿真Simulation是必不可少的步骤。在Vivado或Quartus中编写Testbench重点验证计时准确性仿真1.1秒看秒计数器是否只加了一次毫秒计数器是否从0走到999再归零。按键功能模拟按键抖动看消抖模块是否输出干净脉冲模拟模式切换看状态机跳转和显示模式是否正确。闹钟功能设置一个闹钟时间仿真到该时间点看alarm_trigger是否准确拉高。显示驱动观察seg_data和seg_sel的波形看扫描顺序、段码数据是否正确有无“鬼影”。上板调试引脚分配根据正点原子提供的原理图正确分配数码管段选、位选、按键和LED的FPGA引脚。这是硬件连接的基础错了就全错了。先调显示上电后先写一个简单的测试程序让所有数码管显示“8.”确保硬件连接和扫描驱动基本正确。再调计时屏蔽按键和闹钟只让时钟走起来用手机秒表对照看一分钟误差多大。由于使用的是晶振时钟误差应该极小。如果误差明显检查分频计数器的值是否计算正确。集成调试逐个加入按键功能、闹钟功能。遇到问题可以借助板载的LED作为调试信号比如用LED闪烁来表示某个按键被按下或者闹钟被触发。闪烁效果校时时的闪烁可以通过一个分频的闪烁使能信号来控制。例如用一个1Hz的使能信号blink_en在校时状态下当blink_en为高时将被调整位的显示数据替换为“灭”的段码共阳极为0xFF为低时显示正常数字这样就产生了0.5Hz的闪烁效果。5. 常见问题与进阶优化在带学生的过程中我总结了一些高频问题Q1数码管显示暗淡或不均匀A1检查位选信号的驱动电流是否足够。FPGA的IO口驱动能力有限如果直接驱动8个LED并联的数码管位选可能电流不足。正点原子的板子通常已经加了驱动电路如三极管或锁存器请确认你的代码是直接驱动FPGA引脚还是通过驱动芯片。如果是直接驱动确保代码中的位选信号极性正确共阳极是低电平点亮。亮度不均可能是动态扫描时每位点亮的时间不一致检查扫描计数器逻辑。Q2按键感觉不灵敏有时按了没反应A2首先用示波器或逻辑分析仪看按键原始波形确认抖动情况。然后检查消抖模块的稳定时间参数CNT_MAX。20ms对于大多数按键是安全的如果设置得过长比如100ms就会感觉反应迟钝。另外确保你的状态机能够正确响应单周期的key_pulse。Q3闹钟在设定时间不响A3这是最常出现的问题。按以下步骤排查确认alarm_en寄存器确实是1。确认闹钟设置的时间寄存器alarm_hour和alarm_min确实被你成功修改了可以通过在设闹钟模式下观察数码管显示来确认。确认比较逻辑的位宽一致。current_hour和alarm_hour如果一个是5位寄存器存0-23另一个是6位寄存器直接比较就会出错。仿真在Testbench中构造时间走到闹钟设定点的场景看alarm_trigger信号。Q4如何显示毫秒A4毫秒是0-999的三位数。我们需要在8位数码管中分配3位给它。例如显示格式为“HH-MM-SS-msm”其中“msm”是毫秒的百位、十位、个位。注意毫秒的更新频率是1kHz扫描显示时要确保数据在更新过程中不会出现撕裂即显示的数字一部分是旧的一部分是新的。一个简单的方法是将毫秒计数器ms_cnt在每毫秒开始时ms_en上升沿锁存到一个显示缓冲寄存器ms_disp中整个1ms内都显示这个锁存值。进阶优化建议增加整点报时仿照闹钟逻辑在59分50秒开始每秒响一下整点时响一个长音。增加显示亮度调节通过PWM控制位选信号的占空比来调节亮度。使用状态机实现更复杂的菜单例如长按模式键进入设置等。将时间参数时、分、秒封装成一个结构体SystemVerilog或向量在模块间传递更清晰。这个基于正点原子新起点开发板的数字钟项目虽然基础但涵盖了FPGA开发从模块设计、时序分析、仿真验证到上板调试的全流程。把这里面的每一个环节搞透你对同步数字电路设计和Verilog编码的理解会上一个大台阶。最关键的不是仅仅让代码跑起来而是要理解每一行代码背后的硬件意义知道在FPGA这片“数字土壤”里它最终会长成什么样的电路。这才是课程设计真正的价值所在。