1. 从零开始:为什么选择Verilog和Quartus来打造你的数字时钟?

大家好,我是老张,在FPGA和数字逻辑设计这个行当里摸爬滚打了十几年。今天想和大家聊聊一个非常经典,但又常做常新的项目——用Verilog在Quartus平台上实现一个多功能数字时钟。很多朋友刚接触FPGA时,第一个像样的项目往往就是数字时钟。它麻雀虽小,五脏俱全,几乎涵盖了数字系统设计的核心概念:时钟分频、状态机、按键消抖、数码管动态扫描、模块化设计,还有最重要的——仿真验证。

你可能会问,现在单片机做时钟不是更简单吗?确实,用一块STM32或者51单片机,配合RTC芯片,几行C代码就能搞定。但咱们玩FPGA的,追求的是用硬件描述语言(HDL)从底层构建一个并发的、实时的数字系统。这个过程能让你真正理解“时间”在数字电路里是如何被精确计量和控制的,理解每一个时钟沿、每一个寄存器背后的意义。这就像亲手搭建一座机械钟表,虽然复杂,但成就感是调用现成库函数无法比拟的。

Quartus作为Altera(现在是Intel FPGA)的官方开发套件,是学习FPGA设计最主流的环境之一。它集成了设计输入、综合、布局布线、编程和仿真(结合ModelSim)的完整流程。对于数字时钟这个项目,我们不仅能学习写Verilog代码,更能学会如何使用Quartus进行工程管理、查看RTL视图来理解综合后的电路结构,以及编写Testbench进行严谨的仿真测试。这整套流程,是成为一名合格数字电路工程师的基本功。

我这次要带大家实现的,可不是一个简单的计时器。我们要做的是一个多功能复合数字时钟系统,它集成了基础计时、可调闹钟、独立秒表、倒计时定时器四大功能,并且通过有限状态机(FSM)进行优雅的模式切换。我会把我在实际项目中踩过的坑、总结的技巧,还有如何让代码更稳健、更易于维护的经验,都揉碎了讲给大家听。无论你是正在学习FPGA的大学生,还是希望巩固基础的工程师,相信这篇超详细的解析都能让你有所收获。

2. 顶层设计:如何规划一个模块化的数字时钟系统?

做项目最怕一上来就埋头写代码,写到一半发现架构有问题,推倒重来,那感觉太痛苦了。所以,在动手敲第一行Verilog之前,我们必须先把整个系统的“蓝图”画清楚。一个优秀的设计,一定是模块化、层次清晰的。

回想一下原始文章里那个顶层模块Digital_clock,它其实已经给出了一个很好的框架。它通过例化(Instantiate)多个子模块,像搭积木一样把系统构建起来。我们来深入分析并优化一下这个架构。我们的系统核心输入是一个高频时钟(比如50MHz),以及几个功能按键。输出则是驱动数码管的段选、位选信号,以及一个控制蜂鸣器响铃的信号。

那么,系统需要哪些核心“积木块”呢?我习惯把它们分为基础设施模块和功能模块两大类。

基础设施模块是系统的基石,为所有功能提供支持:

  1. 时钟分频模块 (fenping):这是数字时钟的“心跳发生器”。板载的50MHz晶振太快了,我们需要把它分频成两个实用的低频时钟:1Hz(秒脉冲)用于计时和倒计时,100Hz(10ms周期)用于秒表的高精度计时以及按键消抖和数码管动态扫描。分频的精度直接决定了时钟走时的准确性。
  2. 按键消抖模块 (key_jitter):机械按键在按下和弹起时,由于触点抖动,会产生一系列毛刺脉冲。如果不处理,一次按键可能会被误认为多次操作。这个模块的作用就是过滤掉这些抖动,产生一个干净、稳定的按键使能信号。通常采用延时采样或状态机的方法实现。
  3. 显示驱动模块 (display):这是连接数字逻辑和现实世界的桥梁。它负责将内部用二进制表示的时、分、秒、毫秒等数据,转换成6位数码管能够显示的段码和位码。为了节省FPGA的IO口,几乎都采用动态扫描方式,即快速轮流点亮每一位数码管,利用人眼的视觉暂留效应形成稳定显示。

