尧图精选

FPGA按键消抖实战:原理、Verilog实现与仿真调试

🕒 发布时间:2026/9/28 1:09:11 📁 来源:尧图网络
最近带几个刚接触FPGA的朋友做小项目发现大家几乎都会在按键输入这块卡住。明明代码看着没什么问题下载到板子上却出现按一下触发两次状态乱跳这类现象。其实问题通常不在功能逻辑而在按键本身的物理特性——抖动。这篇就把按键消抖这件事从头到尾讲透包含原理、三种常用写法、完整仿真流程以及我在实际调试中踩过的坑希望对正在入门Verilog的同学有帮助。1. 为什么按键必须消抖1.1 抖动是怎么产生的机械按键内部是靠金属簧片接触来导通电路的。按下和松开的瞬间簧片不是一步到位而是在几十微秒到十几毫秒内反复接通、断开多次就像网球落地后要弹跳几下才稳住一样。这个现象在示波器上看得非常清楚原本应该是一个干净的电平跳变实际却是密密麻麻的毛刺脉冲。如果直接把这样带毛刺的电平送给FPGA的输入引脚在时钟沿采样时可能采到高电平也可能采到低电平更可能在同一个按键动作期间采到多次跳变。对于计数器、状态机这类对边沿敏感的电路一次按键就会被误判成好几次逻辑自然就乱了。需要特别说明的是不同按键的抖动时间差异很大。常见贴片轻触开关大概在5到10毫秒老式直插按键可能到20毫秒左右劣质按键甚至更夸张。所以消抖时间的选取要留足余量我通常至少取10毫秒保守一点取20毫秒。1.2 硬件消抖与软件消抖处理抖动有两种路线。硬件上可以在按键输出端加RC低通滤波器和施密特触发器用电容吸收毛刺但会占用PCB面积、增加元器件成本而且参数一旦焊死就不好调整。更多时候我们选择在逻辑里做消抖也就是软件消抖。软件消抖的核心思路其实很简单既然抖动只持续一小段时间那我在按键电平稳定之后再采样不就行了。具体到实现有延时采样、状态机检测、连续采样判断三种主流做法。这三种方法我后面都会给出完整代码并对比它们的适用场景。注意FPGA引脚输入默认是高电平按键按下接地所以空闲时读到1按下时读到0。下面代码示例都基于这种低电平有效接法。如果你的板子是按键接高电平把所有判断反过来即可。1.3 消抖前先确认你的按键电路我遇到过不少同学写好了消抖代码还是不行最后发现是板子上的按键电路本身有问题。多数开发板的按键都配置了RC滤波比如正点原子、野火的板子硬件上已经滤掉了一部分毛刺这时候消抖代码可以适当放宽。但如果是自己画的板子没加RC纯粹靠IO直读那消抖时间和采样策略就要做得更稳健。建议拿到一块新板子先用示波器或者逻辑分析仪看一下按键按下时的实际波形确认抖动持续时间和毛刺形态。没有仪器的话写一个最简单的计数器代码把按键直接接进去数数如果按一下数字乱跳那就说明抖动必须处理。2. 三种常用消抖方法与代码实现2.1 延时消抖最直观的入门方案思路最简单检测到按键电平变化后先等20毫秒再读取一次电平如果还是有效值就确认按键真的按下了。这个方法逻辑清晰适合刚接触Verilog的同学理解消抖概念。module debounce_delay( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); parameter DELAY_CNT 20_000_000; // 20ms 50MHz reg [24:0] cnt; reg key_tmp; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; key_tmp 1b1; key_out 1b1; end else begin if (key_in ! key_tmp) begin // 电平发生变化 cnt cnt 1b1; if (cnt DELAY_CNT) begin key_tmp key_in; key_out key_in; cnt 0; end end else begin cnt 0; end end end endmodule这套方案的优点是代码量小、好理解缺点也很明显只要按键电平变了就开始计时如果抖动发生在变化后的第一个瞬间可能还没到20毫秒电平又跳回去了导致把一次按下判成两次。所以它适合对时序要求不苛刻的场景比如控制LED亮灭、蜂鸣器开关。2.2 状态机消抖稳定可靠的正统方案这个是我在实际项目里最常用的方案。核心是用一个状态机把按键过程拆成等待按下、确认按下、等待释放、确认释放四个阶段只有在每个阶段都持续检测到稳定电平后才输出一次有效脉冲。module debounce_fsm( input wire clk, input wire rst_n, input wire key_in, output reg key_press // 单次按下输出一个高电平脉冲 ); parameter IDLE 2d0; parameter PRESS_CONFIRM 2d1; parameter WAIT_RELEASE 2d2; parameter RELEASE_CONFIRM 2d3; parameter DELAY_CNT 1_000_000; // 10ms 50MHz可调 reg [1:0] state; reg [19:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cnt 0; key_press 1b0; end else begin key_press 1b0; // 默认不产生脉冲 case (state) IDLE: begin if (key_in 1b0) begin cnt cnt 1b1; if (cnt DELAY_CNT) begin state PRESS_CONFIRM; cnt 0; end end else begin cnt 0; end end PRESS_CONFIRM: begin key_press 1b1; // 确认按下输出脉冲 state WAIT_RELEASE; end WAIT_RELEASE: begin if (key_in 1b1) begin cnt cnt 1b1; if (cnt DELAY_CNT) begin state RELEASE_CONFIRM; cnt 0; end end else begin cnt 0; end end RELEASE_CONFIRM: begin state IDLE; end endcase end end endmodule这个状态机的设计思路是在IDLE状态持续检测按键为低按下并且低电平保持超过消抖时间才确认是有效按下然后立刻输出一个周期宽度的脉冲进入等待释放阶段。释放也同样需要持续检测高电平超时防止松开瞬间的毛刺造成误触发。这样处理后每次按下只会在PRESS_CONFIRM状态产生一次脉冲之后无论抖动多厉害只要没有稳定释放状态就不会回到IDLE自然也就杜绝了重复触发。这个方案在我的项目中稳定运行了很长时间包括用在旋转编码器、矩阵键盘、模式切换等场景可靠性非常高。2.3 滑动窗口滤波消抖更优雅的进阶玩法最近在一些开源项目里看到有人用滑动窗口滤波的思路做消抖本质上是连续采样N次只有当N次采样值完全一致时才判定电平有效。这种方法相当于一个简易的数字低通滤波器兼顾消抖和抗干扰。module debounce_window( parameter N 16, parameter WIDTH 4 )( input wire clk, input wire key_in, output wire key_out ); reg [N-1:0] shift_reg; always (posedge clk) begin shift_reg {shift_reg[N-2:0], key_in}; end // 所有位相同才输出 assign key_out (shift_reg) ? 1b1 : (|shift_reg) ? 1b0 : key_out; endmodule上面是一个简化版把每次采样的值送入移位寄存器然后检查N位的值是否全部一致。全部为1输出1全部为0输出0否则保持上一次的输出。窗口宽度N越大滤波越强但响应也越慢。实际使用时要注意采样时钟频率的选择。如果直接用系统时钟50MHz采样窗口16位只能覆盖320纳秒对毫秒级抖动没用。所以需要在前面再加一级分频或者计数器让采样周期在1毫秒左右N取16的话就能覆盖16毫秒的抖动。关于上面这段代码有一个细节需要修正assign语句里key_out被赋值两次这在Verilog里是不允许的。正确写法应该用wire加三元运算符或者把输出改成reg用always块实现。下面给一个修正版module debounce_window( parameter N 16, parameter WIDTH 4 )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg [N-1:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg 0; key_out 1b1; end else begin shift_reg {shift_reg[N-2:0], key_in}; if (shift_reg) key_out 1b1; else if (|shift_reg) key_out 1b0; // 其他情况保持 end end endmodule滑动窗口消抖的好处是思路统一以后处理其他传感器信号也可以复用。缺点是占用的寄存器资源随窗口宽度线性增长而且在高速时钟下需要额外设计采样控制逻辑。对于按键消抖这种低频场景状态机方案其实更节省资源、更直接滑动窗口更适合需要在复杂信号中做滤波的情况。3. 用Icarus Verilog跑仿真验证消抖效果3.1 为什么推荐Icarus Verilog刚入门FPGA的同学可能在Quartus或Vivado里写完代码非得综合、布线、下载到板子才能验证功能。这个过程耗时耗力而且一旦有问题定位起来很麻烦。其实在写逻辑阶段就应该用仿真工具先做功能验证。Icarus Verilog简称iverilog是一个开源的Verilog仿真器支持命令行操作安装简单、启动快配合GTKWave看波形完完全全能满足入门和大部分开发场景的需求。最关键的是它免费、跨平台Windows、Linux、macOS都能跑。3.2 编写testbench测试状态机消抖以状态机消抖模块为例测试思路是模拟按键按下时带随机抖动然后看模块输出是否只产生了一个脉冲。Testbench代码如下timescale 1ns / 1ps module tb_debounce_fsm; reg clk; reg rst_n; reg key_in; wire key_press; // 50MHz时钟 always #10 clk ~clk; debounce_fsm uut( .clk(clk), .rst_n(rst_n), .key_in(key_in), .key_press(key_press) ); initial begin clk 0; rst_n 0; key_in 1; #100; rst_n 1; // 模拟第一次按下按下瞬间抖动10次每次1us #100; key_in 0; // 抖动开始 #1; key_in 1; #1; key_in 0; #1; key_in 1; #1; key_in 0; // 持续稳定按下 // 等待20ms #20_000_000; // 模拟松开同样带抖动 key_in 1; #1; key_in 0; #1; key_in 1; #1; key_in 0; #1; key_in 1; // 稳定释放 #20_000_000; #50; $finish; end initial begin $dumpfile(tb_debounce.vcd); $dumpvars(0, tb_debounce_fsm); end endmodule在终端执行以下命令iverilog -o tb_debounce.vvp tb_debounce_fsm.v debounce_fsm.v vvp tb_debounce.vvp仿真结束后生成tb_debounce.vcd文件用GTKWave打开查看gtkwave tb_debounce.vcd看波形时关注三个点按键输入key_in在按下阶段有毛刺但key_press输出是否只在确认按下那一刻产生了一个高电平脉冲按下到脉冲输出之间时间差是否约等于你设定的消抖时间整个仿真过程中输出是否完全没有出现多余的脉冲。3.3 耗时优化缩短消抖时间参数在实际仿真中如果消抖时间设成20毫秒仿真器要模拟几千万个时钟周期跑起来会很慢。我在实际调试时通常会把参数临时改小比如从20毫秒改成20微秒DELAY_CNT 1000先验证逻辑正确性确认无误后再改回真实参数做最终验证。这样能大幅缩短仿真时间而且因为抖动模型只是微秒级消抖参数改小并不影响验证效果。这个技巧在复杂项目的仿真阶段非常实用建议养成习惯。3.4 两级触发器同步这里还有一个新手容易忽略的细节按键输入是异步信号直接进入FPGA逻辑会存在亚稳态风险。所谓亚稳态就是触发器在采样时如果刚好遇到输入信号变化输出可能在一段时间内徘徊在高电平和低电平之间逻辑后续取到的值就成了未知数。解决方法是先把按键信号打两拍同步再接消抖逻辑。两级触发器同步能极大降低亚稳态传递的概率是FPGA设计的基本功。在消抖模块里可以这样加reg key_sync1; reg key_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync1 1b1; key_sync2 1b1; end else begin key_sync1 key_in; key_sync2 key_sync1; end end后面所有消抖逻辑都基于key_sync2来判断这样处理才是工业级可靠的做法。很多开发板例程里会忽略这一步但对要求高的场景建议加上。4. 实战调试从仿真到上板验证4.1 按键计数实验把消抖模块接一个计数器每按一次按键计数器加1然后用LED或数码管显示计数值。这个实验能直观检验消抖效果。module key_counter( input wire clk, input wire rst_n, input wire key_in, output wire [3:0] led ); wire key_press; reg [3:0] cnt; debounce_fsm u_debounce( .clk(clk), .rst_n(rst_n), .key_in(key_in), .key_press(key_press) ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 4d0; else if (key_press) cnt cnt 1b1; end assign led ~cnt; // LED低电平点亮 endmodule如果消抖没生效按一次键计数器可能直接加2甚至加3LED显示的数值跳跃。消抖生效后每次按键只会稳定加1。我自己在调试时遇到过一种情况状态机输出的脉冲宽度是一整个时钟周期如果用posedge key_press去驱动别的模块在边界处有时会出现竞争稳妥的做法是在外部模块里统一使用系统时钟采样key_press或者把脉冲做成电平信号再处理。4.2 消抖时间参数怎么选消抖时间的选取并没有一个固定的最优值我在不同项目里用的参数差异比较大。普通轻触按键通常10毫秒足够如果按键质量一般或者用在电磁环境复杂的地方取到20毫秒如果按键需要在快速连按场景下使用比如游戏手柄方向键消抖时间要适当缩短到3到5毫秒否则快速操作会被滤掉。参数换算公式很简单DELAY_CNT 消抖时间(秒) × 时钟频率(Hz)。比如板载50MHz时钟想要10毫秒消抖就是0.01 × 50_000_000 500_000。时钟频率不同同样的代码参数跑出来的实际消抖时间完全不同换板子时记得同步改参数。4.3 综合时的存储单元告警在Quartus里综合状态机消抖模块如果出现Number of logic cells used偏大的提示通常不是代码问题而是消抖计数值位数较多导致的。用一个计数器实现消抖当消抖时间达到20毫秒时计数器位宽大致需要log2(20ms / 周期)即log2(1_000_000) ≈ 20位。这在现代FPGA里根本不算资源但如果你的工程很小这种告警比例会显得比较吓人心里有数就行。还有一点想提醒大家状态机的编码方式尽量用localparam或parameter定义状态名不要直接用裸数字。在综合工具里如果多个状态直接使用原始数字编号工具会按照用户指定的编码方式使用有时为了做无效状态优化反而会引入一些不可预测的逻辑。使用符号名代码可读性高排查问题时也能少走弯路。4.4 上板实测技巧与常见误区真正上板测试时有一些看似不起眼的点容易让人花一晚上排查。首先确认按键没有按下的时候FPGA引脚读到的确实是高电平而不是悬空状态。有些开发板为了省电阻按键输入脚没有上拉悬空时电平是随机跳动的这种情况无论消抖代码写得多完美结果都会乱套。如果板子没有上拉需要在外围电路或者引脚配置里开启内部上拉。另外调试按键功能时尽量断开其他外设的影响。我有一次调一个项目按键模块单独仿真完全正常上板后却发现偶尔误触发排查半天才发现是旁边的UART接收中断把电平干扰了和按键本身毫无关系。还有一个高频误区直接在testbench里用force或者release操作按键信号。这在仿真里可以把信号强制成某个值看起来方便但真实物理按键的毛刺特性模拟不出来效果反而不好。正确做法是像上面示例那样在时钟边沿附近随机翻转信号模拟真实的抖动过程。5. 进阶扩展矩阵键盘与多按键消抖5.1 矩阵键盘的消抖时机开发板上的按键通常是独立按键但实际项目里经常用到矩阵键盘。矩阵键盘扫描时如果每一行每一列都要处理抖动逻辑会变得很复杂。我通常的做法是在扫描层面做一次整体的消抖判断。具体思路是每隔固定时间比如5毫秒扫描一次矩阵连续两次扫描结果一致才确认为有效按键。这样相当于把矩阵键盘的消抖统一到一个扫描周期内处理比每个键单独做状态机节省资源逻辑也更清晰。5.2 多按键同时按下的处理在某些场景下需要支持多键同时按下例如Shift字母这种组合按键。这种情况下消抖和去重逻辑要放在同一层处理。我自己的做法是消抖后输出每个键的按下状态位图然后用一个状态寄存器记录当前哪些键被按下组合判断在更高层级完成。这样做的好处是消抖模块保持纯粹职责单一上层逻辑可以灵活处理各种组合场景。如果你在消抖模块里直接做按键判断后续扩展组合按键功能时就得重构代码。6. 经验总结与代码规范建议这篇内容从抖动原理、三种消抖实现一直讲到了仿真调试和常见坑点把我能想到的都说了一遍。最后分享几个在做按键消抖这件事上的重要结论。状态机消抖是通用性最强、可靠性最高的方案适合绝大多数场景。延时消抖适合初学者练手理解原理滑动窗口滤波适合需要统一信号处理思路的项目。不管哪种方案都必须对上板环境的按键电路有清晰认识悬空输入、缺少上拉这类问题是代码解决不了的。多花几分钟写好两级同步、状态机编码方式规范、计数器位宽估算能够避免很多调试时令人头痛的麻烦。我在多次项目实践中验证认真做好这些细节比临时排查各种突发问题要高效得多。希望这篇内容能让你少走些弯路写出真正可靠、能直接上板运行的按键消抖模块。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →