Skip to content

第21篇:按钮电路与机械抖动 —— 真实世界的信号长什么样

承接上一篇:GPIO 输入路径已经搞清楚了——上拉输入、施密特触发器、IDR 寄存器。这一篇我们把理论落地:画接线图、算电流,然后面对一个 LED 教程从未遇到的问题——机械抖动。


我们的接线方案

在 LED 教程里,我们用的硬件是 Blue Pill 板上自带的 LED——连接在 PC13 上,不需要任何外部接线。按钮不一样——Blue Pill 上没有板载的用户按键(复位按钮是 NRST 引脚专用的,不能当通用按钮用),所以你需要自己接一个按钮。

接线方案如下:

text
         STM32F103C8T6 内部
         ┌─────────────────────┐
         │                     │
         │    VDD (3.3V)       │
         │      │              │
         │   [R_pullup ~40kΩ]  │
         │      │              │
         │      ├──── PA0 ─────┤─── 排针 PA0
         │      │              │
         │                     │
         │     GND ────────────┤─── 排针 GND
         │                     │
         └─────────────────────┘

         外部接线:
         PA0 排针 ──┤  按钮  ├─── GND 排针

   松开按钮:PA0 通过内部上拉电阻接到 VDD → 读到高电平 (1)
   按下按钮:PA0 直接接到 GND             → 读到低电平 (0)

就这么简单——按钮的两根线分别插到 Blue Pill 排针上的 PA0 和 GND 孔位。不需要电阻、不需要电容、不需要任何其他元件。STM32 内部的 40kΩ 上拉电阻帮我们搞定了默认电平的问题。

电流计算

按下按钮时,电流从 VDD(3.3V)经内部上拉电阻(约 40kΩ)流向 GND:

text
I = VDD / R_pullup = 3.3V / 40000Ω = 82.5μA

82.5 微安。这个电流非常小——STM32 的每个引脚最大能承受 25mA 的电流,82.5μA 只是额定值的 0.3%。而且按钮按下的时间通常很短(几百毫秒级别),对功耗的影响可以忽略。即使在电池供电的项目中,这个电流也完全不是问题。

为什么选 PA0

上一篇我们提到了选 PA0 的原因:EXTI0 有独立的中断向量。这里再补充一个实际原因——PA0 在 Blue Pill 排针上的位置很好找。Blue Pill 板子右侧的排针上,PA0 通常在最上面几个位置,旁边的 GND 引脚也很近,用一根短杜邦线就能接好。

如果你手边只有 4 脚的轻触按键也不用担心——4 脚按键的对角两脚是连通的(同一个触点),相邻两脚之间才是开关。你只需要选对角的两脚分别接 PA0 和 GND 就行。

对比方案:下拉接法

作为参考,还有一种下拉接法:

text
         STM32F103C8T6 内部
         ┌─────────────────────┐
         │                     │
         │   [R_pulldown ~40kΩ]│
         │      │              │
         │      ├──── PA0 ─────┤─── 排针 PA0
         │      │              │
         │     VDD ────────────┤─── 排针 3.3V
         │                     │
         └─────────────────────┘

         外部接线:
         PA0 排针 ──┤  按钮  ├─── 3.3V 排针

   松开按钮:PA0 通过内部下拉电阻接到 GND → 读到低电平 (0)
   按下按钮:PA0 直接接到 VDD             → 读到高电平 (1)

下拉方案是"高电平有效"(Active High)——松开=低,按下=高。对应代码中的 ButtonActiveLevel::High

我们不用下拉方案,原因有三:(1) 上拉方案中按钮接地,GND 在板子上到处都是,接线更方便;(2) 绝大多数 MCU 开发资料默认用上拉方案,社区资源更丰富;(3) 如果按钮线被意外拉断或脱落,上拉方案引脚回到高电平(安全状态),而浮空的引脚电平不确定,可能导致误触发。


机械抖动:按钮的"原罪"

接好了线,理论上按钮应该产生理想的信号:按下瞬间从高电平干净地跳到低电平,松开瞬间从低电平干净地跳回高电平。就像这样:

text
理想的按钮信号:

高 ───────────┐                 ┌───────────
              │                 │
低            └─────────────────┘
              │← 按下 →│← 松开 →│

但现实中,机械开关不是理想器件。按钮内部的金属触点在闭合和断开的瞬间,由于弹簧效应和金属弹性,会经历一个短暂的"弹跳"过程——触点反复接触、断开、再接触,直到最终稳定。

用示波器看,实际信号是这样的:

text
实际的按钮信号(按下瞬间):

高 ───┐  ┌┐ ┌┐  ┌┐  ┌─────────────
      │  ││ ││  ││  │
低    └──┘└─┘└──┘└──┘
      │← 5~20ms →│
       抖动区间
      最终稳定为低电平

实际的按钮信号(松开瞬间):

低 ─────────────┐  ┌┐ ┌┐  ┌─────
                │  ││ ││  │
高              └──┘└─┘└──┘
                │← 5~20ms →│
                 抖动区间
                最终稳定为高电平

抖动的持续时间取决于开关的物理特性——便宜的轻触按键可能抖 10-15ms,质量好的可能只有 2-5ms。但几乎不存在完全不抖动的机械开关。

不处理的后果

如果代码不处理抖动,直接在主循环中读取引脚状态,会发生什么?

假设主循环每 1ms 执行一次(对于 72MHz 的 STM32 来说绰绰有余)。按下按钮的 10ms 抖动期间,CPU 可能采样到这样的序列:

text
采样:  1 1 0 1 0 0 1 0 0 0 0 0 0 0 ...
         ↑       ↑ ↑       ↑
         按下    抖动中的假"释放"和假"按下"