功能模块是实现具体业务逻辑的核心:

  1. 模式控制模块 (set_mode):这是整个系统的大脑,一个经典的状态机。它根据“模式键”的输入,在“计时模式”、“闹钟设置模式”、“秒表模式”、“定时器模式”之间循环切换。它会输出一个state_mode信号,告诉其他模块当前处于什么状态。
  2. 计时模块 (jishi):这是最基础的功能。在1Hz时钟的驱动下,完成秒、分、时的递增,并处理进位逻辑(60秒进1分,60分进1时,24时归零)。同时,在计时模式下,它需要响应“设置键”、“确认键”、“切换键”来调整当前时间。
  3. 闹钟模块 (alarm_clock):其逻辑与计时模块类似,但它存储的是一个预设的“目标时间”。在计时模式下,系统会不断比较当前时间与闹钟时间,一旦相等,就触发响铃信号。它也需要一套独立的设置逻辑。
  4. 秒表模块 (stopwatch):这是一个独立的精密计时器。它使用100Hz时钟(10ms分辨率)进行计数。需要有“启动/暂停”、“复位”控制逻辑,其计时过程与主时钟完全独立。
  5. 定时器模块 (timing):这是一个倒计时功能。用户可以设置一个时长(如10分钟),然后启动倒计时,减到零时触发提醒。它同样需要设置和启动控制。
  6. 响铃控制模块 (Bell):这是一个判决器。它监控当前时间是否等于闹钟时间,或者定时器是否归零。如果条件满足,则驱动bell_out信号控制蜂鸣器发出声音,直到用户按下关闭键。

把这些模块的关系理清,用一张框图在脑子里画出来,你的顶层设计就完成了。接下来,我们就可以逐个击破,深入每个模块的内部细节。

3. 核心模块深度解析:从分频消抖到状态机控制

3.1 时钟分频与按键消抖:系统的稳定基石

我们先从最底层的两个模块讲起,它们是系统稳定可靠的前提。

时钟分频模块:我们的目标是产生精准的1Hz和100Hz时钟。假设输入是50MHz(周期20ns),那么要得到1Hz,就需要进行50,000,000分频。直接用一个计数器累加50M次吗?理论上可以,但计数器会非常宽(需要26位),而且仿真起来很慢。更常见的做法是级联分频。比如,先进行5000分频得到10KHz,再进行10000分频得到1Hz。在代码中,我们通常用一个计数器reg [25:0] cnt_1Hz;来实现。这里有个关键点:分频后的时钟clk_1Hz最好不要直接用作其他模块的时钟,而是作为一个使能信号(en_1Hz)。因为FPGA设计里,全局时钟网络资源宝贵,且多时钟域会增加时序分析的复杂性。推荐的做法是:在clk_50M的驱动下,当cnt_1Hz计满时,产生一个周期为一个clk_50M宽度的脉冲信号作为使能。其他模块在clk_50M下判断这个使能信号为高时,才进行秒计数操作。这就是所谓的“使能信号”设计风格,能让整个系统同步在同一个主时钟下,更稳定。

// 示例:1Hz使能信号生成
reg [25:0] cnt;
reg en_1Hz;
always @(posedge clk_50M or posedge rst) begin
    if (rst) begin
        cnt <= 0;
        en_1Hz <= 0;
    end else begin
        if (cnt == 26‘d49_999_999) begin // 计数50M-1次
            cnt <= 0;
            en_1Hz <= 1‘b1; // 产生一个时钟周期的高脉冲
        end else begin
            cnt <= cnt + 1‘b1;
            en_1Hz <= 1‘b0;
        end
    end
end

按键消抖模块:消抖的本质是“去噪声”。机械按键的抖动期通常在5-10ms。我们的策略是:当检测到按键电平变化后,启动一个约20ms的计时器,在此期间持续采样按键状态。如果20ms后按键状态稳定为新状态,才认为这是一次有效的按键动作。这可以用一个状态机轻松实现:IDLE(空闲)-> FILTER(滤波等待)-> DOWN(确认按下)。在Quartus里仿真这个模块特别有意思,你可以给key_in输入一个带有毛刺的方波,观察key_negedge输出是否只在每次稳定按下后才出现一个干净的单脉冲。

