☰
基于Verilog的FPGA打地鼠设计:状态机与按键消抖实现解析
2026/10/16 16:58:45 网站建设 项目流程

简介:基于Verilog实现的FPGA打地鼠游戏设计,以Basys2开发板为控制主板,通过PS2键盘完成击打操作,VGA显示器呈现开机画面、地鼠出现等级及胜负动画,蜂鸣器按难度自动切换背景音乐,数码管实时显示分数、命数与最高分记录。整套方案覆盖输入控制、图像显示、音效播放、计分判胜等完整逻辑,可直接下板运行,适合数字逻辑课程设计、FPGA入门实践及电子设计竞赛参考。压缩包共618个文件,大小21.38MB,以50个vhd、34个v的硬件源码为主体,另含7个ucf引脚约束、mif初始化文件、仿真脚本及bit下载文件等,便于查看源码层次、复现仿真或直接烧录。已有1832人学习,说明这套方案具备较高参考价值。一套完整工程级方案,从VGA图像生成到键盘解码、从状态机控制到音频输出均有实现,可帮助FPGA学习者快速理解多模块协同设计思路,也可作为打地鼠类项目的改造基础。

1. 基于 Verilog 的打地鼠设计:一个课程设计为什么值得你下载复现

拿到这个压缩包时我刚接触 FPGA 不到两个月,本来只打算随便找个模块练练手,结果发现这份基于 Verilog 的打地鼠设计把数字逻辑课上最零散的点全串起来了:按键消抖、状态机、伪随机数、动态扫描显示、管脚约束。它不是一个只能跑仿真的玩具工程,而是一份能真正烧进芯片、用手玩起来的完整代码包。如果你正在写课程设计、准备电子竞赛的练手项目,或者刚学完 Verilog 语法不知道下一步干什么,这份资源能让你少走很多弯路。以我个人的体验来说,从看懂到跑通,一个晚上足够。

2. 整体架构与模块拆分:9 个文件、4 个模块各自负责什么

看一份 Verilog 工程,我习惯先不看代码,先把文件列表和模块接口理清楚。这个项目的文件组织不算复杂,但也没有把所有逻辑堆在一个文件里,整体属于“课程设计里比较规范”的写法,适合照着学模块划分。

2.1 资源目录里到底有什么:把文件和数据流对齐

先列一份文件清单,这份清单是我拿到工程后自己整理出来的,原包的文件名可能略有差异,但功能一一对应:

文件职责说明
top_mole.v顶层模块例化所有子模块,完成信号互连
clk_div.v时钟分频产生 1kHz 使能和 1MHz 计数参考
debounce.v按键消抖两级同步 + 计数消抖,输出单周期脉冲
mole_game_ctrl.v游戏状态机IDLE / PLAY / OVER 三态流转与计分
random_gen.v伪随机序列LFSR 产生地鼠出现位置
seg_display.v数码管驱动动态扫描显示分数和剩余时间
tb_mole.vTestbench仿真激励,验证核心状态转移
约束文件管脚映射把逻辑端口绑定到板载器件位置
README/说明使用文档记录按键定义和拨码开关用法

这份文件划分的好处是每个模块可以单独仿真。以前我写练习工程时总喜欢把所有逻辑写进一个 always 块,结果上板出问题没法定位,而这份工程的结构让每一步都有明确边界。数据流方向也很清晰:按键信号进 debounce 消抖后给状态机,状态机把当前游戏状态发给 random_gen 决定地鼠位置,同时把计分和倒计时发给 seg_display 去驱动数码管,LED 则直接由状态机输出控制。

2.2 模块间信号约定:为什么先画接口再写逻辑

项目里采用的是一套比较典型的端口定义方式。以顶层模块为例,外部只暴露最少的管脚:

