☰
FPGA七人抢答器Verilog实现:消抖锁存与共阳极数码管显示
2026/10/15 4:49:59 网站建设 项目流程

简介:基于DE2-115开发板的七人抢答器Verilog工程,是一套面向FPGA初学者与数字逻辑课程设计的完整参考方案。工程以七路独立抢答逻辑为内核,利用DE2-115搭载的Cyclone IV E系列FPGA资源,通过Verilog HDL实现了按键消抖、时钟分频与状态机控制等模块,并借助板载按键与指示灯完成输入输出映射。压缩包共102个文件,除3个核心Verilog源文件外,还包含Quartus工程配置与工作空间文件(qpf/qsf/qws),以及综合仿真阶段生成的多种辅助文件,整体仅3.1MB,下载轻便。已有854人学习,可为课程设计或自主练习提供直接参考。对正在学习FPGA开发或准备课程设计的学生而言,这份资料展现了从源码到工程构建的完整流程,可帮助理解引脚约束、编译下载等操作要点;随附的说明文件还介绍了功能验证方式,是兼具教学性与实用性的实战范例。

1. 先到先得不是写个判断就完事:七人抢答器到底难在哪

七人抢答器是FPGA课设里出镜率最高的题目,但多数实现停在“能按能亮”的层面——按键抖动没消、异步输入没同步、数码管段码反了,一到验收就翻车。DE2-115上的七人抢答器,核心不是“有七路输入”,而是两件事:用Verilog把“先到先得”这个顺序语义锁存下来,再让结果稳定地显示在共阳极数码管上。这篇文章适合做课设的学生、准备电子设计竞赛的队伍,以及想从仿真走向板级验证的FPGA新手。我会把模块划分、消抖计数、锁存仲裁、段码表这些细节一次讲透,附可直接落地的Verilog代码和排错路径。

2. 把DE2-115资源盘清:为什么七路输入要用拨码开关而不是按键

2.1 需求拆解:七路输入、一个锁存、一块显示

先别急着写代码,把需求翻译成数字逻辑的状态集合。七人抢答器对外行为只有三个:空闲等待、锁定第一个抢答者、主持人清除后回到空闲。中间任何时刻,七个输入里后到达的信号都必须在锁存器这一层被屏蔽掉,否则显示会跟着开关状态乱跳。

从状态角度拆,T0时刻无人抢答,系统等待;T1时刻选手3按下,系统记录“选手3”并进入锁定态;T2时刻选手5再按下,数据必须不能覆盖T1的锁存值;只有主持人给一个清除信号,系统才回到T0。这个“只认第一个、后到全忽略”的顺序语义,是整个设计的灵魂。

信号定义也随之清晰:7路选手输入、1路主持人复位、1路主持人清除、1路编号显示、1路指示。DE2-115板载的资源里,这些信号都能找到对应外设,不需要外接任何元器件就能先跑通逻辑。

2.2 DE2-115外设选型:KEY不够用,SW来凑

DE2-115的板载按键只有KEY[3:0]四个独立按键,做七人抢答器根本不够。而拨码开关SW有18个,取SW[6:0]七位正好对应七位选手。常见做法是:SW[0]对应选手1,SW[1]对应选手2,依次类推;KEY[0]做主持人复位,KEY[1]做主持人清除;HEX0显示抢答选手编号,LEDR[0]点亮表示“已有人抢答”。

一个很实际的问题是,拨码开关不能模拟真人手速——你没法让七个人同时去拨开关。所以这个方案更多是验证逻辑正确性,不是做真人对抗赛。等锁存、消抖、显示全部调通后,再把SW输入换成GPIO口外接的七个独立按钮,逻辑部分一行都不用改。我见过有人一开始就买按钮往GPIO上接,结果消抖没做好,按一下显示跳好几个编号;换回拨码开关先把底层逻辑验证完,再换按钮就快很多。这是做这个题目的血泪经验:先拨码开关,后真按钮。

2.3 顶层信号与模块划分

整个设计拆成三个模块加一个顶层:debounce负责同步和消抖,arbiter负责先到先得锁存,seg7_display负责把锁存结果译成段码。信号流向是SW进debounce,debounce输出干净的七路电平进arbiter,arbiter输出锁存结果进seg7_display,最终送HEX0。