CPU 看到的是:高→低→高→低→高→低→低→低→低... 它会认为按钮被按下了三四次,而不是一次。如果你的代码是"每次按下切换 LED 状态",那你会发现按一次按钮,LED 可能亮、可能灭、可能根本没反应——因为多次翻转互相抵消了。

这不是理论推演——你可以很容易地验证。写一个最简单的轮询程序,不加任何消抖,然后快速按一下按钮,用计数器记录检测到的"按下"次数。你会发现一次按压被计了 2-5 次,偶尔甚至是 7-8 次。


硬件消抖(可选方案)

消除抖动有两种思路:硬件消抖和软件消抖。先说硬件方案。

RC 低通滤波

最经典的硬件消抖方案是在按钮两端并联一个电容,利用 RC 电路的低通滤波特性来平滑快速跳变:

text
         VDD (3.3V)

        [R_pullup]

  PA0 ─────┤──────── 按钮 ────── GND

        [C = 100nF]

          GND

按钮断开时,电容通过上拉电阻缓慢充电到 VDD(高电平)。按钮闭合瞬间,电容通过按钮(几乎短路)快速放电到 GND。但抖动期间触点反复断开时,电容通过上拉电阻充电——由于 RC 时间常数 τ = R × C 的存在,电容电压不会瞬间跳回高电平。

如果 R = 40kΩ(内部上拉),C = 100nF:

text
τ = 40000 × 0.0000001 = 0.004s = 4ms

4ms 的时间常数看起来不长,但问题在于抖动期间触点反复断开又闭合,每次短暂的断开时间内电容只能充一点点电。用充电公式 V = VDD × (1 - e^(-t/τ)) 计算,断开 1ms 后电容充到 3.3 × (1 - e^(-1/4)) ≈ 0.73V——远低于施密特触发器的上升阈值(约 1.6V),所以短时间内断开时的抖动确实会被过滤掉。但如果断开持续到 3ms 以上,电容会充到 3.3 × (1 - e^(-3/4)) ≈ 1.88V——已经超过阈值了,信号会"泄漏"过去。

这就暴露了硬件消抖的核心困难:RC 参数需要在"过滤短抖动"和"不误杀真实的长断开"之间找平衡,而不同开关的抖动时间差异很大,一个参数很难通吃。

如果用外部电阻(比如 10kΩ)加 100nF 电容:

text
τ = 10000 × 0.0000001 = 0.001s = 1ms

1ms 的时间常数意味着 5ms 后电容几乎完全充到 VDD(5τ)。对于 5ms 以内的抖动来说,这个 RC 组合确实能起到不错的滤波效果。但抖动超过 5ms 的开关(便宜的轻触按键抖动可达 10-15ms)就可能过滤不干净。

硬件消抖的局限性

硬件消抖方案的问题在于:

  1. 参数不通用:不同开关的抖动时间差异大(2ms 到 20ms),RC 参数很难一个值通吃。
  2. 额外元件:需要电容,有时候还需要外部电阻,增加了 BOM 成本和 PCB 面积。
  3. 不完全可靠:即使有 RC 滤波,极端情况下仍可能有残余抖动穿透。

所以实际工程中,硬件消抖通常是"锦上添花"——如果空间和成本允许,加一个电容当然更好。但软件消抖是必须的,它作为最后一道防线,能可靠地处理所有情况。


软件消抖:我们的路线

软件消抖的核心思想很简单:不信任第一次采样。检测到引脚电平变化后,不立即认为状态变了,而是等一段时间再采样确认。如果连续多次采样结果一致,才认为状态真正发生了变化。

具体实现有几种方式,我们会逐步演进:

  1. 阻塞延时消抖(第 05 篇):检测到变化后 HAL_Delay(20) 等待,然后再采样。简单但有代价——CPU 被阻塞 20ms,什么都干不了。

  2. 非阻塞时间戳消抖(第 06 篇):用 HAL_GetTick() 记录变化时间,每次循环检查是否已经过了足够长的时间。不阻塞 CPU,但需要手动管理状态变量。

  3. 状态机消抖(第 07 篇):用 7 个状态的有限状态机来精确管理整个消抖和事件检测过程。这是我们的最终方案,也是最可靠的方案。

每一种都是前一种的自然演进——先用最简单的方式解决问题,看到问题后用更好的方式解决。这种"先脏后净"的学习路径比直接给出最终方案要好得多,因为你理解了每一步的"为什么"。


我们的硬件准备清单

总结一下你需要的硬件:

  • Blue Pill 开发板 — 和 LED 教程同一块,不需要换
  • ST-Link V2 调试器 — 和 LED 教程一样
  • 一个按钮开关 — 最普通的轻触按键(tactile switch),2 脚或 4 脚都行
  • 一到两根杜邦线 — 用来连接按钮和排针(PA0 和 GND 在排针上不一定相邻,通常需要杜邦线跳接)

接线只有两根:

  • 按钮一端 → PA0
  • 按钮另一端 → GND

PC13 板载 LED 保持不变,不需要额外接线。

⚠️ 如果你手边确实没有按钮开关,可以用一根杜邦线模拟——一端插 PA0,另一端碰一下 GND 再松开。效果和按钮一样,只是没有弹簧回弹,抖动可能会少一些(但仍然会有)。


我们回头看

这一篇做了三件事:画了按钮的接线图(上拉方案,按钮接 PA0 和 GND),计算了电流(82.5μA,完全安全),然后详细解释了机械抖动这个按钮的"原罪"。

核心结论:机械开关在按下和松开的瞬间会产生 5-20ms 的电平震荡,不做处理就会被误读为多次按键。硬件消抖有帮助但不完全可靠,软件消抖是必须的

下一篇我们开始写代码——先用 HAL 的 API 把引脚读出来,看看实际效果。

基于 VitePress 构建