3.2 状态机与多模式切换:系统的大脑

模式控制模块是整个系统的指挥中心。它管理着四个主要状态:MODE_CLOCK(计时), MODE_ALARM(闹钟设置), MODE_STOPWATCH(秒表), MODE_TIMER(定时器)。状态转移完全由经过消抖后的mode_key信号触发。

写状态机时,我强烈推荐使用“三段式”写法,这是业内的最佳实践,能让代码清晰且易于综合。

  1. 第一段(时序逻辑):同步状态转移。在时钟边沿,根据复位信号和下一状态更新当前状态。
  2. 第二段(组合逻辑):下一状态逻辑。根据当前状态和输入信号(如按键),决定下一个时钟周期应该跳转到哪个状态。
  3. 第三段(组合或时序逻辑):输出逻辑。根据当前状态,产生对应的输出信号,比如点亮代表当前模式的LED灯 (led_mode)。
// 三段式状态机示例框架
localparam MODE_CLOCK = 2‘b00, MODE_ALARM = 2’b01, MODE_STOPWATCH = 2‘b10, MODE_TIMER = 2’b11;
reg [1:0] current_state, next_state;

// 第一段:状态寄存器
always @(posedge clk or posedge rst) begin
    if (rst) current_state <= MODE_CLOCK;
    else current_state <= next_state;
end

// 第二段:下一状态逻辑
always @(*) begin
    next_state = current_state; // 默认保持
    case (current_state)
        MODE_CLOCK: if (mode_key_pressed) next_state = MODE_ALARM;
        MODE_ALARM: if (mode_key_pressed) next_state = MODE_STOPWATCH;
        // ... 其他状态转移
    endcase
end

// 第三段:输出逻辑
always @(posedge clk) begin
    case (current_state)
        MODE_CLOCK: led_mode <= 4‘b0001;
        MODE_ALARM: led_mode <= 4’b0010;
        // ...
    endcase
end

这个state_mode信号会输出到所有功能模块。每个功能模块内部都要判断:只有当state_mode与自己的模式匹配时,才响应按键进行设置或运行。这就实现了用同一套物理按键,通过状态机来区分上下文,控制不同功能,大大节省了IO资源。

3.3 计时与功能模块设计:逻辑实现的关键

计时模块是理解计数器进位逻辑的绝佳例子。我们需要三个计数器:秒second、分minute、时hour。它们都在en_1Hz使能下工作。秒从0加到59,然后归零,同时产生一个carry_second进位信号给分计数器。分计数器同理,时计数器加到23后归零。在设置模式下,通过set_key(选择调整位)、change_key(增减数值)、confirm_key(确认)三个按键配合,可以修改时、分、秒的值。这里要注意,设置过程中,正常的计时应该暂停,直到确认完成。

闹钟模块和定时器模块在存储和比较逻辑上与计时模块类似,但触发条件相反。闹钟是“等于”触发,定时器是“减到零”触发。它们的设置流程可以设计得和计时模块高度复用。

秒表模块则是一个独立的精密计数器。它使用clk_100Hz(10ms)作为计数基准。需要三个计数器:10毫秒位(0-99)、秒位(0-59)、分位(0-59)。它有自己的控制状态机,通常有IDLE(复位后)、RUNNING(计时中)、PAUSED(暂停)三个状态,由start/stop_key和reset_key控制。

显示模块是代码量可能最大的模块之一,因为它要处理多路数据的选择和动态扫描。它的核心是一个高速计数器(比如用clk_50M或clk_1K),产生循环变化的位选信号bit_select[5:0]。同时,根据当前state_mode和bit_select的值,从计时、闹钟、秒表、定时器的数据中,选择出对应的一位BCD码,再通过一个查找表(case语句)转换为7段数码管的段码seg_select[7:0]输出。动态扫描的频率要足够快(通常>60Hz),否则会有闪烁感。

4. 仿真验证实战:编写Testbench与解读波形图