顶层信号定义如下:

信号方向位宽说明
CLOCK_50input1板载50MHz时钟
KEY[0]input1主持人复位,低有效
KEY[1]input1主持人清除锁存,低有效
SW[6:0]input7选手1~7抢答输入,高有效
HEX0output7选手编号显示,共阳极低有效
LEDR[0]output1抢答完成指示
lockoutput7调试用,锁存结果输出

模块表也一并列出来,方便建工程时对照:

模块文件名职责
debouncedebounce.v两级同步加计数消抖
arbiterarbiter.v锁存第一个有效输入
seg7_displayseg7_display.vone-hot状态译成段码
top_quiztop_quiz.v顶层例化与管脚连接

3. 抢答核心逻辑:同步、消抖与先到先得的锁存实现

3.1 为什么优先编码器不是抢答器

最容易出现的认知偏差,是把抢答器当成一个带优先级的编码器。比如有人会这样写:assign num = SW[0] ? 3'd1 : SW[1] ? 3'd2 : ...,七个三目运算符从选手1一路判到选手7。问题是,这段组合逻辑只描述“当前时刻谁按着”,不保存历史。选手松手后显示立刻归零,两个选手同时按着时永远显示编号小的那个人,完全不符合“第一个到达者获胜”的抢答语义。

抢答器要保存的东西,不是电平值,而是“某个选手第一个到来”这个事件。事件必须被寄存器锁存,锁存后所有后续输入对这个寄存器失效。这是组合逻辑做不了的事,必须有时序逻辑参与。理解这一点,设计思路就完整了:先同步消抖,再锁存,最后译码显示。

3.2 异步输入同步化与拨码开关消抖

拨码开关和按键都是异步信号,直接进时钟域有两个风险:一是机械抖动产生毛刺,二是信号在时钟沿附近变化时可能触发亚稳态。一个常见误区是以为同步能替代消抖,或者消抖能替代同步。其实两级同步器解决的是信号在不同时钟域之间传递时的建立保持问题,消抖解决的是机械触点抖动问题,两者缺一不可。

debounce模块里我通常把两级同步和计数消抖放在一起写,计数窗口做成参数,仿真时缩小、上板时放大。代码是这样:

module debounce #( parameter integer CNT_TICKS = 249999 // 5ms @ 50MHz,仿真时可改小 ) ( input wire clk, input wire rst_n, input wire sw_in, output reg sw_out ); reg [17:0] cnt; reg sw_sync1, sw_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 18'd0; sw_sync1 <= 1'b0; sw_sync2 <= 1'b0; sw_out <= 1'b0; end else begin // 第一级同步,第二级同步 sw_sync1 <= sw_in; sw_sync2 <= sw_sync1; // 只有同步后的电平与当前稳定值不一致时才开始计数 if (sw_sync2 != sw_out) begin if (cnt == CNT_TICKS) begin // 连续CNT_TICKS个周期未见变化,认为信号稳定 sw_out <= sw_sync2; cnt <= 18'd0; end else begin cnt <= cnt + 1'b1; end end else begin // 毛刺提前消失,放弃本次计数 cnt <= 18'd0; end end end endmodule

逻辑说明:sw_sync1和sw_sync2形成两级同步链,sw_sync2才是真正参与逻辑的信号。当sw_sync2与当前输出sw_out不同时,计数器开始累加;如果在计数过程中sw_sync2又变回旧值,说明刚才只是一个毛刺,计数器立即清零。只有当输入持续稳定CNT_TICKS个时钟周期,sw_out才翻转。这种“确认后翻转”的写法比单纯边沿检测抗干扰能力强很多。

参数说明:CNT_TICKS的换算公式是“期望时间(秒) × 50MHz - 1”。拨码开关的机械抖动通常小于1ms,5ms窗口已经留了余量;如果现场有强干扰,可以加到10ms,也就是把CNT_TICKS改成499999,但此时计数器位宽要扩到19位,18位的最大值是262143,不够存499999。这是改参数最容易翻车的地方,务必连位宽一起改。

