资讯动态

FPGA按键消抖实战:从抖动原理到Verilog实现与仿真验证

发布时间:2026/9/28 19:30:34 来源:尧图企业网站定制
刚开始学Verilog那阵子我做过一个特别典型的实验按键控制LED翻转。代码逻辑简单得不能再简单——检测按键下降沿每次检测到就翻转一次LED。下载到板子上之后指示灯直接给我表演了一段“失灵状态”按一下它可能闪两下也可能乱闪一通跟喝多了似的。问了学长人家一句话打发我“按键要消抖。”可那句“加个计数器等20ms”到底怎么写里面藏着多少坑网上教程往往说得不清不楚。这篇内容专门写给刚接触Verilog的小白围绕按键消抖这件事把抖动产生的原理、三种主流消抖思路计数器延时、状态机、滑动窗口滤波、用Icarus Verilog写testbench仿真验证以及最后综合上板时会遇见的实际问题全部串一遍。看完你不仅能写出来能用的消抖模块还能明白每一行代码到底在干嘛。这篇文章里的代码我都在教学和项目里实际跑过属于“可以直接抄、抄完能理解”的那种。1. 按键抖动为什么一个简单的按键能让你抓狂1.1 抖动是怎么产生的按键消抖这件事根源不在FPGA而在按键本身的机械结构。常见的轻触按键内部靠金属簧片接触导通你用手指按下去时簧片并不是“啪”一下稳稳接触到一起而是会发生一次迅速的碰撞和反弹。整个接触过程里触点会在“接通-断开-接通-断开”之间来回弹跳好几次。这个弹跳过程用示波器看非常直观按下瞬间输入引脚上的电平不是一条干净的下跳沿而是一串高高低低的毛刺持续几毫秒到二十几毫秒不等随后才稳定到低电平。松开时同样有一串反向的跳变。机械触点的弹跳时间主要由按键本身的物理特性决定不同厂家、不同批次的按键差异都不小老化的按键抖动更严重。你可以把它想象成开关的金属片是一根振动中的弹簧片手指按下去之后弹簧片要“抖”好几下才能安静下来。对机械世界来说是几毫秒的微小瑕疵但对工作在几十兆赫兹时钟下的FPGA来说这段时间相当于几十万甚至上百万个时钟周期足够让它误判几十次“按键被按下”。1.2 一个按键在FPGA眼里是什么样子如果不做任何处理直接把按键引脚接到FPGA这个引脚的电平信号在抖动期间会不断在高低电平之间跳变。更麻烦的是FPGA的时钟采样是异步的每一次时钟上升沿采到的电平可能是高、可能是低完全取决于那一刻触点碰巧接触成什么样。假设你在代码里写的是“检测到下降沿就认为按键被按下LED翻转一次”那一次真实按键动作里这个下降沿检测可能会被触发十几次甚至更多。体现在结果上就是LED本来只想闪一次结果闪了好几次。这也解释了为什么很多初学者觉得自己写的“按键检测”代码没问题一到板子上就翻车。1.3 FPGA和单片机处理抖动的本质区别很多单片机教程教过“延时消抖”检测到按键变化后用delay函数死等20ms再读一次电平确认。这个思路在FPGA里不能照搬因为FPGA没有delay函数也没有阻塞CPU的说法。FPGA是并行硬件所有always块都在同一个时钟节拍下有规律地运行你不可能“让整个电路暂停20ms”再去干别的。所以在Verilog里消抖的通用做法是用一个计数器去数时钟周期等计数达到某个值就代表“一段真实时间过去了”。这种做法不会阻塞其他模块因为计数器只是众多并行逻辑中的一个部分。理解了这个本质区别你就明白为什么消抖代码里到处是计数器而不是“延时10ms”这种语句了。2. 消抖思路硬件电路、软件延时与计数器到底选哪个2.1 三种思路的对比做按键消抖大体有三条路模拟电路方案、单片机软件方案、FPGA逻辑方案。对数字逻辑设计者来说真正需要掌握的是第三种但另外两种也不该完全不知道。模拟电路方案最常见的是“RC低通滤波 施密特触发器”组合。R和C的乘积决定了一阶低通的时间常数把快速弹跳的毛刺滤掉只留下缓慢变化的电平再用施密特触发器把缓变信号整形为标准数字电平。这个方案有效但要挨个调R、C参数占PCB面积而且频率响应一旦设计好就很难改灵魂不够灵活。单片机软件方案本质上是“延时 连续多次读取确认”代码写起来很自由因为MCU是串行执行延时就是等着。FPGA里没有这种自由但FPGA方案有个纯粹的优势完全在逻辑内部搞定不牺牲PCB面积、不增加物料成本参数用parameter就能调还能在不同按键之间复用同一个模块。2.2 核心逻辑等20ms不是“睡觉”是计数在Verilog里你要等20ms不是让谁来睡一觉而是把一个计数器从0累加到某个目标值。比如FPGA系统时钟是50MHz周期20ns那么20ms就是20ms除以20ns等于1,000,000个时钟周期。也就是说计数器从0数到999999这段时间刚好是20ms。你可能会问怎么知道需要计到多少公式就一个目标计数值 延时时间 / 时钟周期这个公式以后写任何定时器都用得上建议直接刻在脑子里。单次计数范围超过2^20 1048576所以这个场景下计数器位宽至少20位。2.3 消抖设计要满足的三个要求一个合格的消抖模块至少要满足三个条件第一抖动期间不能产生“按键按下”的输出脉冲否则消了个寂寞第二真正稳定的按下动作要被可靠识别不能因为参数设置太敏感而丢按键第三松开按键时的抖动同样要过滤掉很多初学者只处理按下消抖忽略松开抖动结果会出现一种很难排查的现象按一次按键状态机可能识别成了两次动作。这三个要求也是后面几种Verilog写法的验收标准。你在仿真和上板时就用这三条去逐项验证。3. 第一种实现计数器延时消抖最直观的写法3.1 计数器方案的基本思路计数器延时的消抖思路并不复杂持续监测按键电平一旦发现相邻两个时钟周期电平发生变化就把计数器清零如果电平一直保持不变就让计数器累加只有当计数器累加到预设目标值时才认为电平已经稳定更新消抖后的输出。换句话说按键信号必须“连续稳定N个时钟周期”我们才相信它真的稳定了。这个思路有个好处它对长毛刺天然免疫。如果抖动过程中某段毛刺持续时间超过单个时钟周期电平变化就会触发计数器清零那前面累计的计数就作废重新开始等待。3.2 完整代码逐段拆解直接给一个完整的计数器消抖模块我按实际工程习惯把它封装成带同步器的独立模块。module key_debounce_counter #( parameter DEBOUNCE_NS 20_000_000, // 期望消抖的时长单位ns这里默认20ms parameter CLK_PERIOD_NS 20 // 时钟周期单位ns这里默认50MHz )( input wire clk, input wire rst_n, input wire key_in, // 原始按键输入按下为低电平 output reg key_out // 消抖后的按键电平 ); // 计数器需要数的时钟周期数 localparam CNT_MAX DEBOUNCE_NS / CLK_PERIOD_NS; // 使用$clog2自动计算计数器位宽避免手算错 localparam CNT_W $clog2(CNT_MAX); // 两级同步寄存器消除跨时钟域亚稳态 reg [1:0] sync_ff; // 上一拍同步后的按键电平 reg key_sync_d; // 计数器 reg [CNT_W-1:0] cnt; wire key_sync sync_ff[1]; // 同步器把外部异步按键信号打两拍 always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff 2b11; // 复位置高默认未按下 end else begin sync_ff {sync_ff[0], key_in}; end end // 消抖计数逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync_d 1b1; cnt d0; key_out 1b1; end else begin key_sync_d key_sync; // 比较当前拍和上一拍有变化就清零重新计时 if (key_sync ! key_sync_d) begin cnt d0; end else begin if (cnt CNT_MAX - 1) begin // 持续稳定足够长时间输出跟随当前电平 key_out key_sync; cnt d0; end else begin cnt cnt 1b1; end end end end endmodule代码不长但有三个地方你必须理解透第一为什么必须加两级同步器因为按键信号对FPGA来说是异步信号它变化的时间点不一定落在时钟建立保持时间窗口内直接拿去比较和判断有概率采到亚稳态。打两拍之后绝大多数亚稳态会被解析成稳定电平这是工程上最基本的安全措施。第二key_sync_d存储的是key_sync上一拍的值。key_sync ! key_sync_d等价于检测到电平变化也就是边沿。有变化说明信号还在跳变立刻清零计数。没变化说明信号暂时稳定计数器往前走一步。第三key_out并不是在检测到按键下降沿时立刻拉低而是要等连续低电平持续了CNT_MAX个周期才拉低。这样抖动期产生的短暂低电平无论如何都不会反映到输出上。3.3 计数参数到底怎么设如果你用的是50MHz时钟CLK_PERIOD_NS设为20DEBOUNCE_NS设为20_000_000那么CNT_MAX 20_000_000 / 20 1_000_000。也就是说计数器要数到999999大概耗时20ms。如果综合工具告诉你计数器位宽不够或者你手动定义成reg [15:0] cnt那大概率会出问题。1,000,000已经超过16位能表示的范围65535计数会回绕消抖时间完全错乱。我自己见过不少刚入门的人在这里翻车代码看着很顺眼但板子上表现就是“按键灵敏度诡异有时要按好久才响应”。用$clog2自动计算位宽能少操很多心。提示这里的“20ms”不是绝对标准。消抖时长一般取10ms到50ms之间设计时看实际按键的手感。如果按键老化得厉害可以适当把参数调大。但是也别调太大否则会明显感觉按键迟钝。4. 第二种实现三段式状态机消抖4.1 为什么计数器都写完了还要学状态机计数器方案能做好基本消抖但它有一个不够优雅的地方它输出的是“稳定的电平”而不是“一次干净的按键事件”。如果你想实现“每按一次按键计数器加一”这种功能电平输出还得额外做边沿检测。而且计数器方案对“按住不放”和“松开抖动”虽然能处理但状态表达不直观代码一复杂就容易绕晕。状态机方案解决的最大痛点是把“输出一个单周期脉冲”和“等待释放”这些行为显式建模成状态代码逻辑清晰得多。对于工业级的按键控制、菜单切换、设备启停这类场景状态机消抖几乎是标准写法。4.2 四状态消抖状态机这里用三段式状态机把状态转移、计数器、输出逻辑分开写。状态分成四个IDLE闲PRESS_DETECT按下检测中PRESS_CONFIRM已确认按下RELEASE_DETECT释放检测中。module key_debounce_fsm #( parameter DEBOUNCE_NS 20_000_000, parameter CLK_PERIOD_NS 20 )( input wire clk, input wire rst_n, input wire key_in, output reg key_pulse // 消抖后输出的单周期脉冲 ); localparam CNT_MAX DEBOUNCE_NS / CLK_PERIOD_NS; localparam CNT_W $clog2(CNT_MAX); localparam IDLE 2d0; localparam PRESS_DETECT 2d1; localparam PRESS_CONFIRM 2d2; localparam RELEASE_DETECT 2d3; reg [1:0] sync_ff; wire key_sync sync_ff[1]; reg [1:0] state; reg [1:0] next_state; reg [CNT_W-1:0] cnt; // 同步器按键按下为低电平 always (posedge clk or negedge rst_n) begin if (!rst_n) sync_ff 2b11; else sync_ff {sync_ff[0], key_in}; end // 第一段状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段组合逻辑描述状态转移条件 always (*) begin next_state state; case (state) IDLE: begin if (key_sync 1b0) next_state PRESS_DETECT; end PRESS_DETECT: begin if (key_sync 1b1) next_state IDLE; else if (cnt CNT_MAX) next_state PRESS_CONFIRM; end PRESS_CONFIRM: begin if (key_sync 1b1) next_state RELEASE_DETECT; end RELEASE_DETECT: begin if (key_sync 1b0) next_state PRESS_CONFIRM; else if (cnt CNT_MAX) next_state IDLE; end default: next_state IDLE; endcase end // 第三段计数器控制 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; end else begin case (state) IDLE: cnt d0; PRESS_DETECT: begin if (key_sync 1b0) begin if (cnt CNT_MAX) cnt cnt 1b1; end else begin cnt d0; end end RELEASE_DETECT: begin if (key_sync 1b1) begin if (cnt CNT_MAX) cnt cnt 1b1; end else begin cnt d0; end end default: cnt cnt; endcase end end // 输出按键脉冲只有当“按下检测”状态里计数到达目标时输出一拍高电平 always (posedge clk or negedge rst_n) begin if (!rst_n) key_pulse 1b0; else key_pulse (state PRESS_DETECT) (cnt CNT_MAX - 1); end endmodule这段代码的核心是状态机不会在检测到低电平的瞬间就输出按键事件而是先进入PRESS_DETECT状态在这个状态里持续观察按键是否一直保持低电平。判定成立切换状态的同时输出一个单周期脉冲。之后进入PRESS_CONFIRM等待用户松手。4.3 为什么要单独设计“释放检测”状态很多初学者会写出三态状态机空闲、按下确认、等待释放。其中“等待释放”这个状态里一旦检测到高电平就立刻跳回空闲。这样做遇到一个问题松手过程也存在抖动如果按键在抖动中短暂回到了低电平状态机可能会把这个低电平误认为一次新的按下。结果是按一次键状态机输出了两三个脉冲下一个功能模块就遭殃了。我的做法是在松开后也加一个RELEASE_DETECT要求高电平也持续CNT_MAX个周期才能确认已经释放。只要过程中出现了低电平就说明还在抖动或不稳定状态马上退回PRESS_CONFIRM继续等待。这个细节是工程上排查“按键一次触发多次”的关键建议直接当成必修课。对比一下计数器方案和状态机方案计数器方案追求简单、实时输出电平状态机方案追求精确事件语义、防重复触发。实际项目里我建议默认用状态机除非你只是需要一枚简单的“按下去保持低电平”的按键。5. 进阶玩法滑动窗口滤波消抖5.1 滑动窗口是什么滑动窗口滤波是数字信号处理里的经典手段用在按键消抖上思路也很直白每个采样周期把自己看到的电平写进一个移位寄存器窗口窗口里存的是最近N次采样的结果。当窗口里的N个值全部为高就认为输入稳定为高全部为低就认为输入稳定为低否则维持上一次输出。可以把这想象成一场投票最近N个采样周期里所有人都投“高”才相信是高所有人都投“低”才相信是低。只要混进一个反对票就不切换状态。比起计数器方案只统计“连续时长”滑动窗口更强调“这一段时间内的全部采样值”能比较自然地抑制持续时间短的毛刺。5.2 一个带采样时钟的滑窗实现有人喜欢每个时钟周期都采一次样然后开一个几百位甚至百万位的寄存器窗口。但以50MHz时钟为例要滤除20ms抖动窗口得开100万个寄存器成本太高基本不现实。更合理的做法是先用一个分频器生成较低频的采样使能信号每1ms或0.1ms采一次样窗口长度控制在几十个样本以内。这样总滤波时间大约等于采样周期乘以窗口长度。下面是我实际用过的方案采样周期设为0.1ms窗口设为200个样本总滤波窗口20ms。代码里的SAMPLE_CNT用来产生采样使能信号。module key_debounce_window #( parameter CLK_PERIOD_NS 20, // 50MHz时钟周期20ns parameter SAMPLE_PERIOD_NS 100_000, // 采样周期100us parameter WINDOW_LEN 200 // 窗口长度200总滤波时间20ms )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); // 采样分频计数器和采样使能 localparam SAMPLE_CNT SAMPLE_PERIOD_NS / CLK_PERIOD_NS; reg [1:0] sync_ff; wire key_sync sync_ff[1]; reg [31:0] cnt; reg sample_en; reg [WINDOW_LEN-1:0] window; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sync_ff 2b11; end else begin sync_ff {sync_ff[0], key_in}; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt d0; sample_en 1b0; end else begin if (cnt SAMPLE_CNT - 1) begin cnt d0; sample_en 1b1; end else begin cnt cnt 1b1; sample_en 1b0; end end end // 窗口移位只有采样使能有效时才写入新样本 always (posedge clk or negedge rst_n) begin if (!rst_n) begin window {WINDOW_LEN{1b1}}; end else if (sample_en) begin window {window[WINDOW_LEN-2:0], key_sync}; end end // 输出判断窗口全高/全低才切换 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_out 1b1; end else if (sample_en) begin if (window {WINDOW_LEN{1b1}}) key_out 1b1; else if (window {WINDOW_LEN{1b0}}) key_out 1b0; end end endmodule这个方案的核心优势是参数化程度高SAMPLE_PERIOD_NS和WINDOW_LEN两个参数组合一下就能覆盖从微秒级毛刺到几十毫秒抖动的场景不需要改动逻辑结构。而且输出变化有明确的延迟上界不像计数器方案还需要额外处理“什么时候清零”的细节。5.3 滑动窗口的边界条件你得想清楚滑动窗口不是万能的前提是采样周期和窗口长度要搭配好。总滤波时间大约等于SAMPLE_PERIOD_NS * WINDOW_LEN如果这个值小于按键抖动最大持续时间中间就会漏判导致抖动期间已经误切换输出。反过来如果总滤波时间太长按键手感会变得非常迟钝按下去要等上百毫秒才有反应。还有一点窗口长度太长意味着窗口寄存器很多但200个寄存器在FPGA里根本不叫事。真正的取舍在于采样周期采样周期太短窗口要做得很大采样周期太长可能漏掉抖动中短暂的电平变化失去“观察全部样本”的意义。我的经验是采样周期取0.1ms到1ms之间针对20ms级别消抖窗口长度取几十到几百都是合理的。6. 用Icarus Verilog做仿真让波形告诉你消抖有没有生效6.1 为什么仿真这一步绝对不能省很多小白学到消抖代码照着抄完就往板子上烧结果LED乱闪然后又回来百思不得其解。真正专业的做法是先在仿真环境里把行为验证清楚再上板。代码对不对、计数器有没有清零、状态机有没有跳错看波形一眼就能明白远比在板子上抓瞎高效。Icarus Verilog命令名是iverilog是免费开源的Verilog仿真工具配合GTKWave看波形是目前最适合入门的学习组合。不需要商业Vivado/VCS迈出这一步的心理门槛低很多。6.2 模拟按键抖动的testbench仿真最关键的是构造一个带抖动的按键输入信号。下面这个testbench我用task封装了“按下释放”全过程模拟了按下期间和释放期间的多次弹跳。为了让仿真不至于跑太久我把消抖时间默认参数从20ms缩小到2ms这样计数器更快就能走完。timescale 1ns/1ps module tb_key_debounce; reg clk; reg rst_n; reg key_in; wire key_out; // 生成50MHz时钟 initial clk 0; always #10 clk ~clk; // 模拟一次带抖动的按键动作 task do_key_press; begin // 按下前先稳定一段时间 #20_000; // 按下阶段的抖动低-高-低-高-低 key_in 0; #500_000; key_in 1; #300_000; key_in 0; #600_000; key_in 1; #200_000; // 稳定按下5ms超过2ms阈值应该被识别 key_in 0; #5_000_000; // 释放阶段的抖动高-低-高 key_in 1; #400_000; key_in 0; #300_000; // 稳定释放5ms key_in 1; #5_000_000; end endtask // 生成VCD波形文件 initial begin $dumpfile(tb_key_debounce.vcd); $dumpvars(0, tb_key_debounce); end // 复位和激励 initial begin clk 0; rst_n 0; key_in 1; #200; rst_n 1; // 连续做两次按键动作 repeat (2) do_key_press; #50_000_000; $finish; end key_debounce_counter #( .DEBOUNCE_NS (2_000_000), // 仿真时缩到2ms .CLK_PERIOD_NS (20) ) uut ( .clk (clk), .rst_n (rst_n), .key_in (key_in), .key_out(key_out) ); endmodule这里的task用#延时控制信号跳变模拟的就是示波器上那种不规则的毛刺序列。注意task里的毛刺宽度是几百微秒而消抖阈值为2ms所以这些抖动不会导致key_out翻转。等到稳定低电平维持了5ms超过2ms门槛key_out才会拉低。6.3 从波形图里读出有效信息仿真跑起来之后用GTKWave打开tb_key_debounce.vcd主要观察四路信号key_in、key_sync、cnt和key_out。你会看到key_in抖动的时候cnt一直增长一点就被清零永远到不了目标值只有进入稳定低电平阶段后cnt才一路往上走走到CNT_MAX - 1时key_out跟着变低。松开按键时同理稳定高电平持续时间足够后key_out恢复高。如果发现key_out在抖动期间就翻转了那说明要么DEBOUNCE_NS设得太小导致毛刺宽度超过了计数阈值要么毛刺宽度本身已经大于消抖时间这种情况真相就是你的按键抖动特别厉害需要调大消抖参数。仿真能帮你在几秒钟内定位这类问题这就是写testbench的价值。如果你想验证状态机版本把顶层例化替换成key_debounce_fsm然后观察state和key_pulse这两个信号。重点看key_pulse是不是只在真正稳定按下后输出一个单周期脉冲以及一次完整按键过程中有没有出现第二个多余的key_pulse。7. 综合与上板那些文档里不会写的坑7.1 异步信号的亚稳忘记处理前面代码里我一直在强调两级同步器这一点怎么强调都不为过。按键相对FPGA时钟完全异步直接采样有很大概率在某个时钟沿上违反触发器的建立保持时间。如果采到亚稳态逻辑值可能在一个很短的时间内反复震荡也可能在一个cycle后稳定成错误值。两级同步器并不能100%消除亚稳态但它能把亚稳态传播到后面的概率降低到工程上可以忽略的水平。市面上很多教程给的“裸消抖代码”不带同步器不是因为它不需要而是因为作者省略了这层安全措施。如果你在板子上遇到奇怪且随机出现的误触发第一件事就检查按键输入有没有打两拍。7.2 按键极性和复位默认值别搞反绝大多数开发板上的按键按下是低电平默认通过上拉电阻保持高电平但也存在少数底板是高电平有效。这个极性问题会导致一个很隐蔽的bug复位时你把key_out设置成0如果按键实际是高电平有效那复位瞬间就相当于一个“按下事件”被输出可能一复位就触发一次误动作。解决方式其实就一条确定好你的按键极性然后让同步器复位值、计数器清零、输出默认值、状态机初始状态全部围绕同一个极性来设置。代码里默认按键按低为有效所以复位时同步器寄存器置为全1、key_out置1这些细节要刻意保持一致。7.3 长按重复和组合按键不要在消抖模块里硬写有些项目需要长按按键实现连发效果比如按住音量键持续增音。这种需求我的建议是不要把长按计时塞进消抖模块而是让消抖模块只输出一个干净的“稳定电平信号”或“单次按下脉冲”在更高层的逻辑里再做长按计时。同样如果系统里有多个按键且需要判断“同时按下”之类的组合动作也必须在各自消抖稳定之后的信号上做组合逻辑不能拿未消抖的原始信号去与、或。因为原始信号抖动时组合逻辑的输出会被毛刺影响可能产生不存在的组合事件。顶层的实例化也简单一个按键一个模块实例模块参数不变代码整洁度会好很多。7.4 时钟频率变了参数改多少不同板子的系统时钟频率不一样常见有25MHz、50MHz、100MHz参数换算要会。下表是消抖目标为20ms时不同频率对应的计数器目标值和最小位宽。时钟频率时钟周期20ms目标计数值计数器最小位宽25MHz40ns500,00019 bit50MHz20ns1,000,00020 bit100MHz10ns2,000,00021 bit125MHz8ns2,500,00022 bit如果你是通过参数化模块直接用$clog2(CNT_MAX)位宽会自动计算换时钟频率只需要改CLK_PERIOD_NS和DEBOUNCE_NS不需要改代码结构。这是参数化设计的好处值得每位初学者养成习惯。最后再分享一点我自己的实操体会现在我做任何涉及按键的项目都是把“两级同步器 状态机消抖 单周期脉冲输出”封装成一个底层模块所有按键输入一律走这个模块。哪怕有些按键本身手感很好、不太需要消抖我也不会省掉同步器。省掉它你换来的是几次半夜调bug的时间成本不省它最多多花几十个逻辑单元。这笔账怎么算都很清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