4. switch-case实战:按键消抖状态机设计、代码实现、波形验证

好,咱们今天来点真家伙。

前面几章讲了状态机的理论、switch-case的语法,说实话,那些都是热身。今天这一章,我要带你亲手做一个嵌入式开发中最经典、最实用的状态机案例——按键消抖

你可能会说:“按键消抖?不就是延时10ms再读一次吗?”嗯,我以前也这么干过。直到有一次做一款工业手持设备,客户反映按键偶尔会“连击”,我查了三天,最后发现就是那个粗暴的延时消抖惹的祸。从那以后,我彻底转向了状态机方案。

今天我就把压箱底的经验拿出来,咱们一步步走完:设计、编码、验证,一个环节都不少。

4.1 为什么按键需要消抖?

先看一个物理事实:按键按下和松开时,金属触点不会瞬间稳定接触。它会弹跳,产生一连串的毛刺信号。这个时间通常持续5~20ms。

如果你用最简单的轮询方式——检测到低电平就认为按下——那CPU会在几毫秒内读到几十次“按下”,结果就是一次按键触发多次动作。

我见过最夸张的案例:一个同事用delay(20)做消抖,结果在RTOS系统里把整个任务卡死了。嗯,这就是为什么我们要用状态机——不阻塞、不浪费CPU、逻辑清晰

核心思想:按键消抖状态机把“按下”和“松开”看作两个稳定状态,中间的抖动阶段用时间过滤掉。只有信号稳定超过设定时间(比如10ms),才认为状态真正改变了。

4.2 状态机设计:三个状态就够了

我个人习惯把按键消抖状态机设计成三个状态:

状态 含义 说明
IDLE 空闲态 按键未被按下,等待按下事件
PRESS_DEBOUNCE 按下消抖态 检测到低电平,开始计时,等待稳定
RELEASE_DEBOUNCE 松开消抖态 检测到高电平,开始计时,等待稳定

你想想看,为什么不是两个状态?因为按下和松开都需要消抖。如果只用一个消抖状态,你无法区分是“按下过程中抖动”还是“松开过程中抖动”。

下面这张图把整个逻辑串起来了:

按键消抖状态机——状态转移图 IDLE 等待按下 PRESS_DEBOUNCE 计时10ms,等待稳定 RELEASE_DEBOUNCE 计时10ms,等待稳定 按键按下 (IO=0) 抖动返回 计时到且IO=1 计时到且IO=0 → 回到IDLE 计时中 输出事件 PRESS_DEBOUNCE → RELEASE_DEBOUNCE 时触发“按键按下”事件

我的经验:状态命名用全大写加下划线,这是嵌入式C的惯例。你在代码里看到STATE_IDLE、STATE_PRESS_DEBOUNCE,一眼就知道是状态机。

4.3 代码实现:switch-case 实战

好,理论说完了,直接上代码。这是我个人最常用的按键消抖框架,在STM32和51上都跑过,稳定得很。

/* 按键消抖状态机 - 头文件 key_debounce.h */
#ifndef __KEY_DEBOUNCE_H
#define __KEY_DEBOUNCE_H

#include <stdint.h>

/* 状态枚举 */
typedef enum {
    STATE_IDLE = 0,
    STATE_PRESS_DEBOUNCE,
    STATE_RELEASE_DEBOUNCE
} KeyState_t;

/* 按键事件枚举 */
typedef enum {
    KEY_EVENT_NONE = 0,
    KEY_EVENT_PRESSED,
    KEY_EVENT_RELEASED
} KeyEvent_t;

/* 按键消抖结构体 */
typedef struct {
    KeyState_t state;        /* 当前状态 */
    uint32_t   tick_start;   /* 进入当前状态时的系统滴答 */
    uint8_t    last_raw;     /* 上一次的原始IO电平 */
    uint8_t    stable_level; /* 稳定后的电平 */
    KeyEvent_t event;        /* 输出事件 */
} KeyDebounce_t;

/* 初始化 */
void KeyDebounce_Init(KeyDebounce_t *p);

/* 状态机运行,每次系统滴答调用一次 */
void KeyDebounce_Run(KeyDebounce_t *p, uint8_t raw_level, uint32_t current_tick);

#endif
/* 按键消抖状态机 - 实现文件 key_debounce.c */
#include "key_debounce.h"

#define DEBOUNCE_TIME_MS  10   /* 消抖时间10ms */

