简介:这是一份基于 FPGA 开发板的 Verilog 拔河游戏机工程设计报告,适合数字系统设计课程学生、Verilog HDL 初学者及 FPGA 实践爱好者参考。资源为单个 doc 文档,大小约 452KB,源自河海大学物联网工程学院课程设计,完整覆盖实验要求、方案比选(脉冲信号方案与按键消抖模块方案)、系统框图、模块划分及代码设计思路。报告重点讲解双按键控制 LED 模拟拔河过程的实现方法,包含时钟分频、按键消抖、LED 移动译码等关键模块,并详细说明按键按下速度、信号过滤、LED 变化与绳子位置之间的关系,以及通过标识符控制比赛开始与结束的逻辑,涵盖设计标准文档、功能仿真、约束综合、布局布线、时序仿真与下载验证等完整流程。读者可从中掌握 Verilog HDL 基本语法、模块化设计方法和 FPGA 开发板应用,可作为课程设计参考或 Verilog 项目起步学习资料。已有 551 人学习浏览,供相关方向同学借鉴。
1. Verilog拔河游戏机:从按键到胜负的一个完整状态机项目
拔河游戏机这个题目,在FPGA课设清单里经常被划进“简单项目”,但真在板子上把它调到能顺畅比赛,至少要处理按键抖动、跨时钟域同步、优势判决、LED显示刷新和胜负锁定五件事。它和流水灯最大的差别在于:流水灯是单向状态推进,拔河是两路输入同时驱动一个共享状态,天然涉及竞争与仲裁。这个项目适合正在从“会写Verilog语法”过渡到“能独立完成一个状态机设计”的人,也适合老手用来验证自己对边沿检测、消抖窗口和可综合代码风格的理解。文章整条链路我会按五层模块展开:消抖、边沿检测、优势判决、状态机、显示输出,最后落到仿真和板级调试。
2. 按键消抖与脉冲统计:把物理按键变成稳定脉冲
2.1 按键电路的默认电平与verilog消抖参数选取
开发板上的按键基本都是机械触点,按下和释放的瞬间,电平会在几十毫秒内反复弹跳。如果拿这个裸信号去做按键次数统计,一次按下会被计成好几次,拔河比赛的判决就完全失真。所以模块第一件事是把物理按键信号变成一段确认后的稳定电平。我一般用计数式消抖,整个项目拆成五个独立文件:debounce、edge_detect、tug_arbiter、game_fsm、top。前两个模块负责物理信号到逻辑脉冲的转换,中间是比赛规则,最后控制显示和流程。
消抖的核心参数是确认时间:系统时钟100MHz,MAX_CNT取250000,对应2.5ms的稳定确认窗口。对绝大多数机械按键,这个窗口足够覆盖触点弹跳。确认时间太短消不掉抖动,太长会拖慢连按节奏。拔河类游戏把消抖窗口压在1~5ms之间比较合适,按键反应慢几毫秒完全不影响手感,判决的确定性才重要。按键极性也要先查原理图,大多数开发板按键按下为低电平、释放为高电平,下面的代码按这个极性写。
// debounce.v 计数式消抖,连续 MAX_CNT 拍不一致才确认新电平 module debounce #( parameter MAX_CNT = 250_000 // 100MHz 下 2.5ms )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg [31:0] cnt; reg key_tmp; // 当前被确认的电平 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 32'd0; key_tmp <= 1'b1; // 复位按未按下处理 key_out <= 1'b1; end else if (key_in == key_tmp) begin cnt <= 32'd0; // 输入与确认值一致,计数清零 end else begin if (cnt == MAX_CNT - 1) begin key_tmp <= key_in; // 持续不一致足够久,确认新电平 key_out <= key_in; cnt <= 32'd0; end else begin cnt <= cnt + 1'b1; end end end endmodule逻辑说明:key_in和key_tmp一致时计数器清零,说明当前电平稳定;不一致时cnt开始累加,直到连续2.5ms都保持新电平,才把key_tmp和key_out一起更新到新值。机械抖动时电平快速来回,电平每一次弹回原值都会让cnt清零,只有真正按下后持续稳定,输出才会跟着改变。这个模块的失效模式比较单一:按键引脚悬空导致电平浮动,这时消抖逻辑怎么调都没用,要先用万用表确认引脚外部有上拉电阻。参数上要注意MAX_CNT位宽,32位足够覆盖几十毫秒的计数。
2.2 边沿检测模块:把消抖后的电平变成单拍脉冲
消抖后的key_out是一个电平信号,按下多久就低多久。拔河判决关心的是“按下的那一下”,而不是按住的时间长度,所以下一步要做边沿检测,把每次按键触发成一个单时钟周期的脉冲信号。检测边沿的标准做法是先把信号打两拍,再用前后两拍的电平组合判断跳变方向。
// edge_detect.v 两级寄存同步 + 下降沿检测 module edge_detect ( input wire clk, input wire rst_n, input wire sig_in, // 消抖后的按键电平,按下为低 output reg pulse_fall // 单拍下降沿脉冲 ); reg sig_d1, sig_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sig_d1 <= 1'b1; sig_d2 <= 1'b1; pulse_fall <= 1'b0; end else begin sig_d1 <= sig_in; sig_d2 <= sig_d1; pulse_fall <= (~sig_d1) & sig_d2; // 下降沿判定 end end endmodule代码说明:sig_d2保存的是sig_d1上一拍的值,当sig_d1为0且sig_d2为1时,说明信号刚刚从高变低,此时pulse_fall输出一拍高电平。两级寄存同时完成了跨时钟域同步,避免按键信号直接进逻辑时引入亚稳态。如果板子按键按下为高电平,把判定表达式改成sig_d1 & (~sig_d2)即可。边沿检测之后,pulse_fall就是后面判决模块能直接使用的输入。
2.3 为什么原始脉冲计数不能直接当比分
到这一步,不少人会顺手写两个计数器,分别统计pulse_a和pulse_b的次数,差值当成比分。功能上能跑,但有个明显问题:谁手速快谁就无限累积优势,一局游戏十几秒就一边倒结束,缺少拉锯过程。而且手速差会被按键行程放大,判决的物理意义不明确。我习惯不在统计端做总比分,而是做“优势积分”:每次有效按键给当前方向累加一点,双方差值累积到阈值就移动一格,移动后积分清零。游戏被切成一段段小回合,每次移动代表“这一小段时间内谁更连续地按键”,相当于一个滑动窗口在判断近期节奏。这个模型下一章展开实现。
| 参数 | 典型值 | 说明 |
|---|---|---|
| 系统时钟 | 100MHz | 开发板主时钟,所有模块共用 |
| MAX_CNT | 250000 | 消抖确认时间约2.5ms |
| 按键极性 | 按下为低 | 以板卡原理图为准 |
| 边沿类型 | 下降沿 | 和按键极性配套 |
3. 拔河判决模型:阈值积分器怎么实现移动判定
3.1 对比:边沿触发移位与净优势积分两种判决方案
市面上能搜到的拔河机代码里,第一种方案是“边沿触发直接移位”:检测到甲按键脉冲,绳子上的LED就向左移一格;检测到乙按键脉冲就向右移一格。这种写法代码最短,反馈也直接,但游戏体验不好,双方手速稍有差异,比赛就从第一拍定死了。第二种方案是“净优势积分”:甲乙双方的按键脉冲共同驱动一个有符号积分值,差值达到阈值才移动一格,移动后积分清零。
| 方案 | 实现逻辑 | 优点 | 缺点 |
|---|---|---|---|
| 边沿触发移位 | 谁按键谁移一格 | 代码量少,响应直观 | 手速碾压无拉锯,游戏结束快 |
| 净优势积分 | 差值到阈值才移动 | 有节奏感,比赛有来回 | 要调阈值,多一个状态量 |
第二种更接近拔河的物理含义:比的是在一段时间里谁更稳定地持续发力,而不是谁手速偶尔快一下。所以项目采用积分判定。注意这个“积分”不是把比分无限制累加,而是饱和式积分,优势每次被“消耗”掉才产生移动,这样不会出现某一方攒了巨大优势然后一波带走的局面。
3.2 阈值积分器核心代码与移动输出时序
阈值积分器的实现思路是:score用有符号数表示净优势,甲按键加一、乙按键减一,双方同按不变化。score绝对值达到THRESHOLD时输出move脉冲并清零score,相当于把这段时间的按键优势兑换成一次移动。这个模型和滑动窗口滤波的等价关系在于:滑动窗口维护的是最近N个结算周期内的按键分布,阈值积分器维护的是一个被限幅的净计数,两者对均匀按键频率的判决结果非常接近,但积分器在Verilog里实现更简单,时序也更干净。
// tug_arbiter.v 按键优势积分与移动判决 module tug_arbiter #( parameter THRESHOLD = 4 // 净优势阈值,达到即移动一次 )( input wire clk, input wire rst_n, input wire en, // 比赛进行中使能 input wire hit_a, // 甲有效按键脉冲 input wire hit_b, // 乙有效按键脉冲 output reg move, // 移动脉冲,1拍 output reg dir // 0 向甲侧,1 向乙侧 ); reg signed [3:0] score; // 甲正乙负 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin score <= 4'sd0; move <= 1'b0; dir <= 1'b0; end else if (!en) begin score <= 4'sd0; move <= 1'b0; end else begin move <= 1'b0; // 同一拍双方都按键:优势抵消,不积分 if (hit_a && hit_b) begin score <= score; end else if (hit_a) begin // 甲侧加一,达到阈值前触发移动并清零 score <= (score == THRESHOLD - 1) ? 4'sd0 : score + 1'b1; if (score >= THRESHOLD - 1) begin move <= 1'b1; dir <= 1'b0; end end else if (hit_b) begin score <= (score == -THRESHOLD + 1) ? 4'sd0 : score - 1'b1; if (score <= -THRESHOLD + 1) begin move <= 1'b1; dir <= 1'b1; end end end end endmodule逻辑说明:score是4位有符号数,范围-8到7。甲连续第4次按键时,score从3变到4会溢出,所以代码在score等于THRESHOLD-1时直接触发move并写0,不做实际累加,这样既避免溢出,又保证移动和清零在同一拍完成。乙侧逻辑完全镜像。注意同一拍双按时不积分,这是判决公平性的关键,否则测试时同时注入两个脉冲会让比赛出现偏移。
参数调节上,THRESHOLD=4意味着双方净按键比超过4:1才移动一格,一局比赛大概能持续几十秒,拉锯感明显。想更快决出胜负或适应展示场合的节奏,可以把阈值降到2或3。score位宽与THRESHOLD的匹配关系要检查:THRESHOLD最大取6,留出符号位余量,否则加法溢出后score变成负数,游戏会往反方向跑,这是最容易忽视的坑。
3.3 同一拍双按、阈值溢出与比赛僵局的处理
双按处理已经在判决模块内解决:同时到来的hit_a和hit_b让score保持不变,等效于优势抵消。物理上两个人几乎不可能在同一时钟沿按键,但这个保护对测试时同步注入的脉冲很有必要,它保证了判决模块在任何输入组合下都不会漂移。判决模块只负责“数学比较”,僵局、超时、开局这类事务应该交给状态机去管,职责分得越清,仿真越容易定位问题。
如果两个人都停手,score停在某个非零值,比赛会僵住。常见做法是在游戏层加一个倒计时:开赛60秒内没有产生任何move,判定平局并自动回到初始状态。这个超时计数器放在状态机里,让tug_arbiter保持纯组合判决和积分逻辑,后续要复用这个仲裁器时也不用带着超时包袱。
4. 显示驱动与游戏状态机:LED移位、边界锁定与顺序控制
4.1 LED独热码移位与两端边界检查
拔河绳上的每个点用一个LED表示,我用独热码而不是二进制编码。独热码的好处是当前绳上位置可以直观地从led_pos寄存器的哪一位为1看出来,板级调试时看LED就能验证移位方向,仿真时看波形也只需要数位数,不用做数值转换。15个LED对应15位寄存器,正中间是第7位,复位后中点单独点亮。
// led_pos 为独热码,1所在位置表示当前绳上点位 always @(posedge clk or negedge rst_n) begin if (!rst_n) led_pos <= 15'b000_0000_1000_0000; // 中点 else if (state == S_PLAY && move && !dir && led_pos != 15'b000_0000_0000_0001) led_pos <= led_pos >> 1; // 甲侧移动,向左 else if (state == S_PLAY && move && dir && led_pos != 15'b100_0000_0000_0000) led_pos <= led_pos << 1; // 乙侧移动,向右 end边界判断是关键:甲方向移到最左端时led_pos是1,此时如果继续右移,寄存器会变成全0,LED全灭,之后比赛无法判定胜负。必须用精确的端点值判断,不能在代码里简单写 “大于0就移”。参数化LINE_W时,端点值要用表达式生成,避免手写不同位宽的字面量。LED闪烁指示胜负时,我习惯用一个分频计数器在S_DONE状态里周期性地把led_pos清零再恢复,闪烁频率约5Hz即可。
4.2 三段式状态机的状态定义与转移条件表
整个游戏流程用三段式状态机管理,状态只有三个:S_IDLE等待开局、S_PLAY比赛进行、S_DONE胜负锁定。按下START键从S_IDLE进入S_PLAY,LED到达任一端点进入S_DONE,S_DONE中按下复位键回到S_IDLE。三段式写法的好处是状态寄存、次态组合、输出逻辑各占一个always块,后仿真或板级抓波形时能直接断言state按预期变化。
| 现态 | 触发条件 | 次态 | 输出动作 |
|---|---|---|---|
| S_IDLE | 按下START键(消抖后脉冲) | S_PLAY | 复位比分,点亮中点,使能判决 |
| S_PLAY | led_pos到达左端或右端 | S_DONE | 停止判决,胜负灯常亮 |
| S_PLAY | 60秒无移动 | S_IDLE | 平局,清除显示 |
| S_DONE | 按下复位键(消抖后脉冲) | S_IDLE | 清除所有显示 |
// 次态组合逻辑:三段式状态机中的转移部分 always @(*) begin next_state = state; case (state) S_IDLE: if (start_pulse) next_state = S_PLAY; S_PLAY: if (left_done || right_done) next_state = S_DONE; else if (timeout) next_state = S_IDLE; S_DONE: if (rst_pulse) next_state = S_IDLE; default: next_state = S_IDLE; endcase endstart_pulse和rst_pulse必须来自第2章的消抖与边沿检测链路,不能直接把按键引脚接进状态判断。裸按键信号进FSM,一次按下会在S_IDLE和S_PLAY之间来回弹跳,这是这个项目里最常见的“状态机失灵”原因。输出逻辑里把en和score清零都挂到状态条件上:只有S_PLAY状态才允许判决模块积分,其余状态强制score保持0。
4.3 时钟分频与按键跨时钟域的同步顺序
项目里有三档节奏:消抖计数、判决时钟、LED闪烁。我习惯把所有分频都做成时钟使能,而不是生成新的clk信号。用计数器产生一个clk_out往下传,会在FPGA里增加时钟偏移,而且组合毛刺可能被当成有效时钟沿。正确做法是全部模块共用同一个系统时钟,模块内部用使能信号决定“本拍是否工作”。判决模块需要的1kHz节拍,可以在tug_arbiter内部做周期计数器产生en_signal。
按键信号进入判决模块的顺序是固定的:先消抖,再边沿检测,然后进FSM和仲裁器。消抖模块天然承担了跨时钟同步的作用,因为它的输出只在时钟沿更新,等于打了多拍。这个单时钟设计里亚稳态风险不大,但保持“所有异步输入先同步再使用”的习惯,后面把判决器移植到多时钟系统时会少踩很多坑。
5. 仿真验证与故障排除:testbench、license报错与波形检查
5.1 testbench骨架:用task注入不同频率的按键动作
testbench的核心任务是模拟出“甲连续快按、乙偶尔慢按”的比赛场景,并观察移动方向和频率。用task把“按下-保持-释放”封装成一个动作,每次调用产生一次下降沿和一次上升沿,对应一次有效拉动。注意按键保持的时长与有效次数无关——拔河比的是连按频率,不是按住时长,这个区别正好能在仿真里验证清楚。
`timescale 1ns/1ps module tb_tug; reg clk = 0; reg rst_n = 0; reg key_a = 1, key_b = 1; // 默认未按下,高电平 wire move, dir; // 例化顶层,仿真时把消抖窗口缩短到50个时钟周期 tug_top #( .DEBOUNCE_MAX(50), .THRESHOLD(4) ) dut ( .clk(clk), .rst_n(rst_n), .key_a(key_a), .key_b(key_b), .move(move), .dir(dir) ); always #5 clk = ~clk; // 100MHz 时钟 task press_a; // task 封装一次完整按键动作 begin key_a = 0; #30_000; // 按下30us,远大于消抖确认时间 key_a = 1; #20_000; end endtask task press_b; begin key_b = 0; #200_000; // 乙按得更久,但只产生一次脉冲 key_b = 1; #50_000; end endtask initial begin #1000 rst_n = 1; repeat (4) press_a; // 甲快按4次,首次触发移动 repeat (2) press_b; repeat (4) press_a; // 再次触发移动 #1_000_000; $display("move=%b dir=%b", move, dir); $finish; end endmodule初始化里rst_n先拉低1000ns,再释放复位。甲快按4次时score累计到阈值,move应输出一拍脉冲且dir=0;乙只按2次,score回退;甲再按4次再触发移动。task里乙的按下时间是200us,但按键释放和按下各只有一次边沿,所以不会产生两次脉冲,这是验证“按压时长不直接产生优势”的关键场景。仿真通过的条件是move恰好出现两次、方向一致。
5.2 仿真检查点与波形对照表
只看最终胜负很容易漏掉中间故障。仿真时要按信号链逐级检查:先从按键物理信号开始,确认极性;再看消抖输出是否延迟一段后跟随输入;再看边沿脉冲是否只有单拍;最后看score的加减节奏和move的出现时机。
| 信号 | 正确现象 | 常见错误 |
|---|---|---|
| key_a/key_b | 按下为低,释放为高 | 极性写反,消抖后输出一直为0 |
| key_out | 延迟2.5ms后跟随key_in | 抖动毛刺没有被滤掉 |
| pulse_fall | 每次按键恰好1拍 | 出现多个脉冲或一直为1 |
| score | 甲按加一,乙按减一 | 溢出变成负数,移动反向 |
| move | 达到阈值时1拍脉冲 | move持续多拍 |
| state | S_IDLE→S_PLAY→S_DONE | 卡在S_IDLE不动 |
波形里重点看score的联动:score累到3次后,第4次按键那一拍move同时拉高,score下一拍变0。如果move出现了但led_pos没动,问题在FSM的使能条件,而不是判决模块,这种按信号链定位的方法比对着整个系统猜要快得多。
5.3 Quartus仿真license报错与iverilog快速回归
用Quartus自带仿真器时,很多人会碰到一句“17.1 error: failure to obtain a verilog simulation license.”。这是Quartus安装包里仿真组件授权不完整导致的,并不是代码错误。常见做法是先检查license环境变量和安装路径,如果暂时解决不了,逻辑仿真可以直接换用开源工具链iverilog,完全不受厂商license限制。尤其在这个项目里,所有模块都是纯Verilog,没有任何厂商专用原语,iverilog编译非常顺畅。
iverilog -o tb_tug.vvp tb_tug.v tug_top.v debounce.v edge_detect.v tug_arbiter.v game_fsm.v vvp tb_tug.vvp gtkwave tb_tug.vcd第一行把所有设计文件和testbench一起编译成vvp可执行文件,第二行执行仿真并生成vcd波形文件,第三行用GTKWave打开波形。整个流程不依赖厂商IDE,在vscode里配好Verilog插件就能写代码、跑仿真、看波形,适合做每日回归。注意如果一运行就$finish且没有任何波形,第一件事检查复位逻辑,没有有效复位的话状态机寄存器全是x,后续逻辑全部无法推进。
提示:仿真缩减消抖参数时,一定要通过顶层参数向下传递,不能用全局宏硬改设计文件,否则板级综合时容易忘记还原窗口值,导致真实按键抖动滤不掉。
6. 参数化与复用:把拔河仲裁逻辑搬出游戏
6.1 板级调试:按键极性、示波器观测与分频策略
板级跑通后第一件事不是开比赛,而是把消抖后的key_out引到一个空置LED上做链路验证。按下按键灯亮、释放灭,快速短按灯不闪烁,这一步确认了按键极性、上拉和消抖窗口都正确。如果短按时LED乱闪,把MAX_CNT翻倍再试。这个验证把物理链路和逻辑设计分开,避免拿整机行为猜按键问题。随后把move和dir临时引到两个LED上,手动模拟快按节奏,看移动脉冲是否按预期出现。
- 确认板上按键按下为低还是高
- 消抖后信号先单独验证
- 再用LED验证move和dir
- 最后接FSM和LED移位显示
6.2 参数化手感调节与拨码开关接口
手感调节集中在两个参数:THRESHOLD决定需要多少净按键才移动一格,超时长度决定僵局等待。把这两个参数从parameter改成端口输入,用板上的拨码开关直接控制,比赛现场就能边试边调,不用重新综合。阈值越大比赛越公平但节奏慢,适合双方实力接近的场合;阈值小则比赛刺激,适合演示。调试时把score做成调试总线引到逻辑分析仪,观察优势积分的实时符号和幅度,比对着LED瞎猜快得多。
6.3 把拔河判决器改造成通用双通道仲裁器
把tug_arbiter的端口抽象一层:hit_a和hit_b换成两路请求信号,move对应grant脉冲,dir对应授权方向,一个拔河判决器就变成了公平的仲裁器。它不会让高频请求方占死通道,因为每次grant后score清零,优势被消耗,必须再次累积才能获得下一次授权。常见做法是让子域两路DMA请求共用一组阈值积分器,保证低频率请求方也能在竞争窗口内获准。用systemverilog断言来验证led_pos的独热性也很直接:在仿真中加一句property,保证任何时刻led_pos只有一位为1,移位越界和全0状态都能被自动捕获。
本文还有配套的精品资源,点击获取