FPGA数字时钟设计:Verilog实现与正点原子开发板实战

1. 项目缘起:从需求到板卡选型

最近在带学生做FPGA的课程设计,发现“数字电子时钟”这个题目真是经久不衰。它麻雀虽小,五脏俱全:计数器、分频、显示驱动、人机交互(校时、闹钟)一个不落,非常适合用来检验对Verilog硬件描述语言和同步数字电路设计的掌握程度。不过,很多同学在具体实现时,往往会卡在一些细节上,比如数码管的动态扫描时序没调好导致闪烁、按键消抖处理不当、闹钟比较逻辑有漏洞等等。

这次我们选用的硬件平台是正点原子的新起点开发板。选它有几个很实在的理由:首先,它核心的FPGA芯片是Xilinx Artix-7系列的XC7A35T,逻辑资源对于这个课程设计来说绰绰有余,而且正点原子的资料和例程一向比较齐全,社区支持好,能省去很多底层硬件摸索的时间。其次,这块板子上自带了一个8位的共阳极数码管模块,正好契合我们“支持8位或6位显示”的需求——你可以用它显示“时-分-秒-毫秒”(8位),或者精简为“时-分-秒”(6位)。板载的按键、LED灯也为我们实现校时、闹钟开关及状态指示提供了现成的物理接口。

所以,这个项目的目标很明确:在正点原子新起点开发板上,用Verilog编写一个数字钟,功能上要能正常走时,能通过按键校准时间,能设置并开关闹钟,并用LED灯来指示闹钟开关状态。听起来简单,但每一个环节都有值得深究的地方。下面,我就结合代码和实测波形,把整个设计思路、关键实现细节以及最容易踩的坑,给大家掰开揉碎了讲清楚。

2. 系统顶层设计与模块划分

做FPGA设计,最忌讳一开始就埋头写代码。合理的顶层架构是成功的一半。对于这个数字钟,我们可以把它分解成几个各司其职的模块,通过清晰的接口连接起来。这样不仅代码好维护,调试起来也方便。

整个系统的核心数据流是“时间信息”的生成、处理与显示。我设计的顶层模块(比如叫top_digital_clock)主要包含以下子模块:

  1. 时钟与复位管理模块 (clk_gen):开发板输入的是50MHz的系统时钟,我们的秒、毫秒信号都需要通过对这个高频时钟分频得到。这个模块负责产生全局同步的时钟使能信号,而不是直接分频出多个时钟域,这是同步设计的好习惯。
  2. 时间计数核心 (time_counter):这是心脏部分。它接收秒使能、毫秒使能信号,维护着时、分、秒、毫秒四个计数器。同时,它要处理来自“按键处理模块”的校时信号,对相应的计数器进行加一操作。
  3. 按键消抖与处理模块 (key_debounce):板载的机械按键存在抖动问题,直接读取会导致多次误触发。这个模块需要对每个按键输入进行消抖滤波,并产生干净的、持续时间一个时钟周期的脉冲信号,供其他模块使用。我们至少需要三个按键:模式键(切换校时/闹钟设置/正常显示)、加键、减键。
  4. 闹钟设置与比较模块 (alarm_setting):该模块内部维护一套闹钟时间寄存器(时、分)。通过按键可以修改这些寄存器。它还会不断将当前时间与闹钟时间进行比较,当两者完全匹配且闹钟开关开启时,产生一个闹铃触发信号。
  5. 数码管动态显示驱动 (seg_driver):这是最考验时序把握的模块之一。它需要将时间计数核心和闹钟设置模块输出的BCD码(二进制编码的十进制数)转换成共阳极数码管的段选信号,并通过位选信号循环点亮8位数码管,利用人眼视觉暂留实现“同时”显示。它需要处理“显示模式”(正常时间、校时闪烁、闹钟设置)的逻辑。
  6. 闹钟开关与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 <= 1'b0; end else begin if(cnt_ms == 49999) begin // 从0计数到49999,共50000个周期,即1ms cnt_ms <= 0; ms_en <= 1'b1; // 使能脉冲高电平维持一个时钟周期 end else begin cnt_ms <= cnt_ms + 1; ms_en <= 1'b0; 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 <= 1'b0; end else if(ms_en) begin // 只在ms_en有效时计数 if(cnt_sec == 999) begin cnt_sec <= 0; sec_en <= 1'b1; end else begin cnt_sec <= cnt_sec + 1; sec_en <= 1'b0; end end else begin sec_en <= 1'b0; // 确保sec_en只持续一个周期 end end