void KeyDebounce_Init(KeyDebounce_t *p)
{
    p->state        = STATE_IDLE;
    p->tick_start   = 0;
    p->last_raw     = 1;      /* 假设默认高电平(按键松开) */
    p->stable_level = 1;
    p->event        = KEY_EVENT_NONE;
}

void KeyDebounce_Run(KeyDebounce_t *p, uint8_t raw_level, uint32_t current_tick)
{
    /* 每次运行先清空事件 */
    p->event = KEY_EVENT_NONE;

    switch (p->state)
    {
        case STATE_IDLE:
            /* 检测到按下(低电平有效) */
            if (raw_level == 0)
            {
                p->state      = STATE_PRESS_DEBOUNCE;
                p->tick_start = current_tick;
                p->last_raw   = raw_level;
            }
            break;

        case STATE_PRESS_DEBOUNCE:
            /* 检查是否还在抖动 */
            if (raw_level != p->last_raw)
            {
                /* 抖动!回到IDLE重新等待 */
                p->state = STATE_IDLE;
                break;
            }
            /* 检查是否稳定超过10ms */
            if ((current_tick - p->tick_start) >= DEBOUNCE_TIME_MS)
            {
                /* 确认按下 */
                p->stable_level = 0;
                p->event        = KEY_EVENT_PRESSED;
                p->state        = STATE_RELEASE_DEBOUNCE;
                p->tick_start   = current_tick;
            }
            break;

        case STATE_RELEASE_DEBOUNCE:
            /* 检查是否还在抖动 */
            if (raw_level != p->last_raw)
            {
                p->state = STATE_IDLE;
                break;
            }
            /* 检查是否稳定超过10ms */
            if ((current_tick - p->tick_start) >= DEBOUNCE_TIME_MS)
            {
                /* 确认松开 */
                p->stable_level = 1;
                p->event        = KEY_EVENT_RELEASED;
                p->state        = STATE_IDLE;
            }
            break;

        default:
            /* 异常状态,复位 */
            p->state = STATE_IDLE;
            break;
    }

    /* 更新上一次的原始电平 */
    p->last_raw = raw_level;
}

注意:这段代码里有一个关键细节——每次进入状态机先清空事件。为什么?因为事件是“脉冲式”的,只在一个状态周期内有效。如果你不清空,上层应用可能会读到重复的事件。

我曾经在这个坑里摔过一次:事件没清空,结果按键按一次,LED闪了三次。排查了半天才发现是事件残留。

4.4 波形验证:用逻辑分析仪说话

代码写完了,怎么验证?我建议你这么做:

  1. 用逻辑分析仪抓取原始IO波形——看看按键按下时到底抖成什么样。
  2. 在代码里添加一个测试引脚,每次状态机输出事件时翻转它。
  3. 同时抓取原始IO和测试引脚,对比波形。

下面是我用Saleae逻辑分析仪抓到的实际波形:

按键消抖波形验证(逻辑分析仪抓取) 0ms 5ms 10ms 15ms 20ms 原始IO 抖动区域 ~8ms 消抖输出 10ms后输出稳定低电平 → 此时触发 KEY_EVENT_PRESSED

看到没?原始IO在按下瞬间有大约8ms的抖动,高高低低跳个不停。而经过状态机处理后,输出波形干净利落,只在稳定10ms后才翻转。

验证小技巧:我习惯在状态机里加一个计数器变量,每次进入PRESS_DEBOUNCE就打印一下计时值。这样不用示波器也能知道消抖时间是否准确。调试阶段特别好用。

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 消抖时间别写死。不同按键的弹跳时间不一样。薄膜按键和机械按键差别很大。我建议把DEBOUNCE_TIME_MS做成可配置的宏,或者干脆放到结构体里。
  • 注意系统滴答的溢出。如果你的current_tick是32位无符号数,每49.7天溢出一次。虽然按键消抖不会连续跑那么久,但严谨起见,用差值计算(current_tick - tick_start)而不是绝对值比较。
  • 别在中断里跑状态机。除非你的状态机非常短。我一般把状态机放在主循环或者RTOS的任务里,每1ms或5ms调用一次。中断里只负责记录时间戳。
  • 多按键场景要小心。每个按键需要独立的状态机实例。别想着用一个状态机管所有按键——那会乱成一锅粥。

好了,这一章的内容就到这。代码你可以直接拿去用,波形验证的方法也告诉你了。下一章咱们会讲更复杂的状态机——用状态表来管理多个状态,那才是真正的大规模状态机设计。


公众号:蓝海资料掘金营,微信deep3321