FPGA安路赛项第八讲:按键同步与消抖

第 08 讲:按键同步与消抖 —— 物理噪声的数字滤波、双级同步与单周期脉冲


课前导读:跨越“物理机械世界”与“纯净数字世界”的深渊

在前几讲中,我们操作的都是芯片内部的确定性信号(计数器、移位寄存器、PWM 比较器)。这些信号由全局 50MHz 时钟统一步调,整齐划一。

但在本讲中,我们将第一次直面真实物理世界的“粗暴入侵”——机械按键(Key Switch)。

初学者往往以为:按下一个按键,电平就是干脆利落地从高(1)跌落到低(0)。
然而现实世界极其残酷!

  • 机械开关内部是充满弹性的微型金属弹片。在你的手指按压或松开的瞬间,金属片会在微观层面发生剧烈的碰撞、反弹与多次离合!
  • 这种微观反弹持续 5 ~ 20 毫秒(ms)。
  • 对于 50MHz 的 FPGA 来说,20ms 是整整 1,000,000(一百万)个时钟周期!
  • 如果不做任何处理,你轻轻按一下按键,FPGA 就会认为你以每秒几万次的手速狂按了上百次按钮!原本“按一下开关灯”,会瞬间变成“灯在 20ms 内疯狂暴闪了几十次,最后随机停在开或关”。

本讲将带你深入物理现实,彻底攻破异步信号亚稳态消除(打两拍)、连续平稳计时滤波算法以及工业级单周期脉冲发生技术。


第一章:物理真相——机械弹性抖动与亚稳态危机

1.1 显微镜下的机械抖动(Mechanical Bounce)

我们用高速示波器连接到机械按键的引脚上,捕捉手指按下与松开瞬间的电压波形:

按键未按 (3.3V) ──────┐
                      │    【前沿抖动 5~20ms】                 【后沿抖动 5~20ms】
                      │  ┌─┐ ┌──┐ ┌─┐                        ┌─┐ ┌──┐ ┌─┐
                      └──┘ └─┘  └─┘ └─── 稳定按下 (0V) ──────┘ └─┘  └─┘ └─── 恢复 3.3V
                                          │
                                          ▼
                      FPGA 在这 20ms 内采样到上千次 01 跳变!
  • 前沿抖动(按下抖动):弹片初次撞击接触点,因机械弹性发生连续回跳,产生密集的脉冲毛刺;
  • 稳定导通期:手指彻底压实,电平稳定在 $0\text{V}$;
  • 后沿抖动(松开抖动):手指松开,弹簧释放回位,再次产生数毫秒的抖动毛刺。

结论:按键输入不仅按下需要滤波,松开同样必须滤波!


1.2 异步输入的最大杀手:建立/保持时间违例与亚稳态(Metastability)

除了毛刺,还有更隐蔽的致命物理危机:

  • 板载的 50MHz 时钟一刻不停地跳变;
  • 人类按按键是完全随机的异步事件。按下的电平跳变边缘,极大概率正好撞在某个时钟上升沿的采样窗口内!
  • 违规后果:触发器输入电压未能提前稳定(违背建立时间 $t_{\text{su}}$ 或保持时间 $t_h$),触发器内部的两个背对背反相器将陷入平衡木中间状态,输出电平浮动在 $1.5\text{V}$ 的不定态 x,并在电路内部引发致命震荡。

治病良方:双触发器级联(工业界俗称“打两拍”)

外部脏信号 key_n ──► [ D 触发器 1 ] ──(中间信号 key_sync_ff1)──► [ D 触发器 2 ] ──► 安全干净信号 key_sync_ff2
 (完全异步随机)               ▲                                            ▲
                            clk                                          clk
  • 第 1 级触发器:正面迎接异步信号冲击。即使它不幸陷入亚稳态,在经历一个完整时钟周期(20ns)后,亚稳态绝大概率会跌落稳定为 0 或 1;
  • 第 2 级触发器:采样第 1 级的输出,此时信号已经恢复纯净,彻底消除了亚稳态传导风险!

