恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
FPGA计数器设计精讲:从秒表模块到Verilog实现与调试
首页
资讯中心
/
FPGA计数器设计精讲:从秒表模块到Verilog实现与调试
FPGA计数器设计精讲:从秒表模块到Verilog实现与调试
发布时间:2026/8/29 17:19:54
1. 项目缘起从“秒表”到“计数模块”的思考最近在整理FPGA学习笔记时翻到了之前做的一个秒表电路项目。这个项目本身不复杂但其中的“计数模块”却是一个值得深挖的典型。很多初学者在接触FPGA时第一个动手做的可能就是计数器比如用按键控制LED闪烁或者做个简单的数字钟。秒表的计数模块本质上就是一个带使能、清零和进位输出的多级十进制计数器。听起来简单对吧但就是这个“简单”的模块在实际设计和调试中却藏着不少门道。比如如何确保计数精度如何优雅地处理异步清零和同步使能的关系进位信号是组合逻辑产生还是时序逻辑产生更稳妥这些细节直接决定了你的电路是“实验室玩具”还是“工业级可靠”。今天我就以这个“秒表计数模块”为引子把计数器设计的那些核心要点、常见坑点以及我的调试心得掰开揉碎了讲清楚。无论你是刚入门Verilog还是已经做过一些项目相信都能从中获得一些启发。2. 秒表计数模块的核心需求与规格定义在动手写代码之前我们必须先明确这个模块要干什么以及干到什么程度。这就像盖房子先画图纸盲目开工的结果往往是反复返工。2.1 功能需求拆解一个基础的秒表通常需要显示“分:秒:百分秒”或者“时:分:秒”这样的格式。以最常见的“分:秒:百分秒”MM:SS:HS为例我们的计数模块需要实现以下功能百分秒计数HS范围0-99。这是最基础的计数器每10毫秒假设系统时钟为100Hz计一个数计到99后归零并向秒计数器产生一个进位脉冲。秒计数SS范围0-59。接收百分秒的进位每计满一次即60秒归零并向分计数器产生一个进位脉冲。分计数MM范围0-59或更大如99。接收秒的进位其最大值决定了秒表的最大计时范围。控制信号使能信号en高电平时计数器正常工作低电平时计数器保持当前值。这对应秒表的“启动/暂停”功能。清零信号clr有效时将所有计数器同步或异步归零。这对应秒表的“复位”功能。输出显示需要将三个计数器的当前值BCD码或二进制输出供后续的数码管驱动或LCD显示模块使用。2.2 关键设计决策点基于以上需求我们需要做出几个关键设计选择这些选择直接影响代码结构和电路性能计数进制与编码每个计数器是十进制还是十六进制输出用二进制还是BCD码对于显示而言BCD码每4位表示一个十进制数更直接但会浪费一些逻辑资源二进制码节省资源但需要额外的“二进制转BCD”模块。对于秒表这种小规模设计直接使用BCD计数器是更直观的选择。清零方式同步清零还是异步清零异步清零只要清零信号有效立即清零不受时钟控制。反应快但容易因清零信号上的毛刺导致误操作在跨时钟域时风险更高。同步清零在时钟有效边沿到来时如果清零信号有效则执行清零。更稳定抗干扰能力强是FPGA设计中的推荐做法。我们的秒表采用同步清零。进位生成方式进位信号是组合逻辑产生还是时序逻辑产生组合逻辑进位通过判断当前计数值是否等于最大值如cnt 9来产生。会产生较长的组合逻辑路径可能影响时序。时序逻辑进位在计数值达到最大值后的下一个时钟周期产生一个周期宽度的脉冲。更符合同步设计思想时序更干净。我们采用这种方式。使能信号优先级当使能en和清零clr同时有效时谁优先通常清零的优先级应该最高因为无论是否在计数用户按下复位键都期望立即归零。所以设计为clr en。明确了这些我们的模块接口Module Port就可以定义出来了module stopwatch_counter ( input wire clk, // 系统时钟例如100Hz input wire rst_n, // 低电平有效的全局复位 input wire en, // 计数使能高有效 input wire clr, // 同步清零高有效 output reg [3:0] mm_high, mm_low, // 分十位和个位 (BCD) output reg [3:0] ss_high, ss_low, // 秒十位和个位 (BCD) output reg [3:0] hs_high, hs_low // 百分秒十位和个位 (BCD) );3. BCD计数器链的Verilog实现与细节剖析有了清晰的规格我们就可以开始编写核心的计数器代码了。这里采用自底向上的方式先实现一个通用的十进制BCD计数器模块再将其例化三次级联成完整的秒表计数器。3.1 底层参数化BCD计数器模块我们设计一个可复用的BCD计数器它包含使能、同步清零和进位输出。module bcd_counter #( parameter MAX_VAL 9 // 计数器最大值对于十进制个位是9 )( input wire clk, input wire rst_n, input wire en, // 计数使能 input wire clr, // 同步清零 output reg [3:0] cnt, // BCD计数值输出 output reg carry // 进位输出计满后产生一个时钟周期的高脉冲 ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 异步复位全局复位通常异步确保确定初始状态 cnt 4d0; carry 1b0; end else if (clr) begin // 同步清零优先级最高 cnt 4d0; carry 1b0; end else if (en) begin // 使能有效时计数 if (cnt MAX_VAL) begin cnt 4d0; carry 1b1; // 计满产生进位 end else begin cnt cnt 4d1; carry 1b0; // 未计满进位为0 end end else begin // 使能无效保持当前值进位清零 carry 1b0; end end endmodule关键点解析参数化设计使用parameter定义MAX_VAL使得这个模块不仅可以用于十进制0-9稍加修改也能用于六进制0-5用于秒和分的十位。这提高了代码的复用性。复位策略rst_n采用异步复位这是FPGA中处理全局复位的常见做法能确保电路上电后或复位后处于一个已知的确定状态。而功能性的clr则采用同步清零避免毛刺问题。进位生成逻辑注意carry信号的生成是时序逻辑。它不是在cnt MAX_VAL时立即变高而是在这个条件满足且时钟有效沿到来时在下一个周期变高并只持续一个周期。这种设计避免了组合逻辑环路和毛刺是更稳健的做法。carry在非计数状态下en0被显式清零防止残留的进位信号造成误触发。3.2 顶层秒表计数模块的例化与级联现在我们在顶层模块中例化6个这样的BCD计数器百分秒、秒、分各需要十位和个位两个计数器并将它们按照进位关系连接起来。module stopwatch_counter ( input wire clk, // 假设为100Hz时钟 input wire rst_n, input wire en, input wire clr, output wire [3:0] mm_high, mm_low, output wire [3:0] ss_high, ss_low, output wire [3:0] hs_high, hs_low ); // 内部连线进位信号 wire carry_hs_low; // 百分秒个位-十位进位 wire carry_hs_high; // 百分秒十位-秒个位进位 wire carry_ss_low; // 秒个位-十位进位 wire carry_ss_high; // 秒十位-分个位进位 wire carry_mm_low; // 分个位-十位进位 // 分十位进位暂时不需要因为我们的分显示范围是0-59十位最大为5。 // 百分秒个位计数器 (0-9) bcd_counter #(.MAX_VAL(9)) u_hs_low ( .clk(clk), .rst_n(rst_n), .en(en), // 秒表总使能 .clr(clr), .cnt(hs_low), .carry(carry_hs_low) ); // 百分秒十位计数器 (0-9) bcd_counter #(.MAX_VAL(9)) u_hs_high ( .clk(clk), .rst_n(rst_n), .en(en carry_hs_low), // 仅当总使能且个位有进位时计数 .clr(clr), .cnt(hs_high), .carry(carry_hs_high) ); // 秒个位计数器 (0-9)但受限于59其使能逻辑稍后处理 bcd_counter #(.MAX_VAL(9)) u_ss_low ( .clk(clk), .rst_n(rst_n), .en(en carry_hs_high), // 百分秒满100即十位有进位时秒个位计1 .clr(clr), .cnt(ss_low), .carry(carry_ss_low) ); // 秒十位计数器 (0-5) bcd_counter #(.MAX_VAL(5)) u_ss_high ( // 注意MAX_VAL改为5 .clk(clk), .rst_n(rst_n), .en(en carry_hs_high carry_ss_low), // 秒个位满且百分秒进位 .clr(clr), .cnt(ss_high), .carry(carry_ss_high) ); // 分个位计数器 (0-9) bcd_counter #(.MAX_VAL(9)) u_mm_low ( .clk(clk), .rst_n(rst_n), .en(en carry_hs_high carry_ss_high), // 秒满60十位进位时分个位计1 .clr(clr), .cnt(mm_low), .carry(carry_mm_low) ); // 分十位计数器 (0-5) bcd_counter #(.MAX_VAL(5)) u_mm_high ( .clk(clk), .rst_n(rst_n), .en(en carry_hs_high carry_ss_high carry_mm_low), // 分个位满且秒进位 .clr(clr), .cnt(mm_high), .carry() // 此进位可悬空或用于更高位此处不用 ); endmodule级联逻辑深度剖析这里的级联使能逻辑是核心也是容易出错的地方。百分秒十位u_hs_high的使能en carry_hs_low。这意味着百分秒十位只有在“总使能有效”且“百分秒个位恰好产生进位”的同一个时钟周期才会计数。注意carry_hs_low是一个脉冲信号宽度只有一个时钟周期。这种设计是精确的。秒个位u_ss_low的使能en carry_hs_high。当百分秒从99跳变到00时carry_hs_high会拉高一个周期此时秒个位加1。这实现了“每100个百分秒即1秒秒加1”的功能。秒十位u_ss_high的使能en carry_hs_high carry_ss_low。这比秒个位多了一个条件carry_ss_low。这是因为秒的计数范围是0-59当秒个位从9归0时carry_ss_low有效且同时满足1秒时间到carry_hs_high有效秒十位才能加1。如果秒是0-99那么使能条件就只需要en carry_hs_high即可。分个位u_mm_low的使能en carry_hs_high carry_ss_high。当秒从59跳变到00时carry_ss_high有效同时1秒时间到carry_hs_high有效此时分个位加1。这里carry_hs_high是必须的因为它标志着“1秒”这个基本时间单位的完成是所有上位计数器递进的基础。注意这种级联方式在物理上构成了一个较长的组合逻辑路径使能信号经过多级与门。对于低速的秒表100Hz时钟完全足够。但如果时钟频率很高或者计数器级数非常多可能需要考虑使用同步使能链或其他优化方法来改善时序。4. 仿真验证用Testbench揪出隐藏的Bug代码写完了直接上板调试那绝对是莽夫行为。尤其是计数器这种时序逻辑肉眼根本无法观察其内部状态变化。一个健壮的Testbench是我们的第一道也是最重要的防线。4.1 编写全面的测试平台我们的测试需要覆盖以下几个关键场景上电复位和异步复位功能。同步清零功能及其与使能的优先级。正常计数功能观察各级计数和进位是否正确。使能信号的控制启动/暂停。计数满后自动归零和循环。timescale 1ns / 1ps module tb_stopwatch_counter(); reg clk; reg rst_n; reg en; reg clr; wire [3:0] mm_h, mm_l, ss_h, ss_l, hs_h, hs_l; // 实例化被测模块 stopwatch_counter uut ( .clk(clk), .rst_n(rst_n), .en(en), .clr(clr), .mm_high(mm_h), .mm_low(mm_l), .ss_high(ss_h), .ss_low(ss_l), .hs_high(hs_h), .hs_low(hs_l) ); // 生成100Hz时钟周期10ms为了仿真速度快我们可以用10ns周期模拟 localparam CLK_PERIOD 10; // 10ns - 100MHz仿真时放大频率以便观察 always #(CLK_PERIOD/2) clk ~clk; initial begin // 初始化信号 clk 0; rst_n 0; en 0; clr 0; #100; // 等待一段时间 // 测试1释放复位 rst_n 1; #20; $display(After reset release: MM%d%d, SS%d%d, HS%d%d, mm_h, mm_l, ss_h, ss_l, hs_h, hs_l); // 应该全为0 // 测试2使能计数观察一小段时间 en 1; #(CLK_PERIOD * 150); // 观察150个时钟周期 $display(After 150 cycles: MM%d%d, SS%d%d, HS%d%d, mm_h, mm_l, ss_h, ss_l, hs_h, hs_l); // 此时HS应该为15因为150个100MHz周期对于100Hz计数逻辑不匹配此处仅为演示流程 // 实际仿真时应让时钟频率与设计匹配或调整观察点。 // 测试3测试暂停功能 en 0; #(CLK_PERIOD * 50); $display(After pause 50 cycles: MM%d%d, SS%d%d, HS%d%d, mm_h, mm_l, ss_h, ss_l, hs_h, hs_l); // 数值应保持不变 // 测试4测试清零功能优先级 en 1; // 使能有效 clr 1; // 清零也有效 #(CLK_PERIOD * 2); $display(When en1 clr1: MM%d%d, SS%d%d, HS%d%d, mm_h, mm_l, ss_h, ss_l, hs_h, hs_l); // 所有计数器应立即清零无论之前状态如何 clr 0; // 测试5长时间运行观察进位和归零 en 1; // 让仿真运行足够长时间覆盖秒、分的变化。 // 例如运行 10000 个时钟周期并定期打印状态。 for (integer i0; i1000; ii1) begin #(CLK_PERIOD * 10); // 每100个周期检查一次 if ({hs_h, hs_l} 8h99) begin $display(HS reached 99 at time %t, $time); end if ({ss_h, ss_l} 8h59) begin $display(SS reached 59 at time %t, $time); end end // 测试6在非整秒时刻清零 #(CLK_PERIOD * 37); clr 1; #(CLK_PERIOD); clr 0; $display(Clear at random time: MM%d%d, SS%d%d, HS%d%d, mm_h, mm_l, ss_h, ss_l, hs_h, hs_l); #100; $finish; end // 可选将波形数据导出到VCD文件用于在GTKWave等工具中查看 initial begin $dumpfile(stopwatch_counter.vcd); $dumpvars(0, tb_stopwatch_counter); end endmodule4.2 仿真结果分析与调试技巧运行仿真后我们需要在波形查看器中仔细检查复位和初始值rst_n拉高后所有计数器输出是否立刻变为0carry信号是否为0使能控制en拉高后hs_low是否每个时钟上升沿加1en拉低后计数是否立刻停止清零功能与优先级在计数过程中拉高clr下一个时钟沿是否所有计数器归零当en1且clr1时是否执行了清零而非计数进位链这是检查的重点。你需要放大波形找到hs_low从9变0的时刻观察carry_hs_low是否产生了一个单周期脉冲同时hs_high是否在carry_hs_low有效的那个时钟沿加1了归零与循环当hs_high和hs_low都为9时下一个时钟沿是否同时变为0并且carry_hs_high产生脉冲这个脉冲是否正确地让ss_low加1六十进制与十进制检查ss_high是否在ss_low9且carry_hs_high1时才加1当ss_high5且ss_low9且carry_hs_high1时秒计数器是否正确地归零并产生carry_ss_high调试心得我经常遇到的一个坑是使能条件写错。比如ss_high的使能我最初写成了en carry_ss_low忽略了carry_hs_high。结果就是秒十位在秒个位从9到0时就加1而不管是否过了1秒导致“秒”走得飞快。仿真波形清晰地暴露了这个问题ss_high的递增速度是hs_high的10倍。所以仿真时一定要耐心地跟踪每一个进位信号的产生和传递路径。5. 实际部署的考量与优化建议仿真通过只能说逻辑功能基本正确。要把它变成板卡上稳定运行的电路还需要考虑更多实际问题。5.1 时钟与计时精度我们的设计假设输入clk是精确的100Hz。在实际中你需要一个准确的100Hz时钟源。方案A外部低频晶振。直接使用100Hz有源晶振精度高电路简单但这类晶振不常见。方案BFPGA内部PLL分频。更通用的方法是使用FPGA的主晶振如50MHz通过PLL或计数器分频产生100Hz。这里有个大坑分频产生的时钟不宜直接作为全局时钟驱动大量逻辑。更好的做法是生成一个100Hz的使能脉冲Enable Pulse计数器模块仍然使用高速主时钟但只在使能脉冲有效时计数。这就是“使能时钟”设计。// 生成100Hz使能脉冲的模块 (假设主时钟clk_50m为50MHz) module clk_en_gen_100hz ( input wire clk_50m, input wire rst_n, output reg en_100hz ); reg [18:0] cnt_div; // 50M / 100 500_000, 需要19位计数器 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt_div 0; en_100hz 0; end else begin if (cnt_div 19d499_999) begin // 从0计数到499999 cnt_div 0; en_100hz 1b1; end else begin cnt_div cnt_div 1; en_100hz 1b0; end end end endmodule // 修改stopwatch_counter的顶层将en输入作为使能脉冲 // 原en端口改名为en_pulse内部逻辑需将en_pulse作为计数条件 // 在底层bcd_counter中en条件变为 (en en_pulse)这样做的好处是整个设计是同步于单一高速时钟的避免了使用分频时钟带来的潜在时序问题时钟偏斜、毛刺等。5.2 按键消抖与控制信号处理板卡上的按键 (en,clr) 必然存在机械抖动。如果直接连接到计数器一次按压可能会被误认为是多次触发。必须添加消抖模块。通常采用采样滤波法连续检测到按键稳定低电平或高电平超过15-20ms才认为是一次有效的按键动作。这可以用一个计数器来实现。控制信号同步如果按键信号来自异步的时钟域通常如此在进入FPGA逻辑前还需要进行至少两级寄存器同步以消除亚稳态风险。5.3 资源与时序分析使用综合工具如Vivado、Quartus进行综合和实现后要查看报告。资源使用查看使用了多少查找表LUT、寄存器FF。我们这个设计应该非常少几乎可以忽略。时序报告重点关注“最差负时序裕量Worst Negative Slack, WNS”。由于我们采用了同步设计和使能时钟方案且逻辑简单时序应该很容易满足。但如果WNS为负说明关键路径延迟太大。对于这个设计关键路径很可能在多级进位使能的组合逻辑链上en carry_hs_high carry_ss_high carry_mm_low。如果时钟频率极高比如超过200MHz可能需要对此进行流水线或寄存器打拍优化。5.4 扩展性与高级功能一个基本的秒表模块完成后可以考虑以下扩展这会让你的项目更出彩分段计时Lap Time增加一个锁存器模块。当按下“分段”键时将当前计数器的值锁存到另一组寄存器中输出显示而背景计数器继续运行。这需要额外的控制逻辑和寄存器组。报警功能设置一个目标时间当计时到达时产生一个脉冲驱动蜂鸣器或LED。这需要一个比较器电路。更宽的范围修改参数轻松扩展到“时:分:秒”甚至更长的计时。动态使能使能信号en可以不是一个简单的按键而是来自其他复杂逻辑比如比赛中的起跑枪声检测信号这使得模块可以集成到更大的系统中。回过头看一个看似简单的秒表计数模块从需求分析、设计决策、代码实现、仿真验证到实际部署每一步都蕴含着数字逻辑设计的核心思想同步设计、模块化、层次化、以及最重要的——对时序的深刻理解。它不仅是学习计数器的一个练习更是理解FPGA设计中数据流、控制流和时钟域管理的一个微型样板。下次当你再写计数器时不妨多问自己几个“为什么”为什么用同步清零进位信号这样产生会不会有毛刺我的使能逻辑在极端情况下会不会漏掉一个计数思考清楚这些问题你的代码质量会提升一个档次。