FPGA实现会议发言限时器:从需求分析到Verilog代码全解析
2026/9/7 7:31:47 网站建设 项目流程

简介:完整且可直接参考的会议发言限时器设计资料,基于FPGA平台,面向FPGA开发初学者和数字逻辑课程设计人员。资源包共1个docx文档,压缩后约7.54MB,内容涵盖设计任务书、技术指标、方案框图、VHDL核心代码说明、电路原理图和操作流程。设计实现了0到99分钟限时设置、四位数码管实时显示、暂停与继续控制、最后1分钟与结束时蜂鸣器提醒、LED状态指示等功能;并针对按键抖动、分频计时、报警音调切换等关键问题,给出了计时与显示、按键消抖、蜂鸣器驱动、LED显示等子模块的详细实现思路。阅读后可系统掌握基于FPGA的精确计时控制方法,以及数码管、按键、蜂鸣器、LED等外设的硬件连接要点。目前已有319人学习浏览这一资料,可用于课程设计、电子竞赛或嵌入式开发入门参考。 会议发言限时器,第一反应就是用单片机。我最初也这么想,直到认真梳理了需求,才意识到这个看似简单的设备,用FPGA来做反而更贴合它的本质。一个限时器,核心价值不在于算力,而在于计时的绝对可靠和响应的零延迟——这两个词,恰好是FPGA的看家本领。

这篇博文会把整个设计思路、模块划分、关键代码怎么写、以及踩过的坑,完整拆开来讲。如果你正在学FPGA,但苦于找不到一个有明确应用场景、又不会复杂到劝退的项目,这篇很适合你;如果你打算用MCU方案却想对比一下技术选型,这里也有我的一些思考。一个真的能上桌开会用的设备,比流水灯有意思多了。

1. 为什么发言限时器用FPGA而不是单片机:一次需求倒逼的选型

做技术方案最忌讳的就是先入为主。接到会议发言限时器这个需求时,市面上90%的现成产品确实是单片机方案,STC、STM32都很常见。但我不想要“人人都在用所以我也用”的答案,而是先把需求拆成了硬性指标。

第一个指标是上电即用。会议场景里,设备不允许出现“开机白屏三秒钟”、“系统启动中”这种状态。主持人按下开关,面板就要立刻亮起来进入待机状态。MCU方案绕不开内部晶振起振、Bootloader跳转、外设初始化这套流程,哪怕优化到极致也有几十毫秒的盲区。FPGA是纯硬件逻辑,配置完成后立即工作,没有软件启动过程,这是天然的优势。

第二个指标是计时不允许“卡顿”或“漂移”。我用过不少MCU做的定时器,主程序里一旦插入LCD刷新、按键扫描、蜂鸣器驱动这些任务,中断响应就会产生微秒级的抖动。累积几次虽然不影响家用,但会议场合对着几十双眼睛,每秒钟的跳动都必须干脆利落。FPGA里每个模块都是独立的硬件电路,计数器的递增只受晶振控制,CPU负载、任务调度这些概念根本不存在。

想明白这两点,“用谁做”的答案就清楚了。两者的本质差别在于执行模型:CPU是取指令→执行指令的串行模型,FPGA是电路与电路并行工作的硬件模型。限时器这种“一秒钟做一件事,然后刷新显示、检查状态”的固定流程,在FPGA里就是几个模块各干各的,互不干扰,可靠性从根上就不一样。

2. 系统架构拆解:一块FPGA怎么同时管住计时、显示和按键

开讲具体模块之前,我先把整个板子的系统拓扑画出来,这样读者对“什么东西挂在哪儿”有个整体认识,后面看代码才不会迷路。

2.1 硬件组成与接口分配

我用的主控是Xilinx Artix-7系列的XC7A35T,晶振用的是板上常见的50MHz有源晶振。选这颗芯片的原因很简单:开发板好买、Vivado支持完善、逻辑资源对这个小项目来说绰绰有余。按键选了4个独立按键,分别是“开始/暂停”、“复位”、“分+”、“秒+”,没有用矩阵键盘,因为会议室里没人需要坐那儿弹钢琴,4个键足够覆盖所有操作。

