简介:本资源是一套完整的FPGA数字秒表设计工程,面向电子类专业本科生、FPGA初学者及数字系统设计实践者,解决从计数器建模、状态机控制到数码管动态扫描显示的全流程实现问题。项目支持99分59.99秒高精度计时,集成启动/暂停/清零功能,采用单按键状态机交互逻辑,并适配共阴极六位数码管显示,涵盖数字逻辑、时序电路与硬件描述语言综合应用。压缩包含165个文件,总大小4.32MB,主体为Verilog源码(.v)、Quartus工程文件(.qpf/.qsf)、引脚约束(.pin)、仿真波形(.vwf)、综合映射报告(.rpt/.logdb)及备份文件(.bak),结构完整,便于工程复现与模块级调试。已有2126人学习下载,提供可直接编译下载的sof配置文件、顶层BDF原理图及分模块设计(如fenpin.v分频器、segshow.v数码管驱动),是掌握FPGA时序控制与人机交互设计的典型入门实践案例。
1. 项目概述:从零到一,用FPGA打造一个精准的数字秒表
最近在整理手头的几个FPGA小项目,发现“数字秒表”这个经典题目,虽然听起来基础,但麻雀虽小五脏俱全,它几乎涵盖了FPGA数字系统设计的核心流程。无论是刚入门的新手想找个练手项目,还是有一定经验的工程师想回顾一下基础时序逻辑和状态机设计,这个项目都再合适不过。它不像复杂的图像处理或高速通信那样需要深厚的领域知识,却能让你完整地走一遍从需求分析、模块划分、代码编写、仿真验证到最终上板调试的全过程。今天,我就结合自己这些年踩过的坑和积累的经验,把这个FPGA数字秒表的设计思路、实现细节和调试心得系统地梳理一遍,希望能给正在学习或重温FPGA的你一些实实在在的参考。
这个秒表的核心功能很简单:精确计时、启动/暂停、复位归零,并通过数码管显示分、秒、百分秒。但实现起来,你需要考虑时钟分频、按键消抖、BCD码转换、动态扫描显示等一系列问题。更重要的是,如何用硬件描述语言(如Verilog或VHDL)来“描述”出这样一个并发的、时序严格的硬件电路。下面,我们就从最核心的设计思路开始拆解。
2. 整体设计与核心思路拆解
2.1 需求分析与功能定义
在动笔写代码之前,我们必须把需求想清楚。一个完整的数字秒表,至少需要以下功能:
- 计时精度:显示到百分之一秒(即0.01秒)。这意味着我们的基准时钟周期需要是10ms。
- 显示范围:至少显示“分:秒:百分秒”,例如“12:34:56”表示12分34秒56。
- 控制按键:至少需要三个独立按键,功能定义为“启动/暂停”、“复位/清零”。
- 显示方式:通常使用4-6位数码管进行动态扫描显示。
基于这些需求,我们可以倒推出系统需要的关键模块。这不仅仅是功能实现,更是硬件思维训练的起点:每个模块对应一个硬件实体,它们之间通过信号线连接,并行工作。
2.2 系统架构与模块划分
根据需求,我将整个系统划分为以下几个核心模块,这是FPGA设计中最关键的“分而治之”思想:
- 顶层模块(top):负责实例化所有子模块,并定义它们之间的连接关系。它相当于系统的“主板”。
- 时钟管理模块(clk_gen):FPGA开发板通常提供固定的高频晶振(如50MHz或100MHz)。我们的秒表需要10ms(100Hz)的基准计时脉冲。因此,这个模块的核心是一个分频器,将高频主时钟分频得到精准的100Hz时钟使能信号。这里强烈建议使用时钟使能信号而非生成新的时钟网络,因为后者会引入棘手的时钟域问题,增加时序收敛难度。
- 按键消抖模块(key_debounce):机械按键在按下和弹起时会产生物理抖动,持续约5-20ms。如果直接采样,会误触发多次。消抖模块通过延时采样或状态机的方式,确保每次按键动作只产生一个稳定的有效脉冲。这是数字系统可靠性的基石,忽略它,你的系统行为会变得诡异难测。
- 秒表核心控制模块(stopwatch_core):这是整个系统的大脑。它接收100Hz的计时使能信号和经过消抖的按键信号,内部维护着分、秒、百分秒的计数器。根据“启动/暂停”键的状态,决定是否累加计数器。“复位”键则将所有计数器清零。这里通常需要一个状态机(如IDLE、RUNNING状态)来清晰描述控制逻辑。
- 数码管驱动模块(seg_driver):负责将核心模块输出的二进制计时数据(通常是BCD码格式)转换成数码管各段(a, b, c, d, e, f, g, dp)的亮灭信号,并实现多位数码管的动态扫描,利用人眼视觉暂留效应,实现稳定显示。
整个数据流是这样的:高频时钟进入clk_gen产生计时使能 -> 按键经过key_debounce滤除抖动 ->stopwatch_core根据控制信号和使能信号更新计时数值 ->seg_driver将数值转换成扫描信号驱动数码管显示。架构清晰后,我们就可以深入每个模块的细节了。
3. 核心模块详细设计与实现
3.1 时钟管理模块:精准的脉搏发生器
时钟是数字系统的心跳。我们的秒表需要每10ms(100Hz)计数一次。假设FPGA板载晶振为50MHz(周期20ns)。
设计要点:
- 避免直接分频出新时钟:新手常犯的错误是写一个计数器,计数到一定值后翻转一个
clk_100hz信号。这会产生一个新的时钟域,你需要为其单独做时钟约束,并且与原始时钟域交互时需要复杂的同步处理。 - 推荐使用时钟使能(Clock Enable)方案:让系统始终使用主时钟
clk_50m,但生成一个脉冲信号cnt_en,每10ms产生一个周期的高电平脉冲。核心计数器只在cnt_en为高时才检查是否更新。这样整个系统仍在同一个时钟域内,大大简化了设计。
Verilog实现示例:
module clk_gen ( input wire clk_50m, // 50MHz主时钟 input wire rst_n, // 低电平复位 output reg cnt_en // 100Hz时钟使能脉冲 ); // 计算分频系数:50MHz / 100Hz = 500_000 // 每计数 (500_000 - 1) 个周期,产生一个使能脉冲 parameter DIV_CNT_MAX = 500_000 - 1; reg [18:0] div_cnt; // 需要19位计数器 (2^19 > 500k) always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin div_cnt <= 0; cnt_en <= 1'b0; end else begin cnt_en <= 1'b0; // 默认拉低,仅产生一个周期脉冲 if (div_cnt == DIV_CNT_MAX) begin div_cnt <= 0; cnt_en <= 1'b1; // 计数满,产生使能脉冲 end else begin div_cnt <= div_cnt + 1; end end end endmodule注意:
DIV_CNT_MAX的值必须计算准确。cnt_en的脉宽只有一个主时钟周期(20ns),足够让下游模块在下一个时钟上升沿捕获到。
3.2 按键消抖模块:让输入稳定可靠
按键消抖有硬件(RC滤波)和软件两种方式。在FPGA内部,我们通常用状态机实现软件消抖,更灵活。
设计思路:
- 对按键输入进行高频采样(用主时钟
clk_50m)。 - 设置一个约20ms的计时窗口(对应50MHz时钟,需计数1,000,000次)。
- 当检测到按键电平变化时,启动计时器。
- 在计时窗口内,如果按键电平保持稳定,则认为抖动结束,输出有效的按键状态变化。
状态机实现示例(三段式):
module key_debounce ( input wire clk, input wire rst_n, input wire key_i, // 原始按键输入,低电平有效 output reg key_press // 消抖后的按下脉冲,高电平有效一个周期 ); localparam IDLE = 2'b00; localparam CHECK = 2'b01; localparam PRESS = 2'b10; reg [1:0] state, next_state; reg [19:0] delay_cnt; // 20ms计数器 (50MHz * 0.02s = 1,000,000) reg key_i_sync0, key_i_sync1; // 同步打拍,消除亚稳态 // 同步级 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_i_sync0 <= 1'b1; // 默认上拉,按键按下为0 key_i_sync1 <= 1'b1; end else begin key_i_sync0 <= key_i; key_i_sync1 <= key_i_sync0; end end // 状态转移逻辑 always @(*) begin next_state = state; case (state) IDLE: if (key_i_sync1 == 1'b0) next_state = CHECK; // 检测到按下 CHECK: begin if (key_i_sync1 == 1'b1) next_state = IDLE; // 中途弹起,回IDLE else if (delay_cnt == 20'd999_999) next_state = PRESS; // 稳定20ms end PRESS: next_state = IDLE; // 输出脉冲后回到IDLE default: next_state = IDLE; endcase end // 状态寄存器与计数器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; delay_cnt <= 0; key_press <= 1'b0; end else begin state <= next_state; key_press <= 1'b0; // 默认拉低 case (state) IDLE: delay_cnt <= 0; CHECK: begin if (delay_cnt < 20'd999_999) delay_cnt <= delay_cnt + 1; end PRESS: begin key_press <= 1'b1; // 产生一个周期的高电平脉冲 delay_cnt <= 0; end endcase end end endmodule实操心得:这里用了两级寄存器 (
key_i_sync0,key_i_sync1) 对异步的按键输入进行同步,这是处理跨时钟域信号(虽然这里按键相对clk是异步)的标准操作,能极大降低亚稳态风险。消抖时间20ms是个经验值,可根据实际按键特性微调。
3.3 秒表核心控制模块:状态机与计数逻辑
这是项目的算法核心。我们需要一个状态机来管理“运行”和“暂停”两种状态,并在运行状态下,每收到一个cnt_en脉冲,就更新百分秒、秒、分计数器。
计数规则:
- 百分秒计数器(0-99):每0.01秒加1,满100归零,同时向秒计数器进位。
- 秒计数器(0-59):每1秒加1,满60归零,同时向分计数器进位。
- 分计数器(0-59):每60秒加1,通常设计为满60后归零或停止,根据需求定。
Verilog实现关键部分:
module stopwatch_core ( input wire clk, input wire rst_n, input wire cnt_en, // 100Hz时钟使能 input wire key_start_pause, // 消抖后的启动/暂停脉冲 input wire key_reset, // 消抖后的复位脉冲 output reg [3:0] min_ten, min_unit, // 分的十位和个位(BCD码) output reg [3:0] sec_ten, sec_unit, // 秒的十位和个位 output reg [3:0] hsec_ten, hsec_unit // 百分秒的十位和个位 ); // 状态定义 localparam S_IDLE = 1'b0; localparam S_RUN = 1'b1; reg state; // 内部计数器(二进制格式,方便运算) reg [6:0] cnt_hsec; // 0-99, 7位足够 reg [5:0] cnt_sec; // 0-59 reg [5:0] cnt_min; // 0-59 // 状态转移 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; end else begin case (state) S_IDLE: if (key_start_pause) state <= S_RUN; S_RUN: if (key_start_pause) state <= S_IDLE; default: state <= S_IDLE; endcase end end // 计时逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_hsec <= 0; cnt_sec <= 0; cnt_min <= 0; end else if (key_reset) begin // 复位优先级最高 cnt_hsec <= 0; cnt_sec <= 0; cnt_min <= 0; end else if (state == S_RUN && cnt_en) begin // 运行状态且有时钟使能 // 百分秒计数 if (cnt_hsec == 7'd99) begin cnt_hsec <= 0; // 秒进位 if (cnt_sec == 6'd59) begin cnt_sec <= 0; // 分进位 if (cnt_min == 6'd59) begin cnt_min <= 0; // 或保持59,看需求 end else begin cnt_min <= cnt_min + 1; end end else begin cnt_sec <= cnt_sec + 1; end end else begin cnt_hsec <= cnt_hsec + 1; end end end // 二进制转BCD码,用于显示驱动 // 此处以百分秒为例,秒和分类似 always @(*) begin // 百分秒:0-99 -> 十位和个位 hsec_ten = cnt_hsec / 10; hsec_unit = cnt_hsec % 10; // 秒 sec_ten = cnt_sec / 10; sec_unit = cnt_sec % 10; // 分 min_ten = cnt_min / 10; min_unit = cnt_min % 10; end endmodule注意事项:注意代码中复位和按键的优先级。通常,全局复位和手动复位键 (
key_reset) 的优先级应高于其他控制逻辑。同时,BCD码转换组合逻辑块 (always @(*)) 会随着内部计数器变化而实时更新,确保显示数据是最新的。
3.4 数码管驱动模块:动态扫描的艺术
为了用有限的IO口驱动多位数码管,动态扫描是必用技术。其原理是快速轮流点亮每一位数码管(每次只点亮一位),利用人眼视觉暂留(约24Hz以上)形成所有位同时亮的错觉。
设计要点:
- 扫描时钟:扫描频率建议在100Hz到1kHz之间。太低会闪烁,太高会增加功耗且可能亮度不足。通常用主时钟分出一个几百Hz的扫描时钟 (
scan_clk)。 - 位选信号:循环移位产生,依次选中每一位数码管。
- 段选信号:根据当前位选信号,从要显示的多位BCD数据中,选出对应的一位,通过查表法(或case语句)转换为7段码。
Verilog实现核心:
module seg_driver ( input wire clk, input wire rst_n, input wire [3:0] data0, data1, data2, data3, data4, data5, // 6位BCD输入,例如分十、分个、秒十、秒个、百分秒十、百分秒个 output reg [5:0] seg_sel_n, // 位选信号,低电平有效,控制6个数码管 output reg [7:0] seg_data // 段选信号(包括小数点dp),低电平点亮 ); // 扫描时钟生成(约500Hz) reg [15:0] scan_cnt; wire scan_clk_en = (scan_cnt == 16'd99999); // 50MHz / 500Hz = 100_000 always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_cnt <= 0; else if (scan_cnt == 16'd99999) scan_cnt <= 0; else scan_cnt <= scan_cnt + 1; end // 位选计数器(0-5循环) reg [2:0] sel_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) sel_cnt <= 0; else if (scan_clk_en) begin if (sel_cnt == 3'd5) sel_cnt <= 0; else sel_cnt <= sel_cnt + 1; end end // 位选信号译码(低有效) always @(*) begin seg_sel_n = 6'b111111; if (!rst_n) seg_sel_n = 6'b111111; else seg_sel_n[sel_cnt] = 1'b0; // 选中当前位 end // 根据当前位选,选择要显示的数据 reg [3:0] data_to_show; always @(*) begin case (sel_cnt) 3'd0: data_to_show = data0; // 最高位,例如分的十位 3'd1: data_to_show = data1; 3'd2: data_to_show = data2; 3'd3: data_to_show = data3; 3'd4: data_to_show = data4; 3'd5: data_to_show = data5; // 最低位,例如百分秒的个位 default: data_to_show = 4'd0; endcase end // 7段数码管译码器(共阴极数码管,低电平点亮段) always @(*) begin case (data_to_show) 4'd0: seg_data = 8'b1100_0000; // "0" - dp g f e d c b a 4'd1: seg_data = 8'b1111_1001; // "1" 4'd2: seg_data = 8'b1010_0100; // "2" 4'd3: seg_data = 8'b1011_0000; // "3" 4'd4: seg_data = 8'b1001_1001; // "4" 4'd5: seg_data = 8'b1001_0010; // "5" 4'd6: seg_data = 8'b1000_0010; // "6" 4'd7: seg_data = 8'b1111_1000; // "7" 4'd8: seg_data = 8'b1000_0000; // "8" 4'd9: seg_data = 8'b1001_0000; // "9" default: seg_data = 8'b1111_1111; // 全灭 endcase // 可以在特定位置(如秒与百分秒之间)点亮小数点,例如当sel_cnt==2时 // if (sel_cnt == 3'd2) seg_data[7] = 1'b0; // 点亮dp段 end endmodule踩坑提醒:段码表 (
seg_data) 的具体值取决于你使用的数码管是共阴极还是共阳极,以及FPGA板卡上数码管段信号是高电平有效还是低电平有效。务必查阅开发板原理图确认!这是上电后数码管不亮或显示乱码的最常见原因。
4. 系统集成、仿真与上板调试
4.1 顶层模块集成与引脚约束
将所有子模块在顶层模块中实例化并连接起来。这一步相对直接,但务必保证端口连接正确。
顶层模块示例框架:
module top_stopwatch ( input wire sys_clk, // 系统时钟,如50MHz input wire sys_rst_n, // 系统复位,低有效 input wire key_start, // 启动/暂停按键 input wire key_reset, // 复位按键 output wire [5:0] seg_sel, // 数码管位选 output wire [7:0] seg_data // 数码管段选 ); // 内部连线声明 wire cnt_en_100hz; wire key_start_db, key_reset_db; wire [3:0] min_t, min_u, sec_t, sec_u, hsec_t, hsec_u; // 模块实例化 clk_gen u_clk_gen (...); key_debounce u_key_start (...); key_debounce u_key_reset (...); stopwatch_core u_core (...); seg_driver u_driver (...); endmodule接下来是引脚约束(XDC/UCF文件),这是连接FPGA内部逻辑与外部物理引脚的关键。你需要根据开发板原理图,将sys_clk,sys_rst_n,key_*,seg_sel,seg_data这些信号分配到正确的FPGA引脚上。约束文件出错,功能绝对无法实现。
4.2 仿真验证:用ModelSim/QuestaSim确保逻辑正确
在烧录到板子之前,必须进行充分的仿真测试。编写一个简单的测试平台(Testbench),模拟时钟、复位和按键输入,观察内部信号和输出波形。
测试要点:
- 复位测试:上电后,所有计数器是否清零,状态机是否进入IDLE。
- 按键功能测试:模拟按下“启动”键,观察
cnt_en脉冲到来时,计数器是否开始累加;再模拟“暂停”,计数器是否停止;模拟“复位”,是否瞬间清零。 - 进位测试:让仿真运行足够长时间,观察百分秒满100后是否向秒进位,秒满60后是否向分进位。
- 显示数据测试:检查
seg_driver模块输出的seg_sel是否在循环扫描,seg_data输出的段码是否正确对应data_to_show。
仿真通过,意味着你的设计在逻辑功能上是正确的,可以进入下一步。
4.3 上板调试与常见问题排查
将编译好的比特流文件下载到FPGA后,才是真正的挑战开始。以下是我总结的常见问题及排查清单:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 数码管完全不亮 | 1. 引脚约束错误。 2. 数码管共阳/共阴极配置错误。 3. 扫描时钟太快或太慢。 4. 位选/段选信号极性弄反。 | 1. 用示波器或逻辑分析仪抓取seg_sel和seg_data信号,看是否有波形。2. 确认原理图,修改段码表极性。 3. 调整扫描分频系数,尝试1kHz或100Hz。 4. 将位选信号固定使能一位,段选信号固定输出一个数字(如“8”),单独测试。 |
| 数码管显示乱码 | 1. BCD码转换逻辑错误。 2. 段码表(译码器)数据错误。 3. 动态扫描时序混乱,数据与位选不同步。 | 1. 在仿真中检查min_ten等BCD码输出是否正确。2. 核对段码表,确认对应关系。 3. 检查 seg_driver中data_to_show的选择逻辑是否与sel_cnt严格对应。 |
| 按键控制不灵敏或连击 | 1. 按键消抖模块未工作或参数不当。 2. 按键硬件电路为上拉,代码中极性处理错误。 3. 消抖后的脉冲被多次采样。 | 1. 仿真测试消抖模块,看输出脉冲是否干净。 2. 确认开发板按键电路,通常按下为低电平,代码中应检测下降沿。 3. 确保状态机在 PRESS状态只输出一个周期脉冲,并立即回到IDLE。 |
| 计时不准(偏快或偏慢) | 1. 时钟分频计算错误。 2. 系统主时钟频率与约束文件或代码中假设的不符。 | 1. 重新计算分频系数。50MHz主时钟,100Hz使能,分频系数是 50,000,000 / 100 = 500,000。 2. 核对开发板晶振频率,修改 clk_gen模块中的DIV_CNT_MAX参数。 |
| 部分数码管位不亮或常亮 | 1. 位选信号对应引脚约束错误或虚焊。 2. 该位数码管对应的BCD数据始终为0或超出0-9范围。 3. 扫描计数器 sel_cnt位数不够,未能覆盖所有数码管。 | 1. 用仪器测量该位选引脚的实际波形。 2. 检查送到 seg_driver的该位数据输入。3. 确认 sel_cnt的位宽,6个数码管需要计数0-5,至少3位。 |
调试心得:FPGA调试,仪器(逻辑分析仪)是你的好朋友。Vivado/Quartus内嵌的ILA(集成逻辑分析仪)工具极其强大,可以像软件调试一样设置触发条件,捕获内部任何信号的波形。当现象不符合预期时,不要盲目猜测,抓取关键信号(如cnt_en,state,cnt_hsec,key_press等)的波形,与仿真波形或理论行为对比,往往能快速定位问题根源。
5. 项目优化与扩展思考
一个基础功能实现后,可以考虑以下优化和扩展,让项目更具挑战性和实用性:
- 提高计时精度与稳定性:使用FPGA内部的PLL(锁相环)IP核来生成更精准的时钟,而非简单的数字分频。这对于需要高精度计时的应用至关重要。
- 添加更多功能:
- 计次/分段计时功能:在计时过程中,按下一个“计次”键,能保持当前时间显示并存储,同时后台继续计时。这需要引入存储单元(如寄存器组或Block RAM)。
- 报警功能:设置一个目标时间,计时到达后通过LED或蜂鸣器报警。这需要增加一个数值比较器。
- 更换显示设备:尝试用VGA或LCD显示屏来显示时间,界面可以更美观,同时学习视频时序控制或字符/图形显示驱动。
- 跨时钟域处理进阶:如果引入外部异步信号(如GPS的1PPS脉冲)作为时间基准,就需要认真设计同步器(如两级触发器同步),并处理好脉冲检测与可能的数据丢失问题。
- 低功耗设计:在不操作时,让部分模块(如扫描模块)进入低功耗状态,或者降低扫描频率。
这个简单的数字秒表项目,就像一把钥匙,帮你打开了FPGA世界的大门。它串联起了组合逻辑、时序逻辑、状态机、分频、消抖、显示驱动等基础知识。当你亲手解决掉那些棘手的bug,看到数码管上数字随着你的按键精准地跳动时,那种成就感就是学习硬件设计最大的乐趣。希望这份详细的复盘能帮助你少走弯路,更深入地理解FPGA设计的每一个环节。
本文还有配套的精品资源,点击获取