☰
FPGA按键消抖实战:原理、Verilog实现与仿真调试
2026/9/28 1:09:09 网站建设 项目流程

最近带几个刚接触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 <= 1'b1; key_out <= 1'b1; end else begin if (key_in != key_tmp) begin // 电平发生变化 cnt <= cnt + 1'b1; 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 = 2'd0; parameter PRESS_CONFIRM = 2'd1; parameter WAIT_RELEASE = 2'd2; parameter RELEASE_CONFIRM = 2'd3; 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 <= 1'b0; end else begin key_press <= 1'b0; // 默认不产生脉冲 case (state) IDLE: begin if (key_in == 1'b0) begin cnt <= cnt + 1'b1; if (cnt >= DELAY_CNT) begin state <= PRESS_CONFIRM; cnt <= 0; end end else begin cnt <= 0; end end PRESS_CONFIRM: begin key_press <= 1'b1; // 确认按下,输出脉冲 state <= WAIT_RELEASE; end WAIT_RELEASE: begin if (key_in == 1'b1) begin cnt <= cnt + 1'b1; 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) ? 1'b1 : (|shift_reg) ? 1'b0 : 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 <= 1'b1; end else begin shift_reg <= {shift_reg[N-2:0], key_in}; if (&shift_reg) key_out <= 1'b1; else if (|shift_reg) key_out <= 1'b0; // 其他情况保持 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 <= 1'b1; key_sync2 <= 1'b1; 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 <= 4'd0; else if (key_press) cnt <= cnt + 1'b1; end assign led = ~cnt; // LED低电平点亮 endmodule

如果消抖没生效,按一次键计数器可能直接加2甚至加3,LED显示的数值跳跃。消抖生效后,每次按键只会稳定加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. 经验总结与代码规范建议

这篇内容从抖动原理、三种消抖实现一直讲到了仿真调试和常见坑点,把我能想到的都说了一遍。最后分享几个在做按键消抖这件事上的重要结论。

状态机消抖是通用性最强、可靠性最高的方案,适合绝大多数场景。延时消抖适合初学者练手理解原理,滑动窗口滤波适合需要统一信号处理思路的项目。不管哪种方案,都必须对上板环境的按键电路有清晰认识,悬空输入、缺少上拉这类问题是代码解决不了的。

多花几分钟写好两级同步、状态机编码方式规范、计数器位宽估算,能够避免很多调试时令人头痛的麻烦。我在多次项目实践中验证,认真做好这些细节,比临时排查各种突发问题要高效得多。希望这篇内容能让你少走些弯路,写出真正可靠、能直接上板运行的按键消抖模块。

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

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

立即咨询