3.3 锁存与清除:one-hot 仲裁的完整代码

arbiter模块是整台抢答器的心脏。它的任务是:在valid为0时监视七路输入,一旦发现任何一个输入有效,立即把当前输入快照进lock寄存器,并把valid拉高;之后所有输入一律忽略。clear信号到来时,lock和valid同时清零,系统回到等待状态。

module arbiter ( input wire clk, input wire rst_n, input wire [6:0] sw_in, // 消抖后的七路输入 input wire clear, // 主持人清除,高有效 output reg [6:0] lock, // 锁存结果,one-hot output reg valid // 1表示已有人抢答 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin lock <= 7'b0; valid <= 1'b0; end else if (clear) begin lock <= 7'b0; valid <= 1'b0; end else if (!valid) begin // 只要出现任意一个有效输入,立刻锁存当前状态 if (|sw_in) begin lock <= sw_in; valid <= 1'b1; end end // valid == 1时不做任何操作,lock保持第一个快照 end endmodule

逻辑说明:|sw_in是归约或运算,等价于判断sw_in是否非零。锁存动作发生在单个时钟上升沿,只要采样到的sw_in非零,就把这一拍的完整状态存入lock。valid=1之后,后面的always分支全部被跳过,后到的选手输入永远改不了lock的值。清除信号到来时,lock和valid同时被拉低。

参数说明:clear是电平敏感信号。如果直接用KEY[1]取反产生,会导致主持人按住按键期间系统一直处于清除状态,必须松开后再抢答,这在实际使用中够用,但不够健壮。后面避坑章节会讲如何把清除改成单周期脉冲。另外要提一个边界情况:如果两个选手的输入在同一个时钟沿被采样到,lock会同时锁存两个1,破坏one-hot假设。这种情况在真实拨码开关操作中几乎不会出现,但为了防止它在显示端造成乱码,seg7_display里用casez通配符做了显式优先级裁决,编号小者优先。这是所有抢答器都无法完全避免的同拍竞争,写清楚规则总比靠运气强。

4. 共阳极数码管与顶层例化:把逻辑搬上DE2-115

4.1 段码表:DE2-115的数码管和51单片机不一样

DE2-115板载的七段数码管是共阳极,段选信号低电平有效。这和很多开发板的共阴极设计正好相反,也是新手最容易踩的第一个坑——把网上搜来的共阴极段码表直接抄进代码,结果数码管要么全灭要么显示完全不对。

下面这张表是选手编号1到7对应的共阳极段码,数字0用作空闲状态显示。二进制位从HEX0[0]到HEX0[6],对应数码管的a到g段,a是最右边的低位:

显示段码(二进制)说明
01000000空闲状态
11111001仅b、c段点亮
20100100a、b、d、e、g点亮
30110000a、b、c、d、g点亮
40011001b、c、f、g点亮
50010010a、c、d、f、g点亮
60000010a、c、d、e、f、g点亮
71111000a、b、c点亮

上板前先做一个极简自检很有必要:把HEX0直接赋值成一个确定的段码,比如assign HEX0 = 7'b1111001,编译下载后看是不是显示数字1。如果显示的不是1,说明极性理解反了,赶紧检查共阳极共阴极。这个自检动作能省下后面一整轮调试时间。

seg7_display模块把lock的one-hot状态翻译成段码,用casez做通配符匹配,优先级从选手1到选手7递减。代码里default落到了0,意为灭掉或者显示0,恰好和空闲状态一致。

module seg7_display ( input wire [6:0] lock, // 锁存结果,one-hot output reg [6:0] hex0 // 共阳极段码,低有效 ); reg [2:0] num; // 把one-hot状态翻译成选手编号,低位优先 always @(*) begin casez (lock) 7'b1?????? : num = 3'd1; // 选手1:SW0 7'b01????? : num = 3'd2; // 选手2:SW1 7'b001???? : num = 3'd3; 7'b0001??? : num = 3'd4; 7'b00001?? : num = 3'd5; 7'b000001? : num = 3'd6; 7'b0000001 : num = 3'd7; default : num = 3'd0; endcase end // 编号译成共阳极段码 always @(*) begin case (num) 3'd0: hex0 = 7'b1000000; // 0 3'd1: hex0 = 7'b1111001; // 1 3'd2: hex0 = 7'b0100100; // 2 3'd3: hex0 = 7'b0110000; // 3 3'd4: hex0 = 7'b0011001; // 4 3'd5: hex0 = 7'b0010010; // 5 3'd6: hex0 = 7'b0000010; // 6 3'd7: hex0 = 7'b1111000; // 7 default: hex0 = 7'b1000000; endcase end endmodule

逻辑说明:casez中的问号表示“任意值”,7'b1??????匹配lock[6]为1的任何状态,也就是选手1(SW0)拥有最高优先级;7'b01?????匹配lock[6]=0、lock[5]=1的状态,对应选手2。这样即使lock里意外出现多个1,显示结果也是确定可预期的。第二个case块只做段码映射,纯组合逻辑,不产生锁存器。

4.2 顶层例化与管脚约束

顶层模块把三个子模块串起来。SW作为七路输入,先经过七个并行的debounce模块,输出干净的sw_debounced信号,再送进arbiter。KEY[1]取反后作为clear,KEY[0]作为系统复位。lock和valid额外引出到顶层方便仿真观察。

module top_quiz #( parameter integer CNT_TICKS = 249999 // 仿真时可传小参数加速 ) ( input wire CLOCK_50, input wire [3:0] KEY, // KEY[0]复位,KEY[1]清除 input wire [6:0] SW, // 选手1~7输入 output wire [6:0] HEX0, // 选手编号显示 output wire [9:0] LEDR, // LEDR[0]指示抢答完成 output wire [6:0] lock, // 调试用锁存结果 output wire valid // 调试用有效标志 ); wire rst_n = KEY[0]; wire clear = ~KEY[1]; wire [6:0] sw_debounced; genvar i; generate for (i = 0; i < 7; i = i + 1) begin : debounce_gen debounce #(.CNT_TICKS(CNT_TICKS)) u_deb ( .clk (CLOCK_50), .rst_n (rst_n), .sw_in (SW[i]), .sw_out(sw_debounced[i]) ); end endgenerate arbiter u_arb ( .clk (CLOCK_50), .rst_n (rst_n), .sw_in (sw_debounced), .clear (clear), .lock (lock), .valid (valid) ); seg7_display u_disp ( .lock (lock), .hex0 (HEX0) ); assign LEDR[0] = valid; endmodule

逻辑说明:generate循环把debounce模块复制七份,每一份对应一路SW输入。这里把CNT_TICKS做成了顶层参数,仿真时向top_quiz传一个很小的值(比如5)就能让消抖一两个时钟周期完成,不需要在仿真里等25万个周期。真实上板时默认249999,对应5ms。复位和清除都接在板载按键上,注意KEY是按下去为0、常态为1,所以rst_n直接用KEY[0],clear取反用KEY[1]。

参数说明:顶层参数CNT_TICKS会顺着generate循环传递给每个debounce实例。如果你计划把消抖时间改到10ms以上,记得同时把debounce内部的cnt位宽从18位扩到19位,否则计数器溢出后消抖窗口变成不可预期的值。

4.3 Quartus建工程与烧录

工程搭建步骤很常规,但每一步都有对应的坑。新建工程时器件型号选Cyclone IV E系列里的EP4CE115F29C7,这是DE2-115的主芯片。然后把top_quiz.v、debounce.v、arbiter.v、seg7_display.v四个文件都加进工程,top设为top_quiz。

管脚约束不要手敲,从DE2-115官方管脚分配文件里复制对应信号再改方向。需要约束的引脚只有CLOCK_50、KEY[3:0]、SW[6:0]、HEX0、LEDR[0]这几个,其余不用管。编译时重点看两类警告:一类是“Inferred latch”,说明组合逻辑的case分支没写全,大概率是seg7_display里漏了default;另一类是“multiple drivers”,说明某个wire被多个always块赋值,赶紧查是不是端口连接写重了。

烧录用JTAG方式,下载sof文件后按一下KEY[0]复位,然后拨动SW[2]模拟选手3抢答,HEX0显示3,LEDR[0]点亮。再拨SW[5],显示保持3不动。按KEY[1]清除,LEDR[0]熄灭,拨动SW[5]再试,显示5。这一套流程走通,基础功能就算完成了。

5. 抢答器常见问题排查:数码管不亮、显示乱跳、复位失灵的5条踩坑记录

5.1 数码管全灭或显示完全不是预期字符

现象:编译下载后HEX0要么全灭,要么显示的数字和按下的选手对不上,比如选手1抢答却显示0,或者显示成乱码。

原因:DE2-115的数码管是共阳极、段码低电平有效。网上能找到的大部分段码表是共阴极高有效的,直接抄过来等于把逻辑反了。还有一个隐蔽因素:HEX0[0]到HEX0[6]对应a到g段,顺序和某些板卡不一致,抄表时容易错位。

解决:先把HEX0直接赋值一个已知段码做自检,assign HEX0 = 7'b1111001,如果显示不是1,说明极性或顺序理解有误。确认共阳极方向后,把表里所有段码取反,或者照着本文第4章的共阳极段码表核对一遍字段顺序。数码管显示属于“看着简单、实测全灭”的典型,自检比推理快得多。

5.2 显示的数字会跳,或者亮一下灭一下

现象:抢答成功后,HEX0的显示在几个数字之间跳变,或者明明按住了开关,显示却不稳定。

原因:消抖窗口太短,或者干脆没做消抖。拨码开关的机械抖动时间通常在几百微秒到几毫秒之间,如果将窗口设在微秒级,毛刺会一路穿过debounce进入arbiter,导致同一物理动作被识别成多次事件。虽然arbiter有锁存,但“第一个被识别到的事件”本身可能是抖出来的,显示就会跳到错误编号。

解决:把CNT_TICKS加大到249999(5ms)以上,必要时加到999999(约20ms)。改参数时同步检查debounce里计数器位宽,18位计数器上限是262143,999999超过了,必须扩到20位。经验法则是“窗口比实测抖动时间长5倍以上”,先抓波形再定参数,不要凭感觉拍一个值。

5.3 指示灯亮了但显示的总选手不对

现象:LEDR[0]正常点亮,说明arbiter确实锁到了信号,但显示编号总是差一位或显示两个人。

原因:一种可能是lock里存了多个1。两个选手的输入恰好在一个时钟沿同时到达,lock同时快照了两个bit,one-hot假设被打破。另一种可能是seg7_display的casez通配符把优先级规则写反了,比如把7'b1000000写成了匹配选手7而实际它对应选手1。

解决:先把lock信号引到LEDR[6:0]看实灯状态,谁亮着就是谁被锁存了。如果确实出现两个灯同时亮,确认是同时按键导致的物理竞争,还是仲裁代码逻辑缺陷。逻辑缺陷就修代码,物理竞争则接受casez的低位优先裁决——抢答器不需要支持“两个人同时按还能精确分胜负”,那是裁判该处理的事。

5.4 仿真正常但上板无反应,复位看起来无效

现象:ModelSim波形完全正确,下载到DE2-115后怎么拨SW都没反应,LEDR[0]不亮。

原因:最常见的是复位按键状态没确认。DE2-115的KEY是按下为低、常态为高,但有些板卡的KEY默认电平不同。如果代码按低有效复位写,而板子上默认低电平,系统就一直处于复位状态。另一种情况是KEY[0]上有抖动,上电瞬间产生毛刺让系统反复复位。

解决:先确认按键常态电平,量一下KEY[0]对地电压,常态应该是高电平。再在top里把复位信号打两拍再使用,消除上电毛刺。最省事的验证方式是:下载完程序后手动按一下KEY[0]再松开,如果这时抢答正常,问题就在复位噪声或上电时序,而不是逻辑代码。

5.5 主持人清除后第二次抢答锁存不上

现象:第一次抢答正常,按KEY[1]清除后,再拨任何SW都没有反应,LEDR[0]不亮,像是锁死了一样。

原因:clear用了电平信号。KEY[1]按下时clear一直为高,arbiter内的清除逻辑持续生效;如果按的时间稍长,松开瞬间的边沿又碰到新抢答信号的上升沿,两个事件在同一个时钟沿竞争,锁存数据被清掉。还有一类情况是KEY[1]没有消抖,抖动毛刺反复触发清除,把新抢答也顺带清了。

解决:把KEY[1]的按下动作转成单周期脉冲,而不是电平。用下降沿检测实现:KEY常态为1,按下变0,采样到key1_r2 == 1 && key1_r1 == 0时输出一个周期的高脉冲,输入给arbiter的clear。代码很简单:

reg key1_r1, key1_r2; wire clear_pulse; always @(posedge CLOCK_50 or negedge rst_n) begin if (!rst_n) begin key1_r1 <= 1'b1; key1_r2 <= 1'b1; end else begin key1_r1 <= KEY[1]; // KEY按下时为0 key1_r2 <= key1_r1; end end assign clear_pulse = key1_r2 & ~key1_r1; // 下降沿脉冲

把clear_pulse接到arbiter的clear端口,清除动作只维持一个时钟周期,之后系统立刻恢复等待状态,不会有多余的电平覆盖新的抢答信号。这个改动对新手的价值在于:它彻底消除了按键操作时长对逻辑的影响,抢答器用起来才像“仪器”而不是“玄学”。

6. 上板前先仿真:testbench、关键波形与三个进阶方向

调DE2-115之前先花十分钟把testbench写好,能省掉大半天的板级调试。仿真验证点只有三个:锁存第一个输入、后到输入被忽略、清除后可再次抢答。借助顶层参数,仿真时把消抖窗口从249999改到5,几个时钟周期就能完成一次完整流程。

module tb_top(); reg clk = 1'b0; reg [3:0] key = 4'b1111; reg [6:0] sw = 7'b0000000; wire [6:0] hex0; wire [9:0] ledr; wire [6:0] lock; wire valid; always #10 clk = ~clk; // 20ns周期,对应50MHz top_quiz #(.CNT_TICKS(5)) uut ( .CLOCK_50(clk), .KEY(key), .SW(sw), .HEX0(hex0), .LEDR(ledr), .lock(lock), .valid(valid) ); initial begin // 复位 key[0] = 1'b0; #100; key[0] = 1'b1; #200; // 选手3(SW[2])抢答 sw[2] = 1'b1; #200; $display("P3 pressed: lock=%b valid=%b", lock, valid); // 选手5(SW[4])随后按下,应被忽略 sw[4] = 1'b1; #200; $display("P5 pressed: lock=%b valid=%b", lock, valid); // 主持人清除 key[1] = 1'b0; #100; key[1] = 1'b1; #200; // 第二次抢答,选手6(SW[5]) sw = 7'b0; sw[5] = 1'b1; #200; $display("Round2 P6: lock=%b valid=%b", lock, valid); #100; $finish; end endmodule

看波形时重点盯三个事件点的两个信号:第一段P3按下后,lock变成3’b0000100类的单bit电平且保持不变;第二段P5按下后,lock没有变化;清除后valid拉低,P6按下重新锁存。这三个事件对上了,板上测试只是走个过场。

仿真通过后,如果想让这个课设从“能跑”变成“有亮点”,三条路可以选。第一,倒计时抢答:加一个50MHz分频到1Hz的时钟,主持人按KEY[2]开始倒计时,抢答成功后暂停计时,用HEX1显示剩余秒数,视觉反馈强很多。第二,违规判定:增加主持人“开始”按键,开始之前有任何选手输入就点亮红灯并锁定违规编号,这在竞赛场景下是刚需。第三,GPIO外接真实按钮:把顶层输入从SW换成GPIO_0上的七个引脚,例化逻辑一行不用动,但按钮的机械抖动更明显,CNT_TICKS要适当加大。

我自己的习惯是永远先把仿真波形打印出来存档,再上板调。板上出问题时,回头翻波形能立刻定位是逻辑问题还是物理连接问题,而不是在开发板和电脑之间反复烧录瞎试。抢答器这个题目不大,但同步、消抖、锁存、译码这一套链路,恰好是FPGA数字设计的基本功,踩过的坑以后再做大系统都能用上。希望帮到你。

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

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

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

立即咨询