简介:EDA课程设计抢答器项目是一份完整的FPGA数字系统设计资料,面向电子信息类专业学生及EDA课程设计初学者。设计基于Altera Quartus II软件,在DE2开发板上实现,涵盖输入模块、计时器、信号检测、结果显示与复位功能。压缩包内共139个文件,包括VHDL源程序(vhd)、原理图设计(bdf)、仿真波形(vwf)、工程配置(qsf/qpf)以及软核配置文件(sof/pof)等,可直观了解从逻辑设计到下载验证的完整流程,整包仅664KB。已有3087人学习浏览。该资源特别适合初次接触EDA课程设计的学生参考,借助完整工程文件可以快速掌握抢答器各模块的编写思路,结合仿真结果理解时序与优先级检测原理,并可直接部署到DE2开发板进行硬件验证。
1. EDA课程设计——抢答器:为什么一个“简单题目”值得认真做
先从结论说起:抢答器是EDA课程设计里出镜率最高的题目,但大多数交上来的工程都会在演示时翻车——按键没反应、倒计时不走、数码管乱码。原因只有一个:把“写代码”当成了“做设计”。这个题目表面是按键优先级和计数器拼在一起,实际考的是状态划分、消抖、扫描显示和时序意识。下面这套方案我反复验证过,从需求拆分、VHDL实现、仿真到板级调试全覆盖,适合正在准备课程设计的同学,也适合想用FPGA做小数字系统的开发者,照着做能直接跑通一块入门级开发板。
2. 拆需求、定方案:从“按键按下”到“数码管显示”的信号链
2.1 抢答器的功能拆解:到底要做哪几件事
我一般拿到这类题目,第一件事不是翻开开发板手册,而是把功能需求一条条列出来。抢答器不是“谁先按下就亮谁的灯”,而是一个完整比赛流程:主持人按下开始键后抢答才生效;四名参赛者各有一个按键,先按下的选手被锁存;锁存后其他按键全部无效;抢答成功后进入答题倒计时,超时报警;如果主持人没按开始就有人按键,要判犯规并显示犯规选手编号。
把这些需求整理成一张表格,后面写代码就是照着表格填:
| 功能项 | 输入 | 输出行为 | 说明 |
|---|---|---|---|
| 开始抢答 | start_n 按键 | 倒计时启动,抢答使能打开 | 主持人控制 |
| 抢答锁存 | 4 路按键 key_n | 显示选手编号,锁存后续按键 | 只认首个有效沿 |
| 犯规判断 | 开始前有按键按下 | 显示犯规编号并报警 | 与正常抢答状态分离 |
| 答题倒计时 | 锁定后自动计时 | 数码管显示剩余秒数 | 默认 30 秒 |
| 超时提示 | 倒计时到 0 | 蜂鸣器长鸣 | 一轮结束,回到初始 |
这张表里最关键的一点是“锁存”和“犯规”被设计成两个独立状态。很多翻车代码就是把犯规和抢答混在一个判断里,导致犯规后倒计时也启动了。我建议从一开始就把四个状态分清楚:空闲、抢答中、已锁存、犯规。后面所有模块都围绕这四个状态来配合。
2.2 模块划分与信号约定:先把接口定死再写代码
状态定下来之后,就要把整个系统拆成模块。我的习惯是先画信号流,再写代码。一个典型的抢答器至少要拆成四块:按键消抖、抢答锁存状态机、倒计时、数码管扫描显示。蜂鸣器作为独立控制模块挂在状态机输出上。
模块之间的接口我先用一张表约定死,写代码时就不会出现两个模块对信号理解不一致的问题:
| 信号 | 方向 | 含义 | 极性 |
|---|---|---|---|
| clk_50m | 输入 | 系统时钟 | 上升沿 |
| rst_n | 输入 | 异步复位 | 低有效 |
| start_n | 输入 | 主持人开始键 | 低有效 |
| key_n[3:0] | 输入 | 四路抢答键 | 低有效 |
| en_cnt | 内部 | 倒计时使能 | 高有效 |
| locked | 内部 | 已锁存标志 | 高有效 |
| foul | 内部 | 犯规标志 | 高有效 |
| timeout | 内部 | 倒计时到 0 | 高有效 |
| seg[7:0] | 输出 | 七段码 | 共阴 |
| sel[3:0] | 输出 | 位选 | 低有效 |
| beep | 输出 | 蜂鸣器 | 高有效 |
这里我特别强调极性约定。教学板上按键几乎都是按下为低电平,数码管则可能是共阴或共阳。如果你的板子数码管是共阳,七段码表要全部取反。这个看似不起眼的约定,其实是上板翻车率最高的一个位置。
2.3 开发环境与语言选型:VHDL 还是 Verilog
语言选型上,课程设计要求用 VHDL 还是 Verilog,通常由学校或指导老师定,不纠结。如果让你自己选,我建议:熟悉 C 语言的人选 Verilog,学数字电路理论更扎实的人选 VHDL。两者区别并不大,关键在用你熟悉的语法把状态机写清楚。
开发环境方面,常见做法是使用 FPGA 厂商自带的全流程工具,从综合、布线到仿真一条龙。如果你手头是一块中低端教学板,建议先用芯片厂商原厂工具做引脚约束和下载;仿真则可以另用专业的仿真工具,也可以直接用开发环境集成的仿真器。工具之间最大的坑是版本,高版本工程在低版本工具里打不开,交作业前一定要注意统一版本。
开发板选型上,抢答器这种规模,任何一款入门级 FPGA 都能装下,资源占用不到 5%。我一般建议选板上自带按键、数码管、蜂鸣器的型号,这样不用外接器件,引脚约束也简单。
3. 抢答锁存与倒计时:核心逻辑怎么写才不出岔子
3.1 抢答锁存状态机:四状态写法与完整代码
核心逻辑是一台状态机。我把四个状态定名为 idle、run、answered、foul_st。idle 等待主持人按开始;run 是抢答窗口期;answered 表示已有人抢答成功;foul_st 表示犯规锁定。抢答成功和犯规都会锁存状态,直到复位才清除,所以这个状态机天然就把“后续按键无效”这个需求实现了。
下面这段是锁存模块的完整代码,我按实际可综合的写法给出,用了 case 语句而不是一堆 if 嵌套,仿真时看状态跳转更清楚:
-- 抢答锁存模块:状态机实现判优、锁存、犯规检测 -- 外部按键先经过消抖模块,再进入本模块 library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity locker is port ( clk_50m : in std_logic; -- 50 MHz 系统时钟 rst_n : in std_logic; -- 异步复位,低有效 start_n : in std_logic; -- 主持人开始键,低有效 key_n : in std_logic_vector(3 downto 0); -- 消抖后的抢答键,低有效 timeout : in std_logic; -- 倒计时到 0,电平信号 en_cnt : out std_logic; -- 倒计时使能 locked : out std_logic; -- 已锁存标志 player : out std_logic_vector(2 downto 0); -- 选手编号 1~4 foul : out std_logic -- 犯规标志 ); end locker; architecture arch of locker is type state_type is (idle, run, answered, foul_st); signal state : state_type; -- 按键编码:低电平那一路对应编号 function encode_key(k : std_logic_vector(3 downto 0)) return std_logic_vector is begin if k(0) = '0' then return "001"; elsif k(1) = '0' then return "010"; elsif k(2) = '0' then return "011"; else return "100"; end if; end function; begin process (clk_50m, rst_n) begin if rst_n = '0' then state <= idle; en_cnt <= '0'; locked <= '0'; foul <= '0'; player <= (others => '0'); elsif rising_edge(clk_50m) then case state is when idle => en_cnt <= '0'; locked <= '0'; foul <= '0'; if start_n = '0' then state <= run; -- 主持人开始,进入抢答窗口 elsif key_n /= "1111" then foul <= '1'; -- 未开始就按键,判犯规 state <= foul_st; player <= encode_key(key_n); end if; when run => en_cnt <= '1'; -- 启动倒计时 if key_n /= "1111" then locked <= '1'; player <= encode_key(key_n); state <= answered; -- 锁存首个按键 end if; when answered => en_cnt <= '1'; -- 答题倒计时继续 if timeout = '1' then state <= idle; -- 超时,一轮结束 end if; when foul_st => en_cnt <= '0'; -- 犯规后倒计时不启动 when others => state <= idle; end case; end if; end process; end arch;这段代码有几个设计点需要说明。第一,key_n 输入的是消抖后的信号,不是我直接接按键引脚,这一点一定要记住,否则按键抖动会让状态机在多个状态间跳来跳去。第二,idle 状态下同时判断 start_n 和 key_n,用 if 的优先级让主持人按键优先于犯规判断。第三,answered 状态里检查 timeout,超时自动回到 idle;foul_st 则保持到复位,由主持人按复位键清除,这更符合真实比赛流程。
关于 timeout 的处理,我采用的是电平方式:倒计时模块在减到 0 时把 timeout 拉高,状态机看到之后自动回 idle。优点是不用担心漏掉一个时钟周期的脉冲,缺点是模块之间要注意时序配合。如果你习惯脉冲信号,也可以把它改成单周期脉冲,但状态机里就要用边沿检测逻辑,复杂度会高一点。
3.2 倒计时模块:1 秒基准怎么生成最省资源
倒计时的核心是 30 秒递减,而 30 秒的基础是稳定的 1 秒信号。在 50 MHz 时钟下,1 秒就是计数 20,000,000 次。最常见的写法是定义一个计数器,每次计满产生一个 tick 脉冲。我建议把计数值做成顶层可见的参数,方便仿真时改小,这一点在第五章还会具体说。
下面是倒计时模块的核心代码,我把 countdown 设计成独立模块,只通过 en_cnt 和 timeout 两个信号与锁存器交互:
-- 倒计时模块:1 秒基准 + 30 秒递减 -- 空闲时预置 30,en_cnt 拉高后每个 1 秒 tick 减 1 library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; use ieee.numeric_std.all; entity countdown is generic ( CNT_1S : integer := 20_000_000 -- 50 MHz 下 1 秒计数值 ); port ( clk_50m : in std_logic; rst_n : in std_logic; en_cnt : in std_logic; -- 倒计时使能 sec_h : out std_logic_vector(3 downto 0); -- 十位 BCD sec_l : out std_logic_vector(3 downto 0); -- 个位 BCD timeout : out std_logic -- 到 0 时拉高 ); end countdown; architecture arch of countdown is signal tick_1s : std_logic; signal sec : integer range 0 to 30; signal cnt : integer range 0 to CNT_1S - 1; begin -- 1 秒基准分频 process (clk_50m, rst_n) begin if rst_n = '0' then cnt <= 0; tick_1s <= '0'; elsif rising_edge(clk_50m) then if cnt = CNT_1S - 1 then cnt <= 0; tick_1s <= '1'; -- 一个时钟周期的高脉冲 else cnt <= cnt + 1; tick_1s <= '0'; end if; end if; end process; -- 秒递减 process (clk_50m, rst_n) begin if rst_n = '0' then sec <= 30; elsif rising_edge(clk_50m) then if en_cnt = '0' then sec <= 30; -- 空闲时回到初始值 elsif tick_1s = '1' and sec > 0 then sec <= sec - 1; -- 每个 1 秒 tick 减 1 end if; end if; end process; -- 到 0 输出 timeout timeout <= '1' when en_cnt = '1' and sec = 0 else '0'; -- 整数转 BCD(十位、个位) sec_h <= std_logic_vector(to_unsigned(sec / 10, 4)); sec_l <= std_logic_vector(to_unsigned(sec mod 10, 4)); end arch;这里我用了 generic 参数 CNT_1S,而不是在代码里写死计数长度。这样仿真时把 CNT_1S 改成 20,几毫秒就能看到一次递减;综合时恢复 20_000_000,代码不用动。如果你用的是 Verilog,对应做法就是 parameter,原理一样。
需要注意 sec 这个信号我定义成 integer 而不是 std_logic_vector,因为它内部要频繁做减法和比较,用整数最直观。输出时再转成 BCD 四位向量,方便数码管模块直接译码。超时判断用的是电平方式:en_cnt 为高且 sec 为 0 时 timeout 保持为高,直到状态机回到 idle 把 en_cnt 拉低,这样锁存模块就能在下一个周期跳回初始状态。
3.3 顶层例化:把四个模块的信号接起来
有了锁存器和倒计时,顶层要做的就是例化并连线。下面给出顶层的关键部分,省去了显示和蜂鸣器的细节,重点看信号是怎么连接的:
-- 顶层模块:例化锁存器、倒计时、显示、蜂鸣器 library ieee; use ieee.std_logic_1164.all; entity top is port ( clk_50m : in std_logic; rst_n : in std_logic; start_n : in std_logic; key_n : in std_logic_vector(3 downto 0); seg : out std_logic_vector(7 downto 0); sel : out std_logic_vector(3 downto 0); beep : out std_logic ); end top; architecture arch of top is signal key_clean : std_logic_vector(3 downto 0); signal en_cnt : std_logic; signal locked : std_logic; signal player : std_logic_vector(2 downto 0); signal foul : std_logic; signal timeout : std_logic; signal cnt_sec_h : std_logic_vector(3 downto 0); signal cnt_sec_l : std_logic_vector(3 downto 0); begin -- 按键消抖 u_debounce : entity work.debounce port map ( clk_50m => clk_50m, rst_n => rst_n, key_in => key_n, key_out => key_clean ); -- 抢答锁存状态机 u_locker : entity work.locker port map ( clk_50m => clk_50m, rst_n => rst_n, start_n => start_n, key_n => key_clean, timeout => timeout, en_cnt => en_cnt, locked => locked, player => player, foul => foul ); -- 倒计时模块 u_countdown : entity work.countdown generic map (CNT_1S => 20_000_000) port map ( clk_50m => clk_50m, rst_n => rst_n, en_cnt => en_cnt, sec_h => cnt_sec_h, sec_l => cnt_sec_l, timeout => timeout ); end arch;顶层例化最容易出的问题是信号名写错或漏连。我的习惯是每例化一个模块,就去原模块 entity 端口表核对一遍,而不是凭记忆连接。另外,timeout 信号在顶层是倒计时模块的输出、锁存器的输入,方向不能搞反,这在编译时不会报错,但仿真时就会看到状态机永远跳不回去。
4. 消抖、显示与蜂鸣器:把周边模块补完整
4.1 按键消抖:不消抖的抢答器就是一台随机数生成器
如果说状态机是大脑,消抖就是感官。机械按键在按下和释放的瞬间会产生约 5~20 ms 的抖动,如果不处理,一次按下会被状态机识别成多次,抢答结果完全随机。消抖的常见做法有两种:一种是延时采样法,检测到电平变化后等 10~20 ms 再读一次;另一种是移位寄存器稳定法,连续多次采样一致才认为有效。我推荐第二种,因为它在可综合的前提下逻辑最简单。
这里给出一个 20 ms 稳定窗口的消抖模块:
-- 按键消抖:连续稳定约 20 ms 才更新输出 library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity debounce is port ( clk_50m : in std_logic; rst_n : in std_logic; key_in : in std_logic_vector(3 downto 0); -- 原始按键,低有效 key_out : out std_logic_vector(3 downto 0) -- 消抖后按键,低有效 ); end debounce; architecture arch of debounce is signal dff1 : std_logic_vector(3 downto 0); signal dff2 : std_logic_vector(3 downto 0); signal cnt : std_logic_vector(19 downto 0); -- 2^19 / 50MHz 约 10 ms begin process (clk_50m, rst_n) begin if rst_n = '0' then dff1 <= (others => '1'); dff2 <= (others => '1'); cnt <= (others => '0'); key_out <= (others => '1'); elsif rising_edge(clk_50m) then dff1 <= key_in; -- 第一级同步 dff2 <= dff1; -- 第二级同步,用于边沿检测 if dff2 /= dff1 then cnt <= (others => '0'); -- 检测到变化,重新计时 elsif cnt(19) = '0' then cnt <= cnt + 1; -- 输入稳定,累加计时 else key_out <= dff2; -- 稳定超时,更新输出 end if; end if; end process; end arch;这个模块的原理是用两级触发器把异步按键同步到时钟域,再通过比较 dff1 和 dff2 检测变化,计数器负责计时。cnt(19) 在 50 MHz 下对应约 10 ms,如果你想要更稳妥的 20 ms 窗口,把 cnt 扩到 20 位即可。它有一个优点值得一提:对四路按键同时处理,不占额外状态,直接输出 key_clean 供锁存器使用。
使用时有几个注意点。cnt 清零条件是 dff2 与 dff1 不一致,所以只要按键还在抖动,计数器就会反复清零,输出不会提前更新。当按键一直按住不放,计数器溢出一次后 key_out 更新为按下值,之后不再变化,这是符合预期的。复位后 key_out 默认为全 1,也就是没有按键,这能保证上电后状态机稳定在 idle,不会误判犯规。
4.2 数码管动态扫描:为什么不能分成多个进程分别驱动
数码管的驱动方式几乎都是动态扫描。四位数码管共用同一组段码线,靠位选信号轮流点亮,只要刷新频率高于 60 Hz,人眼看到的就是稳定的数字。扫描频率我一般取 1 kHz 左右,每一位点亮 1 ms,轮流刷新 4 位。频率太低会闪烁,太高会因为位选切换时的拖影导致亮度不均。
先给一张共阴数码管的七段码表,后面译码函数直接查表:
| 字符 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | P | F | 熄灭 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seg[7:0] | 0x3F | 0x06 | 0x5B | 0x4F | 0x66 | 0x6D | 0x7D | 0x07 | 0x7F | 0x6F | 0x73 | 0x71 | 0x00 |
如果板子是共阳数码管,把表中每个字节按位取反就行。这里 P 和 F 分别用于显示“选手号”和“犯规”,P 的段码是 a、b、e、f、g 亮,显示效果是字母 P;F 是 a、e、f、g 亮,一眼能分辨出犯规状态。
显示模块的扫描骨架我写成下面这样,在 50 MHz 下用一个计数器分频得到约 1 ms 的扫描周期:
-- 数码管动态扫描:4 位轮流点亮 process (clk_50m, rst_n) variable scan_cnt : integer range 0 to 49999; variable scan_idx : integer range 0 to 3; begin if rst_n = '0' then scan_cnt := 0; scan_idx := 0; seg <= (others => '0'); sel <= (others => '1'); elsif rising_edge(clk_50m) then if scan_cnt = 49999 then scan_cnt := 0; case scan_idx is when 0 => sel <= "1110"; seg <= bcd_to_seg(cnt_bcd0); when 1 => sel <= "1101"; seg <= bcd_to_seg(cnt_bcd1); when 2 => sel <= "1011"; seg <= bcd_to_seg(player_bcd); when 3 => sel <= "0111"; seg <= bcd_to_seg(foul_bcd); end case; scan_idx := (scan_idx + 1) mod 4; else scan_cnt := scan_cnt + 1; end if; end if; end process;注意这里 bcd_to_seg 是一个查表函数,实现就是 case 语句返回对应的 8 位段码。代码里的 cnt_bcd0、cnt_bcd1、player_bcd 需要在顶层根据 locked、foul 和 sec_h、sec_l 组合产生。这个组合逻辑我在顶层里用一个普通进程完成,原则是:空闲时只显示倒计时初始值 30;锁存时显示选手号加剩余时间;犯规时显示 F 加选手编号。
一个新手常犯的错误是把四位数码管的段码输出分成多个进程分别驱动,这在综合时会因为多驱动源报错,或者产生不可预知的竞争。动态扫描的本质是分时复用,同一时刻只有一个位选有效,所以段码和位选必须是同一个进程配合产生,绝不能分开。
4.3 蜂鸣器提示:有源和无源驱动方式完全不同
蜂鸣器部分我的建议是先用一句话确认你的板子类型:有源蜂鸣器内部带振荡电路,给高电平就响;无源蜂鸣器需要外部提供方波才能发声。课程设计绝大多数板子用的是有源蜂鸣器,下面代码就是按有源设计的。如果你是无源蜂鸣器,把最后输出的高电平换成 2 kHz 左右的方波信号,代码量会再增加一个分频器。
蜂鸣器控制逻辑分两类场景:抢答成功或犯规需要短促的提示音,大约 200 ms;倒计时超时需要持续长鸣,直到系统复位。我把它做成独立模块,输入一个触发信号和一个持续信号:
-- 蜂鸣器控制:trig 上升沿触发 200ms 提示音,buzz 持续拉响 library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity beep_ctrl is port ( clk_50m : in std_logic; rst_n : in std_logic; trig : in std_logic; -- 单次触发信号,抢答或犯规 buzz : in std_logic; -- 持续提醒信号,超时 beep : out std_logic ); end beep_ctrl; architecture arch of beep_ctrl is signal cnt : integer range 0 to 10_000_000; -- 200 ms 计数值 signal beep_t : std_logic; begin process (clk_50m, rst_n) begin if rst_n = '0' then cnt <= 0; beep_t <= '0'; elsif rising_edge(clk_50m) then if trig = '1' then cnt <= 10_000_000; beep_t <= '1'; -- 触发加载 elsif cnt > 0 then cnt <= cnt - 1; -- 递减期间保持响 else beep_t <= '0'; end if; end if; end process; beep <= beep_t or buzz; -- 提示音或持续音,任一有效就响 end arch;触发信号的来源要注意,trig 必须是从抢答状态进入锁存状态那一刻的脉冲。如果在状态机上直接取 locked 的电平信号,蜂鸣器会一直响到复位,体验很差。我一般在顶层用一个打拍的方式把电平转换成上升沿脉冲:先寄存一拍 locked 信号,再用当前值和上一拍的值组合取边沿,也就是让 trig 等于 locked 为高且上一拍为低的那个时钟周期。
蜂鸣器这个模块写起来不难,但它最容易暴露顶层信号设计的问题。比如犯规时如果错误地把 en_cnt 拉高,蜂鸣器提示音刚响起倒计时也跟着跑,这种问题在仿真里一眼就能发现,但前提是你在 Testbench 里覆盖了犯规场景。这就引出了第五章的内容。
5. 仿真验证与常见问题排查:上板之前的最后一次机会
5.1 Testbench 怎么写:覆盖正常抢答、犯规、超时三条路径
我见过不少同学把代码写完直接下载板子,灯一亮就说“好了”。事实上抢答器至少有三条路径需要验证:正常抢答、犯规、超时。每一条路径对应的状态转移都不一样,靠肉眼观察板子不仅慢,还可能漏掉时序问题。仿真能让你在几分钟内看到完整的状态跳转波形,这是上板前成本最低的验证手段。
下面是一个完整的 Testbench 骨架,覆盖了犯规和正常抢答两个场景:
-- 顶层 Testbench:覆盖犯规与正常抢答 library ieee; use ieee.std_logic_1164.all; entity tb_top is end tb_top; architecture sim of tb_top is signal clk_50m : std_logic := '0'; signal rst_n : std_logic := '0'; signal start_n : std_logic := '1'; signal key_n : std_logic_vector(3 downto 0) := (others => '1'); signal seg : std_logic_vector(7 downto 0); signal sel : std_logic_vector(3 downto 0); signal beep : std_logic; constant HALF_PERIOD : time := 10 ns; -- 50 MHz begin clk_50m <= not clk_50m after HALF_PERIOD; dut : entity work.top port map ( clk_50m => clk_50m, rst_n => rst_n, start_n => start_n, key_n => key_n, seg => seg, sel => sel, beep => beep ); process begin -- 复位 rst_n <= '0'; wait for 100 ns; rst_n <= '1'; -- 场景一:开始前有人按键,判犯规 key_n <= "1110"; wait for 1 ms; -- 超过消抖窗口 key_n <= "1111"; wait for 200 us; -- 复位,进入下一轮 rst_n <= '0'; wait for 100 ns; rst_n <= '1'; -- 场景二:主持人开始后第 3 路抢答 start_n <= '0'; wait for 100 us; start_n <= '1'; key_n <= "1011"; wait for 1 ms; key_n <= "1111"; wait for 200 ms; wait; end process; end sim;这个 Testbench 的等待时间依赖 CNT_1S。如果倒计时还是 20,000,000,场景二之后等 200 ms 根本看不到倒计时变化。所以仿真前记得把顶层里 countdown 的 generic 改小,比如 CNT_1S => 20,然后场景二里 wait for 200 ms 就能看到几十秒的递减过程。这也是我强调用 generic 而不是写死常量的原因。
波形检查的顺序我建议是:先看 en_cnt,它应该在你按下 start_n 后拉高,抢答成功后继续保持,超时后回到低。再看 player 和 locked,抢答键按下后应该在消抖结束的下一拍更新。最后看 timeout,它在 sec 减到 0 时应该产生电平。如果这三组信号都对了,显示和蜂鸣器大概率也不会出问题。
5.2 排查清单:五条实战踩坑记录
第一条,综合通过但上板没反应。现象是程序下载成功,按任何键都没现象。原因是引脚约束没做或极性反了,最常见的是把按键和数码管引脚约束到了别的引脚上。解决办法是检查工程里的引脚分配文件,对照开发板原理图逐个核对,特别要注意按键是否为低有效。
第二条,按键消抖没生效,抢答结果时对时错。现象是快速连续按同一个键,偶尔判成别的选手。原因是消抖模块的计数窗口太短,或者按键信号没有先经过两级触发器同步。解决办法是把消抖窗口加大到 15~20 ms,并确保 dff1、dff2 两级同步在消抖模块内部完成。
第三条,倒计时启动后马上变成 0。现象是主持人按下开始,数码管秒数从 30 瞬间跳成 0。原因是 en_cnt 信号没有送到倒计时模块,或者 sec 在空闲时被清零而没有预置 30。解决办法是检查顶层 countdown 例化的 en_cnt 连接,并用仿真看 sec 的初始值和递减时序。
第四条,数码管显示乱码或闪得厉害。现象是数字能变但笔画乱跳。原因通常是动态扫描的位选和段码不在同一个进程,导致切换瞬间输出错误,或者扫描频率低于 60 Hz。解决办法是把位选和段码放进同一个进程,扫描周期控制在 1 ms 左右,并在切换时先关闭位选再送段码,避免拖影。
第五条,犯规后蜂鸣器一直长鸣不止。现象是犯规提示音响了不停止。原因是犯规状态下 buzz 信号被持续拉高,或者 trig 误接成了电平。解决办法是确认超时信号只在倒计时为 0 时有效,而不是在整个 answered 状态都有效,同时把 trig 处理成上升沿脉冲。
这五条是我自己反复遇到过的问题,也是帮别人排查时见得最多的五类。如果你照着做发现还有新情况,优先加仿真信号观察,别在板子上靠猜测调。
6. 上板调试的链路法:从 LED 到蜂鸣器逐级点亮
板子下载成功不等于设计完成。我调试抢答器时习惯用链路法,从最简单的模块开始逐级验证,每验证一级就把前面的模块排除掉,最后只剩一个待查对象。这个习惯帮我省了大量时间。
第一步,先把四路抢答按键的输出接到开发板的四个 LED 上,不接任何逻辑,直接看按键按下时 LED 是否对应点亮。这一步验证的是按键引脚约束和直流电平,如果这都不亮,后面的代码都不用看。第二步,把消抖模块的输出接到 LED,连续快速按键,观察 LED 是否出现闪烁误触发,验证消抖窗口是否有效。第三步,把倒计时的秒脉冲接到 LED,用手表对照闪烁频率是否约 1 秒一次,确认分频正确。第四步,让数码管先显示固定数字,验证段码和位选,再接入倒计时值。第五步才接蜂鸣器,验证提示音和长鸣。
这个顺序的核心思想是“一次只验证一件事”。很多人在上板阶段浪费的每一分钟,几乎都是因为同时怀疑了五个模块,结果改了这个又动了那个,最后连问题在哪都不清楚。链路法把不确定范围逐级缩小,真正出问题的时候,你只需要检查当前这一级的输入输出关系。
我还留了一个习惯:每次修改代码后,先用仿真跑一遍对应场景,再下载上板验证。仿真的速度永远比板子快,而且你能看到任何你想看的内部信号。如果哪天你发现板子表现和仿真完全对不上,先别急着怀疑仿真工具,回头检查综合后的引脚约束和时钟连接,八成是约束层面的问题。希望这些经验能帮到你少走几趟弯路。
本文还有配套的精品资源,点击获取