简介:一套基于Verilog HDL的FPGA四人抢答器完整设计资源,面向数字逻辑课程设计、电子竞赛及FPGA入门学习者。工程实现了4路抢答、20秒倒计时、超时报警、超前抢答犯规提示,以及抢答优先封锁和蜂鸣提醒;系统复位后进入抢答状态,按下有效键后将锁定其余输入并持续显示台号,采用层次化设计方法,模块划分清晰,便于理解代码结构和修改功能。压缩包共180个文件,大小约23.92MB,以Quartus工程文件为主,包含源码、工程配置、编译数据库、编译报告和仿真波形等类型,覆盖从编写、编译到仿真的完整流程,解压后即可用Quartus直接打开编译。已有8933人学习浏览,资源来自有经验的开发者,除最终工程外还保留了多份备份与过程件,可帮助排查报错、对比修改前后版本;这些中间文件也有助于理解Quartus工程的组织方式,适合课设或竞赛快速落地。
1. FPGA/Verilog四人抢答器:第一课是先按下的那路信号怎么被记住
FPGA/Verilog四人抢答器是很多开发者拿到板子后的第一个完整项目,也是大学数字逻辑课程设计里出现频率最高的题目之一。它的需求一句话就能说清:四个按键,谁先按下,数码管就显示谁的编号,其他人再按无效,主持人按复位后进入下一轮。逻辑上不难,但真正把它做到上板稳定运行,你会发现难点不在“谁先按”这个判断,而在按键抖动、异步信号跨时钟域、锁存优先级、数码管扫描显示这些基本功上。一个反直觉的事实是:这个项目里最容易被忽视的部分——按键消抖,往往决定了整个系统能不能正常跑起来。这篇文章我会从模块拆分讲到每个模块的Verilog实现,再落到仿真验证和上板避坑,按我自己做课程项目的习惯走一遍。
2. 架构怎么拆:把抢答器切成能单独验证的小块
拿到这类定时逻辑项目,我的第一反应不是打开编辑器写代码,而是先把需求翻译成模块边界。这样做最大的好处是每个模块都能单独仿真、单独上板验证,出了问题不用在顶层代码里大海捞针。四人抢答器的完整行为大致是这样:复位后处在“等待抢答”状态;某一路按键按下并被消抖确认后,系统进入“已锁定”状态并保存该选手编号;后续按键输入都被忽略;主持人按下复位,回到等待状态。围绕这个状态流转,我通常把系统拆成五个部分。
2.1 需求拆解:一盏LED和一个数码管背后的模块清单
先说功能需求。一个四人抢答器必须有四路抢答按键输入、一个复位按键、至少一位数码管显示抢答选手编号。如果要做成完整的竞赛系统,还需要主持人的“开始”按键、倒计时显示和超时提示,但这些属于扩展功能,基础版可以不放。我建议先把基础版跑通,再往上加倒计时,理由后面会说。
按照这样的需求,模块划分如下表:
| 模块 | 输入 | 输出 | 职责 |
|---|---|---|---|
| 按键消抖模块 | 原始按键信号 | 消抖后的按键电平 | 滤除机械抖动,输出稳定的按下/释放状态 |
| 仲裁锁定模块 | 四路消抖后的按键电平 | 选手编号、锁定标志 | 判断第一路有效按下,锁存编号 |
| 数码管显示模块 | 选手编号 | 段选、位选信号 | 把编号翻译成数码管驱动波形 |
| 复位管理模块 | 复位按键 | 同步复位信号 | 处理上电复位与按键复位抖动 |
| 顶层模块 | 时钟、按键、复位 | 数码管接口 | 例化以上模块并完成信号连接 |
如果你只是写一个跑马灯或者按键点灯,模块化不是必需品;但抢答器涉及状态锁定,如果不拆开,消抖和锁存逻辑会缠在一起,仿真时很难判断是消抖没滤干净还是锁定逻辑写错了。我见过不少同学把消抖和仲裁写在一个always块里,最后出现“按下没反应”和“编号被覆盖”两种问题互相干扰,排错排到怀疑人生。拆开之后,每个模块的输入输出都清楚,测试平台也好写。
2.2 仲裁与锁定:为什么第一拍信号决定胜负
抢答器最核心的逻辑是仲裁。仲裁的输入是四路按键,但它们来自四个不同的选手,按下时刻完全异步——可能A比B早1微秒,也可能A和B落在同一个时钟周期里。FPGA是同步时序电路,所有信号要在时钟边沿采样,所以第一步必须把异步按键信号同步化:用两级触发器打两拍,消除亚稳态传播。亚稳态是数字电路里一个绕不开的话题:如果按键信号正好在时钟建立保持时间窗口内变化,触发器输出会处于不确定状态,可能变成0也可能变成1,甚至振荡一段时间。打两拍不能消除亚稳态,但能给亚稳态足够的收敛时间,让后面逻辑拿到稳定的值。
同步之后是仲裁。仲裁逻辑要回答的问题只有一个:四路输入中,哪一路是第一个变成有效电平的?回答方式很直接:按优先级依次判断,同时用一个lock信号把结果锁住。lock为0时允许仲裁,一旦某一路有效,lock置1,后续任何输入都不能再改写编号。lock只有在复位时才清零。这就是“先到先得”的硬件实现。
这里有一个容易被忽略的设计边界:如果两路按键在同一时钟沿同时有效,仲裁结果怎么定?物理上两只手几乎不可能做到精确同拍,但电路上必须有一个确定行为。常用做法是给四路固定优先级,比如key[0]最高、key[3]最低。这在课程设计里完全够用。如果项目要求绝对公平,需要引入更复杂的循环优先级仲裁,但抢答器场景下固定优先级已经是行业里的常规做法。
3. Verilog 代码怎么写:消抖、锁存、显示三件事分开做
这一章直接给能用的代码。我先说一个总的原则:所有输入信号进FPGA之前,先过同步器;所有跨模块的信号,在模块内自己处理时序。基于这个原则,消抖模块内部自带同步,仲裁模块拿到的已经是干净信号。下面三块代码组合起来,就是一个能在绝大多数开发板上直接跑的最小系统。时钟参数我以50MHz为例,其他频率的换算方法会写在参数说明里。
3.1 按键消抖模块:计数器方案与参数换算表
机械按键按下时,触点会因弹性产生毫秒级的高频抖动,持续时间通常几毫秒到十几毫秒。如果不做处理,一次按下会被判断成多次操作。消抖的思路是:检测到按键电平变化后开始计时,如果在设定时间内电平保持稳定,才认为是一次有效按下。这里给出一个边沿触发的消抖模块。
module debounce #( parameter CNT_MAX = 1_000_000 // 50MHz下对应20ms ) ( input wire clk, input wire rst_n, input wire key_in, // 按键信号,按下为低电平 output reg key_out // 消抖后的电平,按下变为低 ); reg [19:0] cnt; reg key_ff1, key_ff2, key_ff3; // 第一级同步:把异步按键信号打两拍,降低亚稳态概率 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_ff1 <= 1'b1; key_ff2 <= 1'b1; key_ff3 <= 1'b1; end else begin key_ff1 <= key_in; key_ff2 <= key_ff1; key_ff3 <= key_ff2; end end // 检测按下沿:key_ff2为低而key_ff3仍为高,说明刚发生按下动作 wire key_posedge = key_ff2 & ~key_ff3; // 低电平有效:稳定低电平持续CNT_MAX个时钟周期后,key_out拉低 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; key_out <= 1'b1; end else if (key_posedge) begin cnt <= 0; // 检测到新按下沿,重新计时 end else if (cnt < CNT_MAX) begin cnt <= cnt + 1; if (cnt == CNT_MAX - 1) // 持续稳定满20ms key_out <= 1'b0; end end endmodule这段代码先通过三级寄存器做同步和边沿检测:key_ff1采样原始输入,key_ff2和key_ff3组成两级同步链,key_posedge输出一个脉冲,表示“这一拍刚检测到按键被按下”。后面的计数器只在这个边沿脉冲到来时清零,然后在接下来20ms内累加,到达CNT_MAX就确认按键按下。这样一次短促的抖动虽然会产生边沿脉冲,但计数器在抖动间歇期会被反复清零,始终到不了CNT_MAX,就不会误触发。
CNT_MAX的换算方法是:延时时间 = CNT_MAX / 时钟频率。50MHz下20ms对应1_000_000,100MHz下就要改成2_000_000。如果你的板子时钟不是PLL分频出来的整数关系,先用计数器分频得到低频使能信号,再套用这个模块。下面是几个常见时钟频率的参考参数:
| 时钟频率 | CNT_MAX(20ms) | CNT_MAX(10ms) |
|---|---|---|
| 50MHz | 1_000_000 | 500_000 |
| 100MHz | 2_000_000 | 1_000_000 |
| 25MHz | 500_000 | 250_000 |
3.2 仲裁锁定模块:锁存信号的优先级怎么排
消抖后的四路按键信号送入仲裁模块。这个模块要做两件事:判断谁是第一个有效按下者,以及保证这个结果不被后续按键覆盖。我常用for循环实现优先级,这个写法在综合时会展开成并联的比较逻辑,i=0对应最高优先级。
module lock_arbiter #( parameter WIDTH = 4 ) ( input wire clk, input wire rst_n, input wire [WIDTH-1:0] key_in, // 消抖后的按键,按下为低 output reg [3:0] winner // 锁存的选手编号,1~4 ); reg lock; integer i; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin winner <= 4'd0; lock <= 1'b0; end else if (!lock) begin for (i = 0; i < WIDTH; i = i + 1) begin if (key_in[i] == 1'b0) begin winner <= i + 4'd1; lock <= 1'b1; end end end end endmodule逻辑说明:lock是锁存标志,复位时清零。每一拍都检查lock,只有为0时才扫描四路输入。一旦发现某一路按下,立即写入编号并置位lock。由于lock已经为1,后续所有扫描分支都不会再执行,后面的选手无论怎样按键都改不了winner。复位后winner回到0,系统进入新一轮抢答。
这里要特别说明for循环在硬件综合中的语义。很多新手以为for循环是顺序执行的软件逻辑,其实综合工具会把循环展开成并行的优先级网。i=0分支的信号在硬件上离输出更近,所以当两路在同一时钟沿有效时,key_in[0]会优先赢得仲裁。这个行为在仿真和上板时都能复现,属于“设计意图”而非bug。如果你希望key_in[3]优先级最高,把循环倒过来写就行。
另外注意赋值方式。这里统一用非阻塞赋值<=,是为了避免同一always块内部出现读改写竞争。如果你把lock写成阻塞赋值=,综合出的锁存器行为会很奇怪,我见过最多的问题之一就是锁存逻辑写错导致“后按的覆盖先按的”,这个坑我放在第5章详细排查。
3.3 顶层模块例化:四位消抖怎么拼、数码管怎么亮
有了消抖和仲裁,顶层就是把它们连起来。四路按键需要例化四个消抖模块,Verilog里可以用generate块生成,避免手写四遍重复代码。显示部分用一位数码管,段选信号按共阳接法写,共阴板子需要把编码取反。
module top_quizzer ( input wire clk, input wire rst_n, input wire [3:0] key_in, // 4个选手按键,按下为0 output reg [6:0] seg, // 数码管段选,共阳低电平有效 output reg [3:0] an // 数码管位选,低电平选中 ); wire [3:0] key_debounced; wire [3:0] winner_code; genvar g; generate for (g = 0; g < 4; g = g + 1) begin : gen_debounce debounce #( .CNT_MAX(1_000_000) ) u_debounce ( .clk(clk), .rst_n(rst_n), .key_in(key_in[g]), .key_out(key_debounced[g]) ); end endgenerate lock_arbiter #( .WIDTH(4) ) u_arbiter ( .clk(clk), .rst_n(rst_n), .key_in(key_debounced), .winner(winner_code) ); // 七段译码:显示1~4,无抢答时显示0 always @(*) begin case (winner_code) 4'd1: seg = 7'b1001111; 4'd2: seg = 7'b0010010; 4'd3: seg = 7'b0000110; 4'd4: seg = 7'b1001100; default: seg = 7'b1000000; endcase end assign an = 4'b1110; // 只点亮最右侧一位数码管 endmodulegenerate块里的gen_debounce是例化名,每路消抖模块内部信号独立命名,不会互相干扰。key_debounced把四路消除抖动后的电平汇总成一个4位总线,送给仲裁模块。七段译码用组合逻辑实现,winner_code一变,段选立即更新,不需要时钟。an固定只点亮一位数码管,如果你要显示倒计时或多位数字,需要改成动态扫描,这个扩展方案在第6章给出。
需要注意的是:不同开发板的数码管接口极性不一样,公共端和段码可能都是低电平有效,也可能都是高电平有效。上面代码按“共阳、段选低电平点亮”写,如果你的板子是共阴,把case里的7位编码全部按位取反。判断方法很简单:先下载一个让数码管全亮0的测试工程,看实际硬件是高有效还是低有效,再决定编码方向。这一步不要靠猜,血泪经验告诉我猜错极性会浪费至少半小时。
4. 仿真怎么验证公平性:testbench 写法与波形判读
代码写完直接上板是很多新手踩坑的开端。我的习惯是先写testbench,把所有边界情况在仿真里跑通,再动开发板。仿真的好处是能看到内部信号,比如消抖后的波形、lock信号的跳变沿,这些在板子上几乎没法直接观测。下面给出一套能验证“先按先锁、后按无效、复位可重开”的最小测试平台。
4.1 搭建测试环境:模拟四路异步抢答
testbench里要模拟四路按键的真实行为:按下时带短暂抖动、按下时长超过消抖窗口、后按的选手无论如何不能改变结果。我按时间顺序构造场景:第一轮A先按,确认锁定后B再按,检查winner保持为1;复位后C按,检查winner变为3。
`timescale 1ns/1ps module top_quizzer_tb; reg clk = 0; reg rst_n = 0; reg [3:0] key_in; wire [6:0] seg; wire [3:0] an; always #10 clk = ~clk; // 50MHz时钟 top_quizzer u_top ( .clk(clk), .rst_n(rst_n), .key_in(key_in), .seg(seg), .an(an) ); task press_key(input integer idx); integer k; begin key_in[idx] = 0; #100_000; // 模拟100us短抖动 key_in[idx] = 1; #50_000; key_in[idx] = 0; // 真正按下 #25_000_000; // 保持25ms,确保消抖完成 key_in[idx] = 1; // 松开 #1_000_000; end endtask initial begin key_in = 4'b1111; #100 rst_n = 0; #100 rst_n = 1; // 第一轮:选手0先按 press_key(0); // 选手1后按,此时应被锁定 press_key(1); #200_000; // 复位,进入第二轮 rst_n = 0; #100 rst_n = 1; #200_000; // 第二轮:选手2先按 press_key(2); #200_000; $finish; end endmodule这段testbench里,press_key是一个task,用于模拟一次完整的按键动作,包括短暂抖动、稳定按下和释放。这样写能让测试代码复用,后续要测任意选手顺序,只需要改变调用顺序即可。#100_000在timescale 1ns/1ps下表示100微秒,作为抖动宽度;按下过程保持25毫秒,确保消抖计数器计满,输出才真正变低。
4.2 仿真波形该看什么:三个关键信号不能漏
打开仿真后,不要只盯着winner_code。按顺序看三个信号:key_debounced、仲裁模块里的lock、以及winner_code。key_debounced在按键按下瞬间会有一段毛毛糙糙的抖动吗?不会,因为消抖模块要等20ms稳定后才更新输出,所以你看到的应该是干净的下跳沿。lock应该在第一个key_debounced变低后的一拍内拉高,并且在复位之前一直保持高电平。winner_code从0变成选手编号之后,后续所有按键都不能改变它。
我习惯在testbench里用$monitor或者波形里加光标来检查这几个时间点,也可以用断言:比如在第二轮复位后,检查winner_code是否回到0。如果lock拉高时序不对,说明仲裁模块里lock被意外清零,或者在else if (!lock)分支之外还有别的赋值路径覆盖了它。这一步排查的复杂度远低于上板后用示波器抓引脚,所以强烈建议至少把仿真跑通一次。
5. 常见问题排查:上板以后最容易翻车的四个位置
仿真通过不代表上板一次成功,因为仿真环境是理想化的,真实世界里有时钟抖动、有引脚极性、有物理按键磨损。我在带A同学调这块板子时遇到过几种奇怪的现象,几乎每一条都能在代码逻辑之外找到一个确定的物理原因。下面四条是我见过最多的高频问题,按“现象→原因→解决”的方式列出来,遇到类似情况可以直接对照排查。
5.1 按键按下完全没反应,或者偶尔多跳一个数字
现象:按选手1的键,数码管半天不亮;多按几下,偶尔能亮,但显示的编号不是1。原因:消抖参数CNT_MAX和实际时钟频率对不上。很多课程的示例代码按50MHz写,但板子上实际时钟可能是100MHz或者48MHz。CNT_MAX小了,消抖窗口比真实按键抖动时间还短,按键在抖动过程中会被判定为多次有效按下,仲裁模块会锁到错误的编号;CNT_MAX大了,比如设成50ms,物理按键如果老化,按下时间不够长,可能整个按下过程都未能到达CNT_MAX,于是表现为“没反应”。解决:先确认时钟频率,然后按第3章的参数表换算CNT_MAX。改完再测,如果还是偶尔跳数,可以用逻辑分析仪或者示波器看真实按键的抖动波形,用实测抖动宽度加3倍余量。
5.2 后按的人把先按的人编号覆盖了
现象:A先按,数码管正常显示1;B后按,数码管立刻变成2。原因:仲裁模块的lock信号没有被正确锁存。常见代码错误有两个:一是lock虽然在按下分支里置1,但另一个分支里忘了写保持,比如用组合逻辑给lock赋值且条件覆盖不全,综合时综合出一个不可控的latch;二是仲裁逻辑写在了两个always块里,一个块置lock,另一个块又在某些条件下清lock。解决:检查仲裁模块的always块,确保复位之外只有一条路径会改变lock,就是“检测到有效按键时置1”;其余所有分支都保持原值。最有效的排查方法是回到仿真,把lock信号拉出来看波形,如果lock在A按下后拉高、B按下前出现过一次低电平,那问题就在这个信号上。
5.3 数码管显示的段码错乱,或者所有位都亮
现象:显示的数字完全不对,比如按选手1显示8;或者原本只应该亮一位数码管,结果几位一起亮,亮度还偏暗。原因:段码极性搞反,或者位选没有正确接。七段数码管有共阳和共阴两种,段码编码正好是反的;如果板子上位选是高电平有效,而代码里按低电平有效写,会导致所有位常亮,动态扫描时显示内容互相干扰。解决:先查开发板原理图,确认数码管公共端是接电源还是接地,然后对应调整编码。有一个我自己常用的笨办法:写一个只显示数字0的测试工程,逐个试段码,确定哪一位对应哪个字段,再组合成正式编码。这个方法慢但可靠。
5.4 复位后状态不确定,偶尔不能再次抢答
现象:一轮结束按复位,数码管有时归0,有时还保留旧编号,有时显示一个不在预期范围内的数字。原因:复位信号本身来自物理按键,按下时同样有抖动;而且复位信号相对时钟异步,如果复位沿正好落在时钟边沿附近,寄存器会进入亚稳态,导致复位不彻底。这是很多工程里“上电后表现一切正常,跑一会儿出现随机故障”的潜在根源。解决:给复位信号加两级同步器,像处理普通按键一样先打两拍;同时建议把复位信号展宽,保证低电平持续时间大于一个时钟周期。我常用的写法是生成一个内部全局复位信号,由物理复位触发、由计数器展宽到100微秒以上再释放,这样FPGA内部所有时序逻辑都能稳定复位。
6. 继续扩展:给它装上倒计时和动态扫描显示
基础版跑通以后,这个系统离真正比赛用的抢答器还差两块:主持人的倒计时和跟手的多位显示。倒计时的思路是做一个自由运行的秒计数器,主持人按下开始后递减,到0或有人抢答时停止。BCD显示部分用动态扫描,让两位数码管轮流点亮,利用人眼余晖看起来像同时显示。
module countdown #( parameter INIT_SEC = 30 ) ( input wire clk, input wire rst_n, input wire start, input wire stop, output reg [7:0] bcd_out ); localparam CLK_DIV = 50_000_000; // 50MHz下计数到1秒 reg [25:0] clk_cnt; reg [4:0] sec_cnt; reg running; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 0; sec_cnt <= INIT_SEC; running <= 0; end else if (start) begin running <= 1; end else if (stop || sec_cnt == 0) begin running <= 0; end else if (running) begin clk_cnt <= clk_cnt + 1; if (clk_cnt == CLK_DIV - 1) begin clk_cnt <= 0; sec_cnt <= sec_cnt - 1; end end end assign bcd_out = {sec_cnt / 10, sec_cnt % 10}; endmodule代码里直接用了除法和取模,方便阅读,但FPGA里做除法会消耗大量LUT,实际工程建议改成两个BCD位分别递减:个位递减到0时置9并向十位借位。显示部分把bcd_out和选手编号一起送到扫描模块,每个位选轮流点亮一路数据。我个人的习惯是每加一个功能,就先单独仿真那个模块,再集成到顶层重新跑一遍完整抢答流程。这个习惯帮我省掉了大量上板调试时间。希望帮到你。
本文还有配套的精品资源,点击获取