注意:使能信号(ms_en,sec_en)必须是单周期脉冲。在秒计数器模块中,判断if(sec_en)来增加秒值。这样,所有逻辑都在clk_50m的同步下,非常稳定。

3.2 数码管动态扫描:消除闪烁的奥秘

8位数码管,如果静态驱动需要8*8=64个IO口,动态扫描只用8个段选(a-g, dp)和8个位选。我们的驱动模块要循环点亮每一位,每位点亮一段时间(比如1ms)。关键在于扫描频率要足够快

常见坑点:扫描频率过低导致闪烁。人眼视觉暂留时间约0.1秒。如果8位数码管扫描一轮的时间超过0.1秒,比如一位持续20ms,一轮就是160ms,你就会明显感觉到闪烁。所以,每位点亮时间建议在1-5ms之间,这样扫描频率在125Hz以上,基本无闪烁。

另一个坑点是段选和位选的配合时序。必须在切换位选信号之前,提前准备好下一位的段选数据。否则会在切换瞬间,上一个位选还没关闭,新的段选数据就上去了,造成“鬼影”(短暂显示错误内容)。正确的时序是:

  1. 关闭当前位选(置高,共阳极为高电平关闭)。
  2. 输出下一位的段选数据。
  3. 开启下一位的位选(置低)。
  4. 保持一段时间,然后循环。

代码实现上,可以用一个状态机或者简单的计数器来实现:

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 <= 8'hFF; // 关闭所有位选 end else begin if(dwell_cnt == 49999) begin // 假设每位持续1ms (50_000个周期) dwell_cnt <= 0; // 先关闭当前位选 seg_sel <= 8'hFF; // 更新段选数据为下一位的数字 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 <= 1'b1; key_in_r1 <= 1'b1; cnt <= 0; key_stable <= 1'b1; 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 <= 1'b1; key_pulse <= 1'b0; 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_minalarm_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 <= 1'b0; 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.1秒,看秒计数器是否只加了一次,毫秒计数器是否从0走到999再归零。
  2. 按键功能:模拟按键抖动,看消抖模块是否输出干净脉冲;模拟模式切换,看状态机跳转和显示模式是否正确。
  3. 闹钟功能:设置一个闹钟时间,仿真到该时间点,看alarm_trigger是否准确拉高。
  4. 显示驱动:观察seg_dataseg_sel的波形,看扫描顺序、段码数据是否正确,有无“鬼影”。

上板调试

  1. 引脚分配:根据正点原子提供的原理图,正确分配数码管段选、位选、按键和LED的FPGA引脚。这是硬件连接的基础,错了就全错了。
  2. 先调显示:上电后,先写一个简单的测试程序,让所有数码管显示“8.”,确保硬件连接和扫描驱动基本正确。
  3. 再调计时:屏蔽按键和闹钟,只让时钟走起来,用手机秒表对照,看一分钟误差多大。由于使用的是晶振时钟,误差应该极小。如果误差明显,检查分频计数器的值是否计算正确。
  4. 集成调试:逐个加入按键功能、闹钟功能。遇到问题,可以借助板载的LED作为调试信号,比如用LED闪烁来表示某个按键被按下,或者闹钟被触发。
  5. 闪烁效果:校时时的闪烁,可以通过一个分频的闪烁使能信号来控制。例如,用一个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_houralarm_min确实被你成功修改了(可以通过在设闹钟模式下,观察数码管显示来确认)。
  • 确认比较逻辑的位宽一致。current_houralarm_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这片“数字土壤”里,它最终会长成什么样的电路。这才是课程设计真正的价值所在。