简介:基于DE2-115开发板的Verilog七人抢答器项目,面向FPGA初学者与数字逻辑课程设计人群,演示多路按键输入消抖、时钟分频与抢答优先级判定的综合实现。资源包共102个文件,约3.1MB,核心为seven.v、CLK_KEY.v、KEY_DOWN.v三个Verilog源码,以及Quartus 17的.qpf/.qsf工程配置,其余为编译生成的.qdb、.rpt、.summary等报告与数据库文件,便于对照分析综合与布局布线结果。已有854人学习下载。通过该项目可掌握DE2-115开发板按键、LED等外设的使用方式,理解抢答器中关键的时钟同步和按键消抖逻辑,并学会用Quartus完成从编写、综合到下载验证的完整流程。代码结构清晰,适合作为课程设计或竞赛练习的基础模板,也可据此扩展为更多路数的抢答装置。
1. 抢答器为什么用FPGA而不是单片机:一个并行锁存需求引发的课程设计
第一次在DE2-115开发板上写七人抢答器,是在某高校EDA课程设计的验收前一周。A同学最开始想用单片机做,理由是“抢答器不就是读七个按键再显示编号吗,轮询几行代码就搞定了”。结果联调时翻车了:七个选手几乎同时按下按键,单片机主频有限,等程序扫到后按键的GPIO时,第一个按下的人已经被后面的人“顶”掉了。FPGA方案的核心优势正好卡在这个需求上:七路按键信号是物理并行的,用Verilog写一个状态机加锁存器,能在纳秒级把第一个有效按键钉死在寄存器里,后续按键一概不认。这篇笔记把整个工程的模块划分、状态机设计、按键消抖、数码管扫描、管脚约束和调试坑完整拆开讲,适合刚学完基础语法但没完整跑过时序系统的FPGA初学者,也适合课程设计答辩前想快速复现一套可演示代码的人。
2. 模块化设计:把抢答流程拆成四个可单独测试的Verilog模块
抢答器功能上看是个小系统,但直接写一个巨型always块把所有逻辑塞进去,综合也能过。问题在于出bug之后面对的是几百行的黑匣子,只能靠仿真波形一遍遍猜。我习惯把工程切成四个模块:按键消抖、赛制状态机、数码管显示、蜂鸣器驱动。每个模块都能单独仿真和上板验证,最后顶层只负责连线。这样分工明确,调试时也能定位到具体模块。
2.1 顶层互联:一张表看懂谁管什么
先把赛制捋清楚:主持人按下“开始”键后,七位选手才能抢答;第一位有效按键被锁存,数码管显示其编号,同时蜂鸣器响一声提示;此后任何选手按键都无效;主持人按下“复位”键进入下一轮。这个流程决定了顶层模块的接口。
| 模块名 | 输入 | 输出 | 职责 |
|---|---|---|---|
key_debounce | 原始按键、时钟 | 消抖后按键电平 | 消除机械抖动与亚稳态 |
rush_fsm | 消抖后按键、主持人控制 | 胜者编号、锁定标志、蜂鸣器脉冲 | 赛制核心状态机 |
seg_display | 胜者编号、锁定标志 | 段码、位选 | 数码管动态扫描显示 |
buzzer_drv | 蜂鸣器脉冲、时钟 | 蜂鸣器管脚驱动 | 产生约1kHz提示音 |
顶层模块将上述模块实例化并连接。工程命名为rush_answer_top,顶层源码如下:
// 七人抢答器顶层:负责例化四个子模块并把信号连起来 module rush_answer_top #( parameter CLK_FREQ = 50_000_000 )( input wire clk_50m, // DE2-115 板载 50MHz 时钟 input wire rst_n, // 复位键,低有效 input wire host_start, // 主持人“开始”键,高有效 input wire host_reset, // 主持人“复位”键,高有效 input wire [6:0] player_key, // 七个选手按键,按下为高 output wire [6:0] seg_data, // 数码管段码 a~g output wire [7:0] seg_sel, // 数码管位选,低有效 output wire buzzer // 蜂鸣器驱动 ); // 消抖后的内部信号 wire [6:0] key_clean; wire host_start_clean; wire host_reset_clean; // 状态机输出 wire [2:0] winner_id; // 胜者编号,1~7 wire lock_flag; // 1 表示本轮已锁定 wire buzz_pulse; // 锁定瞬间产生一个脉冲 // 七路选手按键各自做消抖 genvar i; generate for (i = 0; i < 7; i = i + 1) begin : player_db_gen key_debounce #( .WAIT_CYCLES(CLK_FREQ / 1000 * 20) // 20ms 消抖窗口 ) u_player_db ( .clk (clk_50m), .rst_n (rst_n), .key_in (player_key[i]), .key_out(key_clean[i]) ); end endgenerate // 主持人两个按键同样需要消抖 key_debounce #( .WAIT_CYCLES(CLK_FREQ / 1000 * 20) ) u_host_start_db ( .clk (clk_50m), .rst_n (rst_n), .key_in (host_start), .key_out(host_start_clean) ); key_debounce #( .WAIT_CYCLES(CLK_FREQ / 1000 * 20) ) u_host_reset_db ( .clk (clk_50m), .rst_n (rst_n), .key_in (host_reset), .key_out(host_reset_clean) ); // 赛制状态机 rush_fsm u_fsm ( .clk (clk_50m), .rst_n (rst_n), .host_start(host_start_clean), .host_reset(host_reset_clean), .player_key(key_clean), .winner_id (winner_id), .lock_flag (lock_flag), .buzz_pulse(buzz_pulse) ); // 数码管动态扫描 seg_display u_display ( .clk (clk_50m), .rst_n (rst_n), .winner_id(winner_id), .lock_flag(lock_flag), .seg_data (seg_data), .seg_sel (seg_sel) ); // 蜂鸣器驱动 buzzer_drv u_buzzer ( .clk (clk_50m), .rst_n (rst_n), .buzz_pulse(buzz_pulse), .buzzer (buzzer) ); endmodule逻辑说明:顶层把所有按键先送进消抖模块,得到统一的干净信号后再进状态机。这样做的目的是让状态机看到的输入是“稳定后的真实按键”,而不是带毛刺的物理电平。player_key位宽是7位,对应第1到第7号选手;winner_id只有3位,因为最多显示数字7,二进制位数够用即可。seg_sel设计成低有效,这与DE2-115上多数数码管位选管脚的电平极性一致。
参数说明:CLK_FREQ定义了板载时钟频率,用于计算消抖等待周期WAIT_CYCLES。CLK_FREQ / 1000 * 20在50MHz下等于1,000,000,即20ms内50MHz时钟跑了100万个周期。这个时间窗口对机械按键足够,因为常见按键抖动一般不超过10ms,留一倍余量更稳妥。generate-for循环是Verilog-2001语法,Quartus Prime完全支持,比手写七个实例化更简洁,改位数时只需调整player_key位宽和循环边界。
2.2 赛制状态机:空闲、允许、锁定三个状态如何切换
状态机是整个抢答器的灵魂。我把它设计成三段式状态机:三个状态分别为IDLE(等待主持人喊开始)、READY(允许抢答)、LOCKED(已锁定胜者)。状态转移逻辑和输出逻辑分开写,后续要加超时、犯规等状态时不需要动顶层。
// 赛制状态机:负责抢答判定、锁存与主持人复位 module rush_fsm ( input wire clk, input wire rst_n, input wire host_start, // 主持人开始,脉冲有效 input wire host_reset, // 主持人复位,脉冲有效 input wire [6:0] player_key, // 消抖后的选手按键,高有效 output reg [2:0] winner_id, // 胜者编号,1~7 output reg lock_flag, // 锁定标志 output reg buzz_pulse // 蜂鸣器触发脉冲,持续约1个时钟周期 ); // 状态编码:用独热码,方便综合器优化 localparam IDLE = 3'b001; localparam READY = 3'b010; localparam LOCKED = 3'b100; reg [2:0] state, next_state; reg [2:0] winner_r; // 第一段:状态寄存 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 第二段:组合逻辑决定下一状态 always @(*) begin next_state = state; case (state) IDLE: begin if (host_start) next_state = READY; // 主持人没按开始,停留在空闲 end READY: begin // 有人抢答,立即锁存 if (player_key != 7'b0000000) next_state = LOCKED; // 主持人也能取消本轮 else if (host_reset) next_state = IDLE; end LOCKED: begin // 主持人复位进入下一轮 if (host_reset) next_state = IDLE; end default: next_state = IDLE; endcase end // 第三段:输出寄存,同时提取胜者编号 integer j; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin winner_id <= 3'd0; lock_flag <= 1'b0; buzz_pulse <= 1'b0; end else begin // 默认保持 buzz_pulse <= 1'b0; lock_flag <= (state == LOCKED); if (state == READY && (player_key != 7'b0000000)) begin // 用 for 循环从低位到高位扫描,找到第一个 1 的编号 winner_r = 3'd0; for (j = 0; j < 7; j = j + 1) begin if (player_key[j]) winner_r = j + 1; // 越靠后的循环赋值会覆盖前面的 end winner_id <= winner_r; lock_flag <= 1'b1; buzz_pulse <= 1'b1; // 锁定瞬间给蜂鸣器一个脉冲 end end end endmodule逻辑说明:状态机三段式写法将状态寄存、次态组合逻辑、输出寄存严格分开。IDLE状态下只有主持人按下host_start才进入READY,否则就算选手疯狂按键也没什么反应,这一步就完成了“禁止抢跑”的赛制要求。READY状态检测到player_key任意一位为1,立即跳到LOCKED,同时记录胜者编号。这里用for循环从低位到高位扫描,winner_r最终会被最高位覆盖,也就是说如果两个人几乎同时按下,最后编号大的胜出,这实际是一种“高优先级覆盖低优先级”的编码方式,具体规则可自行调整。
参数说明:winner_r是阻塞赋值,在同一个always块里先清零再循环,最终赋值的结果就是最高位有效键的编号加1。buzz_pulse设计为单个时钟周期的高电平脉冲,蜂鸣器驱动模块收到这个脉冲后会自动播放一段固定时长的提示音,避免了在状态机里做耗时计数。
2.3 按键消抖:20ms连续稳定计数器的两种写法
按键消抖看起来是基本功,但抢答器里它直接决定赛制公平性。20ms消抖窗口意味着按键必须保持稳定高电平20ms以上才会被认为有效,这能有效过滤掉按下瞬间的机械抖动。代码里还要做两级同步,防止外部异步信号进入FPGA内部时产生亚稳态。
// 按键消抖模块:连续高电平超过 WAIT_CYCLES 个时钟周期才输出有效 module key_debounce #( parameter WAIT_CYCLES = 1_000_000 // 默认 20ms @50MHz )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg [19:0] counter; reg [1:0] sync_ff; // 第一级:打两拍同步,消除亚稳态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff <= 2'b00; end else begin sync_ff[0] <= key_in; sync_ff[1] <= sync_ff[0]; end end // 第二级:连续高电平计数,输出对应电平 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin counter <= 20'd0; key_out <= 1'b0; end else if (sync_ff[1]) begin if (counter < WAIT_CYCLES) begin counter <= counter + 1'b1; key_out <= 1'b0; // 还未稳定,输出保持低 end else begin key_out <= 1'b1; // 持续高电平超过门限,判定有效 end end else begin // 检测到低电平,立刻清零计数器 counter <= 20'd0; key_out <= 1'b0; end end endmodule逻辑说明:sync_ff打两拍的做法是异步信号处理的常规手段——第一拍寄存原始输入,第二拍寄存第一拍结果,这样即使外部信号发生亚稳态,也能在两个周期内稳定下来。计数逻辑只在sync_ff[1]为高时累加,一旦检测到低电平立即清零,所以只有“连续”高电平达到门限才会输出高。这个消抖策略对高有效按键有效,如果板子上按键是低有效,需要把判断条件取反。
参数说明:WAIT_CYCLES的位宽要跟计数器匹配。50MHz下20ms对应1,000,000,需要20位宽;如果把门限改成5ms,就是250,000,19位也能放下,但我习惯统一保留20位,避免改参数时忘记改位宽导致综合时报截断警告。这个消抖逻辑的边界在于:如果选手快速连击,每次按下时间小于20ms,模块不会输出有效高电平。这是合理设计,因为赛制要求选手“按稳”,而不是“碰一下就算”。另一个细节是复位时key_out强制为0,避免上电瞬间误判抢答。
3. 工程落地:DE2-115管脚约束、数码管扫描与综合烧录
写Verilog只是走了一半路,另一半在Quartus工程里。DE2-115开发板上有丰富的外设,但每个外设都对应固定的管脚号,不写约束文件,综合器根本不知道clk_50m这个信号要接到板子上哪个物理引脚。数码管也不是直接接7个段码就能用的,必须做动态扫描,否则8位数码管要占几十个管脚。这一章讲工程落地时的三个关键点。
3.1 数码管动态扫描:为什么看起来同时在亮
DE2-115上有8个共阳极数码管,如果每个数码管独立接段码,需要7×8=56个管脚,这在教学板上根本不可能。实际板子的做法是所有数码管的段码线并联,公共端分别引出,依靠“位选”信号轮流点亮每一个数码管。只要轮流速度足够快,人眼会因为视觉暂留效应看到所有数码管“同时”亮起。
// 数码管扫描模块:轮流点亮两位数码管,用来显示胜者编号 module seg_display ( input wire clk, input wire rst_n, input wire [2:0] winner_id, // 胜者编号 1~7 input wire lock_flag, // 锁定标志 output reg [6:0] seg_data, // 段码,低有效 output reg [7:0] seg_sel // 位选,低有效 ); reg [15:0] scan_cnt; reg scan_pos; // 0 显示个位,1 显示十位 reg [6:0] seg_tmp; // 段码表:共阳极数码管,低电平点亮 always @(*) begin case (winner_id) 3'd1: seg_tmp = 7'b1111001; // 1 3'd2: seg_tmp = 7'b0100100; // 2 3'd3: seg_tmp = 7'b0110000; // 3 3'd4: seg_tmp = 7'b0011001; // 4 3'd5: seg_tmp = 7'b0010010; // 5 3'd6: seg_tmp = 7'b0000010; // 6 3'd7: seg_tmp = 7'b1111000; // 7 default: seg_tmp = 7'b1111111; // 全灭 endcase end // 扫描时钟:对50MHz分频得到约500Hz的位选切换 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 16'd0; scan_pos <= 1'b0; end else begin if (scan_cnt < 16'd49_999) scan_cnt <= scan_cnt + 1'b1; else begin scan_cnt <= 16'd0; scan_pos <= ~scan_pos; // 每1ms切换一次位选 end end end // 输出段码和位选,先关位选再切段码,避免拖影 always @(*) begin seg_sel = 8'b1111_1111; // 默认全部熄灭 seg_data = 7'b111_1111; if (scan_pos == 1'b0) begin // 第一位:显示胜者编号 seg_sel[0] = 1'b0; // 点亮最右侧数码管 if (lock_flag) seg_data = seg_tmp; end else begin // 第二位:锁定后显示“—”,未锁定时熄灭 seg_sel[1] = 1'b0; if (lock_flag) seg_data = 7'b0000110; // “—”的段码 end end endmodule逻辑说明:scan_cnt计数到50,000后翻转一次scan_pos,对应1ms翻转一次。两个数码管轮流点亮,每个人看到的是一号管亮1ms、二号管亮1ms,刷新率500Hz,人眼完全不会感到闪烁。seg_tmp用一个组合逻辑case把3位的胜者编号转换成7段码,低电平点亮。位选信号seg_sel[0]为0时,最右侧数码管公共端被拉低,此时段码数据只对这个数码管生效;另一位选为1时该数码管熄灭,不会看到错误的显示。
参数说明:分频计数器宽度是16位,最大计数65535,50,000远小于这个值,合理。如果板子上数码管数量多,扫描频率可以提高到1kHz避免闪烁,但要注意分频数越低位选切换越快,留给段码建立的时间越短。实际测下来500Hz对8位数码管也够用,显示稳定没有拖影。另一个容易被忽略的细节是段码表对应共阳极数码管,换成共阴极板子需要把所有7位取反,这是DE2-115和其他开发板不兼容的常见原因。
3.2 QSF管脚约束:写错一个PIN,板子就“装死”
Quartus工程里综合之前的最后一步是写QSF约束文件。管脚约束分三部分:引脚位置、IO电平标准、时序约束。DE2-115大部分外设是3.3V LVTTL,时钟管脚比较特殊,需要单独声明。
# 时钟:DE2-115 板载 50MHz 有源晶振 set_location_assignment PIN_AF14 -to clk_50m set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to clk_50m # 复位键:使用 KEY[0] set_location_assignment PIN_M23 -to rst_n set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to rst_n # 主持人开始键:使用 KEY[1] set_location_assignment PIN_M21 -to host_start set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to host_start # 主持人复位键:使用 KEY[2] set_location_assignment PIN_M19 -to host_reset set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to host_reset # 七个选手按键:测试阶段建议用拨码开关代替 set_location_assignment PIN_AB27 -to player_key[0] set_location_assignment PIN_AC28 -to player_key[1] set_location_assignment PIN_AC27 -to player_key[2] set_location_assignment PIN_AD27 -to player_key[3] set_location_assignment PIN_AB26 -to player_key[4] set_location_assignment PIN_AD26 -to player_key[5] set_location_assignment PIN_AC25 -to player_key[6] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[0] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[1] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[2] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[3] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[4] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[5] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to player_key[6] # 数码管段码 seg_data[0]~[6] 对应 HEX0 的 a~g set_location_assignment PIN_AB17 -to seg_data[0] set_location_assignment PIN_AC18 -to seg_data[1] set_location_assignment PIN_AC19 -to seg_data[2] set_location_assignment PIN_AE20 -to seg_data[3] set_location_assignment PIN_AF20 -to seg_data[4] set_location_assignment PIN_AF19 -to seg_data[5] set_location_assignment PIN_AE18 -to seg_data[6] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[0] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[1] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[2] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[3] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[4] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[5] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[6] # 数码管位选 seg_sel[0] 对应 HEX0 公共端,seg_sel[1] 对应 HEX1 公共端 set_location_assignment PIN_AA17 -to seg_sel[0] set_location_assignment PIN_AA16 -to seg_sel[1] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[0] set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[1]逻辑说明:QSF里每组约束都包含位置和电平标准两行。位置写法是PIN_XX -to 信号名,一个物理管脚对应一个信号,不能重复占用。IO电平标准统一设成“3.3-V LVTTL”,这是DE2-115上绝大多数GPIO的默认电平。seg_sel[7:2]没有用到的位在工程里保持悬空,Quartus会按默认策略处理,不影响功能。
参数说明:这里给出的管脚号是按DE2-115手册整理的命名,不同批次板卡丝印可能有差异,上板前务必对照手头手册核对一遍,尤其时钟管脚写错会导致整个设计无法工作。测试阶段用拨码开关代替选手按键是个实用技巧:拨码开关电平稳定,不需要消抖,能先验证状态机逻辑,再接物理按键验证消抖模块,分步排错会快很多。
3.3 综合与烧录检查顺序:RTL视图、Pin Planner与SignalTap
工程配置完成后,我习惯按固定顺序做检查,而不是直接点编译按钮等结果。
第一步,检查RTL视图。Quartus综合完成后,在“Tools -> Netlist Viewers -> RTL Viewer”里看模块连接图,重点确认状态机和顶层连线是否和设计一致。这一步能发现例化时端口接错位的问题,比上板测试花的时间少得多。第二步,打开Pin Planner,确认每个信号都分配到了正确管脚,没有出现两个信号争抢同一个管脚的情况。第三步,查看编译报告里的“Fitter Settings”,确认没有未分配管脚的警告。第四步,下载.sof文件到板子,先跑最简单的“全灭测试”——把数码管全部点亮,确认硬件通路没问题,再做功能测试。
SignalTap是调试最后的后悔药。把lock_flag和winner_id加进逻辑分析仪,触发条件设为lock_flag上升沿,就能看到抢答瞬间选手按键和状态机的真实时序关系。硬件调试中很多“灵异现象”,最后都是靠SignalTap抓到信号真相,而不是靠眼睛看数码管猜。
4. 避坑实录:七人抢答器调试中遇到的五个“玄学”问题
这章写的是调这个工程时真实踩过的坑。每一条在当时都让我怀疑是板子坏了或者芯片体质差,最后定位到原因后发现全是设计问题。
4.1 复位后还没按开始,一号键就先锁存了
现象:上电复位完成后,没有人碰选手按键,数码管直接显示“7”,蜂鸣器响了一声。
原因:状态机的READY状态检测到player_key非零就锁存,但复位瞬间player_key信号处于不确定状态。如果拨码开关或按键悬空,FPGA输入引脚的电平会被外部噪声扰动,几毫秒内出现一次高电平毛刺,正好被状态机当成抢答信号。
解决:把复位逻辑做成“安全进入IDLE”,复位信号释放后强制等待至少10ms再检测按键;同时对player_key信号做同步打拍,确保进入状态机的是两级寄存器同步后的信号。我在实际工程里是在顶层把按键从input直接打两拍再送进消抖模块,这样即使外部有毛刺,也要连续两拍保持高电平才可能通过同步链,概率骤降。
4.2 七个人同时按下,出来的总是小号选手,但按规则应该是大号胜出
现象:用拨码开关模拟七人同时抢答,数码管稳定显示编号7,而测试人员坚持说最先按下的是4号。
原因:状态机里的for循环从低位向高位扫描,末尾的赋值会覆盖前面的赋值,所以编号7天然具有最高优先级。如果规则要求“先按先答”,而硬件无法判断绝对时间先后,这种静态优先级编码就是不公平的。
解决:课程设计要求不高时,明确规则为“同时按下时编号大者优先”,并在答辩PPT里注明这一条,状态机逻辑不需要改动。如果真要按时间区分,需要给每个按键通道加一个时间戳寄存器,用计数器记录按键边沿到达时刻,然后比较时间戳大小,那是另一个复杂度等级的设计。我当时选的是第一种方案,先把规则讲清楚再展示功能。
4.3 数码管闪烁,切换到第二位数码管时出现拖影
现象:两次数码管显示时,第二位偶尔会出现前一位的残影,特别是胜者编号从暗到亮切换的瞬间。
原因:位选和段码切换发生在同一个时钟沿,seg_sel关闭前一个数码管和seg_data切换段码是同时发生的,导致前一个数码管在新段码生效前短暂显示了错误数据,人眼看到的就是拖影。
解决:标准做法是先关位选再切段码,中间留一个时钟周期的空档。我在seg_display模块里用的组合逻辑写法已经保证了先置位选为全1(全灭),再给seg_data赋值新段码。如果后续改成时序逻辑输出,顺序要写成三段:熄灭、切段码、点亮,这个顺序不能反过来。
4.4 主持人按复位键,状态机像被“点穴”,有时按好几下才生效
现象:抢答结束后按复位键,数码管不回到全灭,要用力多按几次才能恢复下一轮。
原因:host_reset信号直接连到状态机的host_reset输入,没有做边沿检测。主持人按住不放时,状态机虽然已经回到IDLE,但只要松手瞬间出现电平抖动,状态机又会被抖进别的状态;消抖模块输出的是持续电平,不是“一次有效脉冲”,导致一次按键被解释成多次操作。
解决:在状态机输入端把host_reset改成“上升沿脉冲提取”,检测到消抖后信号从0变1时输出一个单周期脉冲,状态机只认这个脉冲。实现方式是保存上一拍的消抖信号,当前拍为1且上一拍为0时产生host_reset_pulse。这个改动让主持人按键行为变成“按一次只生效一次”,无论按多久都不会重复触发。
4.5 烧录完板子没反应,综合报告却一点报错都没有
现象:.sof文件下载成功,但数码管全灭,蜂鸣器不响,按键无任何反应,像是板子没通电。
原因:QSF约束没写全。顶层模块里的seg_data[7]、seg_sel[7:2]等未使用信号没有对应管脚,Quartus默认将未分配管脚设置为“作为输入且三态”,但如果顶层声明中某个输出信号在QSF里找不到对应位置,编译会直接把这个信号优化掉,板子上对应的物理管脚自然没有驱动。
解决:编译完成后打开编译报告,搜索“warning about pins”,看是否有信号没有分配。另一个常见原因是时钟管脚方向写反,把input写成output,综合器会报错误但不中断,现象同样是板子没反应。后来我每次工程建完,第一件事就是把QSF文件里所有管脚和顶层端口名逐一对应,建一个Excel表格打勾确认,再也不靠“应该没问题”来赌运气。
5. 扩展技巧:把抢答器升级成带倒计时和计分的答题系统
抢答器验证通过后,我顺手把它扩展成了课堂答题计时系统:主持人按开始后,数码管从30秒开始倒计时,选手抢答时倒计时暂停并显示剩余秒数;答对加分,答错扣分,分数同样显示在额外两位数码管上。这个扩展只需要在现有状态机上增加两个状态,而不需要改顶层架构。
新增状态设计为COUNTDOWN和SCORE_UPDATE。从READY进入倒计时,倒计时结束无人抢答则超时判负;有人抢答则锁存并冻结剩余时间。计时模块用10Hz脉冲做递减,因为50MHz计数到5,000,000正好是1秒,再分频得到0.1秒精度,数码管刷新仍然走原来的扫描逻辑。
// 倒计时模块:产生1Hz脉冲并维护剩余秒数 module countdown_timer #( parameter CLK_FREQ = 50_000_000, parameter INIT_SEC = 30 )( input wire clk, input wire rst_n, input wire start, // 开始倒计时脉冲 input wire freeze, // 抢答锁存后冻结 output reg [5:0] remain_sec, // 剩余秒数 output reg timeout // 超时标志 ); reg [25:0] clk_cnt; reg sec_tick; // 分频产生1秒脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 26'd0; sec_tick <= 1'b0; end else if (clk_cnt < CLK_FREQ - 1) begin clk_cnt <= clk_cnt + 1'b1; sec_tick <= 1'b0; end else begin clk_cnt <= 26'd0; sec_tick <= 1'b1; // 每秒产生一个单周期脉冲 end end // 倒计时递减 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin remain_sec <= INIT_SEC; timeout <= 1'b0; end else if (start) begin remain_sec <= INIT_SEC; timeout <= 1'b0; end else if (sec_tick && !freeze) begin if (remain_sec > 1) remain_sec <= remain_sec - 1'b1; else begin remain_sec <= 6'd0; timeout <= 1'b1; // 超时 end end end endmodule逻辑说明:CLK_FREQ - 1是分频器的模值,计数到50,000,000 - 1刚好耗时1秒。sec_tick只在最后一个周期拉高一拍,作为递减时钟。freeze信号接自抢答状态机的lock_flag,锁存后sec_tick继续产生,但剩余秒数不再递减,实现了“抢答成功后时间冻结”。INIT_SEC是参数化初始值,改成10、15都可以现场配置。
参数说明:remain_sec是6位,最大支持63秒倒计时,如果题目需要60秒以上,要扩到7位。CLK_FREQ用了50MHz整数,所以分频计数没有精度误差;如果时钟是33.333MHz之类非整数频率,推荐先做一个全局1Hz基准模块,再在这里用标志位,避免重复分频导致累积误差。
这套扩展没有改任何已有模块的接口,只是新增了一个countdown_timer实例,并把timeout信号接回状态机作为超时跳转条件。从那以后,我每次写时序控制,都会先把状态转移画成表格,写明每个状态在什么输入下跳到哪里、输出什么,再动手写Verilog。状态机不是玄学,把规则列清楚,代码只是翻译。希望帮到你。
本文还有配套的精品资源,点击获取