显示部分用了6位数码管,前两位显示分钟,后两位显示秒,中间两位显示剩余秒数的十位和个位。这种安排不需要额外译码器芯片,直接由FPGA的IO口驱动段选和位选,共阴极接法。提醒机制是板上自带的蜂鸣器,通过三极管驱动,FPGA输出一个高电平就能响。

规划IO时要注意一点:数码管的段选信号必须加限流电阻,否则高电平时的电流会拉垮整个板子的电平,严重时直接导致逻辑误判。我第一版没加,结果发现数字一多,其他按键就开始失灵,查了半天才发现是电源轨被拉塌了。

2.2 FPGA内部模块划分

FPGA工程的灵魂是模块划分。模块划得好,后面Debug的心情就完全不同。我按照“高内聚低耦合”的原则,把整个系统拆成了5个功能模块。

  • 分频模块(clk_div):50MHz时钟分频得到1kHz扫描时钟和1Hz计时基准时钟。
  • 按键消抖与检测模块(key_debounce):消除机械抖动,输出稳定的单周期按键脉冲。
  • 计时控制模块(timer_ctrl):系统核心,负责运行状态机、计数、预置时间、到时标志产生。
  • 动态扫描显示模块(display_driver):将计时值转换为段码,通过动态扫描刷新数码管。
  • 蜂鸣器提示模块(buzzer):到时后生成特定频率的驱动信号,维持一段时间后自动关闭。

这5个模块的关系用一句话概括:控制模块是老板,显示和蜂鸣器是打工的,按键是外部来电,分频模块是那个一直提供稳定电源的人。模块之间通过明确的信号线连接,绝不允许出现模块内部信号跨层引出的情况,这样每个模块都能单独测试,后期的可维护性好太多。

2.3 按键处理方案对比:为什么选了状态机消抖

按键消抖是FPGA初学者第一个容易翻车的地方。机械按键在按下和松开的瞬间,会产生大约5~20ms的电平抖动,如果不处理,一次按键会被识别成好几次。

常见的方案有两种。第一种是延时计数消抖:检测到电平变化后,启动一个20ms左右的计数器,等计数器结束再读一次电平,确认稳定才认为按键有效。优点是逻辑简单,缺点是需要占用一个计时器,且在计时期间如果有第二次按键会被漏掉。

第二种是状态机消抖:把按键抖动看成一组状态流转,在空闲态、按下态、确认态之间迁移,只有电平稳定超过设定时间才输出有效的按键脉冲。我用的是第二种方案,因为它可以同时完成消抖和一个按键脉冲只输出一次这两个任务。会议场景里,主持人按“开始”后马上又要按“暂停”,中间留给软件的时间窗口很短,状态机方案处理这种快速连续操作更从容。

// 按键消抖状态机核心代码片段 localparam IDLE = 2'b00; localparam PRESS_DETECT = 2'b01; localparam CONFIRM = 2'b10; reg [1:0] state; reg [15:0] cnt; // 20ms计数器 always @(posedge clk_1k) begin case (state) IDLE: begin if (key_in == 1'b0) begin state <= PRESS_DETECT; cnt <= 0; end end PRESS_DETECT: begin if (key_in == 1'b0) begin cnt <= cnt + 1; if (cnt >= 20) begin state <= CONFIRM; key_pulse <= 1'b1; // 输出一次有效脉冲 end end else begin state <= IDLE; // 抖动,回到空闲 cnt <= 0; end end CONFIRM: begin key_pulse <= 1'b0; if (key_in == 1'b1) state <= IDLE; end endcase end

3. 计时模块的精准之道:同时处理“预置时间”和“倒计时”两套逻辑

会议其实有两种使用习惯:一种是提前设定好每个人的发言时长(比如每人5分钟),另一种是不预设总时长、中途随时暂停。这台限时器两者都要支持,这部分的逻辑设计是整个工程里含金量最高的一段。

3.1 为什么计时基准用1Hz而不是直接用50MHz

FPGA内部时钟是50MHz,计数器递减一次是20纳秒。理论上可以直接用50MHz来做倒计时,但问题在于,50MHz计数器的计数范围太大——1秒钟要数50000000次,这个数字用二进制表示需要26位,一旦显示模块需要读计数值,26根线的同步问题会带来一大堆跨时钟域处理。