module top_mole ( input wire clk_50m, input wire rst_n, input wire [3:0] key_i, // 4 个按键输入,低电平有效 output wire [3:0] led_o, // 4 个地鼠洞指示 LED output wire [7:0] seg_data, // 数码管段选 output wire [3:0] seg_sel // 数码管位选 );

这里有一个容易忽略的约定:key_i在绝大多数入门板上是按下为低电平。如果你用的板子是按下为高电平,后面所有if (key_i == 4'b1110)这类判断都要改,否则状态机永远无法触发。我建议你拿到工程后先看 README 里注明的按键有效电平,再对照板子原理图确认,这一步能省掉后面至少一小时的排错时间。

模块间的内部信号一般会单独列在顶层文件里,比如消抖后的按键脉冲key_pulse[3:0]、地鼠位置mole_pos[3:0]、分数score[7:0]等。先画接口再写逻辑的价值在于:你能在写代码之前发现位宽搭配问题。比如分数计到 99 需要 8 位寄存器,但如果你用的数码管只有两位,段选和位选的位宽不匹配,显示就会乱。

2.3 全同步设计的基线:时钟和复位策略

这份工程让我最满意的是时钟策略没走弯路。常见做法是分频产生一个“新时钟”给其他模块用,比如从 50MHz 分出一个 1kHz 时钟驱动数码管扫描,实际这种跨时钟域的做法很容易出亚稳态问题。而这份工程用的是“系统时钟 + 使能脉冲”的思路:所有模块都跑在 50MHz 主时钟下,clk_div 只产生一个高电平持续一个周期的使能信号,需要慢速逻辑的地方用使能去限定触发时机。

复位信号用的是异步复位、同步释放,rst_n直接接板载复位按键。如果板子上没有专用复位按键,用普通按键代替也可以,但要注意消抖,否则上电瞬间复位信号抖动可能导致状态机初始化不干净,后面游戏计时全乱。这部分我在第 5 章会细讲。

3. 把核心逻辑写明白:状态机、消抖与伪随机地鼠

这一章是整份资源里最有含金量的部分。打地鼠游戏看起来简单,但把三个核心模块各自的写法吃透,能直接迁移到抢答器、交通灯、电子琴这些同类设计上。我会先给出代码片段,再逐段讲参数怎么设、改了会发生什么。

3.1 游戏状态机:IDLE→PLAY→OVER 的状态转移条件怎么写

状态机采用三段式写法,这是 FPGA 开发里最稳的格式。先看核心的状态定义与主状态转移逻辑:

localparam IDLE = 3'd0; // 初始等待 localparam PLAY = 3'd1; // 游戏运行中 localparam OVER = 3'd2; // 游戏结束 reg [2:0] state, next_state; reg [14:0] time_cnt; // 倒计时计数器 // 三段式第一段:时序逻辑描述状态迁移 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 三段式第二段:组合逻辑计算下一状态 always @(*) begin next_state = state; case (state) IDLE: if (key_pulse[0]) next_state = PLAY; // 任意按键启动 PLAY: if (time_cnt == 15'd30000) next_state = OVER; // 30 秒倒计时结束 OVER: if (key_pulse[1]) next_state = IDLE; // 按键重置 default: next_state = IDLE; endcase end

这段代码的逻辑是:游戏上电停在 IDLE,按 key0 进入 PLAY,倒计时 30 秒结束后进入 OVER,再按 key1 回到 IDLE。time_cnt的计数上限 30000 是按 1kHz 使能算的:每个使能周期约 1ms,30000 次就是 30 秒。如果你想让一局变成 15 秒,把这个数改成 15000 即可,不需要动分频模块。

注意第二段用了next_state = state兜底,这是三段式写法里防锁死的常用手段。如果不加这一句,某些非法组合可能使状态机跳到一个未定义状态,整个游戏逻辑直接卡死。早些年我图省事写过两段式,出了 bug 找了一晚上波形,后来才明白兜底赋值的重要性。

第三段是输出逻辑,这里有个关键点:.play状态下的 LED 指示直接用组合逻辑输出,确保按下按键的当拍就能看到灯亮,避免时序输出多延迟一拍导致“按键没反应”的错觉。

3.2 按键消抖与同步:一个 20ms 计数器怎么挽救你的手感

机械按键按下时会有大约 10ms 的抖动,如果直接采电平,状态机会在几毫秒内被反复触发。工程里的 debounce 模块采用“两级同步 + 计数确认”的结构:

// 输入按键低有效,输出单周期高脉冲 reg [1:0] sync_ff; reg [19:0] cnt; reg key_stable; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin sync_ff <= 2'b11; // 无效电平 cnt <= 0; key_stable <= 1'b1; end else begin sync_ff <= {sync_ff[0], key_i[0]}; if (sync_ff[1] != sync_ff[0]) begin cnt <= 0; end else if (cnt == 20'd999999) begin key_stable <= sync_ff[0]; end else begin cnt <= cnt + 1'b1; end end end // 产生单周期脉冲 reg key_stable_d; always @(posedge clk_50m) key_stable_d <= key_stable; assign key_pulse[0] = (~key_stable) & key_stable_d;

20ms 的计数上限是这样算出来的:50MHz 时钟周期 20ns,20ms / 20ns = 1_000_000 个周期,所以计数器上限是20'd999999。如果板子时钟不是 50MHz,这里必须重算,否则消抖时间要么太长要么无效。我一般会把该参数写成CLK_FREQ / 50便于调整,如果你拿到的工程里是硬编码,建议自己改成参数。

两级同步的目的是先消除亚稳态,再进入计数逻辑。很多初学者只做计数不做同步,仿真没问题,上板偶尔出现状态机误触发,就是亚稳态在作怪。按键产生的key_pulse是单周期脉冲,这保证了状态机每按键一次只处理一次事件。如果直接拿key_stable电平去做触发,按住按键不放的状态会让评分逻辑被反复执行。实际打地鼠时你需要快速连打,单脉冲设计正好匹配这种场景。

3.3 地鼠出现位置:LFSR 伪随机和它必须跨过的坑

打地鼠如果没有随机性,玩两局就腻了。工程里用的是 LFSR(线性反馈移位寄存器)生成伪随机序列,比直接在 Verilog 里调$random更贴近可综合的写法。核心代码如下:

reg [15:0] lfsr; wire feedback = lfsr[15] ^ lfsr[14] ^ lfsr[12] ^ lfsr[3]; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) lfsr <= 16'hACE1; // 非零种子 else if (state == PLAY && tick_en) lfsr <= {lfsr[14:0], feedback}; end // 取低 4 位映射到 4 个地鼠洞 assign mole_pos = lfsr[3:0]; // 与按键位置比对,判定是否打中 assign hit = (mole_pos == 4'b1110 && key_pulse[0]) || (mole_pos == 4'b1101 && key_pulse[1]) || (mole_pos == 4'b1011 && key_pulse[2]) || (mole_pos == 4'b0111 && key_pulse[3]);

LFSR 的多项式选位决定了随机序列的长度。这里用的本位反馈对应的是 x^16 + x^14 + x^13 + x^11 + 1,能产生 65535 个不重复状态的完整周期序列。如果你只想用 8 位 LFSR 缩短代码量,注意别把所有位都拿来异或,否则序列周期会大幅缩短,打地鼠时地鼠位置呈现出可感知的规律性。

最大的坑在于 LFSR 不能每拍都移位。如果让它在 PLAY 状态下每个时钟周期都移位,地鼠位置会闪得快到看不清,按键根本来不及反应。常规做法是每 500ms 左右才让 LFSR 移一次位,同时把新位置输出到 LED。具体时间控制在状态机里加一个tick_en使能信号,根据 1kHz 参考计数 500 次产生。这样地鼠就有“出现 → 停留 → 变位置”的节奏感,游戏体验接近实物街机。

种子初始化也值得注意。工程里写死了16'hACE1,这意味着每次上电地鼠出现顺序完全相同。如果你不想费脑子,可以在复位后把某个按键的电平组合灌入 LFSR 的高几位,让种子跟随玩家操作变化。我习惯在初始化时加一个随机种子输入口,把复位前采集到的按键值作为种子,这样每局的开局序列都不一样。

4. 从仿真到上板:Testbench 激励与管脚约束的落地流程

代码写完了不代表能跑。这个工程最需要动手的部分是仿真和上板调试。我照着整个过程走了一遍,这里把步骤拆细,方便你复现。

4.1 写一个能测状态转移的 Testbench:关键激励信号怎么给

仿真不是为了让波形跑起来,而是为了验证状态转移是否符合预期。这个工程的 Testbench 里有以下关键逻辑:

module tb_mole; reg clk_50m = 0; reg rst_n = 0; reg [3:0] key_i = 4'b1111; wire [3:0] led_o; wire [7:0] seg_data; wire [3:0] seg_sel; // 50MHz 时钟 always #10 clk_50m = ~clk_50m; // 复位延时释放 initial begin #100 rst_n = 1; end // 任务:模拟一次按键按下 task press_key; input [1:0] idx; begin key_i[idx] = 0; #1000; // 模拟按下 key_i[idx] = 1; #1000; // 模拟释放 end endtask // 激励序列 initial begin #200; press_key(0); // 启动游戏 #100_000_000; // 运行足够长的时间 press_key(1); // 重置 #1000; $finish; end top_mole u_top( .clk_50m(clk_50m), .rst_n(rst_n), .key_i(key_i), .led_o(led_o), .seg_data(seg_data), .seg_sel(seg_sel) ); endmodule

仿真里模拟按键必须给足够的保持时间。如果只拉低一个时钟周期就释放,20ms 的消抖计数器还没计完,按键脉冲根本产生不了。这里#1000表示保持 1000 个仿真时间单位,也就是 500ns,实际上如果消抖是 20ms,你需要把保持时间至少加大到 20ms 以上,也就是#20000000,才能看到key_pulse正常脉冲输出。工程原始 Testbench 里很可能按仿真加速处理过,比如把消抖计数参数调小,我建议你先用原始参数跑一遍,确认状态机能跳转,再改回真实参数上板。这是我在实践中验证过最稳妥的顺序。

4.2 约束文件这么写:时钟、按键、LED 与数码管的管脚映射与电平注意

不同开发板的管脚定义完全不同,所以约束文件是拿到工程后必须修改的第一个文件。以我手头入门板为例,管脚名用通用占位符表示,你照着自己板子的原理图替换:

set_property PACKAGE_PIN CLK_PIN [get_ports clk_50m] set_property IOSTANDARD LVCMOS33 [get_ports clk_50m] set_property PACKAGE_PIN SW_PIN_0 [get_ports key_i[0]] set_property PACKAGE_PIN SW_PIN_1 [get_ports key_i[1]] set_property PACKAGE_PIN SW_PIN_2 [get_ports key_i[2]] set_property PACKAGE_PIN SW_PIN_3 [get_ports key_i[3]] set_property IOSTANDARD LVCMOS33 [get_ports {key_i[*]}] set_property PACKAGE_PIN LED_PIN_0 [get_ports led_o[0]] set_property PACKAGE_PIN LED_PIN_1 [get_ports led_o[1]] set_property PACKAGE_PIN LED_PIN_2 [get_ports led_o[2]] set_property PACKAGE_PIN LED_PIN_3 [get_ports led_o[3]] set_property IOSTANDARD LVCMOS33 [get_ports {led_o[*]}]

如果你用的板子是按键按下为高电平有效,除了改代码,还要检查约束文件里有没有对按键管脚设置下拉/上拉。不少入门板的按键输入管脚默认悬空,状态机的按键判断会不稳定。我遇到过一种情况:代码里判断低有效,管脚又没接上拉,结果不按键时电平在高低之间跳,游戏自动启动。这种问题仿真是看不出的,只能靠示波器或者用逻辑分析仪抓管脚电平排查。

4.3 下载和上板顺序:先点灯、再跑游戏、最后才调显示

我见过不少同学拿一个新工程直接综合烧录,结果数码管乱跳、LED 不亮,根本不知道从哪下手。这里分享一下我习惯的调试三步法:

第一步,先写一个 10 分钟的专用点灯测试:把顶层里的led_o[0]直接赋值为rst_n,下载后观察复位释放时灯是否点亮。这一步确认时钟管脚约束和复位链路没问题。第二步,把 debounce 模块的输出直接引到 LED,手按按键看灯是否稳定翻转。如果这一步过不了,问题基本在消抖参数或按键有效电平上。第三步才把完整状态机烧进去,此时只剩显示模块可能出现问题,定位范围很小。

数码管动态扫描还有一个具体验证技巧:先固定输出一个数字 8,确认所有段都能亮,再检查位选时序。如果段选没问题而位选乱,十有八九是seg_sel的位宽和模块内部计数器不匹配。

5. 避坑指南:五个我在仿真里抓不到的玄学问题

这个工程我前前后后跑了三天,遇到过一些看着像“玄学”的问题,最后发现全是设计规范问题。这里挑了五条最有代表性的,按“现象 → 原因 → 解决”写清楚,希望对你有用。

5.1 按键按下时状态机乱跳,偶尔一次触发两次

现象:明明只按了一下按键,游戏却从 IDLE 跳到 PLAY 又瞬间跳到 OVER。原因:按键信号没做两级同步直接进了状态机,亚稳态导致组合逻辑输出毛刺;或者消抖时间太短,抖动未被完全滤除。解决:确认 debounce 模块里先有sync_ff两级寄存器同步,再把计数时间调到 20ms 左右。我踩过的坑是把计数上限写成了20'd999_999掉了两个 9,实际只消抖了约 0.2ms,按键在抖动期被采到多次。

5.2 每次上电地鼠位置顺序一样,连玩三局都一样

现象:第一局、第二局、第三局地鼠出现的顺序完全相同,玩家很快就能背板。原因:LFSR 初始化种子写死,且只在复位时才加载种子,上电后序列固定。解决:把种子改成非固定值。最简单的方法是在复位释放后、游戏启动前,把当前几个按键的状态组合写入 LFSR 的低四位,比如lfsr[3:0] <= ~key_i;。这样玩家每次按键入局的时机不同,种子也不同。我建议用按键状态而不是某个计数器高电平组合,因为计数器上电初值固定,仍然存在重复序列的可能。

5.3 数码管显示闪烁,亮度明显不均匀

现象:三位以上的数字显示时,左边比右边亮,或者整体在轻微闪烁。原因:动态扫描周期太长,人眼能感知刷新频率;或者是每位的点亮持续时间不同,造成亮度差异。解决:扫描周期要控制在 4ms 以内,也就是刷新频率 250Hz 以上。如果工程里的位选扫描按 1kHz 使能计数到 15 才换位,实际每位点亮约 15ms,刷新率只有 66Hz,闪烁不可避免。我把分频上限调到 4(即每 4ms 切换一位),刷新率约 62.5Hz 时已经看不出闪烁。注意这个改动会导致每位点亮时间缩短,数码管整体亮度变暗,可以适当增大段选驱动电流,或者接受这个亮度换稳定性。

5.4 仿真状态跳转正确,上板后灯全不亮

现象:Testbench 里波形完全正确,状态从 IDLE 正常跳到 PLAY,LED 输出也符合预期,上板后灯一个都不亮。原因:约束文件里管脚映射错误,最典型的把led_o[0]绑定到某个时钟专用管脚或已经被占用的复用管脚,综合工具报错时被忽略。解决:点灯测试的意义就在这里。不用整块工程,单独拿一个输出模块绑到目标管脚上测试。如果管脚确实被占用,换到板子原理图上另一个空闲 LED 管脚,并同步修改约束。另外检查led_o是否被声明为wire,如果顶层里被误声明为reg且没有驱动,综合后该端口会被优化掉。

5.5 状态机输出慢半拍,程序运行时按键响应迟钝

现象:玩家按下按键,地鼠对应的 LED 要过两个时钟周期才熄灭,体感上游戏很“肉”。原因:状态机的输出逻辑接了额外的时序寄存器,比如先把hit打一拍再输出到 LED,或者 LED 的熄灭条件是组合逻辑里同时依赖了next_state和当前state,导致实际动作延后一拍。解决:检查 LED 输出链路。如果led_o是从某个always @(posedge clk)寄存器取出来的,去掉这一级寄存器直接用assign led_o = mole_pos组合输出。对于打地鼠这种低频交互场景,慢一两个时钟周期(几十纳秒)本来没影响,但如果你在输出前面为了好看多打了两级流水,再加上海量仿真加速带来的判断滞后,实际体感就可能从“即时”变成“迟钝”。

6. 让游戏更好玩:难度递增与双人模式的改造姿势

工程跑通只是第一步,把它改成有挑战性的游戏才是复现这份资源最大的乐趣。这里我给出两个已验证可行的改造方向,改动点很集中,不会破坏原有模块结构。

方向一:难度递增。地鼠本身不会主动消失,但你可以控制它“停留的时间”。原工程里地鼠位置每隔 500ms 由tick_en触发变化一次,把tick_en的产生逻辑从固定计数改成一个可调寄存器speed_reg,每次命中地鼠后让speed_reg减小,地鼠切换位置的间隔变短,反应时间被压缩,难度自然上升。具体改法是:将tick_en的计数值从常量改为speed_reg,并在命中判定hit为高时执行speed_reg <= speed_reg - 10'd100,并加上下限保护防止变成负数。这段逻辑在mole_game_ctrl.v里加十几行就能完成,不需要动其他模块。

方向二:双人分屏打地鼠。把原来的 4 个洞拆成两组,一组属于玩家 A,一组属于玩家 B。需要做的改动是:hit判定扩展成hit_a和hit_b,分别对应key_i[3:2]和key_i[1:0];分数寄存器拆成score_a和score_b;数码管显示改为奇偶位交替刷两组分数。改这部分要小心位选冲突,我的建议是先用一个两位计数器产生seg_sel,前十个数码管给 A,后十个给 B,各扫各的,互不干扰。原工程的seg_display.v已经给了动态扫描的完整框架,照葫芦画瓢把段选数据改成一组二选一即可。

如果你还想更进一步,可以在状态机里加一个“暂停”功能:在 PLAY 状态下按key_i[3]进入一个PAUSE状态,倒计时停止,再按一次恢复。实现就是在状态机 case 里多一个分支,把time_cnt的递增逻辑用enable_pause信号控制住,改动量不超过 20 行。

从那以后我拿到任何一个 Verilog 小项目,第一件事都是先拉模块清单看接口方向,再按“仿真 → 点灯 → 上板”三步强迫自己走一遍流程,至今没再被状态机和消抖折磨过。这个工程我已经在自己板上跑通过好几轮,让我评价的话,它最大的价值不是代码能直接抄,而是把 FPGA 入门最容易踩的那几个坑全部集中在一个 200 行的工程里,非常适合从头到尾亲手推一遍。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询