第二章:key_debounce.v 消抖核心驱动源码解剖

本模块负责在后台把带有毛刺的外部按键信号,清洗为一个极其平稳的稳定电平 key_stable_n,以及一个仅持续 1 个时钟周期的脉冲信号 key_pressed。

module key_debounce #(
    parameter integer DEBOUNCE_CYCLES = 1_000_000 // 50MHz主频下,100万拍 = 20ms
)(
    input  wire clk,          // 50MHz 系统主时钟
    input  wire rst_n,        // 系统复位信号 (低有效)
    input  wire key_n,        // 外部机械按键引脚输入 (脏信号,低有效)
    output reg  key_pressed,  // 报信人:确认按下时,射出 1 个周期的单脉冲
    output reg  key_stable_n  // 官方认证:滤波后极其平稳的最终按键状态
);
    reg        key_sync_ff1; // 双触发器打两拍寄存器 1
    reg        key_sync_ff2; // 双触发器打两拍寄存器 2
    reg [31:0] stable_cnt;   // 20ms 连续平稳倒计时秒表

    // 进程一:双级触发器“打两拍”,消除亚稳态
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            key_sync_ff1 <= 1'b1;
            key_sync_ff2 <= 1'b1;
        end else begin
            key_sync_ff1 <= key_n;
            key_sync_ff2 <= key_sync_ff1;
        end
    end

    // 进程二:连续平稳性检验与脉冲发射
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            stable_cnt   <= 32'd0;
            key_stable_n <= 1'b1; // 默认没按按键 (高电平 1)
            key_pressed  <= 1'b0; // 默认电铃安静 (0)
        end else begin
            key_pressed <= 1'b0;  // 【门铃弹簧设计】:默认每一拍都把脉冲按在 0

            // 分支 1:外来信号和目前认可的稳定状态完全一样 (没动静或回弹了)
            if (key_sync_ff2 == key_stable_n) begin
                stable_cnt <= 32'd0; // 秒表无情清零!
            end
            // 分支 2:外来信号保持新状态,且秒表无间断数满了整整 20ms!
            else if (stable_cnt == DEBOUNCE_CYCLES - 1) begin
                stable_cnt   <= 32'd0;                 // 秒表归位
                key_stable_n <= key_sync_ff2;         // 官方正式承认新状态!
                if (!key_sync_ff2)
                    key_pressed <= 1'b1;              // 若是按下动作,猛敲一下电铃!
            end
            // 分支 3:外来信号发生改变,但连续平稳时间还没满 20ms
            else begin
                stable_cnt <= stable_cnt + 1'b1;      // 秒表继续累加验证
            end
        end
    end
endmodule

第三章:变量角色扮演——“三人保安室”现场推演

很多同学在面对这几个变量时脑子容易打结。我们把芯片内部的消抖逻辑还原为一个活生生的**“公司保安室”故事**:

变量名 真实身份 他的职责与性格
key_stable_n 【保安室的官方黑板】
(滤波认定的稳定值)
上面只记录当前被官方正式认可的门禁状态:1(门关着/没按)或 0(门开着/按下了)。
他极其沉稳甚至“冷血”,绝不听信任何片面的风吹草动,只有保安彻底核验无误后才改写黑板。
stable_cnt 【手拿秒表的严格保安】
(连续平稳计时器)
他负责死死盯着大门(key_sync_ff2)。
一旦发现大门状态和黑板写的不一样,他就按下秒表开始计时 20 毫秒。
如果中途大门有哪怕一丁点晃动弹回,他立刻把秒表清零归位,前功尽弃重新数!
key_pressed 【挂在墙上的回弹电铃】
(单周期触发脉冲)
他的职责是向后级业务电路报信。
只有在确认按键按下的那一瞬间,“叮”敲响一声(维持仅仅 1 个时钟周期的 1),下一拍立刻自动闭嘴熄灭(变回 0)。

3.1 核心攻坚:为什么 else 一上来必须写 key_pressed <= 1'b0;?

