FPGA实现会议发言限时器:从需求分析到Verilog代码全解析

发布时间:2026/9/7 7:31:54
FPGA实现会议发言限时器:从需求分析到Verilog代码全解析 简介完整且可直接参考的会议发言限时器设计资料基于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_div50MHz时钟分频得到1kHz扫描时钟和1Hz计时基准时钟。按键消抖与检测模块key_debounce消除机械抖动输出稳定的单周期按键脉冲。计时控制模块timer_ctrl系统核心负责运行状态机、计数、预置时间、到时标志产生。动态扫描显示模块display_driver将计时值转换为段码通过动态扫描刷新数码管。蜂鸣器提示模块buzzer到时后生成特定频率的驱动信号维持一段时间后自动关闭。这5个模块的关系用一句话概括控制模块是老板显示和蜂鸣器是打工的按键是外部来电分频模块是那个一直提供稳定电源的人。模块之间通过明确的信号线连接绝不允许出现模块内部信号跨层引出的情况这样每个模块都能单独测试后期的可维护性好太多。2.3 按键处理方案对比为什么选了状态机消抖按键消抖是FPGA初学者第一个容易翻车的地方。机械按键在按下和松开的瞬间会产生大约5~20ms的电平抖动如果不处理一次按键会被识别成好几次。常见的方案有两种。第一种是延时计数消抖检测到电平变化后启动一个20ms左右的计数器等计数器结束再读一次电平确认稳定才认为按键有效。优点是逻辑简单缺点是需要占用一个计时器且在计时期间如果有第二次按键会被漏掉。第二种是状态机消抖把按键抖动看成一组状态流转在空闲态、按下态、确认态之间迁移只有电平稳定超过设定时间才输出有效的按键脉冲。我用的是第二种方案因为它可以同时完成消抖和一个按键脉冲只输出一次这两个任务。会议场景里主持人按“开始”后马上又要按“暂停”中间留给软件的时间窗口很短状态机方案处理这种快速连续操作更从容。// 按键消抖状态机核心代码片段 localparam IDLE 2b00; localparam PRESS_DETECT 2b01; localparam CONFIRM 2b10; reg [1:0] state; reg [15:0] cnt; // 20ms计数器 always (posedge clk_1k) begin case (state) IDLE: begin if (key_in 1b0) begin state PRESS_DETECT; cnt 0; end end PRESS_DETECT: begin if (key_in 1b0) begin cnt cnt 1; if (cnt 20) begin state CONFIRM; key_pulse 1b1; // 输出一次有效脉冲 end end else begin state IDLE; // 抖动回到空闲 cnt 0; end end CONFIRM: begin key_pulse 1b0; if (key_in 1b1) state IDLE; end endcase end3. 计时模块的精准之道同时处理“预置时间”和“倒计时”两套逻辑会议其实有两种使用习惯一种是提前设定好每个人的发言时长比如每人5分钟另一种是不预设总时长、中途随时暂停。这台限时器两者都要支持这部分的逻辑设计是整个工程里含金量最高的一段。3.1 为什么计时基准用1Hz而不是直接用50MHzFPGA内部时钟是50MHz计数器递减一次是20纳秒。理论上可以直接用50MHz来做倒计时但问题在于50MHz计数器的计数范围太大——1秒钟要数50000000次这个数字用二进制表示需要26位一旦显示模块需要读计数值26根线的同步问题会带来一大堆跨时钟域处理。所以我在分频模块里先做了一级分频从50MHz分出1kHz的中间时钟再从1kHz二分频出1Hz的基准时钟。用1Hz基准的好处是计时状态机每秒钟只翻转一次内部计数器的位宽只需要4位二进制0~9循环显示逻辑根本不需要关心时钟域同步的问题代码写起来非常干净。1kHz时钟用于按键消抖和数码管动态扫描这个频率也是经过计算的。6个数码管动态刷新率做到每管约166Hz1kHz/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 8b00000000; // 先熄灭 case (scan_cnt) 0: begin seg seg_data[0]; sel 6b111110; // 点亮第0位 end // ... 其余位类似 endcase end4.2 蜂鸣器提醒不是简单拉高电平就完事蜂鸣器提醒看似最简单实际设计时也要讲点技巧。如果直接把蜂鸣器引脚拉高它只会发出一声刺耳的“滴——”长鸣在安静的会议室里反而显得不专业而且会掩盖主持人说话的声音。我选择用1kHz方波驱动蜂鸣器产生一个断续的“滴滴滴”提示音响3秒后自动停止。连续响3秒而非一直响是考虑到了会议主持人的回应用户流程听到蜂鸣器响起后他需要大约1~2秒的时间反应过来并按下暂停键如果蜂鸣器一直响就会盖住当前发言人的最后一句话。响3秒既足够引起注意又不会干扰后续对话。驱动信号的产生逻辑也很简单用1kHz时钟分频出3Hz的开关信号响0.5秒、停0.5秒再用这个开关信号门控1kHz方波输出。这里用到的门控原理是数字电路里非常常用的技巧本质上就是一个与门assign buzzer_out (timer_done beep_gate) ? clk_1k : 1b0;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) ); endmodule5.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 end5.3 动态显示模块的一行关键代码显示模块里有一处需要特别留意的硬件实现细节。6位数码管每一位显示的数字可能不同某个数字对应的段码自然也不同。动态扫描每次只点亮一位所以每一位显示什么数字取决于当前扫描到第几个位这就要求把“刚才计时的分钟十位、分钟个位、秒的十位、秒的个位”这6个实际用4个中间两位固定显示“-”数值通过一个组合逻辑多路选择器选出来再查表转成段码。// 段码查找表共阴极数码管0~9对应段码 function [7:0] seg_code; input [3:0] digit; begin case (digit) 4h0: seg_code 8b11000000; 4h1: seg_code 8b11111001; 4h2: seg_code 8b10100100; 4h3: seg_code 8b10110000; 4h4: seg_code 8b10011001; 4h5: seg_code 8b10010010; 4h6: seg_code 8b10000010; 4h7: seg_code 8b11111000; 4h8: seg_code 8b10000000; 4h9: seg_code 8b10010000; default: seg_code 8b11111111; endcase end endfunction6. 仿真与上板实测几个让我差点摔键盘的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; end6.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。否则你会看到仿真波形里数字刷新极其密集根本没法对着时间轴调试。这是我试过的血泪经验供各位参考。本文还有配套的精品资源点击获取