所以我在分频模块里先做了一级分频,从50MHz分出1kHz的中间时钟,再从1kHz二分频出1Hz的基准时钟。用1Hz基准的好处是:计时状态机每秒钟只翻转一次,内部计数器的位宽只需要4位二进制(0~9循环),显示逻辑根本不需要关心时钟域同步的问题,代码写起来非常干净。

1kHz时钟用于按键消抖和数码管动态扫描,这个频率也是经过计算的。6个数码管动态刷新率做到每管约166Hz(1kHz/6),此时肉眼看不出闪烁,而如果扫描频率低于80Hz,就会明显看到数字在抖。

3.2 倒计时状态机设计:三种状态之间的转换

计时模块的核心是一个状态机,包含三个状态:空闲IDLE、运行RUN、暂停PAUSE。说起来简单,但每个状态之间的边界条件必须判清楚,否则就会出现“暂停之后按复位结果还在走”这种鬼畜问题。

  • 空闲状态:数码管显示预置时间(比如05:00)。此时按“分+”“秒+”可以调整预置值,调整范围是00:01到99:59,超出就不再增加,这样避免主持人误操作把时间调到负值。
  • 运行状态:每秒减1,直到00:00进入到时状态,停止倒计时并拉高蜂鸣器标志。运行状态下“分+”“秒+”被禁用,防止会议中途有人手滑改了时间。
  • 暂停状态:保持当前剩余时间显示,蜂鸣器不工作。按“开始”继续运行,按“复位”回到空闲状态并恢复预置值。

这里有一个容易被忽视的细节:暂停状态下按“复位”,必须把倒计时值恢复到当初预置的值,而不是清零。会议场景里,主持人可能会临时喊停说“下一个环节还有两分钟,给你重新开始的机会”,此时一键回到初始时间比清零重设方便得多。

3.3 倒计时技术实现的核心技巧:BCD码处理

因为要直接驱动数码管显示,倒计时的时间值我用BCD码存储,每一位显示数字对应一个4位二进制数。比如05:00,就存成4个4位数:0、5、0、0。每秒减1的操作,直接在这4位BCD码上做借位运算,而不是把整个倒计时数值先转成二进制、减完再转回BCD——那样反而麻烦。

// 秒个位减1,到0后借位给秒十位 always @(posedge clk_1hz) begin if (state == RUN) begin if (sec_unit == 0) begin sec_unit <= 9; // 借位 if (sec_dec == 0) begin sec_dec <= 5; if (min_unit == 0) begin min_unit <= 9; if (min_dec > 0) min_dec <= min_dec - 1; else; // 已经到00:00,后续由状态机处理 end else min_unit <= min_unit - 1; end else sec_dec <= sec_dec - 1; end else sec_unit <= sec_unit - 1; end end

看到这个嵌套的if-else,有经验的读者可能会问:组合逻辑嵌套这么深,时序会不会出问题?我在设计时把“是否减1”的判断条件全部放到状态机里先处理好,这个模块只做“被允许减1时执行递减”,实际综合出来的电路是纯时序逻辑,路径上只有4个4位比较器和加法器,在50MHz下跑得相当轻松。如果你在代码里写上“如果满足了A条件再判断B,满足B再判断C,然后还要求一个周期内完成”——那段逻辑最好拆开来写到不同的always块里,或者直接用状态机分段处理,别硬刚一条大组合逻辑路径。

4. 显示驱动与提醒机制:1kHz扫描节奏下的视觉稳定

数字电路的显示有个天然矛盾:FPGA引脚有限,不可能给6个数码管每个都配8个段选IO。动态扫描就是解决这个矛盾的标准方案:同一时刻只点亮一位数码管,轮流点亮6位,利用人眼视觉暂留,看起来就像6位同时显示一样。

4.1 动态扫描的刷新时序设计

我用1kHz作为扫描基准时钟,状态机在0~5之间循环,每一位显示对应位选信号拉低(共阴极),同时段选信号输出该位对应的段码。每一位的占空比是1/6,整个周期的刷新率是166Hz,高于人眼能感知的临界频率,所以看起来是稳定的静态显示。