请看进程二的这行代码:

        end else begin
            key_pressed <= 1'b0; // <--- 为什么每次都要先把它写成 0?!
            if (key_sync_ff2 == key_stable_n) begin
               ...

这是硬件设计中极其经典的**“回弹门铃设计”**!

现实逻辑思考:

如果你用手指死死按住一个按键,按了整整 5 秒钟不放。

  • 你希望你的数码设备怎么反应?
    • 是希望它只响应一次操作(例如手机按一下音量加,音量上升 1 格)?
    • 还是希望它在这 5 秒钟里连续执行上亿次操作,音量瞬间爆满死机?
  • 答案显而易见:我们只要按下去的那一瞬间产生一个动作,后面按住不放的时间应该全部静默!

这就是这行代码的绝妙之处:

  1. 默认态:每一个 50MHz 时钟节拍到来,代码第一行就是强制把门铃往下压(设为 0);
  2. 触发态:只有当 20ms 消抖结束、正式确认按下的那一拍,底下的 key_pressed <= 1'b1; 才会推翻默认值,把门铃敲响(变成 1);
  3. 回弹态:仅仅过了 20 纳秒(下一个时钟节拍到来),由于底下的分支条件已经不再满足,开头的默认赋值重新掌权,电铃在下一个上升沿瞬间自动落回 0!
  4. 效果:无论你手按了 1 秒、10 秒还是一小时,key_pressed 在时间轴上严格只产生宽度仅为 20ns 的单脉冲!

第四章:微观时钟拍数演练——放慢时间看抗噪过程

为了让你彻底看清消抖算法是怎么“吞噬”弹簧毛刺的,我们假设消抖目标是连续平稳 3 拍(设 DEBOUNCE_CYCLES = 3),来看看手指按键全过程中每一个时钟周期内部变量的真值表:

时钟拍数 手指物理动作 外界按键输入 key_n 经过两拍安检 key_sync_ff2 黑板状态 key_stable_n 秒表 stable_cnt 怎么变?为什么? 报信电铃 key_pressed 状态说明
第 1 拍 手指悬空没按 1 1 1 0(外面和黑板一样是 1,没变化,秒表清零) 0 静默期,无事件
第 2 拍 手指刚碰上弹片 0 0 1 0 \to 1(外面变成了 0,和黑板不一样!秒表开始跑:1!) 0 捕捉到异动,启动计时
第 3 拍 弹片机械反弹! 1 1 1 1 \to 0(又弹回 1 了!和黑板又一样了!秒表被无情清零!) 0 杂波被成功粉碎吸收!
第 4 拍 手指二次撞击压下 0 0 1 0 \to 1(重新变 0,秒表重新起跑:1!) 0 重新启动计时
第 5 拍 手指持续死死按住 0 0 1 1 \to 2(持续为 0,依然不一致,秒表累加:2!) 0 继续累加验证
第 6 拍
(奇迹瞬间!)
手指持续按住 0 0 1 $\to$ 0 2 \to 0(连续稳稳数满 3 拍!官方正式认可!黑板写上 0!) 叮!变成 1! 成功确认按下!
射出单周期脉冲!
第 7 拍 手指依然死按着 0 0 0 0(外面是 0,黑板也是 0,恢复一致,秒表停转) 自动落回 0! 门铃自动回弹熄灭!
第 8 拍 手指依然死按着 0 0 0 0(信号与黑板一致,秒表保持 0) 0 哪怕按一万年,电铃绝不再响

核心机制总结:

看到第 3 拍的妙处了吗?
只要外界信号在抖动中弹回原状,key_sync_ff2 == key_stable_n 就会瞬间成立,秒表立刻被踹回 0!
这意味着:外界信号必须纹丝不动、连续不打嗝地保持新状态整整 20 毫秒,秒表才有可能数到终点!任何小于 20ms 的机械毛刺,在硬件面前都会被无情抹杀!


第五章:顶层系统联动——key_demo_board_top.v 实战演练

现在,我们把写好的消抖模块作为底层驱动,装配进一个顶层开关灯工程中:

