FPGA数字系统设计入门:从零实现60秒秒表的完整工程实践

发布时间:2026/7/31 5:58:17
FPGA数字系统设计入门:从零实现60秒秒表的完整工程实践 1. 项目概述从课程设计到数字系统入门如果你正在学习数字电路或FPGA设计那么“设计并实现一个60秒的秒表”这个课程设计题目大概率是你绕不开的一个经典项目。它看似简单一个能数到60的计数器而已但麻雀虽小五脏俱全。这个项目几乎涵盖了数字系统设计的核心流程从需求分析、方案选型、模块划分到使用硬件描述语言如Verilog进行编码再到利用EDA工具如Quartus进行综合、仿真最终在开发板或仿真环境中验证功能。它不仅是检验你是否理解计数器、分频器、显示译码等基础模块的试金石更是你从理论学习迈向工程实践的关键一步。无论是用Logisim这样的教学软件进行原理图搭建还是用Quartus Prime进行专业的FPGA开发这个项目都能让你亲身体会到数字逻辑是如何一步步变成实际可运行的“产品”的。2. 核心需求与方案设计2.1 功能需求拆解一个完整的60秒秒表其核心功能远不止“从0数到59”这么简单。我们需要将其拆解为清晰、可实现的子需求计时功能这是核心。需要一个能够从00计数到59然后自动归零的计数器。注意秒表的显示通常是两位十进制数秒和十分之一秒或秒和秒但这里我们简化先实现秒的整数计数。控制功能需要至少三个基本控制信号启动Start、暂停/继续Pause、复位Reset。一个完整的秒表工作流程是上电或复位后显示00按下启动键开始从00计时计时过程中按下暂停键显示当前数值并停止计数再次按下暂停或一个独立的继续键从暂停值继续计时任何时候按下复位键显示归零并停止计数。显示功能需要将计数器内部的二进制数值转换为可供数码管显示的段选信号。对于两位显示我们需要两个数码管分别显示十位和个位。时钟基准数字电路需要一个稳定的时钟信号来驱动。我们的秒表要以1秒为步进计时因此需要一个精确的1Hz时钟源。在FPGA开发中板载晶振通常是50MHz或100MHz等高频率我们必须通过“分频”来得到1Hz信号。2.2 系统架构与模块划分基于以上需求一个典型的、层次清晰的系统架构可以划分为以下几个模块顶层模块top负责将所有子模块像搭积木一样连接起来并定义与外部如按键、数码管、时钟的接口。按键消抖模块key_debounce机械按键在按下和弹起时会产生物理抖动导致单次按压被误识别为多次。此模块用于滤除抖动产生干净、稳定的按键使能信号。分频器模块clk_divider将系统的高频主时钟如50MHz分频产生精确的1Hz时钟信号作为秒表计时的基准脉冲。秒表核心控制模块stopwatch_core这是整个系统的大脑。它接收消抖后的按键信号和1Hz时钟根据当前状态运行、暂停决定是否对计数器进行加一操作并处理复位逻辑。二进制到十进制转换模块bin2bcd计数器内部通常用二进制数存储。例如数值“59”在内部是6‘b111011。但数码管需要显示十进制的“5”和“9”。此模块负责将二进制数转换为二十进制码BCD码分别输出十位和个位的4位二进制值。数码管显示驱动模块seg_driver接收BCD码通过查找表LUT将其转换为对应数码管各段a, b, c, d, e, f, g, dp亮灭的控制信号。如果使用动态扫描方式驱动多位数码管此模块还需负责位选信号的扫描。注意在简单的课程设计中bin2bcd模块的功能可以合并到stopwatch_core或seg_driver中。但将其独立出来是一个好习惯这符合设计“高内聚、低耦合”的原则让每个模块功能单一便于调试和复用。2.3 方案选型状态机 vs. 简单逻辑对于秒表的控制逻辑启动、暂停、复位有两种常见的实现思路方案一使用有限状态机FSM这是更规范、更强大的方法。可以定义三个状态IDLE空闲/复位后、RUNNING运行、PAUSED暂停。通过按键事件启动、暂停在这些状态间切换。在RUNNING状态下每个1Hz时钟上升沿使计数器加一。这种方法逻辑清晰易于扩展更多功能如计次、 lap时间等是工程实践中的首选。方案二使用简单的寄存器加组合逻辑用一个寄存器如running_reg来标识是否处于运行状态。启动键将其置1暂停键将其取反复位键将其清零。然后在每个时钟沿判断running_reg是否为1来决定是否计数。这种方法代码量小对于简单需求足够用但状态管理稍显松散。对于课程设计我建议从方案二入手因为它更直观。但在理解后尝试用方案一FSM重新实现一遍这对你理解时序电路设计大有裨益。下文将主要以方案二为主线进行阐述。3. 核心模块设计与Verilog实现3.1 按键消抖模块数字世界的“防抖滤镜”机械按键的抖动通常持续5-20ms。我们的消抖思路是持续检测按键输入当检测到按键电平变化后启动一个计时器例如20ms在此期间内如果按键电平稳定不变则认为抖动结束输出有效的按键使能脉冲。module key_debounce ( input wire clk, // 系统时钟比如50MHz input wire rst_n, // 低电平复位 input wire key_in, // 原始按键输入低电平有效假设按下为0 output reg key_pulse // 消抖后的按键脉冲高电平有效一个周期 ); parameter DEBOUNCE_TIME 20d1_000_000; // 50MHz时钟下计满1,000,000次约为20ms reg [19:0] counter; // 20ms计时计数器 reg key_in_r0, key_in_r1; // 两级同步寄存器用于消除亚稳态 reg key_stable; // 按键稳定后的信号 wire key_negedge; // 检测到按键下降沿按下 // 1. 同步化输入信号防止亚稳态 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_in_r0 1b1; // 默认未按下高电平 key_in_r1 1b1; end else begin key_in_r0 key_in; key_in_r1 key_in_r0; end end // 2. 检测下降沿按键按下动作 assign key_negedge (~key_in_r0) key_in_r1; // 3. 消抖核心逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin counter 20d0; key_stable 1b1; end else begin if (key_negedge) begin // 检测到下降沿开始计时 counter 20d0; key_stable 1b0; // 进入不稳定期 end else if (!key_stable) begin // 处于不稳定期 if (counter DEBOUNCE_TIME) begin // 计时达到20ms key_stable 1b1; // 认为按键已稳定按下 counter 20d0; end else begin counter counter 1b1; end end // 如果按键释放立即复位状态可根据需要添加释放消抖 if (key_in_r1) begin key_stable 1b1; counter 20d0; end end end // 4. 生成单周期脉冲信号 reg key_stable_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_stable_r 1b1; end else begin key_stable_r key_stable; end end // 当key_stable从0变1从不稳定到稳定按下时产生一个脉冲 assign key_pulse (~key_stable_r) key_stable; endmodule实操心得消抖时间DEBOUNCE_TIME需要根据实际时钟频率计算。公式为计数值 消抖时间(秒) * 时钟频率(Hz)。例如50MHz时钟下20ms需要计数0.02 * 50,000,000 1,000,000。这个参数可以做成parameter方便在不同平台复用。3.2 分频器模块制造“秒”的节奏我们需要从50MHz得到1Hz分频系数高达50,000,000。直接用一个计数器计数会消耗大量寄存器资源。常见的做法是进行两级分频先分到一个中间频率如1kHz再用另一个计数器从中分出1Hz。module clk_divider ( input wire clk_50m, // 50MHz输入时钟 input wire rst_n, output reg clk_1hz // 1Hz输出时钟 ); // 第一级分频50MHz - 1kHz (分频系数50,000) reg [15:0] cnt_1k; wire clk_1k; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt_1k 16d0; end else if (cnt_1k 16d49_999) begin // 0到49999共50000个周期 cnt_1k 16d0; end else begin cnt_1k cnt_1k 1b1; end end assign clk_1k (cnt_1k 16d25_000) ? 1b1 : 1b0; // 产生占空比50%的1kHz方波 // 第二级分频1kHz - 1Hz (分频系数1,000) reg [9:0] cnt_1; always (posedge clk_1k or negedge rst_n) begin // 注意时钟域已变为clk_1k if (!rst_n) begin cnt_1 10d0; clk_1hz 1b0; end else if (cnt_1 10d499) begin // 0到499共500个clk_1k周期对应0.5秒 cnt_1 10d0; clk_1hz ~clk_1hz; // 每500个周期翻转一次得到1Hz end else begin cnt_1 cnt_1 1b1; end end endmodule注意事项这里clk_1k是作为cnt_1的时钟信号使用的这涉及到了时钟域的问题。在简单的设计中可以这样用但在复杂系统中更推荐使用使能信号的方式。即clk_1k作为一个使能脉冲每50,000个周期产生一个高电平脉冲cnt_1和clk_1hz仍然由clk_50m驱动只在使能有效时工作。这样整个设计都在同一个主时钟域下更利于静态时序分析STA。3.3 秒表核心控制模块逻辑中枢这个模块整合了控制逻辑和计数逻辑。我们采用简单的寄存器控制方案。module stopwatch_core ( input wire clk_1hz, // 1Hz计时时钟 input wire rst_n, input wire key_start_pulse, // 消抖后的启动脉冲 input wire key_pause_pulse, // 消抖后的暂停脉冲 input wire key_reset_pulse, // 消抖后的复位脉冲 output reg [5:0] cnt_bin // 二进制计数值0-59 (6位足够) ); reg running; // 运行标志位1运行0暂停 // 控制逻辑处理启动、暂停、复位按键 always (posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin running 1b0; cnt_bin 6d0; end else if (key_reset_pulse) begin // 复位优先级最高 running 1b0; cnt_bin 6d0; end else begin // 处理启动/暂停这里假设同一个按键按一下启动再按一下暂停 // 如果有独立按键逻辑会更简单 if (key_start_pulse) begin running ~running; // 启动/暂停切换 end // 计时逻辑 if (running) begin if (cnt_bin 6d59) begin // 计数到59 cnt_bin 6d0; // 归零 end else begin cnt_bin cnt_bin 1b1; // 加一 end end // 如果暂停键独立可以这样if(key_pause_pulse) running 1‘b0; end end endmodule3.4 二进制到BCD转换模块翻译官我们需要将0-59的二进制数转换成两个4位的BCD码十位和个位。对于小于100的数可以用一个简单的算法连续判断二进制数是否大于等于10如果是则十位加1原数减10直到原数小于10此时原数即为个位。module bin2bcd ( input wire [5:0] bin_in, // 二进制输入0-59 output reg [3:0] bcd_ten, // BCD码十位 output reg [3:0] bcd_unit // BCD码个位 ); integer i; reg [7:0] bcd_temp; // 临时寄存器高4位存十位低4位存个位 always (*) begin bcd_temp 8b0; // 初始化 bcd_temp[5:0] bin_in; // 将二进制数放入临时寄存器低6位 // 循环6次因为输入是6位二进制数 for (i 0; i 6; i i 1) begin // 如果个位低4位 5则加3 if (bcd_temp[3:0] 4d5) begin bcd_temp[3:0] bcd_temp[3:0] 4d3; end // 如果十位4-7位 5则加3 if (bcd_temp[7:4] 4d5) begin bcd_temp[7:4] bcd_temp[7:4] 4d3; end // 整体左移一位 bcd_temp bcd_temp 1; end // 分离十位和个位 bcd_ten bcd_temp[7:4]; bcd_unit bcd_temp[3:0]; end endmodule原理解释上述代码实现的是“加3左移”算法这是一种将二进制数转换为BCD码的经典硬件友好型算法。它模拟了二进制数除以10取余的过程。循环中的“加3”操作是对BCD码的调整因为BCD码是逢十进一而左移是二进制操作逢二进一。通过多次调整和左移最终将二进制权重“分配”到十位和个位上。3.5 数码管显示驱动模块点亮世界这里以驱动两个共阴极数码管为例采用动态扫描方式以减少IO口占用。module seg_driver ( input wire clk, // 扫描时钟建议几百Hz到1kHz input wire rst_n, input wire [3:0] data_ten, // 十位BCD码 input wire [3:0] data_unit, // 个位BCD码 output reg [7:0] seg, // 段选信号a,b,c,d,e,f,g,dp output reg [1:0] sel // 位选信号低电平有效 ); reg [3:0] data_disp; // 当前要显示的数据 reg [15:0] scan_cnt; // 扫描计数器 // 扫描计数器控制位选切换速度 always (posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt 16d0; end else begin scan_cnt scan_cnt 1b1; end end // 动态扫描逻辑根据scan_cnt的高位切换位选和显示数据 always (*) begin case (scan_cnt[15]) // 用计数器高位作为切换信号 1b0: begin sel 2b01; // 选中个位数码管 data_disp data_unit; end 1b1: begin sel 2b10; // 选中十位数码管 data_disp data_ten; end default: begin sel 2b11; // 都不选 data_disp 4h0; end endcase end // 七段译码器BCD码 - 段选信号 (共阴极1点亮) always (*) begin case (data_disp) 4h0: seg 8b0011_1111; // 0 4h1: seg 8b0000_0110; // 1 4h2: seg 8b0101_1011; // 2 4h3: seg 8b0100_1111; // 3 4h4: seg 8b0110_0110; // 4 4h5: seg 8b0110_1101; // 5 4h6: seg 8b0111_1101; // 6 4h7: seg 8b0000_0111; // 7 4h8: seg 8b0111_1111; // 8 4h9: seg 8b0110_1111; // 9 default: seg 8b0000_0000; // 不显示 endcase // seg[7]对应小数点dp这里我们不需要置0 seg[7] 1b0; end endmodule实操心得动态扫描的原理是利用人眼的视觉暂留。两个数码管交替点亮只要切换频率足够快通常60Hz人眼就感觉它们是同时亮的。scan_cnt的位宽和判断位决定了扫描频率。例如如果clk是1kHzscan_cnt[15]的变化频率是1000/2^16 ≈ 15Hz这可能会闪烁。更好的做法是用scan_cnt[10]约500Hz切换或用一个独立的、更快的扫描时钟。4. 系统集成与Quartus工程实践4.1 顶层模块集成顶层模块的任务是将所有子模块实例化并按设计框图连接起来。同时它需要定义与FPGA开发板物理引脚对应的接口。module top_stopwatch ( input wire clk_50m, // 板载50MHz时钟 input wire rst_n_in, // 板载复位按键低电平复位 input wire key_start, // 启动/暂停按键 input wire key_reset, // 复位按键 output wire [7:0] seg, // 连接到数码管段选引脚 output wire [1:0] sel // 连接到数码管位选引脚 ); // 内部连线声明 wire clk_1hz; wire key_start_db, key_reset_db; wire [5:0] cnt_value; wire [3:0] bcd_ten, bcd_unit; // 模块实例化 // 1. 按键消抖 key_debounce u_key_start_deb ( .clk (clk_50m), .rst_n (rst_n_in), .key_in (key_start), .key_pulse(key_start_db) ); key_debounce u_key_reset_deb ( .clk (clk_50m), .rst_n (rst_n_in), .key_in (key_reset), .key_pulse(key_reset_db) ); // 2. 分频器 clk_divider u_clk_div ( .clk_50m (clk_50m), .rst_n (rst_n_in), .clk_1hz (clk_1hz) ); // 3. 秒表核心 // 注意这里将key_start_db同时作为启动和暂停信号按一下开始再按一下暂停 stopwatch_core u_core ( .clk_1hz (clk_1hz), .rst_n (rst_n_in), .key_start_pulse(key_start_db), .key_pause_pulse(1‘b0), // 本例未使用独立暂停键 .key_reset_pulse(key_reset_db), .cnt_bin (cnt_value) ); // 4. 二进制转BCD bin2bcd u_bin2bcd ( .bin_in (cnt_value), .bcd_ten (bcd_ten), .bcd_unit(bcd_unit) ); // 5. 数码管驱动 // 需要一个扫描时钟这里直接从50MHz分频出一个约1kHz的时钟 reg [15:0] div_cnt; wire clk_scan; always (posedge clk_50m or negedge rst_n_in) begin if (!rst_n_in) div_cnt 16d0; else div_cnt div_cnt 1b1; end assign clk_scan div_cnt[15]; // 约762Hz (50M / 2^16) seg_driver u_seg_driver ( .clk (clk_scan), .rst_n (rst_n_in), .data_ten (bcd_ten), .data_unit(bcd_unit), .seg (seg), .sel (sel) ); endmodule4.2 Quartus Prime 工程创建与流程新建工程打开Quartus Prime选择File - New Project Wizard。设置工程目录、名称如stopwatch_60s选择顶层实体名为top_stopwatch。添加文件将编写好的所有.v文件top_stopwatch.v,key_debounce.v,clk_divider.v,stopwatch_core.v,bin2bcd.v,seg_driver.v添加到工程中。选择器件根据你手中的FPGA开发板型号选择正确的器件家族如Cyclone IV E和具体型号如EP4CE6E22C8。引脚分配这是硬件连接的关键一步。你需要查阅开发板的原理图找到clk_50m、rst_n_in、按键、数码管段选和位选对应的FPGA引脚编号。在Quartus中点击Assignments - Pin Planner。在Pin Planner界面在Node Name列找到你的输入输出端口在Location列手动输入或选择对应的引脚号。例如clk_50m可能对应PIN_23key_start对应PIN_88seg[0]对应PIN_104等。全编译点击Processing - Start Compilation或工具栏上的紫色三角形图标。这个过程包括分析综合、布局布线、生成配置文件等。确保没有错误Error警告Warning可以逐一查看有些可以忽略有些可能暗示潜在问题。编程下载用USB-Blaster等下载器连接开发板。点击Tools - Programmer。确保Hardware Setup正确勾选生成的.sof文件点击Start将设计下载到FPGA中。4.3 仿真验证Modelsim-Altera入门在烧录到板子前用仿真验证逻辑是否正确能节省大量调试时间。创建Testbench文件在Quartus中File - New - Verification/Debugging Files - University Program VWF或直接编写.vt文件。更推荐手动编写Testbenchtb_stopwatch.v灵活性更高。编写Testbenchtimescale 1ns/1ns module tb_stopwatch(); reg clk_50m; reg rst_n; reg key_start; reg key_reset; wire [7:0] seg; wire [1:0] sel; // 实例化被测模块 top_stopwatch uut ( .clk_50m(clk_50m), .rst_n_in(rst_n), .key_start(key_start), .key_reset(key_reset), .seg(seg), .sel(sel) ); // 生成50MHz时钟 initial clk_50m 0; always #10 clk_50m ~clk_50m; // 周期20ns即50MHz // 测试激励 initial begin // 初始化 rst_n 0; key_start 1; // 按键默认高电平假设按下为低 key_reset 1; #100; // 等待100ns rst_n 1; // 释放复位 #200; // 测试启动 key_start 0; // 模拟按下启动键 #20000000; // 等待20ms模拟消抖时间 key_start 1; // 释放 #100000000; // 观察计时10秒 // 测试暂停 key_start 0; #20000000; key_start 1; #50000000; // 暂停5秒观察显示是否停止 // 测试继续 key_start 0; #20000000; key_start 1; #30000000; // 继续计时3秒 // 测试复位 key_reset 0; #20000000; key_reset 1; #100; $stop; // 结束仿真 end endmodule运行仿真在Quartus中设置仿真工具为ModelSim-Altera指定Testbench文件。然后Tools - Run Simulation Tool - RTL Simulation。在ModelSim中观察波形检查cnt_bin、bcd_ten、bcd_unit、seg、sel等信号的变化是否符合预期。5. 常见问题、调试技巧与扩展思考5.1 开发板调试常见问题数码管不亮或显示乱码检查引脚分配这是最常见的问题。务必再三核对原理图确保段选seg[7:0]和位选sel[1:0]的每一个引脚都分配正确并且极性共阴/共阳与代码中的段码表匹配。我的代码是针对共阴极数码管段选信号高电平点亮。如果你的板子是共阳极需要将段码表取反seg ~8b0011_1111;。检查扫描频率动态扫描频率太低会导致闪烁太高会导致亮度不足。调整seg_driver模块中的scan_cnt判断位或扫描时钟clk_scan的分频系数通常在100Hz到1kHz之间尝试。检查驱动能力有些数码管可能需要较大的电流FPGA的IO口驱动能力有限。查看开发板手册有时需要外接上拉电阻或驱动芯片。按键不响应或响应异常消抖参数确认消抖模块中的DEBOUNCE_TIME参数计算是否正确。如果设置过短如1ms可能无法滤除抖动过长如100ms则响应迟钝。按键电平确认代码中假设的按键有效电平按下为0还是为1与实际硬件是否一致。开发板上的按键通常是按下时连接到地低电平默认通过上拉电阻为高电平。复位信号确保复位信号rst_n在板子上电后处于正确的无效状态在我的代码中是高电平。有些板子的复位按键是按下为低需要对应。计时不准时钟源这是最主要的原因。我们的1Hz信号是通过对50MHz分频得到的理论上是精确的。但请检查分频器代码尤其是计数器的比较值。从50M分到1Hz需要计数50,000,000次确保计数器位宽足够至少26位且比较值正确。仿真 vs. 实际在仿真中我们为了节省时间常常用很高的时钟频率或缩短分频系数来快速看到效果。但在实际烧录时一定要改回正确的参数。5.2 逻辑仿真与调试技巧简化仿真为了在仿真中快速看到秒表计数效果可以临时修改分频器。例如在Testbench中将系统时钟改为1kHz然后将分频器改成从1kHz分到10Hz每0.1秒一个脉冲这样就能在短时间内看到计数器变化大幅提升仿真效率。切记在最终综合前改回正确参数添加内部观测信号在顶层模块中可以将一些关键内部信号如clk_1hz,cnt_bin,running引到未使用的IO口上或者定义为wire型输出方便在仿真波形中观察。Quartus综合时会优化掉未连接的输出所以不影响最终资源。分段仿真不要一开始就仿真整个顶层。先单独仿真key_debounce看能否产生干净的脉冲再仿真stopwatch_core看控制逻辑是否正确最后集成仿真。这能帮你快速定位问题模块。5.3 项目功能扩展建议完成基础功能后你可以尝试以下扩展让这个秒表更实用、更接近一个真正的产品增加十分之一秒显示将计时精度提高到0.1秒。这需要将clk_1hz改为clk_10hz计数器范围变为0~599对应60.0秒显示部分需要三个数码管十位、个位、十分位。BCD转换模块也需要处理0-599的范围。实现状态机控制用经典的有限状态机FSM重写stopwatch_core模块。定义IDLE、RUN、PAUSE三个状态并绘制状态转移图。这会让你的代码更加结构化易于添加LAP计次功能。添加LAP分段计时功能在计时过程中按下一个LAP键可以记录当前时间并显示但后台计时不停止。这需要增加一个寄存器来存储LAP时间并可能增加一个显示模式切换。使用PLL替代分频器对于FPGA使用锁相环PLLIP核来生成精确的1Hz时钟比用逻辑资源分频更节省资源、更稳定。你可以在Quartus的IP Catalog中找到ALTPLL核进行配置。设计更友好的用户界面比如用不同的LED灯指示运行/暂停状态用蜂鸣器在计时结束时发出提示音或者增加一个模式按键来切换“秒表模式”和“倒计时模式”。这个60秒秒表的课程设计就像数字电路世界里的“Hello World”。它串联起了从理论到实践的完整链条。当你看到数码管上的数字随着自己的按键动作而跳动时那种亲手创造出“生命”的成就感是单纯看书无法比拟的。希望这份详细的拆解和代码能成为你探索更广阔数字设计世界的一块坚实垫脚石。如果在实现过程中遇到任何波形对不上、引脚连不通的情况别慌回头检查时钟、复位、消抖这三个最基础的环节大概率问题就藏在那里。