实际做的时候需要注意一个视觉细节:切换显示位的瞬间,一定要先把段选全部熄灭,再切换位选,否则会看到前一位的残影。这是因为段选和位选信号到达数码管的时间有细微差异,如果同时切换,上一位的段选信号会在新的一位上残留几个微秒,在快速扫描时表现为数字边缘出现浅淡的虚影。

处理办法是在每个扫描周期内加一个极短的消隐窗口:

always @(posedge clk_1k) begin seg <= 8'b00000000; // 先熄灭 case (scan_cnt) 0: begin seg <= seg_data[0]; sel <= 6'b111110; // 点亮第0位 end // ... 其余位类似 endcase end

4.2 蜂鸣器提醒:不是简单拉高电平就完事

蜂鸣器提醒看似最简单,实际设计时也要讲点技巧。如果直接把蜂鸣器引脚拉高,它只会发出一声刺耳的“滴——”长鸣,在安静的会议室里反而显得不专业,而且会掩盖主持人说话的声音。我选择用1kHz方波驱动蜂鸣器,产生一个断续的“滴滴滴”提示音,响3秒后自动停止。

连续响3秒而非一直响,是考虑到了会议主持人的回应用户流程:听到蜂鸣器响起后,他需要大约1~2秒的时间反应过来并按下暂停键,如果蜂鸣器一直响,就会盖住当前发言人的最后一句话。响3秒,既足够引起注意,又不会干扰后续对话。

驱动信号的产生逻辑也很简单:用1kHz时钟分频出3Hz的开关信号(响0.5秒、停0.5秒),再用这个开关信号门控1kHz方波输出。这里用到的门控原理是数字电路里非常常用的技巧,本质上就是一个与门:

assign buzzer_out = (timer_done && beep_gate) ? clk_1k : 1'b0;

5. 完整代码实现:Verilog HDL就是从这些细节里长出来的

纸上谈兵了这么多,这部分把核心代码的关键段落拆出来,配合仿真结果讲清楚每个模块怎么拼成完整的工程。限于篇幅,这里不会贴完整代码,但会突出重点模块的接口设计和状态机逻辑,让有基础的读者能照着思路自己把工程搭出来。

5.1 顶层模块:像项目主管一样把“人”派到位

顶层模块的作用就是实例化所有子模块,并做信号连接。它不该有具体的业务逻辑,只负责“告诉某个模块你去管哪几根线”。我把所有子模块例化在顶层里,只用wire信号连接,整个代码看起来一目了然。

