很多初学者写的第一段 verilog 代码是流水灯,第二段是读按键,然后卡死在按键消抖上。你按下一次按键,LED 却像触电一样乱闪,或者明明按了一次,逻辑里却检测到了十几次跳变。这不是你代码能力的问题,而是所有机械按键的物理宿命:按下和释放的瞬间,金属触点会来回弹跳很多下。FPGA 的时钟又跑得飞快,50MHz、100MHz 的采样把这些弹跳全部如实记录了下来。这篇文章是写给刚开始学 verilog、手里只有一块开发板甚至只有电脑的朋友,把按键消抖从原理到代码再到仿真,完整捋一遍,顺便把我自己踩过的坑都摆出来。
1. 抖动到底是怎么产生的:先搞懂按键的“物理脾气”
1.1 按键内部的小机关
按键本质上是一根金属簧片,靠弹力让两个触点接触或者分离。你按下的时候,簧片不是干脆利落地“啪”一下贴上去,而是在触点上弹来弹去好几次才稳定下来。这个弹跳过程就是抖动,英文叫 bounce。你可以把它想象成你用手指弹一下桌上的圆珠笔,圆珠笔不是稳稳停住,而是先蹦几下。按键里的簧片也一样,只不过弹跳时间很短,肉眼根本看不见。
不要小看这几下弹跳。在电气上,一次按键按下产生的不是一个干净的高到低跳变,而是一连串的高低电频翻转。这个翻转过程通常持续 5 到 20 毫秒,具体时长取决于按键的机械结构和质量。普通 6x6 轻触开关的实测抖动一般在 5ms 到 20ms 之间,质量差一点的甚至能到 30ms。很久以前用单片机做键盘时,有人说“消抖10ms就够了”,其实只对一部分开关成立,保守设计最好按 20ms 算。
1.2 在高速时钟下,一抖就是一场灾难
单片机的处理方式往往很简单:检测到按键变化后,软件延时 10ms,再读一次。但把这种做法直接搬到 FPGA 里是行不通的,因为 FPGA 的时钟太快了。50MHz 时钟的周期是 20ns,100MHz 是 10ns,而按键抖动持续 5ms 到 20ms。相当于在抖动期间,时钟已经跑了几十万个周期,每一拍采样到的电平都可能不同。
后果就是:你以为自己检测到了一次按下,实际上你的逻辑在抖动期间判断出了几十次“按下——释放——按下——释放”的事件。写了一个“按键按一次LED状态翻转一次”的小模块,上板之后 LED 却像抽风一样乱闪,多半就是这个问题。
另外还有一个隐患:FPGA 里的组合逻辑对毛刺特别敏感。如果按键信号没有经过处理就直接参与边沿检测、状态判断,一个几纳秒的毛刺都可能被当成有效事件,后续状态机立刻走错路。这一点在调试时非常隐蔽,因为仿真里如果没建模抖动,你是完全看不到问题的。
1.3 示波器下的真实抖动波形
如果你手头有示波器,可以做一个很直观的实验:把探头接到按键的一脚,地接 GND,按下按键时用单次触发抓下降沿。你会看到一串高低跳变的脉冲,而不是干净的下降沿。拨开波形仔细看,这些脉冲宽度还不规则,有的宽有的窄,毫无规律。所以任何“每隔固定时间采样一次”的做法,理论上都有可能在很倒霉的时刻采到某一段异常状态。
理解了物理层面的抖动,后面所有消抖方案的逻辑就很清楚了:无非就是想办法确认“按键确实稳定处于某个状态”,而不是靠一两个时钟周期的采样结果下结论。
2. 消除抖动有哪些流派:选一条适合你的路
2.1 硬件滤波:RC低通与RS触发器
最原始的办法是在电路上做文章。RC 低通滤波是典型方案:在按键输出和地之间接一个电容,再串联一个电阻。抖动产生的高频分量会被电容吸收,波形变成平滑的斜坡上升或下降,相当于把毛刺滤掉了。但这样出来的波形边沿变缓,后面一般还要接施密特触发器整形,硬件成本和板子面积都会增加,适合产品定型、逻辑不想再改的场合。
RS 触发器方案则是利用按键有常开和常闭两套触点,两个触点分别接到一个基本 RS 触发器的 S 和 R 端。当按键切换时,总有一路信号先到,把触发器锁存住,从而忽略后续的弹跳。这种方法硬件逻辑非常少,但前提是你的按键模块本身引出两个触点连线,很多成品开发板的按键只有一根线加一根地线,根本没法用RS方案。
2.2 逻辑域里的几种消抖思路
到了纯逻辑层面,思路就多起来了。常见的有三种。
第一种是延时重采样。检测到按键变化后,什么都不做,等个 10ms 到 20ms,再去采样一次。如果采到的还是目标电平,就认为按键稳定了。这种做法代码量最少,但存在一个尴尬的盲区:等你延时结束的那一刻,如果抖动刚好还没结束,你又采到错误的电平。所以它只能说“大概率有用”,不能说“可靠”。
第二种是计数器计时法,也就是本文的核心。它的思路是:检测到按键变低(按下)之后启动一个计数器,开始数周期。如果计数满 CNT_MAX 个周期,按键依然保持低电平,才确认这是一次真实按下。任何一次抖动造成的“弹起”都会让计数器清零重来。这种方法逻辑简单、抗抖动能力强,而且参数直观:CNT_MAX 乘以时钟周期就是你要的消抖时间。
第三种是滑动窗口滤波,类似数字信号处理里的滑动平均。把最近 N 次采样结果存到一个移位寄存器里,只有当这 N 次采样全部为低电平,才认为按键稳定按下;只有全部为高电平,才认为稳定释放。这个方法后面我单独用一整章讲,因为它思路清爽,实现也不难。
2.3 方案对比与选型
| 方案 | 核心思想 | 资源消耗 | 抗抖动能力 | 适合场景 |
|---|---|---|---|---|
| RC低通+整形 | 模拟滤波 | 电容电阻等硬件 | 强 | 产品定型、不占逻辑资源 |
| RS触发器 | 电平锁存 | 极少逻辑 | 较强 | 有常开常闭触点的按键 |
| 延时重采样 | 等一段时间再读 | 极少逻辑 | 中 | 低频单片机,不太适合FPGA |
| 计数器计时 | 计时稳定确认 | 计数器加状态 | 强 | FPGA入门经典,教学首选 |
| 滑动窗口 | 连续N次一致 | 移位寄存器 | 强 | 想灵活调节时间常数、逻辑规整 |
我给初学者的建议是:先把计数器计时法吃透,因为这个思路能帮你建立“状态+计时”的硬件思维,后面写状态机、写通信协议都会用到。等你熟练了,再去看滑动窗口和显式状态机。
3. 计数器消抖代码逐行拆解:FPGA里的“计时确认”
3.1 为什么第一步要做两级同步
按键信号相对于 FPGA 的系统时钟来说是异步的。你可能在某个时钟上升沿附近按下按键,这个跳变离时钟沿就差了零点几纳秒,触发器可能进入亚稳态——输出既不是稳定的0也不是稳定的1,而是悬在半空、无法预测的状态。这个亚稳态如果直接传到下一级逻辑,会造成电路行为不可控。
解决办法是加两级同步器,也就是用两个触发器串起来。第一级触发器把异步信号采进来,哪怕进入亚稳态,经过一个时钟周期后,信号大概率会在第二级触发器处稳定为合法的0或1。这就叫“用时间换确定性”。注意我说的不是“一定稳定”,而是“大概率稳定”,两级触发器已经能把亚稳态传播概率压低到工程上可以忽略不计的程度。
3.2 计数器怎么算“稳定”这笔账
两级同步之后,key_d1 可以看作是与系统时钟同步的、相对干净的按键信号。现在关键问题是:什么时候才能确认一次真实按下?
我的做法是在检测到低电平后启动计数,持续数 CNT_MAX 拍。只要中途 key_d1 变高一次,也就是出现了一次弹起,计数器立刻清零重来。只有连续 CNT_MAX 拍都读到低电平,才宣布“这次是真正的按下”。
很多人会问:CNT_MAX 怎么取值?很简单,你要的消抖时间除以时钟周期就行。比如目标消抖时间 20ms,时钟 100MHz,那么 CNT_MAX = 20ms / 10ns = 2_000_000。就这么来的,没有任何玄学。
3.3 完整代码与信号说明
下面是完整代码,我加了比较详细的注释,方便你对着看。
module key_debounce #( parameter CNT_MAX = 21'd2_000_000 // 100MHz时钟下约20ms )( input wire clk, // 系统时钟 input wire rst_n, // 异步复位,低有效 input wire key_in, // 按键输入,按下为低 output reg key_out // 消抖后输出,按下为低 ); // ===== 1. 两级同步器 ===== reg key_d0 = 1'b1; reg key_d1 = 1'b1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_d0 <= 1'b1; key_d1 <= 1'b1; end else begin key_d0 <= key_in; // 第一级:采原始按键信号 key_d1 <= key_d0; // 第二级:再打一拍,降低亚稳态风险 end end // ===== 2. 计数器 + 状态确认 ===== reg [20:0] cnt; // 计数器,21位 reg key_flag; // 是否已经确认按下 reg key_ff; // 输出锁存寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 21'd0; key_flag <= 1'b0; key_ff <= 1'b1; // 复位后输出为高(按键未按下) end else begin if (!key_flag) begin // 状态A:尚未确认按下,等待稳定低电平 if (key_d1 == 1'b0) begin if (cnt < CNT_MAX) cnt <= cnt + 1'b1; if (cnt == CNT_MAX - 1) begin key_flag <= 1'b1; // 连续低电平时间够长,确认按下 key_ff <= 1'b0; // 输出拉低 end end else begin cnt <= 21'd0; // 中途弹起,立刻清零 end end else begin // 状态B:已经确认按下,等待释放 if (key_d1 == 1'b1) begin key_flag <= 1'b0; // 检测到释放 key_ff <= 1'b1; // 输出恢复高 cnt <= 21'd0; end end end end assign key_out = key_ff; endmodule代码不长,但有几个点我要专门展开说。如果你能完全看懂这几段,按键消抖这块基本就通了。
3.4 为什么这几个细节必须这样写
第一,判断计数满用的是cnt == CNT_MAX - 1,不是cnt == CNT_MAX。原因很简单:cnt 从 0 开始递增,当计数到 CNT_MAX 个周期时,最后一个周期里 cnt 的值是 CNT_MAX - 1。等到下一个时钟沿 cnt 才会变成 CNT_MAX,但那已经是第 CNT_MAX + 1 拍了,时间就超了。新手最容易在这类边界条件上翻车。
第二,为什么用reg key_ff锁存输出,不直接用组合逻辑赋值?如果你用assign key_out = (状态判断)这种写法,只要中间信号有毛刺,输出就可能跟着抖动。用寄存器锁存相当于给输出加了一层“稳定化”,保证后续模块拿到的始终是平坦的电平。这在跨模块通信时特别重要。
第三,为什么一定要用非阻塞赋值<=而不是=?因为这是一个时序逻辑 always 块,非阻塞赋值模拟的是触发器的并行特性:右边先采样,左边后更新。如果你写成阻塞赋值,仿真时就会看到各种诡异竞争和中间状态,综合时也可能多出来一些你没预期的锁存器。这不是风格问题,是硬件代码的底线问题。计数器写法也是一样,cnt <= cnt + 1就是在时钟沿瞬间完成递增,不是立刻生效。
另外提一个扩展点:如果某些开发板的按键按下是高电平有效,你只需要把代码里所有key_d1 == 1'b0和key_d1 == 1'b1的判断对调,其余逻辑不用动。极性这个事,上板前一定看原理图确认清楚,不然写反了,消抖模块会变成“一按下就输出高、一释放就输出低”,完全反向。
4. 用 Icarus Verilog 跑仿真:没有开发板也能验证
4.1 环境准备:装好 Icarus Verilog 和 GTKWave
Icarus Verilog 是个开源的 Verilog 仿真工具,免费、命令行操作、支持 Windows 和 Linux。配合 GTKWave 看波形,足以应付绝大多数入门级仿真需求。很多初学者一上来就用 Quartus 或 Vivado,综合一次要等好几分钟,其实逻辑验证完全可以先用 Icarus 在电脑上跑完,再上板综合。
Linux 或 WSL 下安装很简单:
sudo apt install iverilog gtkwaveWindows 下直接去 Icarus Verilog 官网下载安装包,装完以后 iverilog 和 gtkwave 都会出现在程序菜单里。装好之后,命令行里敲iverilog -v确认能识别工具。
4.2 带抖动的 testbench 怎么写
写 testbench 最重要的一点就是:你必须主动制造抖动。很多新手写 testbench 时只给一个干净的下降沿,仿真当然通过,上板却一塌糊涂,就是因为仿真环境里没有建模抖动的过程。
下面这个 testbench 里我定义了一个jitter_press任务,用来模拟一次带弹跳的按键按下。它会在真正按住之前交替翻转键值若干次,模拟物理上的簧片弹跳。
`timescale 1ns / 1ps module tb_key_debounce; reg clk; reg rst_n; reg key_in; wire key_out; // 在仿真里把 CNT_MAX 改小,避免仿真时间过长 key_debounce #( .CNT_MAX(21'd1000) ) uut ( .clk (clk), .rst_n (rst_n), .key_in(key_in), .key_out(key_out) ); // 50MHz时钟,周期20ns initial clk = 0; always #10 clk = ~clk; // 模拟带抖动的按键按下 task jitter_press; integer k; begin for (k = 0; k < 5; k = k + 1) begin key_in = 0; #(20 + k * 10); // 低电平持续时间不规则 key_in = 1; #(15 + k * 7); // 高电平持续时间也不规则 end key_in = 0; // 抖动结束,稳定按住 #(30_000); // 按住30us,确保超过1000个时钟周期 key_in = 1; // 释放 #(30_000); end endtask initial begin rst_n = 0; key_in = 1; #100; rst_n = 1; #100; $display("Test 1: jitter press"); jitter_press; $display("Test 2: clean press"); key_in = 0; #(30_000); key_in = 1; #(30_000); $finish; end endmodule这里有个非常关键的点:为什么仿真里要把 CNT_MAX 改成 1000,而不是用真实的 2_000_000?因为如果保持真实参数,意味着仿真要跑完 20ms 的物理时间,对仿真器来说是 200 万个时钟周期,波形文件巨大,等待时间也很长。把参数改小到 1000,1000 个周期是 20us,验证的就是“计数器满后才输出的逻辑形态”,而不是“20ms 到底有多长”。时间长度由参数决定,逻辑形态才是你要验证的东西。上板前再把参数改回真实值就行。
4.3 编译、运行、看波形,三步走
编译和运行命令:
iverilog -o tb_key_debounce.vvp tb_key_debounce.v key_debounce.v vvp tb_key_debounce.vvp gtkwave tb_key_debounce.vvp第一条命令把 testbench 和被测模块一起编译成 vvp 文件。第二条命令执行仿真。第三条命令打开 GTKWave 查看波形。
在 GTKWave 左侧的模块树里找到tb_key_debounce,点开里面的uut实例,把key_in、key_d1、cnt、key_flag、key_out这几个信号添加到波形窗口。观察重点有三个:
- 抖动期间,
key_out必须保持高电平,不能被误拉低。 - 抖动结束并持续按住足够时间后,
key_out才变低,并且一直保持到释放。 cnt的信号在抖动期间会有多次归零,稳定按下后才一路数到 CNT_MAX - 1。
看到这三个现象,说明消抖逻辑在行为上是正确的。再强调一次:仿真里波形对了,只说明逻辑设计没问题;是否能在真实抖动场景下稳定工作,还要靠参数和实际实验来验证。
5. 滑动窗口滤波法:从“计数确认”到“连续一致”
5.1 滑窗的核心思想
计数器计时法要求“从某个起点开始连续稳定计时”,逻辑上有一种“状态推进”的感觉。而滑动窗口滤波的思路完全不同:它不管起点在哪,只看最近 W 次采样。如果最近 W 次采样全部为 0,就认为当前稳定在低电平;如果全部为 1,就认为当前稳定在高电平;只要窗口内还有混着的采样值,就保持上一个稳定输出不变。
你可以把滑动窗口想象成“现场投票”:每个时钟周期投一次票,只有当最近 W 个成员都投同一个结果,才改变最终决定。任何一票“跑偏”都会被多数票压制住。
这个思路在软件滤波里很常见,比如鼠标键盘的消抖、传感器数据的滑动平均。放到硬件里实现,其实就一个移位寄存器加两个位宽检测逻辑,非常简单。
5.2 一个可用的滑窗消抖代码
下面的代码是滑窗法的基本形态,窗口长度用参数控制。注意我同样加了二级同步器,按键信号必须先同步,否则窗口里的采样数据没什么意义。
module key_debounce_sliding #( parameter WIN_LEN = 16 )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); reg key_d0, key_d1; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_d0 <= 1'b1; key_d1 <= 1'b1; end else begin key_d0 <= key_in; key_d1 <= key_d0; end end reg [WIN_LEN-1:0] win; always @(posedge clk or negedge rst_n) begin if (!rst_n) win <= {WIN_LEN{1'b1}}; // 复位时认为全部是高电平 else win <= {win[WIN_LEN-2:0], key_d1}; // 左移,最新采样放最低位 end // 窗口内全部为0,按下确认 wire all_zero = ~|win; // 窗口内全部为1,释放确认 wire all_one = &win; always @(posedge clk or negedge rst_n) begin if (!rst_n) key_out <= 1'b1; else begin if (all_zero) key_out <= 1'b0; else if (all_one) key_out <= 1'b1; // 其余情况保持原样 end end endmodule~|win是或非门的缩位操作,只要 win 里有一位是 1,结果就是 0;只有全部是 0 才输出 1。&win是缩位与操作,全部是 1 才输出 1。这两个信号就是“连续全0”和“连续全1”的硬件表达。
5.3 滑窗容易踩的一个坑:直接窗口做不了长延时
纯滑窗有个很现实的问题:如果时钟是 100MHz,一个时钟周期 10ns,你想实现 20ms 消抖,窗口长度得是 2_000_000 位。FPGA 里不可能为单个按键搞一个两百万位的移位寄存器,资源直接爆掉。
所以在实际工程里,滑窗法通常配合“采样使能”使用。不是每个时钟周期都移位,而是每隔一段时间产生一个采样脉冲,把此刻的 key_d1 移入窗口。比如用计数器产生 1kHz 的采样时钟,每 1ms 采样一次,窗口长度 16,就代表 16ms 的消抖时间。这样资源占用只有 16 位,而时间常数依然可以做得很大。
再说说滑窗法和计数法的本质差异。计数器法对“时间连续性”要求更高,一旦中途出现一次弹起,计数器清零,必须重新计时。滑窗法只要最近 W 次采样一致就切换输出,它对单次毛刺的容忍度稍高,但也有个边界问题:如果抖动正好让 W 次采样里混了几次相同的错误电平,窗口可能会误判。所以窗口长度的选择要结合实际的抖动时长。总体来说,滑窗法的代码结构更规整,配合采样使能后非常适合需要频繁调节滤波时长的场景,比如按键、编码器、以及低速串行信号去毛刺。
6. 上板实测后才会遇到的那些坑
6.1 计数器位宽算错,消抖直接“失效”
我见过很多同学抄了计数器消抖代码,上板后发现按键还是偶发乱跳,百思不得其解。最后发现是计数器的位宽不够。比如时钟 100MHz,消抖时间 20ms,需要的周期数是 2_000_000,这个数大于 2 的 20 次方(1_048_576),所以 20 位计数器根本数不到 2_000_000,计数器会在最大值回绕,消抖时间缩短一半甚至更短,自然消不干净。
正确的位宽计算方法是:先算出需要的周期数 N,然后保证 2 的位数次方大于 N。2_000_000 小于 2 的 21 次方(2_097_152),所以至少要 21 位。代码里我用的就是reg [20:0] cnt。计算过程不复杂,但非常容易忽略,因为综合工具通常不会因为你位宽不够报错,它只会默默产生一个提前回绕的计数器。
6.2 只处理按下不处理释放,松键时的抖动也够你喝一壶
很多最简教程只处理“按下”这个方向,释放直接忽略。这在实验里好像没什么问题,但如果你用来计数、翻页、切换模式,就可能遇到“按一次算两次”的情况。原因就是释放时也会有抖动,释放瞬间的弹跳如果被当成新的按下事件,就多记了一次。
我的示范代码里做了简单的释放判断,但严格说,释放方向也应该做同样的稳定确认,也就是不仅仅检测到key_d1 == 1就立刻释放,而是要保证释放后的高电平也稳定持续一段时间后再翻转。工业级写法通常用显式状态机,把“按下检测”“按下确认”“释放检测”“释放确认”分开,每个状态都有自己的计数器。这才是彻底的双向消抖。
6.3 亚稳态不是玄学,是真实的时序风险
前面提到两级同步器,这里再说得直白一点。按键信号跟时钟毫无关系,它是异步的。如果一个信号刚好在时钟上升沿附近变化,触发器会采样到一个无法预测的中间电平,这就是亚稳态。亚稳态会在一到两个时钟周期内慢慢稳定下来,但稳定成 0 还是 1 是不确定的。如果不加同步器直接把这个信号拿去判断边沿,你的状态机可能随机地多走一步或少走一步,而且这种随机现象不是每次都能复现。
所以我建议所有的按键输入、外部中断输入,第一件事永远是两级同步。这不仅适用于按键,也适用于任何跨时钟域的单 bit 信号。不要觉得多打两拍浪费时间,这是性价比最高的稳定性投资。
6.4 参数是按时钟频率算出来的,不是拍脑袋定的
最后再强调一遍参数问题。很多人把CNT_MAX随便填一个像 20'd999_999 这样的数,不抬头看一眼自己的时钟频率。50MHz 时钟下,20'd999_999 对应的消抖时间是 20ms;但如果你的开发板时钟是 100MHz,同样的参数消抖时间是 10ms,偏短;如果是 12MHz 的时钟,同样的参数消抖时间直接变成 83ms,按键会迟钝得让人抓狂。
我自己的习惯是先把时钟频率写到注释里,再顺手算好周期和位宽。比如“50MHz 时钟,20ms 消抖,需要 1_000_000 拍,20 位足够”。写注释不是为了好看,是为了下次调试时能快速确认参数来源。这个习惯帮我避掉过不少低级错误。
如果你是自己玩开发板,我强烈建议仿真通过后,再花几分钟看一眼真实按键按下时cnt的波形,确认抖动期间计数器清零了几次、稳定后计时是否满值。多花这几分钟,远比上板后对着一个乱闪的 LED 猜来猜去要省时间。按键消抖算是一个很典型的“小逻辑、大坑点”模块,把这个模块吃透了,后面写状态机、写跨时钟域处理,很多思路都是相通的。