FPGA 入门 21:按键消抖——同步、稳定计时、边沿检测
更新时间:2026-09-12。本文是
fpga/入门层第 23 篇。异步输入为什么要打两拍见进阶 CDC 亚稳态,计时脉冲见 计数器与分频。
本文要回答的问题
- 机械抖动到底持续多久、在 50MHz 时钟下会被采成多少次跳变?为什么消抖阈值常取 20ms?
- 一个外部按键从进 FPGA 引脚到产生「按一下触发一次」的脉冲,中间经过哪三级电路,各自解决什么问题?
- 异步输入为什么要先打两拍?只打一拍或一拍不打会怎样,MTBF 是什么直觉?
- 稳定计数器逐拍是怎么工作的:电平变化时计数器为什么清零、什么时候才真正采纳新电平?
- 消抖后的电平如何变成单周期脉冲?上升沿、下降沿、双击、长按、连加分别怎么实现?
- 多个按键和矩阵键盘怎么省 IO?上拉电阻、RC 滤波、施密特触发器这些板级电路和 FPGA 消抖是什么关系?
- Testbench 怎么模拟抖动、怎么验证「一万次抖动只算一次按下」?
读完本文,你应当能独立写出一个带两级同步、参数化稳定窗口、单周期脉冲输出的按键模块,并能解释链路上每一级存在的硬件理由,而不是把消抖当固定模板背诵。
一、抖动长什么样
机械触点通断瞬间弹动 5~20ms;50MHz 时钟 20ns 一拍 → 一次按下被采成上万次跳变。
理想: ____|‾‾‾‾‾‾‾|____
实际: ___|‾|_|‾‾|_|‾‾‾‾‾|___|‾|____ (按下/松开处抖)二、处理链:同步 → 消抖 → 边沿检测
module key_debounce #(parameter CLK_HZ=50_000_000)(
input wire clk, rst_n, key_in, // 低有效机械按键
output reg key_level, // 消抖后电平(1=按下, 已转高有效)
output wire press_pulse // 按下瞬间单周期脉冲
);
localparam N = CLK_HZ/50; // 20ms 计数值
reg [19:0] cnt;
reg s1, s2, key_d;
wire k = ~s2; // 转成高有效
always @(posedge clk or negedge rst_n) // 两级同步
if (!rst_n) {s1,s2} <= 2'b00;
else {s1,s2} <= {key_in, s1};
always @(posedge clk or negedge rst_n) // 稳定计时
if (!rst_n) begin key_level<=0; cnt<=0; end
else if (k != key_level) begin
cnt <= (cnt >= N-1) ? cnt : cnt + 1;
if (cnt == N-1) key_level <= k; // 稳定 20ms 才采纳
end else cnt <= 0; // 电平一致, 清零重计
always @(posedge clk or negedge rst_n)
if (!rst_n) key_d <= 0; else key_d <= key_level;
assign press_pulse = key_level & ~key_d; // 上升沿=按下
endmodule三、关键参数与信号
| 环节 | 作用 | 取值/写法 |
|---|---|---|
| 两级 FF 同步 | 防异步输入亚稳态 | {s1,s2}<={key_in,s1} |
| 稳定窗口 | 滤掉短于 20ms 的抖动 | N=CLK_HZ/50(50MHz→1e6,20bit) |
| 电平确认 | 持续稳定才更新 | cnt 到 N-1 才采 key_level |
| 边沿检测 | 一次按一下触发 | level & ~level_d 单周期脉冲 |
四、逐拍走一遍:同步器到底在做什么
外部按键信号和内部时钟没有任何相位关系,它可能在任意时刻翻转。设时钟周期 20ns,第一级触发器 s1 每个上升沿采一次引脚:
- 若按键在沿附近、处于建立保持窗口内翻转,s1 的输出不会立刻是干净的 0 或 1,而可能短暂停在非法电平(亚稳态),经过一段随机的收敛时间后才落到 0 或 1。
- s1 的亚稳输出若直接送进一堆组合逻辑和多个寄存器,可能被不同路径解释成不同值,导致一次按键触发彼此矛盾的动作。
- 加第二级 s2,就给 s1 的亚稳输出整整一个时钟周期去收敛。绝大多数情况下,到 s2 采样时 s1 已经稳定为合法电平,于是从 s2 往后看到的永远是干净的 0/1。
两级并不能从理论上保证百分之百不亚稳,它做的是把平均故障间隔时间 MTBF 提高到比产品寿命长很多个数量级。直觉公式是 MTBF 随同步级数和给每级的收敛时间指数增长,所以两拍相对一拍是数量级的改善,三拍用于更恶劣的异步接口,普通按键两拍足够。注意同步器必须是两个直接相连、不带组合逻辑的触发器,并尽量放在同一 slice 里(综合器通常有 ASYNC_REG 约束把它们打包),中间不要插入其他组合逻辑,否则收敛时间被侵蚀。
五、逐拍走一遍:稳定计数器
同步后得到干净但仍在抖动的电平 k,消抖的核心是一个「稳定时长」判定。可以把它想成投票:只有新电平连续保持足够久,才承认按键状态真的变了。
- 每拍比较同步电平 k 和已确认电平 key_level;
- 两者不同,说明可能发生了变化,也可能只是一次抖动毛刺,于是让稳定计数器加 1(或从零开始数),只要中途 k 又跳回去,计数立刻清零重来;
- 计数器累计到阈值 N(50MHz、20ms 即一百万拍)这一拍,才把 key_level 更新为 k;
- 两者相同,计数器保持清零,表示没有待确认的变化。
这样一次真实按下(手指按住通常几百毫秒)轻松稳过 20ms 被采纳;而抖动的任何一次窄跳变都撑不过窗口,被完全滤掉。阈值位宽必须够数:N=1000000 需要 20 位,用 parameter 让模块按输入时钟频率自动算,比写死常数安全。窗口也不是越长越好:取 50ms 虽然滤得更干净,但快速点按时会感觉「迟钝」甚至丢击,20ms 是滤抖与响应速度的经验平衡点。
六、从电平到事件:边沿检测与单周期脉冲
消抖输出 key_level 是电平:按下期间它一直有效。很多逻辑要的是事件——按一下只动作一次。把电平延迟一拍得到 key_d,比较当前与上一拍:
- 上升沿脉冲(刚按下):当前为 1、上一拍为 0,输出高恰好一个周期;
- 下降沿脉冲(刚松开):当前为 0、上一拍为 1;
- 任意沿:两者不等即输出。
脉冲宽度正好一个时钟周期,这让它能直接接到计数器使能、状态机触发、命令解析等逻辑,与 计数器篇 讲的 tick 是同一种东西。务必保证比较在同一时钟域、用非阻塞延迟一拍,别用组合逻辑对引脚做「上升沿」判断(那相当于把引脚当时钟)。低有效按键先取反转成高有效,后续逻辑统一处理有效电平,可读性最好。
七、长按、连加与双击
在「消抖电平 + 边沿脉冲」基础上,可以用计数器构造更丰富的按键语义,这些都是后续菜单、参数调节常用的:
- 长按检测:key_level 有效期间启动一个毫秒计数器,持续超过设定时长(如 1.5 秒)就输出 long_pulse;松开则清零。常用于「短按加一、长按复位/进菜单」。
- 自动连加(auto-repeat):检测到按下后先触发一次,若持续按住,每隔例如 100ms 用 时钟使能 tick 产生一次重复脉冲,模拟键盘按住连发,松手即停。
- 双击检测:记录第一次按下脉冲的时间戳,若在窗口内(如 400ms)出现第二次按下,则输出双击事件,否则按单击处理,通常用一个小状态机管理「等待第二次」的状态(状态机写法见 FSM 入门)。
这些扩展都遵循同一条纪律:先用消抖电平做「状态」,再用计数器和状态机派生「事件」,绝不在原始引脚上直接数时间。
八、多按键与矩阵键盘
独立按键每个占用一个 IO,按键多时不划算。4×4 矩阵键盘用 8 根 IO 扫描 16 个键,思路和 数码管动态扫描 完全对称:
- 4 根行线配置为输出,依次拉低一行(其余高);4 根列线配置为带上拉的输入;
- 读取列线,若某列为低,说明「当前扫描行」与「该列」交叉点的键被按下;
- 逐行轮询,配合每键 10~20ms 稳定确认即完成扫描消抖;
- 用行列编码得到键值,再做一次按下沿检测产生单键事件。
矩阵扫描天然是周期性的,扫描节拍用毫秒 tick,消抖可以直接利用「连续几次扫描都读到同一闭合键」来实现,不必为每键单独放计数器。多键同按、鬼键(ghost key)是矩阵键盘的固有问题,需要二极管防串扰或限制同时按键数,入门先做单键。
九、板级电路与 FPGA 消抖的分工
消抖不只是 FPGA 内部的事,硬件设计也会分担一部分,理解边界有助于定位问题:
- 上拉/下拉电阻:按键断开时引脚必须有确定电平,否则悬空会乱跳。多数开发板在按键端接了上拉(按下拉低、即低有效),FPGA 内部也可在约束里使能弱上拉 PULLUP。
- RC 滤波:电阻电容构成低通,能压掉一部分高频抖动毛刺,但时间常数太大又会让沿变缓,通常只是辅助,不能替代逻辑消抖。
- 施密特触发器:对缓慢变化的输入给出干脆的数字沿,抗噪能力强,很多板级按键/外部信号先经施密特再进 FPGA。
- 器件差异:硅胶按键、轻触开关、旋转编码器、触摸按键的抖动特性不同;旋转编码器的 A/B 相还要求在消抖后做相位判断和方向计数。
即使板级有 RC 和施密特,跨越毫秒级的机械弹跳仍要靠逻辑窗口判定;反过来,纯芯片输出的电子信号(如另一个 IC 的中断线)边沿干净,只需两级同步、不需要消抖。判断准则是:异步要同步、机械要消抖、事件要沿检测,这三件事不要混为一谈。
十、Testbench:人为造一串抖动
验证消抖最有说服力的方式,是在 Testbench 里把抖动波形主动造出来。做法是用 task 把引脚在几十微秒内反复翻转若干次、再保持稳定,仿真时把稳定窗口参数缩小(例如用 parameter 传一个很小的 CLK_HZ 或单独的 STABLE 阈值),不必真的模拟一百万拍:
- 初始 key_in 为高(未按,低有效);
- 任务 key_bounce:在 0 与有效电平间随机/固定翻转十几次,每次持续几十微秒,最后稳定为有效;
- 观察同步后的 s2、稳定计数器、key_level:无论前面怎么跳,key_level 只能在连续稳定达到窗口后翻转一次;
- 观察 press_pulse:它应只出现一个时钟周期的高,且与按下次数一一对应;
- 松开时同样造一串抖动,验证下降过程不产生多余事件。
仿真要点见 Testbench 与仿真、看波形。通过判据可以直接写自检断言:一次 bounce 任务后 press_pulse 的脉冲计数必须恰好为 1,多一个就说明窗口或边沿逻辑写错。上板阶段用 ILA 抓 s2、cnt、key_level 和 pulse,能直观看到毛刺被吃掉的过程。
十一、参数化可复用模块与检查清单
工程上建议把消抖模块写成参数化、输出齐全的通用件,整个项目复用:参数给系统时钟频率和目标稳定毫秒数,内部用位宽自动推导;端口同时给出消抖电平、按下脉冲、松开脉冲,命名统一高有效。这样换板子、换时钟频率只改参数,行为不变。模块应只用单一时钟、同步链寄存器不加复位值依赖(异步输入本来就不确定),并对多键使用 generate 批量例化。
设计或评审一个按键接口时,按下面顺序自查:
- 引脚电平从哪来、有没有上拉,高低有效是否和原理图一致;
- 异步输入是否经过两级、且中间无组合逻辑的同步器;
- 稳定窗口常数是否按实际时钟频率计算、计数器位宽是否够;
- 采纳状态变化的条件是不是「连续稳定达到阈值」,中途抖动能否正确清零;
- 需要事件的地方是否用了边沿检测脉冲,而不是直接用电平判断;
- 长按/连加/双击是否在消抖之后派生,时间基准是否来自毫秒 tick;
- Testbench 是否覆盖按下抖动、松开抖动、快速连点、超长按住、复位中途发生等情形。
按键是 FPGA 系统里最常见的异步输入,也是理解同步、亚稳态、时间滤波和「电平到事件」转换的最佳入门载体。把这条链路真正吃透,后面处理外部中断、旋转编码器、SPI/UART 等异步信号都会轻车熟路。
十二、为什么两级同步能把 MTBF 拉到天文数字
很多人记住了「异步信号打两拍」,却说不清为什么是两拍而不是一拍或十拍。可以用一个生活化模型理解:第一级触发器在输入踩沿时进入亚稳态,相当于一个球正好滚到山脊正中央,理论上可能停很久,但现实中任何一点点扰动都会让它在指数分布的随机时间后滚向某一侧。给它的「滚落时间」越长,下一秒仍停在山脊上的概率就指数级减小。第二级触发器在整整一个时钟周期之后才采样,于是采到仍处于亚稳输出的概率是一个极小的指数小量。
工程上用平均故障间隔时间 MTBF 衡量,它大致与采样频率、数据变化频率成反比,与可用收敛时间(这里接近一个时钟周期减去路径延迟)成指数关系。单级相当于没有留收敛时间,MTBF 可能短到几秒几小时;加上一级变成两级后,指数项直接把 MTBF 拉到几十年、上百年量级;再加一级收益递减,所以普通异步输入约定俗成用两级。真正决定成败的细节是:两级触发器必须紧邻、中间不插逻辑、最好用 ASYNC_REG 属性告诉工具把它们放进同一个 slice 并纳入亚稳态分析;扇出也只能从第二级之后再分。这正是 CDC 进阶篇 量化推导的入门直觉,本文只需要建立「两级 = 给亚稳一个完整周期去收敛」的画面。
要强调的是:同步器只解决「亚稳态传播」,并不能消除采样的不确定性——异步输入在两个时钟沿之间变化时,本来就可能被这一拍或下一拍采到,这是物理事实,消抖与后续协议要能接受这一拍的不确定。对按键这种人工慢速信号,一拍误差毫无影响;对高速总线则需要握手、异步 FIFO 等更完整的跨时钟域结构。
十三、除了计数窗口,还有哪些消抖思路
本文主线是最常用的「连续稳定计时」方案,但了解其他思路有助于在资源、响应速度间取舍:
- 移位积分法:用一个 N 位移移寄存器每拍移入同步后的电平,全部为 1 才认定按下、全部为 0 才认定松开。优点是纯寄存器、结构规整,适合 FPGA;缺点是滤抖时间固定为 N 个时钟周期,50MHz 下要滤 20ms 需要百万级寄存器,不现实,通常配合毫秒 tick 每毫秒移一位,做成约 16 位的「多数/全一致」判决。
- 多数表决法:连续采样若干次取多数,对孤立毛刺鲁棒,在编码器去抖和通信采样里常见。
- RC + 施密特硬件消抖:模拟手段把抖动在进 FPGA 前压平,省逻辑但占板级器件、参数离散,工业产品常与软件消抖同时使用。
- 专用滤波 IP/IO 延时单元:部分 FPGA 的输入单元带可调毛刺滤波,Q&A 类外设或某些 MCU 有集成,FPGA 入门一般用逻辑实现。
- 状态机显式建模:把「未按、可能按下(计时中)、已按下、可能松开(计时中)」画成四态 FSM,语义最清晰、容易加长按/双击,和计数器写法在硬件上等价(FSM 写法见 状态机入门)。
计数器方案之所以最常用,是因为它只用一个计数器就把「任意长度抖动」折算成时间,参数化后与时钟频率解耦;移位法适合固定次数判决;状态机适合要在消抖过程中同时输出多种事件的场景。面试或评审时能说清这几类方案各自的资源与响应特性,比只会背模板更能体现理解。
十四、实战接线、上板调试与常见现象
把模块接到真实板子时,软件之外还有一批容易忽略的工程细节:
- 先读原理图再定极性:开发板按键常见接法是一端接地、另一端接上拉电阻进 FPGA,未按下读到高、按下读到低,即低有效;也有反过来的。以原理图和厂商手册为准,在 FPGA 内部统一转成高有效再处理。
- 约束别漏上拉与电平:未按引脚若悬空会读到乱跳,约束里按需加 PULLUP,电平标准与所在 Bank 的 Vcco 一致(约束写法见 XDC 管脚与时序)。
- 时钟频率要与参数一致:模块按 50MHz 算的窗口接到 100MHz 时钟,稳定时间会缩短一半;参数化模块在例化处传入实际频率可避免这类错误。
- 上板看不到脉冲很正常:单周期脉冲在示波器和人眼上无法观察,调试时用 ILA 触发 press_pulse,或把脉冲接到「按一下翻转一次」的 LED 电平上间接观察(ILA 用法见 ILA 第一次用)。
- 数码管/菜单乱跳先怀疑消抖:数字一次跳很多格、状态机连续跳变,绝大多数是直接用电平计数或漏了边沿检测,而不是数码管本身的问题(显示侧见 数码管动态扫描)。
- 复位期间的按键:异步复位释放要走复位同步,按键模块本身的同步链与控制寄存器复位分开处理,避免把异步输入也塞进复位树(复位纪律见 时钟与复位)。
一套推荐的上板验证流程是:先只接同步后的电平看 LED,确认极性与上拉;再加稳定计时,用 ILA 看抖动期间 cnt 反复清零、稳过后 key_level 只翻转一次;最后挂 press_pulse 到计数器和数码管,按一下数字严格加一。逐级点亮而不是一次写到底,出问题时能立刻定位是同步、消抖、沿检测还是下游逻辑的责任,这也是所有 FPGA 外设调试都适用的方法。
最后再把三个层级的边界强调一遍,它们对应三类完全不同的硬件问题:两级同步处理的是「异步」——外部信号和本地时钟没有相位关系,目标是阻止亚稳态扩散;稳定计时处理的是「机械」——触点在毫秒尺度反复弹跳,目标是只承认持续足够久的电平;边沿检测处理的是「语义」——业务逻辑需要的是一次按下事件而不是一段按下电平,目标是把电平收敛成单周期脉冲。把这三件事拆成三个职责单一的小模块、用清晰的信号名串起来,远比把它们揉在一个 always 块里可靠,也更方便在矩阵键盘、编码器、外部中断等不同输入上复用。等到学习跨时钟域和接口协议时,你会反复看到「先同步、再判定、最后产生事件」这套结构,按键消抖正是它最小、最容易上手的一次练习。
十五、消抖背后的采样与确认原理
机械消抖这个小例子,其实浓缩了数字系统处理"异步、不确定外部输入"的一套通用方法论,吃透它,以后做按键、限位开关、模式选择乃至外部中断都能举一反三。要解决的核心矛盾是:机械触点在闭合/断开瞬间会在几毫秒内反复通断,产生一长串毛刺脉冲,而 FPGA 时钟周期是纳秒级的,足以把每一次抖动都当成一次独立的按键事件,于是按一下被计数成好几下。处理思想分两步,正好对应两个经典概念。第一步是定时采样:不必在每个时钟都去看按键,而是用一个慢节拍(如 1ms 的 tick)周期性采样引脚电平,这个节拍比抖动持续时间短、比系统时钟慢得多,相当于给输入加了时间低通。第二步是连续确认(N 次一致才采信):只有连续 N 次采样都读到相同电平,才承认状态真的变了;任意一次读到不一致,就把确认计数清零重来。这样短暂的抖动永远凑不满 N 次一致,无法通过"确认门"。
这套"采样 + 多次确认"结构本质上是一个数字低通+迟滞判据,它还有一个隐藏好处——天然完成了跨时钟域的稳妥处理。按键来自与 FPGA 时钟完全异步的外部世界,严格说属于异步输入,存在亚稳态风险。慢节拍采样相当于降低了采样频率,工程上更稳妥的做法是在最前面再加一级(或两级)同步寄存器,用同一个稳定时钟对异步输入打两拍,把可能的亚稳态收敛到第一级、让第二级输出确定值,再送入消抖确认逻辑。是否需要加同步器取决于节拍来源:若 tick 由系统时钟分频产生、采样动作仍在系统时钟域,且确认窗口足够宽,简单设计可以接受;但理解"异步输入先同步、再消抖、再边沿检测"这条完整链路,能让你在更高速、更可靠要求的场合不踩坑。本文的检测还区分了按下(电平从 1 到 0,视上拉而定)、释放和"按下一个脉冲",这对应电平信号与边沿事件两种消费方式:有的逻辑关心当前是否按住(电平),有的只关心"被按了一下"(单周期脉冲),用边沿检测寄存器把电平变化转成一个 tick,是 FSM 使能、计数器自增最常用的接口形式。
十六、从一个按键到人机输入接口的扩展
把消抖模块放到更大的人机交互图景里,会发现同一套积木可以搭出丰富的输入设备。单个按键输出消抖电平和按下/释放脉冲,可以直接驱动菜单选择、数值加减速进;多个按键组合则用一个参数化模块统一例化、各自产生脉冲,配合一个小的"命令状态机"把长按、短按、组合键解释成更高层指令。长按与连发现象也能在确认框架上加一个计时器实现:检测到持续按下超过阈值就产生 long_press,或每隔一段时间自动重复触发(auto-repeat),这正是菜单调参时"按住不放数字快速滚动"的实现方式。旋转编码器(旋钮)输出 A、B 两相正交脉冲,需要同时对两相消抖并根据它们变化的先后顺序判断正转/反转,本质是把"消抖 + 边沿检测"扩展到两路带相位关系的信号,是示波器、电机调速度、数字电位器里的常见输入。
再往外看,矩阵键盘用"行扫描 + 列读取"来节省 IO:把若干行线依次拉低、读取列线电平,用较少的 FPGA 引脚读出多得多的按键,扫描本身就是一个周期性 FSM,而每个交叉点的判读依然离不开消抖确认。理解这些外设时建议始终抓住三条主线:其一,所有机械输入都要先假设它会抖动、且与系统时钟异步,用同步+消抖打底;其二,外部世界给出的多是慢变的物理电平,而数字逻辑消费的常常是"发生过一次事件"的脉冲,电平到脉冲的边沿转换是标准胶水;其三,扫描、采样、确认这些周期性动作都应由统一时钟分频出的 tick 驱动,而不是另造时钟。这样,一个看似最基础的消抖实验,实际为你后续学习 UART/SPI 的采样时刻、ADC 数据就绪、外部中断、甚至电机和传感器接口铺好了同一块地基——用确定的数字时钟去安全、可靠地"观察"一个不确定的模拟物理世界。
常见坑对照
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 不消抖直接读 | 按一下触发无数次 | 20ms 稳定计时 |
| 异步输入不打拍 | 亚稳态、偶发错乱 | 两级 FF 同步 |
拿电平 if(key) 计数 | 按下期间连加 | 边沿检测出脉冲 |
| 稳定窗口太短 | 抖动没滤净 | ≥10~20ms |
| 计数器位宽不够 | 窗口算错 | ⌈log2 N⌉ 位 |
| 高低有效搞反 | 逻辑反 | 看原理图,低有效先取反 |
相关与延伸
计时基础:计数器与分频、第一个工程 LED 与计数器。 深入:进阶 CDC 亚稳态与 MTBF、复位策略;ILA 抓按键见 ILA/VIO 调试。
一句话总结
机械按键在通断瞬间会抖动 5~20ms,而 FPGA 时钟只有几十 ns,不处理就会把一次按下读成上万次触发。标准处理链是三步:①异步按键先过两级触发器同步防亚稳态;②消抖——用一个 20ms(50MHz 下约 100 万拍、20 位计数器)的稳定计时器,电平连续稳定 20ms 才认可状态改变,抖动撑不过这个窗口就被滤掉;③边沿检测——把消抖后的电平延迟一拍,用 level & ~level_d 生成宽度 1 周期的“按下脉冲”,做到按一下触发一次。别直接拿按键电平去 if(key) 计数(按下期间会连加),也别跳过同步(亚稳态偶发难复现)。消抖阈值按时钟频率算 N=频率×0.02,注意按键高低有效和计数器位宽。