module top_limit_timer( input wire clk_50m, input wire rst_n, input wire key_start, // 开始/暂停 input wire key_reset, // 复位 input wire key_min, // 分+ input wire key_sec, // 秒+ output wire [5:0] sel, // 位选 output wire [7:0] seg, // 段选 output wire buzzer // 蜂鸣器 ); wire clk_1k, clk_1hz; wire key_pulse_start, key_pulse_reset, key_pulse_min, key_pulse_sec; wire [3:0] min_dec, min_unit, sec_dec, sec_unit; reg [3:0] disp_min_dec, disp_min_unit, disp_sec_dec, disp_sec_unit; reg timer_done; clk_div u_clk_div( .clk_50m(clk_50m), .rst_n(rst_n), .clk_1k(clk_1k), .clk_1hz(clk_1hz) ); key_debounce u_key_start( .clk_1k(clk_1k), .rst_n(rst_n), .key_in(key_start), .key_pulse(key_pulse_start) ); // 其余按键类似,省略 timer_ctrl u_timer_ctrl( .clk_1hz(clk_1hz), .rst_n(rst_n), .key_start_pulse(key_pulse_start), .key_reset_pulse(key_pulse_reset), .key_min_pulse(key_pulse_min), .key_sec_pulse(key_pulse_sec), .min_dec(min_dec), .min_unit(min_unit), .sec_dec(sec_dec), .sec_unit(sec_unit), .timer_done(timer_done) ); display_driver u_display( .clk_1k(clk_1k), .rst_n(rst_n), .disp_min_dec(min_dec), .disp_min_unit(min_unit), .disp_sec_dec(sec_dec), .disp_sec_unit(sec_unit), .sel(sel), .seg(seg) ); buzzer u_buzzer( .clk_1k(clk_1k), .rst_n(rst_n), .timer_done(timer_done), .buzzer(buzzer) ); endmodule

5.2 计时控制模块:把行为级描述提升到状态级设计

计时控制模块的接口设计我有一点心得:输入全部是“单周期脉冲”而非电平。脉冲代表“这一刻发生了某个事件”,电平代表“这一刻处于某种状态”。用脉冲来做条件判断,状态机的迁移会非常清晰,避免出现“按键已经松开了,状态机还在误判”的情况。

按键消抖模块输出的是单周期脉冲,正好匹配这个约定。在按键刚被按下的那个时钟沿,状态机才往前跳一步,这样无论按键按多久,都只会触发一次操作。你可以把脉冲理解成敲门声,电平理解成一直按着门铃不放——会议主持人按一下“开始”,他显然不希望因为自己按得久了点,设备就理解成了“开始、暂停、开始、暂停”连续切换四次。

always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin state <= IDLE; min_dec <= 0; min_unit <= 5; sec_dec <= 0; sec_unit <= 0; end else begin case (state) IDLE: begin if (key_min_pulse) begin if (min_dec == 9) begin min_dec <= 0; end else min_dec <= min_dec + 1; end if (key_sec_pulse) begin if (sec_dec == 5) begin sec_dec <= 0; end else sec_dec <= sec_dec + 1; end if (key_start_pulse) state <= RUN; end RUN: begin if (key_start_pulse) state <= PAUSE; else if (timer_done) begin state <= DONE; // 实际设计中可跳过,此处示意 end else begin // 执行减1操作,逻辑见3.3节 end end PAUSE: begin if (key_start_pulse) state <= RUN; if (key_reset_pulse) begin state <= IDLE; min_dec <= preset_min_dec; // 恢复预置 min_unit <= preset_min_unit; sec_dec <= preset_sec_dec; sec_unit <= preset_sec_unit; end end endcase end end

5.3 动态显示模块的一行关键代码

显示模块里有一处需要特别留意的硬件实现细节。6位数码管,每一位显示的数字可能不同,某个数字对应的段码自然也不同。动态扫描每次只点亮一位,所以每一位显示什么数字,取决于当前扫描到第几个位,这就要求把“刚才计时的分钟十位、分钟个位、秒的十位、秒的个位”这6个(实际用4个,中间两位固定显示“-”)数值,通过一个组合逻辑多路选择器选出来,再查表转成段码。

// 段码查找表:共阴极数码管,0~9对应段码 function [7:0] seg_code; input [3:0] digit; begin case (digit) 4'h0: seg_code = 8'b11000000; 4'h1: seg_code = 8'b11111001; 4'h2: seg_code = 8'b10100100; 4'h3: seg_code = 8'b10110000; 4'h4: seg_code = 8'b10011001; 4'h5: seg_code = 8'b10010010; 4'h6: seg_code = 8'b10000010; 4'h7: seg_code = 8'b11111000; 4'h8: seg_code = 8'b10000000; 4'h9: seg_code = 8'b10010000; default: seg_code = 8'b11111111; endcase end endfunction

6. 仿真与上板实测:几个让我差点摔键盘的Bug

写完代码不仿真直接上板,那是对自己时间的浪费。我在Vivado里做了行为仿真,重点测了三个场景:正常倒计时结束、中途暂停再继续、以及到0之后蜂鸣器行为和复位。仿真通过后上板实测,结果还是遇到了两个Zynq开发板上极易出现的坑,这里把排查链路完整复现一遍。

6.1 故障一:按下“开始”键,数码管数字乱跳

第一次上板,按下“开始”键后,倒计时数字不是稳稳地每秒减1,而是直接爆乱跳。用示波器抓按键引脚,波形确实有毛刺,但我明明已经在按键模块里做了消抖处理。再往深处查,发现问题出在我把消抖用的1kHz时钟直接用来采样按键电平,机械按键的抖动在1kHz采样率下依然可能被采到不稳定电平,导致单周期脉冲被拉长成多周期脉冲。

解决方式是把按键检测分为两级:第一级用1kHz时钟做同步器,把按键信号同步到FPGA时钟域;第二级再用状态机做消抖确认。跨时钟域的单bit信号,不加两级同步器就送进状态机,属于新手高频踩坑点。

// 两级同步器,消除跨时钟域亚稳态 reg key_sync1, key_sync2; always @(posedge clk_1k) begin key_sync1 <= key_in; key_sync2 <= key_sync1; end

6.2 故障二:倒计时到0,蜂鸣器不响

第二个故障是仿真一切正常,上板后蜂鸣器就是不响。排查思路先说结论:问题不出在FPGA逻辑上,而是驱动能力不够。FPGA引脚直接驱动无源蜂鸣器时,高电平输出电流一般在几毫安到十几毫安,而蜂鸣器的工作电流需要20~30mA,引脚直接带不动。

排查链路是这样走的:先用万用表量引脚电平,发现FPGA确实输出了1kHz方波;接着量蜂鸣器两端电压,发现只有0.8V——电压被拉垮了。然后在蜂鸣器和FPGA引脚之间加了一个NPN三极管(S8050)做电流放大,集电极接蜂鸣器到VCC,发射极接地,基极通过1k电阻接FPGA引脚,问题立刻解决。

这类硬件问题在纸上画框图时完全看不出来,所以我后来养成了一个习惯:任何外设引脚,只要不确定负载电流,一律加驱动级。宁可多焊一个三极管,也不要让FPGA引脚直接面对感性负载,长期超规格驱动对引脚的寿命也有影响。

6.3 加入复位信号对仿真结果的决定性影响

再分享一个仿真的经验。很多初学者写testbench时,习惯把所有输入信号一复位就正常赋值,但忽略了复位信号的时序必须满足芯片数据手册的要求。Xilinx FPGA建议复位信号低电平有效,且复位脉冲宽度必须宽于一个时钟周期。

我第一版testbench里复位只拉低了5ns,仿真结果看起来正常,但上板后第一次按复位,系统就会进入不确定状态。原因是异步复位在没有满足恢复时间的情况下被释放,寄存器可能采到亚稳态。在testbench里模拟一个向上板靠拢的异步复位场景,虽然麻烦一点,但能在仿真阶段就暴露这类时序隐患。

7. 项目做完之后的扩展思路:从这个限时器还能长出什么

这个项目做完,我觉得最大的收获不是“做出来一个东西”,而是想清楚了一个问题的闭环:一个看似简单的产品,从需求分析到选型、到编码、到仿真、到上板调试,每个环节有多少坑是文档里不会写但是必须踩的。这类小项目恰好是练基本功的好场景。

后续要做升级,可以从三个方向入手,难度递增:

  • 方向一:加入语音提示。现在只有蜂鸣器“滴滴”声,如果换成语音播报“时间到”,体验好很多。FPGA开发板上很多带音频编解码芯片,比如AN831,用I2S接口输出音频数据,可以存一段TTS生成的“叮咚”提示音,难度中等。
  • 方向二:加数据记录。把每轮发言的实际用时存储下来,会后导出做分析。方案是在FPGA里加一个UART模块,将时间戳通过串口发给上位机。FPGA内的UART,接收部分比发送部分难写,但作为练习项目非常合适。
  • 方向三:改成按键配置式。目前预置时间只能按键调整,如果接上矩阵键盘,就能输入任意初始分数和秒数。这个扩展会引入行扫描、列读取、组合键判定等新问题,对理解键盘扫描原理有直接帮助。B站上很多“FPGA矩阵键盘”的教学视频,原理大同小异,不想从零写也可以参考。

最后再说一个小技巧,做这类含状态机的FPGA项目,仿真时一定把display_driver的扫描逻辑同步到clk_1k上,而不是直接跑到50MHz。否则你会看到仿真波形里数字刷新极其密集,根本没法对着时间轴调试。这是我试过的血泪经验,供各位参考。

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

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

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

立即咨询