FPGA数字时钟设计:从状态机到动态扫描的完整实现

发布时间:2026/7/29 4:46:46
FPGA数字时钟设计:从状态机到动态扫描的完整实现 1. 项目缘起从需求到实现的FPGA数字时钟最近在整理一些FPGA的入门项目发现很多朋友在学完基础语法和简单组合逻辑后面对稍微综合一点的时序逻辑设计比如一个完整的数字时钟还是会感到无从下手。网上的代码要么过于简陋只有显示功能要么过于复杂集成了万年历、闹钟对新手不够友好。所以我想分享一个我自己在带学生时常用的“教学级”FPGA数字时钟项目。这个项目的核心目标是清晰、完整、可扩展它实现了一个标准的24小时制数字时钟并且包含了两个非常实用的功能暂停和调数。更重要的是我会把完整的Verilog代码贴出来并逐行讲解关键设计思路和那些容易踩坑的细节。这个项目麻雀虽小五脏俱全。它涉及了FPGA开发中最核心的几个概念时钟分频、计数器设计、按键消抖、状态机控制以及数码管动态扫描。通过实现它你不仅能巩固Verilog语法更能建立起一个完整的数字系统设计思维。无论你是用Xilinx的Vivado、Intel的Quartus还是国产的安路、高云工具链这套代码和思路都是通用的。下面我们就从最核心的模块划分和设计思路开始一步步把这个时钟“搭”起来。2. 系统架构与核心模块设计一个功能完整的数字时钟绝不能把所有代码都堆在一个模块里。好的模块化设计是项目成功和代码可维护性的基础。根据功能划分我将整个系统分解为五个核心模块它们之间的数据流和控制关系构成了整个时钟的“骨架”。2.1 顶层模块与模块互联顶层模块通常命名为top_digital_clock就像项目的总接线图。它不实现具体功能只负责实例化所有子模块并正确地连接它们之间的信号。这是我们系统的“黑盒”视图输入是系统时钟、复位信号和几个按键输出是连接到数码管的段选和位选信号。首先我们需要明确输入输出。假设我们使用一个50MHz的系统时钟clk_50m一个低电平有效的复位键rst_n以及三个功能按键模式键key_mode、加键key_plus、暂停键key_pause。输出则是用来驱动4位共阴极数码管的段选信号seg8位控制a-g和dp段和位选信号sel4位控制哪一个数码管点亮。在顶层模块中我们会实例化以下子模块时钟分频与按键消抖模块将50MHz高频时钟分频产生1Hz的计时基准时钟和用于扫描、消抖的低频时钟。核心计时与控制模块这是大脑负责时、分、秒的计数并响应按键改变计时状态正常走时、暂停、调时、调分。显示驱动模块将内部的时分秒数值BCD码格式转换为数码管能识别的段码并实现动态扫描避免鬼影。模块间的关键信号连接包括消抖后的干净按键信号传递给控制模块控制模块产生的时分秒BCD码值传递给显示模块显示模块需要的扫描时钟由分频模块提供。这种清晰的接口定义使得我们后期调试、修改或替换某个模块比如换一种按键消抖算法变得非常容易。2.2 核心计时与控制模块的设计哲学这是整个项目的逻辑核心其设计好坏直接决定了时钟的准确性和稳定性。我采用“状态机 计数器”的组合来实现。状态机定义了时钟的几种工作模式而计数器则在每种模式下执行相应的操作。首先定义状态。一个简单而足够用的状态机可以包含四个状态S_RUN正常运行状态时钟每秒自动加一。S_PAUSE暂停状态时钟停止更新但显示当前时间。S_SET_HOUR调时状态在此状态下按“加键”可以调整小时数。S_SET_MIN调分状态在此状态下按“加键”可以调整分钟数秒通常清零从0开始。状态之间的转换由按键触发。例如在任意状态下按“模式键”状态按S_RUN - S_PAUSE - S_SET_HOUR - S_SET_MIN - S_RUN循环切换。在S_SET_HOUR或S_SET_MIN状态下按“加键”对相应位进行加一操作。而“暂停键”可以直接在S_RUN和S_PAUSE之间切换提供快速暂停/启动功能。状态的转换必须用同步时序逻辑来描述也就是在时钟边沿检测按键事件和当前状态来决定下一个状态。这是避免产生毛刺和亚稳态的关键。// 状态定义 localparam S_RUN 2b00; localparam S_PAUSE 2b01; localparam S_SET_HOUR 2b10; localparam S_SET_MIN 2b11; reg [1:0] current_state, next_state; // 状态转换第一段时序逻辑状态寄存器更新 always (posedge clk_1hz or negedge rst_n) begin // 注意状态转换可以用1Hz时钟降低功耗和复杂度 if(!rst_n) current_state S_RUN; else current_state next_state; end // 状态转换第二段组合逻辑根据当前状态和输入决定下一个状态 always (*) begin case(current_state) S_RUN: begin if(key_mode_pulse) next_state S_PAUSE; else if(key_pause_pulse) next_state S_PAUSE; else next_state S_RUN; end S_PAUSE: begin if(key_mode_pulse) next_state S_SET_HOUR; else if(key_pause_pulse) next_state S_RUN; else next_state S_PAUSE; end // ... 其他状态转换逻辑 default: next_state S_RUN; endcase end在状态机框架下我们再设计时分秒计数器。秒计数器cnt_sec在S_RUN状态下每来一个1Hz的时钟脉冲就加1计满60归零并产生一个进位信号carry_min给分钟计数器。分钟和小时计数器同理。但在S_SET_HOUR状态下小时计数器不再受进位信号控制而是受“加键”的脉冲信号控制。这里一个非常重要的细节是必须对按键信号进行边沿检测产生一个时钟周期宽度的脉冲key_plus_pulse而不是直接用按键电平去控制计数器。否则按下按键不松开数值会飞速增加完全无法控制。// 边沿检测产生脉冲信号 reg key_plus_r; always (posedge clk or negedge rst_n) begin if(!rst_n) key_plus_r 1b1; // 假设按键常态为高按下为低 else key_plus_r key_plus_debounced; // key_plus_debounced是消抖后的稳定信号 end // 当检测到下降沿时产生一个时钟周期的高脉冲 wire key_plus_pulse (key_plus_r 1b1) (key_plus_debounced 1b0);小时和分钟的调整需要处理边界。小时加到23后再按应归0分钟加到59后再按应归0。同时在调整模式下秒计数器通常会被清零这样在调整完成后切回运行状态时时间是从整分或整时开始的更符合使用习惯。3. 关键子模块的代码实现与避坑指南有了顶层架构和核心逻辑我们来深入每个子模块看看代码具体怎么写以及哪里最容易出问题。3.1 时钟分频与按键消抖稳定的基石系统时钟是50MHz周期是20ns。我们的秒时钟需要1Hz这是一个非常大的分频比50,000,000:1。直接用一个计数器从0累加到49,999,999在语法上没问题但综合工具可能会报警告而且仿真起来效率极低。更通用的做法是进行两级分频先分频到一个中间频率比如1kHz再用这个1kHz的时钟去产生1Hz信号。这样做也便于我们产生其他所需的时钟比如数码管扫描时钟几百Hz和按键消抖采样时钟10-20Hz。module clk_gen #( parameter CLK_FREQ 50_000_000, // 输入时钟频率50MHz parameter SCAN_FREQ 1000, // 扫描时钟目标频率1kHz parameter DEBOUNCE_FREQ 200 // 消抖采样时钟目标频率200Hz )( input wire clk_in, input wire rst_n, output reg clk_1hz, output wire clk_scan, output wire clk_debounce ); // 产生1kHz扫描时钟 localparam SCAN_CNT_MAX CLK_FREQ / SCAN_FREQ / 2 - 1; reg [15:0] cnt_scan; always (posedge clk_in or negedge rst_n) begin if(!rst_n) begin cnt_scan 0; clk_scan 0; end else if(cnt_scan SCAN_CNT_MAX) begin cnt_scan 0; clk_scan ~clk_scan; end else begin cnt_scan cnt_scan 1; end end // 类似方法产生clk_debounce... // 用clk_1khz进一步分频产生1Hz信号 // ... endmodule注意分频计数器的最大值计算一定要仔细。SCAN_CNT_MAX CLK_FREQ / SCAN_FREQ / 2 - 1这里的除以2是因为我们通过翻转clk_scan来产生方波所以计数值是半个周期。如果参数计算错误会导致产生的时钟频率不准。按键消抖是数字系统中最基础也最易错的部分。机械按键在按下和弹起时会产生持续数毫秒的抖动如果直接采样会被误认为是多次按键。消抖的本质就是滤波。我最推荐也最可靠的方法是“定时采样法”。它的原理是用一个低频时钟比如10-20Hz去采样按键信号只有当连续多次比如2-3次采样到的值都一致时才认为按键状态稳定了并输出这个稳定值。module key_debounce #( parameter SAMPLE_CNT 3 // 连续采样次数 )( input wire clk, // 低频采样时钟如20Hz input wire key_in, // 原始的按键输入 output reg key_out // 消抖后的稳定输出 ); reg [1:0] key_sync; // 同步寄存器用于消除亚稳态 always (posedge clk) begin key_sync {key_sync[0], key_in}; end reg [1:0] cnt; // 采样计数器 reg key_stable; // 内部稳定状态寄存器 always (posedge clk) begin if(key_sync[1] ^ key_stable) begin // 如果采样值与当前稳定值不同 if(cnt) begin // 如果计数器计满了全1 key_stable key_sync[1]; // 更新稳定值 key_out key_sync[1]; // 输出更新 cnt 0; // 计数器清零 end else begin cnt cnt 1; // 计数器加1 end end else begin cnt 0; // 采样值相同计数器清零 end end endmodule避坑指南这里的关键是采样时钟频率的选择。不能太高如1kHz否则可能采样到抖动过程中的毛刺也不能太低如1Hz否则按键响应会严重延迟。20Hz50ms采样一次是一个经验值兼顾了响应速度和稳定性。key_sync两级同步是消除亚稳态的标准操作任何异步信号如按键进入FPGA时钟域前都必须经过这个处理。3.2 显示驱动模块动态扫描的艺术为了用最少的IO口驱动多个数码管动态扫描是唯一的选择。其原理是利用人眼的视觉暂留快速轮流点亮每一个数码管。只要扫描频率足够快60Hz人眼看到的就是所有数码管同时稳定显示。我们的时钟需要显示4位数时分各两位。显示驱动模块的输入是4个4位的BCD码hour_ten,hour_unit,min_ten,min_unit输出是段选seg和位选sel。首先需要一个扫描计数器在clk_scan例如1kHz驱动下循环计数0-3。这个计数器的值决定了当前要点亮哪一位数码管。reg [1:0] scan_cnt; always (posedge clk_scan or negedge rst_n) begin if(!rst_n) scan_cnt 0; else scan_cnt scan_cnt 1; end // 位选信号译码根据scan_cnt将对应的位选线拉低共阴极 always (*) begin case(scan_cnt) 2d0: sel 4b1110; // 点亮最低位分钟个位 2d1: sel 4b1101; // 点亮分钟十位 2d2: sel 4b1011; // 点亮小时个位 2d3: sel 4b0111; // 点亮小时十位 default: sel 4b1111; endcase end接下来需要根据scan_cnt选择当前要显示的数据并通过一个查找表LUT将4位BCD码转换为7段码a-g。reg [3:0] data_display; // 当前要显示的BCD数据 always (*) begin case(scan_cnt) 2d0: data_display min_unit; 2d1: data_display min_ten; 2d2: data_display hour_unit; 2d3: data_display hour_ten; default: data_display 4d0; endcase end // BCD码转7段码共阴极段码高电平点亮 reg [7:0] seg_r; // 包含小数点dp always (*) begin case(data_display) 4d0: seg_r 8b0011_1111; // 0 4d1: seg_r 8b0000_0110; // 1 4d2: seg_r 8b0101_1011; // 2 // ... 2-9 4d9: seg_r 8b0110_1111; // 9 default: seg_r 8b0000_0000; // 全灭 endcase end assign seg seg_r;核心技巧与常见问题消影处理在切换位选信号和段选信号时如果不同步会产生“鬼影”上一个数字的残影。标准的做法是在改变位选信号sel之前先将段选信号seg全部熄灭送不显示任何数字的编码如8‘h00等位选稳定后再送出新的段码。或者更简单一点确保段码数据data_display的变化与scan_cnt的变化严格同步在同一个时钟边沿。扫描频率clk_scan频率不能太低否则会有闪烁感也不能太高否则每个数码管点亮时间太短亮度不足。1kHz每位点亮约1ms是常用值。计算一下4位数码管1kHz扫描每位每秒被点亮250次完全满足视觉暂留。亮度不均由于动态扫描每位点亮时间占空比是1/NN为位数。如果觉得亮度不够可以适当提高段选信号的驱动电流如果IO口支持或者使用专门的数码管驱动芯片。3.3 顶层模块的整合与引脚约束将所有子模块在顶层模块中像搭积木一样连接起来是最后一步。这里的关键是确保信号名称和位宽匹配。module top_digital_clock( input wire clk_50m, input wire rst_n, input wire key_mode, input wire key_plus, input wire key_pause, output wire [7:0] seg, output wire [3:0] sel ); wire clk_1hz, clk_scan, clk_db; wire key_mode_db, key_plus_db, key_pause_db; wire [3:0] hour_ten, hour_unit, min_ten, min_unit; clk_gen u_clk_gen( .clk_in(clk_50m), .rst_n(rst_n), .clk_1hz(clk_1hz), .clk_scan(clk_scan), .clk_debounce(clk_db) ); key_debounce u_deb_mode(.clk(clk_db), .key_in(key_mode), .key_out(key_mode_db)); key_debounce u_deb_plus(.clk(clk_db), .key_in(key_plus), .key_out(key_plus_db)); key_debounce u_deb_pause(.clk(clk_db), .key_in(key_pause), .key_out(key_pause_db)); clock_core u_clock_core( .clk_1hz(clk_1hz), .rst_n(rst_n), .key_mode(key_mode_db), .key_plus(key_plus_db), .key_pause(key_pause_db), .hour_ten(hour_ten), .hour_unit(hour_unit), .min_ten(min_ten), .min_unit(min_unit) ); seg_driver u_seg_driver( .clk_scan(clk_scan), .rst_n(rst_n), .hour_ten(hour_ten), .hour_unit(hour_unit), .min_ten(min_ten), .min_unit(min_unit), .seg(seg), .sel(sel) ); endmodule代码写完后必须进行引脚约束。这是连接FPGA内部逻辑和外部物理引脚的关键一步。你需要根据自己开发板的原理图将clk_50m、rst_n、三个按键、seg[7:0]和sel[3:0]分别映射到正确的FPGA引脚上。在Vivado中这通过XDC文件完成在Quartus中则是QSF文件。一个常见的坑是数码管的段选和位选信号如果直接连接到FPGA的普通IO驱动能力可能不足导致亮度很低。很多开发板会在FPGA和数码管之间加驱动芯片如74HC245这时要确认原理图确保你的输出信号连接到了驱动芯片的输入端。4. 仿真、调试与功能扩展思路代码完成并完成约束后强烈建议先进行仿真这能节省大量板上调试的时间。4.1 使用ModelSim/QuestaSim进行功能仿真编写一个简单的测试平台Testbench模拟输入时钟和按键动作观察内部信号和输出信号的变化。测试点应该覆盖所有功能正常计时仿真足够长的时间观察秒、分、时进位是否正确。暂停功能在某个时刻触发暂停键观察计时是否停止。调数功能切换到调时、调分模式触发加键观察对应数值的变化和边界处理23-0, 59-0。状态切换模拟多次按下模式键观察状态机跳转是否按预期循环。在仿真中你可以把clk_1hz的周期缩短比如用10ns的clk_50m分频产生clk_1hz_sim而不是真的等1秒从而快速验证进位逻辑。通过查看波形图中的current_state、hour_ten/unit、min_ten/unit等信号可以非常直观地定位问题。4.2 上板调试与问题排查如果仿真通过但下载到板子上行为异常可以按照以下顺序排查时钟和复位首先确认系统时钟和复位信号是否正常。可以用一个简单的LED闪烁程序分频驱动LED来测试时钟是否工作。按键输入单独测试按键消抖模块。可以将消抖后的按键信号直接连接到LED按下按键观察LED是否稳定亮灭无闪烁。显示输出单独测试显示模块。可以写一个固定的数据如1234送给显示模块观察数码管显示是否正确、有无鬼影、亮度是否均匀。核心逻辑如果前两步都正常但时钟不走时或控制混乱问题很可能在clock_core模块。这时可以利用FPGA的在线逻辑分析仪如Vivado的ILA、Quartus的SignalTap来抓取内部信号。这是最强大的调试工具可以像仿真看波形一样实时观察FPGA内部的状态机、计数器值精准定位是状态转换错误还是计数器逻辑错误。调试心得遇到问题时化整为零是最有效的策略。不要试图一次性调试整个系统。通过注释代码、模块隔离、设计简单的测试激励逐个验证每个子模块的功能。确保每个齿轮都是好的组装起来的钟表才能走时准确。4.3 项目扩展与进阶思考这个基础时钟项目是一个完美的起点你可以在此基础上添加更多功能深化对FPGA设计的理解增加秒显示扩展为6位数码管显示时分秒。这需要修改显示驱动模块的扫描逻辑和位选控制。增加闹钟功能增加一个设置闹钟时间的状态并比较当前时间与闹钟时间。当匹配时控制一个蜂鸣器或LED闪烁。这会引入新的状态和比较器逻辑。使用IP核或PLL用FPGA内部的锁相环PLLIP核来产生更稳定、更灵活的各种时钟1Hz, 1kHz等代替自己写的分频器。增加串口通信通过UART接收来自电脑的时间设置命令实现无线校时。这需要添加一个UART接收模块并将接收到的数据解析后加载到计时计数器。移植到其他显示设备将驱动从数码管改为LCD1602液晶屏或OLED屏。这需要学习这些屏的通信协议如I2C、SPI和字符写入方式。每一次扩展都是对模块化设计、接口定义、状态机控制和跨时钟域处理能力的又一次锻炼。把这个项目吃透FPGA数字系统设计的大门才算真正向你敞开。

相关新闻