module key_demo_board_top (
    input  wire       sys_clk,    // 50MHz 时钟
    input  wire       sys_rst_n,  // 复位按键
    input  wire       key_n,      // 机械操作按键
    output wire [3:0] led         // 4 颗 LED 输出
);
    wire key_pressed;  // 导线:接消抖模块射出的电铃单脉冲
    wire key_stable_n; // 导线:接消抖后的稳定电平
    reg  led_state;    // 1 位寄存器:记住灯当前是开(1)还是关(0)

    // 1. 安装消抖安全卫士
    key_debounce #(
        .DEBOUNCE_CYCLES(1_000_000) // 设定 20ms 倒计时门槛
    ) u_key_debounce (
        .clk         (sys_clk),
        .rst_n       (sys_rst_n),
        .key_n       (key_n),       // 接收外部带有毛刺的脏信号
        .key_pressed (key_pressed), // 吐出纯净的单脉冲
        .key_stable_n(key_stable_n)
    );

    // 2. 业务核心:按一下翻转一次
    always @(posedge sys_clk or negedge sys_rst_n) begin
        if (!sys_rst_n)
            led_state <= 1'b0;          // 复位关灯
        else if (key_pressed)
            led_state <= ~led_state;    // 听到铃声瞬间翻转:开变关,关变开
    end

    // 3. 输出连线:仅第 0 颗灯受控,前 3 颗强制常灭
    assign led = {3'b000, led_state};
endmodule

5.1 整个电路的现场互动体验:

  1. 未按按键时:key_pressed 一直是 0,led_state 稳固保持当前值不动;
  2. 手指按下一瞬间:消抖模块吃掉前沿毛刺,在第 20ms 的那一瞬时射出一个 20ns 宽度的 key_pressed = 1;
  3. 顶层响应:always 块在时钟沿捕捉到这个高电平,立即把 led_state 取反(灭变亮,或亮变灭);
  4. 手指持续按着不松:key_pressed 已经落回 0,灯保持新状态稳定发光,绝无闪烁;
  5. 手指松开按键:消抖模块吃掉后沿松开毛刺,黑板改写回 1,由于 !key_sync_ff2 为假,松开时不会触发 key_pressed,灯依然保持现状!
  6. 再次按下:灯再次翻转熄灭。按一下、变一次,手感如同机械开关般清脆可靠!

第六章:工程实操常见翻车排错指南

1. 故障:按键按下去,LED 依然会有概率狂跳两次

  • 排查诊断:
    检查你的消抖时间参数 DEBOUNCE_CYCLES 是否设得太小了!有的劣质老旧按键弹性老化严重,抖动时间长达 $15\sim 18\text{ms}$。如果代码只设了 5ms(250,000 拍),毛刺还没震荡结束,计时器就已经数满认定了,导致二次误判。工业界标准安全消抖时间一律建议设为 $20\text{ms}$。

2. 故障:按键无论怎么按,板子完全毫无反应

  • 排查诊断:
    1. 检查 ADC 管脚约束文件中,该按键引脚是否开启了内部弱上拉:
      PULLTYPE = PULLUP;。如果没开上拉且外部没有硬件上拉电阻,引脚未按时悬空,无法形成确定的 $1 \to 0$ 跳变;
    2. 确认开发板原理图按键类型:有的板子按键按下是给高电平(Active-High)。如果是高有效,代码中打两拍的初始值、比较条件以及 if (!key_sync_ff2) 全部需要反转!

本讲核心总结

  1. 机械抖动是不可逾越的物理现实:任何机械触点必有 5~20ms 毛刺,直接接入电路必死无疑。
  2. 双级触发器(打两拍)灭绝亚稳态:跨越异步世界的必备安检门。
  3. “连续平稳计时法”的精髓:只要有毛刺反弹,计时器立刻清零重来;唯有一动不动坚持满 20ms,才承认状态改变。
  4. “门铃弹簧”单周期脉冲范式:利用默认赋值 key_pressed <= 1'b0,打造仅维持一拍的自回弹使能信号,是驱动后级状态机翻转的绝对标准。