代码写完了,直接烧录到板子上看结果?千万别!对于FPGA设计,仿真(Simulation)是保证设计正确性最经济、最有效的手段。没有经过充分仿真的设计,上板调试简直就是灾难。我们会使用Quartus自带的仿真工具(或联合ModelSim)来进行测试。

4.1 如何编写一个全面的Testbench?

Testbench也是一个Verilog模块,但它不需要被综合成硬件。它的任务是模拟真实环境,给我们的设计(DUT, Device Under Test)提供激励(时钟、复位、按键信号),并观察DUT的输出。一个好的Testbench应该能覆盖主要功能场景和边界条件。

首先,我们要生成时钟和复位信号:

`timescale 1ns/1ps // 定义时间单位/精度
module tb_digital_clock();
    reg clk_50M;
    reg rst_n; // 低电平复位
    // ... 其他输入信号定义
    // ... 实例化DUT

    // 生成50MHz时钟,周期20ns
    initial clk_50M = 0;
    always #10 clk_50M = ~clk_50M; // 每10ns翻转一次

    // 生成复位信号
    initial begin
        rst_n = 0; // 开始时复位
        #100;      // 保持100ns
        rst_n = 1; // 释放复位
        #20000000; // 仿真一段时间
        $stop;     // 结束仿真
    end

接下来是模拟按键动作。注意,要模拟出按键的抖动效果!我们可以写一个task来模拟按键按下和释放的过程,中间加入一段随机抖动。

task press_key;
    output key_signal;
    begin
        key_signal = 1‘b0; // 假设按键低电平有效
        #(10000 + $random % 5000); // 按下后抖动一段时间
        key_signal = 1’b0; // 稳定按下
        #2000000; // 按住2ms
        key_signal = 1‘b1; // 开始释放
        #(10000 + $random % 5000); // 释放抖动
        key_signal = 1’b1; // 稳定释放
    end
endtask

在initial块中,我们可以组织测试序列:先复位,然后测试模式切换,再测试在计时模式下调整时间,接着测试设置闹钟并等待触发,最后测试秒表功能等。可以使用$display或$monitor系统任务在控制台打印关键信息,辅助调试。

4.2 解读仿真波形:如何判断设计是否正确?

运行仿真后,Quartus/ModelSim会打开波形窗口。我们需要重点观察以下几组信号:

  1. 时钟与使能:查看clk_50M、clk_1Hz(或en_1Hz)是否正常。en_1Hz是否每1秒出现一个脉冲?
  2. 按键与消抖:给key_0输入一个带有毛刺的信号,观察消抖模块输出的key_0_negedge是否只在按键稳定变化后产生一个单时钟周期脉冲?这是消抖是否成功的关键。
  3. 状态机:观察state_mode或led_mode的变化。当你模拟按下模式键时,状态是否按计时->闹钟->秒表->定时器->计时...的顺序循环跳转?
  4. 计时功能:在计时模式下,观察hour_time, minute_time, second_time这三个信号。在en_1Hz脉冲到来时,秒位是否加1?计满59后是否归零并向分位进位?分和时的进位逻辑是否正确?手动设置时间的功能是否正常?
  5. 闹钟功能:切换到闹钟设置模式,设置一个比当前时间稍晚的时间(比如当前是10:05:00,设置闹钟为10:05:05)。然后切回计时模式,观察当时钟走到10:05:05时,bell_out信号是否变高(或变低,取决于驱动方式)触发响铃?按下关闭键后,响铃是否停止?
  6. 秒表功能:切换到秒表模式,模拟按下启动键,观察stopwatch_Millisecond等信号是否开始以10ms为单位递增?按下暂停键是否停止?复位键是否将所有计数归零?
  7. 显示输出:观察bit_select是否在6个数码管位之间循环扫描?seg_select输出的段码是否与bit_select当前选中的数字相符?你可以将段码翻译成数字,对照内部数据寄存器进行验证。

通过分析这些波形,你就能像侦探一样,一步步验证每个模块、每项功能是否按照预期工作。仿真通过后,你对整个系统的信心会大大增强,上板调试的成功率也会极高。

5. 上板调试与优化:从仿真到实物的最后一步

仿真完美通过,恭喜你完成了最重要的一步!但把程序下载到FPGA开发板(比如DE10-Standard, Cyclone IV EP4CE系列板卡等)上,才是真正的考验。这里分享几个我实践中总结的关键点和常见问题排查技巧。

引脚分配(Pin Assignment):这是新手最容易出错的地方。在Quartus的Assignment Editor里,你需要将顶层模块的输入输出信号,一对一地分配到FPGA芯片的实际物理引脚上。这些引脚连接着板载的时钟、按键、数码管和蜂鸣器。务必对照开发板的原理图进行分配!时钟引脚通常有专用全局时钟输入引脚,使用它们能获得更好的时序性能。按键和数码管一般是连接到通用IO口。分配错误会导致功能异常甚至损坏硬件。

时序约束(Timing Constraints):对于小型设计,Quartus的自动优化可能就够了。但对于更复杂或高速的设计,你需要创建.sdc文件来告诉时序分析工具你的时钟频率、输入输出延迟等要求。对于我们的数字时钟,至少需要为clk_50M创建一个时钟约束:create_clock -name clk_50M -period 20.000 [get_ports clk_50M]。编译后,一定要查看“Timing Analyzer”报告,确保没有时序违规(Setup/Hold Time Violation)。

上电复位(Power-on Reset)处理:在Testbench里我们手动给了复位信号,但板子上电瞬间,寄存器状态是不确定的。为了保证系统从一个已知的初始状态(比如00:00:00)开始工作,必须在代码中实现可靠的上电复位逻辑。通常,我们可以利用一个初始为0的寄存器,在时钟驱动下延迟一段时间后产生一个内部复位信号,或者使用FPGA芯片提供的专用配置完成信号。

常见问题排查:

  • 数码管全亮或全暗:首先检查段选和位选的极性(共阴还是共阳)是否与代码中的驱动逻辑匹配。然后检查动态扫描的频率,太快或太慢都会导致显示异常。可以用示波器或逻辑分析仪抓取bit_select和seg_select的波形。
  • 按键不灵敏或连击:首先确认消抖模块参数(如20ms延时)是否合理。其次检查引脚分配和硬件连接,用万用表测量按键按下和释放时的实际电平。有时需要上拉电阻确保空闲时为高电平。
  • 计时不准:这是最经典的问题。首先检查1Hz分频计数器的计数值是否正确(50M分频)。其次,在仿真中仔细测量en_1Hz脉冲的周期是否为精确的1秒。如果误差很大,可能是分频计数器位宽不够导致溢出计算错误。如果误差是每天几秒,那可能是晶振本身的精度问题,这与代码无关。
  • 模式切换混乱:检查状态机逻辑,特别是状态编码和转移条件。确保消抖后的按键信号是单脉冲,而不是持续多个周期的高电平。可以在代码中增加一些调试信号,通过板载LED显示当前状态,辅助判断。

性能与资源优化:完成基本功能后,可以思考如何优化。例如,多个模块中可能都有类似的BCD转换逻辑,是否可以提取成一个公共模块?显示模块的动态扫描计数器,如果用更低的频率(如1KHz)是否能降低功耗?如果资源紧张,是否可以尝试不同的状态编码方式(如One-Hot编码)?这些优化练习能让你对FPGA设计有更深的理解。

把这个多功能数字时钟项目从头到尾做一遍,你收获的不仅仅是一个会跑的时钟。你实践了从需求分析、模块划分、代码编写、仿真验证到上板调试的完整FPGA开发流程。你深入理解了时钟、计数器、状态机、同步设计、按键消抖、动态显示等核心数字电路概念。更重要的是,你学会了如何用工具(Quartus, ModelSim)来辅助设计和调试。这些经验和技能,是通往更复杂FPGA项目(如图像处理、通信协议实现)的坚实阶梯。希望这篇长文能成为你FPGA学习路上的一个有用路标。如果在实现过程中遇到具体问题,不妨多看看RTL视图,多进行局部仿真,耐心分析波形,问题总会解决的。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