STM32 可移植教程 02:按键状态机,消抖、长按、释放一行也不用多写(实战篇)

STM32 可移植教程 02:按键状态机,消抖、长按、释放一行也不用多写(实战篇)

第一篇我们把 LED 点亮了,而且特意用 HAL_GetTick() 代替了 HAL_Delay()------不让 CPU 傻等。这是嵌入式编程最基础的习惯。

那现在加上按键。你很可能见过这种写法:

c 复制代码
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
    HAL_Delay(20);   // CPU 又在那傻等
    if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
        App_LED_Toggle();
        while (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET); // 死等松开
    }
}

能跑,但问题很多:

  • HAL_Delay(20) 又把 CPU 锁住了 20ms。第一篇白改了。
  • while(等松开) 死循环------按住不放,整个程序卡死在那里,别的什么都不能做。
  • 想加长按功能(按住 1 秒做点别的事)?这段代码完全没法扩展。
  • 再加一个按键?代码量翻倍,状态变量散落各处,迟早出 bug。

本篇不按套路来。我们要写一个按键状态机------用四个状态、几十行代码,一次覆盖消抖、短按、长按、释放检测。后面加第二个按键,复制粘贴状态机就行,不需要改逻辑。

而且全程没有一个 HAL_Delay

如果你还没看第一篇: 本篇依赖第一篇搭建的 VSCode 环境和 LED 驱动(app_led.h/c)。如果环境还没搭好,建议先去看第一篇------环境通了,LED 闪了,这第二篇才能顺利往下走。

本篇目标

最终现象:

text 复制代码
短按(< 1 秒):LED 翻转一次
长按(≥ 1 秒):LED 快速闪烁(100ms 周期),松开后恢复

本篇用到的外设:

text 复制代码
GPIO Output(LED,沿用第一篇)
GPIO Input(按键)

跑通标准:

text 复制代码
按一下,LED 翻转一次(不会一次按出好几次翻转)
按住不放超过 1 秒,LED 开始快速闪
松开后,LED 停止闪烁,维持当前状态
全程不用 HAL_Delay,不阻塞 CPU

本篇不讲外部中断(EXTI)。中断做按键有它的适用场景,但状态机消抖这个方法,中断和轮询都能用。我们先在轮询里把状态机讲透。

准备工作

项目 说明
上一篇工程 已完成第一篇 01_env_led_gpio,LED 能正常闪烁
开发板 任意 STM32 开发板,板上有至少一个独立按键或外接按键
原理图 需要确认按键接到哪个 GPIO,以及是上拉还是下拉
第二篇工程 建议复制 01_env_led_gpio 整个文件夹,改名为 02_key_input

认识按键抖动

在写代码之前,先搞清楚一个问题:按一下按键,引脚上到底发生了什么?

你可能直觉认为:按下去 → 电平从高变低,松开 → 电平从低变高。干净利落。

但实际不是这样。按键内部是金属弹片------按下去的时候,弹片不会一触即稳,而是会像皮球落地一样弹好几下才停下来。这个过程叫机械抖动(bounce)

用示波器去看的话,按下按键那一瞬间的波形大致是这样:

抖动一般持续 5ms 到 20ms(不同按键不同,差一些的可能到 30ms)。在这个窗口里,电平在高低之间来回跳,如果代码在这期间读了引脚,就可能把一次按下误判成好几次。

软件消抖就是等这段抖动过去之后再读电平------但等待期间 CPU 不能傻等,得继续干别的事。下面这张图展示了完整的时间关系:

抖动一般持续 5ms 到 20ms(不同按键不同,差一些的可能到 30ms)。在这个窗口里,电平在高低之间来回跳,如果代码在这期间读了引脚,就可能把一次按下误判成好几次。

怎么解决?

两种手段,一个都不能省:

硬件消抖: 在按键两端并联一个 100nF 左右的小电容。电容的特性是"电压不能突变",相当于一个微型缓冲垫,能把抖动最尖锐的部分吸收掉。很多开发板原理图里都有这个电容(标号可能是 C56、C23 之类的),翻一下你的原理图就能找到。

软件消抖: 硬件电容能减轻抖动,但不能根除------抖动频率不高的时候,电容的效果有限。所以软件上必须再消一次。基本思路很简单:第一次检测到电平变化后,等 20ms 再去读,如果电平还是那个值,才认为"真的变了"。

这 20ms 的等待,就是消抖的核心。问题只在于:这 20ms 你怎么"等"。

HAL_Delay(20) 等 = CPU 傻等 = 阻塞。用状态机等 = CPU 照常干活 = 非阻塞。

硬件连接:上拉还是下拉?

在写代码之前,还有一个硬件问题必须搞明白。

浮空引脚问题

STM32 的 GPIO 设为输入模式时,引脚本身不驱动任何电压------它只是被动地"读"。如果你什么都不接,引脚电平是悬浮的(floating),读到的值不确定------可能是 0,可能是 1,甚至可能被旁边的引脚或环境噪声影响而随机跳动。

所以输入引脚必须有一个确定的默认电平。怎么做?用电阻把引脚"拉"到一个固定的电压上。

两种接法

接法 电路 按下时引脚读到 空闲时引脚读到
上拉(Pull-up) GPIO → 按键 → GND,GPIO 内部上拉到 3.3V 低电平 (0) 高电平 (1)
下拉(Pull-down) GPIO → 按键 → 3.3V,GPIO 内部下拉到 GND 高电平 (1) 低电平 (0)

用大白话讲:

  • 上拉接法: 电阻把引脚"拉"到高电平。按键按下去时,引脚直接被短接到 GND------GND 比你那个几十 kΩ 的上拉电阻"力气大",所以引脚被强行拉到低电平。就像一根绳子把气球往上拉(上拉电阻),你用手一拽绳子(按键接到 GND),气球就下来了。
  • 下拉接法: 反过来。电阻把引脚"拉"到低电平。按键按下去接 3.3V,3.3V 的驱动力比下拉电阻强,引脚被拉到高电平。

两种接法没有好坏之分,只是硬件设计的选择。STM32 内部自带上下拉电阻(约 40kΩ),你不需要外接电阻------在 CubeMX 里配一下就行。

怎么判断你的板子是哪种?

找到你的开发板原理图,定位到按键那一块。看按键不接 GPIO 的那一端:

text 复制代码
按键一端 → GPIO(如 PA0)
按键另一端 → GND  →  说明是上拉接法
按键另一端 → 3.3V →  说明是下拉接法

本文演示板的按键另一端接的是 GND,所以是上拉接法------按下 = 读到低电平。

很多开发板的按键电路里还有一个 100nF 左右的电容并联在按键两端。这就是上面说的硬件消抖电容。你可以在原理图上确认一下它还在。

CubeMX 配置步骤

1. 复制工程

01_env_led_gpio 整个文件夹复制一份,改名为 02_key_input。用 VSCode 打开新目录。

如果你在文件夹里看到了 build 目录(里面有上一次编译生成的文件),可以删掉它------下一篇编译时会重新生成。

2. 在 CubeMX 里打开 .ioc

双击 02_key_input.ioc(系统应该已经关联了 CubeMX,双击就能打开)。

3. 配置按键引脚

在芯片引脚图上找到你的按键引脚(本文演示 PA0),左键点击,在弹出的功能列表里选择:

text 复制代码
GPIO_Input

然后在引脚上右键 → Enter User Label → 输入:

text 复制代码
KEY

User Label 是 CubeMX 最重要的概念之一。 你填的 KEY,CubeMX 会自动生成两个宏:

  • KEY_GPIO_Port --- 按键所在的 GPIO 端口(如 GPIOA
  • KEY_Pin --- 按键所在的引脚号(如 GPIO_PIN_0

你的代码只依赖这两个宏名,不依赖具体的 GPIOA 或 PIN_0。换一块板子,只需要在 CubeMX 里给新引脚设同样的 User Label,代码一行不用改。

接下来在左侧 System Core → GPIO 里,找到刚配置的 KEY 引脚,确认以下参数:

参数 为什么
GPIO mode Input mode 按键是输入设备,我们要读它的电平
GPIO Pull-up/Pull-down Pull-up 配合按键→GND 的硬件电路,空闲时上拉电阻保持高电平
User Label KEY 生成 KEY_GPIO_PortKEY_Pin

如果你的按键是下拉接法 (按键另一端接 3.3V),那么 Pull-up/Pull-down 选 Pull-down (或者 No pull-up and no pull-down,如果你板子上有外部下拉电阻的话)。后面代码里的 APP_KEY_ACTIVE_LEVEL 也要相应改成 GPIO_PIN_SET

4. LED 引脚不动

第一篇配置的 LED 引脚(PB5,User Label = LED)保持不变。本篇 LED 和按键联动------按键控制 LED。

5. 重新生成代码

菜单栏 Project Manager → 确认 Toolchain 一栏选的是 Makefile → 点击右上角 GENERATE CODE

生成完成后,打开 Core/Inc/main.h,你应该能同时看到:

c 复制代码
#define LED_GPIO_Port  GPIOB     /* 第一篇配置的 */
#define LED_Pin        GPIO_PIN_5
#define KEY_GPIO_Port  GPIOA     /* 本篇新增的 */
#define KEY_Pin        GPIO_PIN_0

两个 User Label 对应的宏都生成了,说明 CubeMX 配置成功。

重要提醒: CubeMX 重新生成会覆盖 Makefile。如果你在第一篇里有手动修改过 Makefile(比如改了 C_SOURCES),需要重新添加。建议养成习惯:每次 CubeMX 重新生成后,检查一下 Makefile。

状态机:换个思路想问题

代码之前,先搞清楚"状态机"是什么,以及为什么按键特别适合用状态机来做。

不用状态机的话,你的代码长什么样?

c 复制代码
static uint8_t press_count = 0;
static bool was_pressed = false;

bool now_pressed = (HAL_GPIO_ReadPin(...) == GPIO_PIN_RESET);

if (now_pressed && !was_pressed) {
    // 刚按下的瞬间?
    press_count++;
    if (press_count > 5) {
        // 按住超过 5 次循环 ≈ 长按?
    }
}
was_pressed = now_pressed;

问题是:press_count > 5 里的 5 是什么?它是"每循环一次 +1,循环 5 次 ≈ 大概过了几十毫秒"。这个"大概"跟主循环速度强绑定------主循环里加了别的代码,循环慢了,"5 次"表示的时间就变了。而且每个按键都要单独记 was_pressedpress_count......加功能就加变量,变量多了逻辑就乱。

状态机的直觉

换个角度:一个按键,在任一时刻一定处在下面四个状态之一

text 复制代码
① IDLE(空闲)        --- 没有按键活动,等待被按下
② DEBOUNCE_DOWN      --- 检测到电平变化,正在等待抖动过去
③ PRESSED(已按下)   --- 确认按下,等待松开或超时(长按)
④ DEBOUNCE_UP        --- 检测到松开,正在等待抖动过去

状态之间的跳转由条件触发:

text 复制代码
IDLE ────(检测到按下)───→ DEBOUNCE_DOWN
DEBOUNCE_DOWN ─(稳定 20ms)─→ PRESSED ─→ 触发 PRESS 事件
DEBOUNCE_DOWN ─(电平又跳回去了)─→ IDLE(刚才只是抖动)
PRESSED ────(检测到松开)───→ DEBOUNCE_UP
PRESSED ────(按住 ≥ 1s)───→ 仍为 PRESSED ─→ 触发 LONG_PRESS 事件
DEBOUNCE_UP ──(稳定 20ms)─→ IDLE ─────→ 触发 RELEASE 事件
DEBOUNCE_UP ──(电平又跳回去了)─→ PRESSED(刚才只是抖动)

关键认识: 状态机代码每次被调用时,只做两件事:(1)读一次电平、看一眼时间;(2)根据当前状态决定要不要跳转。耗时几十微秒,做完就退出。下次主循环再调它,它继续从上次的状态开始判断。

这就是"非阻塞"的来源------状态机不在任何地方等待,每次进来,判断一下条件,条件不满足就立刻退出。

用状态机之后,调用者只需要关心"事件"

c 复制代码
App_Key_Tick();                             // 跑一次状态机(几十微秒)
App_KeyEvent e = App_Key_GetEvent();        // 取事件
if (e == APP_KEY_EVENT_PRESS) { ... }       // 有人按了,我该干什么?

消抖、长按计时、松开检测------这些脏活累活都在状态机内部,调用者不需要关心。这就是分层:底层管"按键在做什么",上层管"根据按键做什么"。

完整代码

新建两个文件:

  • Core/Inc/app_key.h
  • Core/Src/app_key.c

在 VSCode 里:左侧文件树 → 右键 Core/Inc → New File → 输入 app_key.h。同理在 Core/Src 下新建 app_key.c

别忘了:在 Makefile 的 C_SOURCES 里加入 Core/Src/app_key.c 找到 Makefile 里一大片 Core/Src/xxx.c 的地方,在附近加上一行。漏了这步的话编译会报 undefined reference

app_key.h

c 复制代码
#ifndef APP_KEY_H
#define APP_KEY_H

#include "main.h"
#include <stdbool.h>
#include <stdint.h>

/* 事件类型:告诉调用者"按键刚才发生了什么" */
typedef enum {
    APP_KEY_EVENT_NONE       = 0, /* 什么都没发生 */
    APP_KEY_EVENT_PRESS      = 1, /* 刚刚按下(只触发一次) */
    APP_KEY_EVENT_RELEASE    = 2, /* 刚刚松开(只触发一次) */
    APP_KEY_EVENT_LONG_PRESS = 3, /* 按住超过 1 秒(只触发一次) */
} App_KeyEvent;

void App_Key_Init(void);
void App_Key_Tick(void);
App_KeyEvent App_Key_GetEvent(void);

/* 供调试或特殊场景直接读取当前稳定电平 */
bool App_Key_IsPressed(void);

#endif

逐行解释:

  • typedef enum { ... } App_KeyEvent; --- 定义了四种事件。NONE 表示"这个周期什么都没发生",PRESS / RELEASE / LONG_PRESS 各对应一种按键动作。用 enum 而不是 #define,好处是调试时能看到名字而不是数字。
  • App_Key_Init() --- 初始化状态机。在 main()MX_GPIO_Init() 之后调用一次。
  • App_Key_Tick() --- 每个主循环周期调用一次。内部跑状态机,耗时极短(几十微秒)。不阻塞。
  • App_Key_GetEvent() --- 取走积压的事件。取走后返回 APP_KEY_EVENT_NONE,直到下一次事件发生。
  • App_Key_IsPressed() --- 返回当前是否处于"按下"状态。在事件模式里一般用不到,但长按闪烁这种需要持续知道"按键还按着吗"的场景会用到。

事件只触发一次 是这套 API 的核心。按一次键,APP_KEY_EVENT_PRESS 只会在第一次检测到按下时出现一次,接下来的每一个 Tick 周期都返回 APP_KEY_EVENT_NONE------直到状态发生新的变化。这保证了你不会因为按了一下按键就触发了 100 次翻转。

app_key.c

这是本篇最核心的代码。代码不少,但结构很清楚:

  1. 可覆盖的配置宏
  2. 读取引脚(带电平适配)
  3. 状态机状态定义
  4. 内部变量
  5. SetEvent 机制
  6. 公开接口(Init、GetEvent、IsPressed)
  7. 状态机核心 Tick
c 复制代码
#include "app_key.h"

/* ── 可覆盖的配置宏 ── */

#ifndef APP_KEY_ACTIVE_LEVEL
#define APP_KEY_ACTIVE_LEVEL  GPIO_PIN_RESET  /* 上拉 + 按键接 GND,按下=低 */
#endif

#ifndef APP_KEY_DEBOUNCE_MS
#define APP_KEY_DEBOUNCE_MS   20u   /* 消抖时间(毫秒) */
#endif

#ifndef APP_KEY_LONG_PRESS_MS
#define APP_KEY_LONG_PRESS_MS  1000u /* 长按时间(毫秒) */
#endif

#ifndef 模式的作用: 如果用户没有提前定义这些宏,就用等号后面的默认值;如果用户在主程序或编译选项里提前 #define 了,就用用户的。这给移植留了后门------换一块板子或换一种按键,可以不动 app_key.c,只在自己的代码里覆盖。

c 复制代码
/* 检查 CubeMX 是否配置了 User Label = KEY */
#ifndef KEY_GPIO_Port
#error "KEY_GPIO_Port is not defined. Set the key pin User Label to KEY in CubeMX."
#endif

#ifndef KEY_Pin
#error "KEY_Pin is not defined. Set the key pin User Label to KEY in CubeMX."
#endif

#error 的作用: 如果你忘了在 CubeMX 里设置 User Label = KEY,编译时直接报错并告诉你原因。不要等下载到板子上发现按键没反应才去排查------让编译器帮你查。

c 复制代码
/* 读取物理引脚,返回"按键是否处于激活状态" */
static bool App_Key_ReadRaw(void)
{
    return HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == APP_KEY_ACTIVE_LEVEL;
}

这一层封装的价值: 你在 CubeMX 里选了上拉(按下=低),APP_KEY_ACTIVE_LEVEL 就是 GPIO_PIN_RESETApp_Key_ReadRaw() 返回 true 表示"按键被按下",false 表示"按键松开"。状态机代码里只用 true / false,不用关心底层电平到底是高还是低。换了板子、改了上下拉,只改 APP_KEY_ACTIVE_LEVEL,状态机逻辑不动。

c 复制代码
/* ── 状态机内部状态 ── */

typedef enum {
    KS_IDLE,            /* 空闲,等待按下 */
    KS_DEBOUNCE_DOWN,   /* 检测到按下,正在消抖 */
    KS_PRESSED,         /* 确认按下,等待松开或长按 */
    KS_DEBOUNCE_UP,     /* 检测到松开,正在消抖 */
} KeyState;

static KeyState  s_state       = KS_IDLE;
static uint32_t  s_entry_tick  = 0;     /* 进入当前状态的时间戳 */
static bool      s_event_taken = true;  /* 当前事件是否已被主循环取走 */

三个关键变量:

  • s_state --- 状态机当前在哪个状态。这是状态机的"记忆"。
  • s_entry_tick --- 进入当前状态时 HAL_GetTick() 的值。用来算"在这个状态待了多久"。消抖需要等 20ms,长按需要等 1000ms,全靠这个时间戳。
  • s_event_taken --- 标记当前事件是否已经被 GetEvent() 取走过。配合 SetEvent() 保证事件不丢失、不重复。
c 复制代码
static App_KeyEvent s_pending_event = APP_KEY_EVENT_NONE;

/*
 * SetEvent() 保证同一事件只触发一次:
 * 只有上一个事件被 GetEvent() 取走之后,才能放入新事件。
 * 这意味着主循环处理慢了一拍,也不会丢"有事件发生过"这个事实------
 * 只是新事件会覆盖旧事件(因为我们只存一个)。
 */
static void SetEvent(App_KeyEvent event)
{
    if (s_event_taken) {
        s_pending_event = event;
        s_event_taken   = false;
    }
}

s_event_taken 机制的精妙之处: 假设主循环在某一圈因为处理别的事情稍微慢了,状态机在此期间连续产生了 PRESS 和 LONG_PRESS 两个事件。SetEvent(PRESS) 先放入,s_event_taken = false。紧接着 SetEvent(LONG_PRESS) 被调用,但 s_event_taken 还是 false(主循环还没来得及取走),所以 LONG_PRESS 被丢弃

这意味着:如果主循环处理速度跟不上事件的产生速度,新事件会被丢弃,旧事件被保留 。对于按键来说,这个取舍是合理的------PRESS 事件还没被处理完就来了 LONG_PRESS,说明主循环正忙,丢弃这个长按事件影响不大。如果真需要不丢事件,可以把 s_pending_event 改成队列。但按键这种低速输入没必要。

c 复制代码
/* ── 公开接口 ── */

void App_Key_Init(void)
{
    /* 初始化时读取一次电平:
     * 如果按键已被按住(比如调试时一直压着),直接进入 PRESSED 状态
     * 避免误触发 PRESS 事件 */
    s_state         = App_Key_ReadRaw() ? KS_PRESSED : KS_IDLE;
    s_entry_tick    = HAL_GetTick();
    s_event_taken   = true;
    s_pending_event = APP_KEY_EVENT_NONE;
}

App_KeyEvent App_Key_GetEvent(void)
{
    s_event_taken = true;               /* 允许 SetEvent 放入新事件 */
    return s_pending_event;             /* 返回旧事件(可能是 NONE) */
}

bool App_Key_IsPressed(void)
{
    /* KS_DEBOUNCE_UP 期间按键还没完全确认松开,算"仍按着" */
    return (s_state == KS_PRESSED || s_state == KS_DEBOUNCE_UP);
}

App_Key_Init 处理边界情况: 如果上电时按键就是按着的(比如用户上电时手正好压着按键),状态机直接进入 KS_PRESSED,不会触发 PRESS 事件。如果初始化为 KS_IDLE,那么 Tick() 第一次跑的时候检测到按下,就会进入消抖然后触发 PRESS 事件------但用户并没有"按下"的动作,这就是误触发。

c 复制代码
/* ── 状态机核心:每次主循环调用一次 ── */

void App_Key_Tick(void)
{
    bool down   = App_Key_ReadRaw();   /* 读当前物理电平(已适配方向) */
    uint32_t now = HAL_GetTick();      /* 当前时间戳 */

    switch (s_state) {

    case KS_IDLE:
        if (down) {
            s_state      = KS_DEBOUNCE_DOWN;  /* 检测到按下,进入消抖 */
            s_entry_tick = now;               /* 记录进入时间 */
        }
        break;

状态一:IDLE(空闲)

逻辑最简单。平时在这等着,一检测到按下就跳去消抖状态。s_entry_tick = now 开始计时------从这一刻起,消抖窗口打开。

c 复制代码
    case KS_DEBOUNCE_DOWN:
        if (!down) {
            /* 电平跳回去了 → 刚才只是抖动,回到空闲 */
            s_state = KS_IDLE;
        } else if (now - s_entry_tick >= APP_KEY_DEBOUNCE_MS) {
            /* 电平维持了 20ms → 确认按下! */
            s_state = KS_PRESSED;
            s_entry_tick = now;                    /* 重置计时(为长按做准备) */
            SetEvent(APP_KEY_EVENT_PRESS);         /* 产生 PRESS 事件 */
        }
        break;

状态二:DEBOUNCE_DOWN(按下方向消抖)

两种可能:

  1. 电平在 20ms 内又跳回去了 → 说明刚才只是抖动。回到 KS_IDLE,就当什么都没发生过。
  2. 电平稳稳维持了 20ms → 真的按下了。进入 KS_PRESSED,重置时间戳(后面要计时长按),触发 PRESS 事件。

注意:这段代码在每个 Tick 都会检查 now - s_entry_tick >= 20ms。前几次 Tick,时间还不够 20ms,什么都不做------这正是"等待"的非阻塞实现:不等,每次都看一眼,条件满足了再行动。

c 复制代码
    case KS_PRESSED:
        if (!down) {
            /* 按键松开了 → 开始消抖 */
            s_state      = KS_DEBOUNCE_UP;
            s_entry_tick = now;
        } else if (now - s_entry_tick >= APP_KEY_LONG_PRESS_MS) {
            /* 按住超过 1 秒 → 产生长按事件 */
            s_entry_tick = now;                    /* 重置计时,避免重复触发 */
            SetEvent(APP_KEY_EVENT_LONG_PRESS);
        }
        break;

状态三:PRESSED(已确认按下)

在这个状态里等两件事:要么松开(跳去消抖),要么时间到了触发长按。

s_entry_tick = now 在触发 LONG_PRESS 后立刻重置 ------这是一个重要设计决策。如果不重置,过 1 秒之后的每一个 Tick 都会满足 now - s_entry_tick >= 1000ms,就会产生海量 LONG_PRESS 事件。重置之后,要再过 1 秒才会再触发一次。对于大多数应用,长按只触发一次就够了。

如果你需要"每按住 500ms 就再触发一次"(比如音量键长按连续加音量),可以把 APP_KEY_LONG_PRESS_MS 改成 500ms,这样每 500ms 触发一次长按事件。

c 复制代码
    case KS_DEBOUNCE_UP:
        if (down) {
            /* 电平又跳回"按下"了 → 刚才只是抖动,回到按下状态 */
            s_state = KS_PRESSED;
        } else if (now - s_entry_tick >= APP_KEY_DEBOUNCE_MS) {
            /* 电平稳定 20ms → 确认松开 */
            s_state = KS_IDLE;
            SetEvent(APP_KEY_EVENT_RELEASE);
        }
        break;
    }
}

状态四:DEBOUNCE_UP(松开方向消抖)

DEBOUNCE_DOWN 对称。松开时也会抖动,同样需要 20ms 确认窗口。

  • 20ms 内又跳回"按下" → 抖动,回到 KS_PRESSED
  • 20ms 持续"松开" → 真的松开了,回到 KS_IDLE,触发 RELEASE 事件

到此,状态机的四个状态全部走完了。 核心逻辑不到 40 行。后面加双击检测、加组合键------都只在这个框架上加状态,不改已有逻辑。

main.c 调用方式

在 main.c 的三个 USER CODE 区域里添加对应代码。

1. 包含头文件

c 复制代码
/* USER CODE BEGIN Includes */
#include "app_led.h"
#include "app_key.h"
/* USER CODE END Includes */

2. 初始化

放在 MX_GPIO_Init(); 之后。顺序有讲究:LED 先初始化,按键后初始化(按键初始化里可能读 GPIO,GPIO 必须先初始化完)。

c 复制代码
/* USER CODE BEGIN 2 */
App_LED_Init();
App_Key_Init();
/* USER CODE END 2 */

3. while 循环

c 复制代码
/* USER CODE BEGIN WHILE */
while (1)
{
    static bool     long_press_mode = false;
    static uint32_t last_blink_tick = 0;

    App_Key_Tick();

    App_KeyEvent event = App_Key_GetEvent();
    switch (event)
    {
    case APP_KEY_EVENT_PRESS:
        App_LED_Toggle();       /* 短按:翻转 LED */
        break;

    case APP_KEY_EVENT_LONG_PRESS:
        long_press_mode = true;            /* 进入长按闪烁模式 */
        last_blink_tick = HAL_GetTick();    /* 记录进入时间 */
        break;

    case APP_KEY_EVENT_RELEASE:
        long_press_mode = false;            /* 退出长按模式 */
        break;

    default:
        break;
    }

    /* 长按模式下:每 100ms 翻转一次 LED(非阻塞) */
    if (long_press_mode) {
        uint32_t now = HAL_GetTick();
        if (now - last_blink_tick >= 100) {
            last_blink_tick = now;
            App_LED_Toggle();
        }
    }

    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */

    /* USER CODE END 3 */
}

这段代码展示了"事件驱动 + 状态标志"的经典配合:

  • switch(event) --- 响应瞬态事件(短按、长按触发那一刻、松开那一刻)。这些事件只在状态变化时触发一次。
  • if (long_press_mode) --- 响应持续状态(长按期间持续闪烁)。这不能放在 switch 里,因为 switch 只在一瞬间执行,而闪烁是持续性的行为。
  • long_press_mode 标志位 --- 这是连接"事件(一瞬间)"和"持续行为(长时间)"的桥梁。事件把它设成 true,主循环持续检查它来做闪烁。RELEASE 事件把它清掉。

这是嵌入式编程里非常通用的模式。 后面做定时器任务调度、串口协议解析、I2C 设备管理------全都是这个思路。

编译、下载和验证

编译

在 VSCode 里按 Ctrl+Shift+B(或终端执行 make -j8)。确保输出里看到:

text 复制代码
arm-none-eabi-size  02_key_input.elf
   text    data     bss     dec     hex filename
   xxxx     xxx     xxx    xxxx    xxxx 02_key_input.elf
Error 0

如果报 undefined reference to 'App_Key_xxx',说明 app_key.c 没有加入编译。去 Makefile 的 C_SOURCES 里加上 Core/Src/app_key.c

下载

终端执行:

bash 复制代码
make flash

(前提:第一篇配好了 flash 任务。如果没配,去 .vscode/tasks.json 确认 flash 任务存在。)

验证

按顺序测这四项,每项都可以独立验证:

测试项 操作 期望现象 如果不对,先查
短按 快速按一下松开 LED 翻转一次(不会闪好几下) 消抖时长是否太短、上拉/下拉方向
长按 按住不放超过 1 秒 LED 开始快速闪烁(每 100ms 翻转一次) APP_KEY_LONG_PRESS_MS 值、long_press_mode 是否被设上
松开 长按闪烁中松开按键 LED 停止闪烁,保持当前亮/灭状态 case APP_KEY_EVENT_RELEASE 是否清掉了 long_press_mode
连续短按 连续快速短按多次 每按一次 LED 翻转一次,不丢、不重复 主循环是否有阻塞代码

最重要的一项测试: 按住按键不放,观察 LED。如果 LED 不是"按 1 秒后开始闪",而是"刚一按下就开始狂闪"------说明消抖没生效,检查 APP_KEY_ACTIVE_LEVEL 和 CubeMX 上下拉配置是否匹配。

工程延申:状态机 vs 延时消抖

第一篇我们对比了 HAL_DelayHAL_GetTick。这一篇对比两种消抖方式。

延时消抖(常见写法)

c 复制代码
/* 检测到按键按下 */
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
    HAL_Delay(20);   // 等 20ms
    if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) {
        // 确认按下
        App_LED_Toggle();
        while (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET); // 等松开
    }
}

问题分析:

问题 具体影响 为什么状态机能解决
HAL_Delay(20) 阻塞 CPU 20ms 不干活,回到第一篇的问题 Tick 不等人,看一眼就走,几十微秒
while(等松开) 死等 按住不放,整个程序卡死在这行 没有 while 死等,状态机停在 KS_PRESSED,主循环照跑
没法做长按 等松开的 while 循环里什么都不能做 KS_PRESSED 里加一个计时条件,天然支持
加第二个按键 每个按键都要独立的 static 变量,逻辑散落各处 把状态机变量封装成 struct,每个按键一个实例
改逻辑就要改结构 想加双击?整段代码重写 加状态 KS_WAIT_SECOND_CLICK,不改已有状态

状态机消抖(本篇写法)

c 复制代码
/* 每个主循环周期调用一次,不阻塞 */
App_Key_Tick();

App_KeyEvent event = App_Key_GetEvent();
switch (event) {
    case APP_KEY_EVENT_PRESS:      /* 短按 */   break;
    case APP_KEY_EVENT_LONG_PRESS: /* 长按 */   break;
    case APP_KEY_EVENT_RELEASE:    /* 松开 */   break;
    default: break;
}

更进一步:多按键扩展

状态机的真正威力体现在多按键场景。延时消抖每加一个按键就是翻倍的 ifstatic 变量。状态机只需要把变量封装成结构体:

c 复制代码
/* 每个按键一个独立的状态机实例 */
typedef struct {
    KeyState   state;
    uint32_t   entry_tick;
    bool       event_taken;
    App_KeyEvent pending_event;
    GPIO_TypeDef *port;
    uint16_t   pin;
    uint16_t   active_level;
} App_KeyInstance;

/* 两个按键,各自独立的状态 */
static App_KeyInstance s_key1;
static App_KeyInstance s_key2;

然后 App_Key_Tick 改成接受一个 App_KeyInstance * 参数,主循环里:

c 复制代码
App_Key_Tick(&s_key1);
App_Key_Tick(&s_key2);

App_KeyEvent e1 = App_Key_GetEvent(&s_key1);
App_KeyEvent e2 = App_Key_GetEvent(&s_key2);

两个按键,逻辑完全一样,只是数据不同------这就是数据和逻辑分离

下面我们就用这个思路,把一个单按键的工程升级为两个按键协作的模式切换器。

一句话: 延时消抖是"解决眼前问题",状态机是"搭建一个可以持续加功能的框架"。刚开始多写几行,后面省几十行。

实战:两个按键协作------LED 模式切换器

前面我们一直在用一个按键。但大多数开发板上都不止一个按键。这一节我们把单按键状态机升级为多实例版本,用两个按键做一个 LED 模式切换器。

功能设计

按键 短按 长按(≥1s)
KEY1(PA0)--- 模式键 切换到下一个 LED 模式 回到模式 0
KEY2(PC13)--- 操作键 在当前模式下执行操作 爆闪(模式 1)

三种 LED 模式:

模式 KEY2 短按 KEY2 长按 松开 KEY2
0 --- 开关 LED 翻转 --- ---
1 --- 快闪 LED 100ms 闪烁 LED 50ms 爆闪 停止闪烁
2 --- SOS 播放 SOS 求救信号 --- 停止,LED 灭

你当然可以只接一个按键,切换模式用短按/长按区分。但两个按键各自分工------一个管选模式,一个管执行------操作逻辑更清晰,也是实际产品里最常见的交互方式。

升级 app_key 为多实例

单按键版本的变量是全局的(static KeyState s_state),只能管一个按键。要管两个按键,就得把状态变量装进 struct,每个按键一个 struct 实例。

app_key.h(多实例版)

c 复制代码
#ifndef APP_KEY_H
#define APP_KEY_H

#include "main.h"
#include <stdbool.h>
#include <stdint.h>

typedef enum {
    APP_KEY_EVENT_NONE       = 0,
    APP_KEY_EVENT_PRESS      = 1,
    APP_KEY_EVENT_RELEASE    = 2,
    APP_KEY_EVENT_LONG_PRESS = 3,
} App_KeyEvent;

/* 每个物理按键对应一个 App_Key 变量 */
typedef struct {
    /* 配置(Init 时写入,之后只读) */
    GPIO_TypeDef *port;
    uint16_t      pin;
    uint16_t      active_level;

    /* 内部状态(Tick/GetEvent 管理,调用者不要直接读写) */
    uint8_t       state;
    uint32_t      entry_tick;
    bool          event_taken;
    App_KeyEvent  pending_event;
} App_Key;

void         App_Key_Init     (App_Key *key, GPIO_TypeDef *port,
                               uint16_t pin, uint16_t active_level);
void         App_Key_Tick     (App_Key *key);
App_KeyEvent App_Key_GetEvent (App_Key *key);
bool         App_Key_IsPressed(const App_Key *key);

#endif

和单按键版本的区别只有两点:

  1. 所有状态变量(stateentry_tickevent_takenpending_event)从全局挪进了 App_Key struct。
  2. 每个函数多了一个 App_Key *key 参数------告诉函数"你在操作哪个按键"。

app_key.c(多实例版) --- 核心状态机逻辑和单按键版本完全相同 ,只是把 s_state 换成 key->state,把 s_entry_tick 换成 key->entry_tick

c 复制代码
#include "app_key.h"

#ifndef APP_KEY_DEBOUNCE_MS
#define APP_KEY_DEBOUNCE_MS   20u
#endif

#ifndef APP_KEY_LONG_PRESS_MS
#define APP_KEY_LONG_PRESS_MS  1000u
#endif

#ifndef APP_KEY_ACTIVE_LEVEL
#define APP_KEY_ACTIVE_LEVEL  GPIO_PIN_RESET
#endif

typedef enum { KS_IDLE, KS_DEBOUNCE_DOWN, KS_PRESSED, KS_DEBOUNCE_UP } KeyState;

static bool read_raw(const App_Key *key)
{
    return HAL_GPIO_ReadPin(key->port, key->pin) == key->active_level;
}

static void set_event(App_Key *key, App_KeyEvent event)
{
    if (key->event_taken) {
        key->pending_event = event;
        key->event_taken   = false;
    }
}

void App_Key_Init(App_Key *key, GPIO_TypeDef *port, uint16_t pin, uint16_t active_level)
{
    key->port         = port;
    key->pin          = pin;
    key->active_level = active_level;
    key->state        = read_raw(key) ? KS_PRESSED : KS_IDLE;
    key->entry_tick   = HAL_GetTick();
    key->event_taken  = true;
    key->pending_event = APP_KEY_EVENT_NONE;
}

App_KeyEvent App_Key_GetEvent(App_Key *key)
{
    key->event_taken = true;
    return key->pending_event;
}

bool App_Key_IsPressed(const App_Key *key)
{
    return (key->state == KS_PRESSED || key->state == KS_DEBOUNCE_UP);
}

void App_Key_Tick(App_Key *key)
{
    bool     down = read_raw(key);
    uint32_t now  = HAL_GetTick();

    switch ((KeyState)key->state) {

    case KS_IDLE:
        if (down) { key->state = KS_DEBOUNCE_DOWN; key->entry_tick = now; }
        break;

    case KS_DEBOUNCE_DOWN:
        if (!down) {
            key->state = KS_IDLE;
        } else if (now - key->entry_tick >= APP_KEY_DEBOUNCE_MS) {
            key->state = KS_PRESSED;
            key->entry_tick = now;
            set_event(key, APP_KEY_EVENT_PRESS);
        }
        break;

    case KS_PRESSED:
        if (!down) {
            key->state = KS_DEBOUNCE_UP;
            key->entry_tick = now;
        } else if (now - key->entry_tick >= APP_KEY_LONG_PRESS_MS) {
            key->entry_tick = now;
            set_event(key, APP_KEY_EVENT_LONG_PRESS);
        }
        break;

    case KS_DEBOUNCE_UP:
        if (down) {
            key->state = KS_PRESSED;
        } else if (now - key->entry_tick >= APP_KEY_DEBOUNCE_MS) {
            key->state = KS_IDLE;
            set_event(key, APP_KEY_EVENT_RELEASE);
        }
        break;
    }
}

这段代码值得你仔细对比单按键版本。 你会发现状态机的 switch-case 一个字都没变------同样的四个状态,同样的转换条件。唯一的变化是把 s_xxx 换成了 key->xxx。这就是 struct 的价值:逻辑写一次,数据各一份。

CubeMX 配置

在之前的基础上,再加一个 KEY2 引脚:

引脚 User Label GPIO mode Pull-up/Pull-down
PA0 KEY1 GPIO_Input Pull-up
PC13 KEY2 GPIO_Input Pull-up
PB5 LED GPIO_Output (沿用)

生成代码后,Core/Inc/main.h 里应该有:

c 复制代码
#define KEY1_GPIO_Port  GPIOA
#define KEY1_Pin        GPIO_PIN_0
#define KEY2_GPIO_Port  GPIOC
#define KEY2_Pin        GPIO_PIN_13
#define LED_GPIO_Port   GPIOB
#define LED_Pin         GPIO_PIN_5

main.c 调用方式

这是本篇最"大"的一段代码,但结构很清楚:两个按键各自跑状态机,主循环根据事件 + 当前模式决定 LED 行为。

c 复制代码
/* USER CODE BEGIN PV */
/* 两个按键实例 */
static App_Key s_key1;
static App_Key s_key2;

/* LED 模式 */
typedef enum {
    MODE_SWITCH = 0,  /* 开关模式 */
    MODE_FAST,         /* 快闪模式 */
    MODE_SOS,          /* SOS 信号 */
    MODE_COUNT
} LedMode;

static LedMode   s_mode            = MODE_SWITCH;
static bool      s_continuous_mode = false;
static uint32_t  s_blink_tick      = 0;
static uint8_t   s_sos_step        = 0;
static uint32_t  s_sos_tick        = 0;
/* USER CODE END PV */
c 复制代码
/* USER CODE BEGIN 2 */
App_LED_Init();
App_Key_Init(&s_key1, KEY1_GPIO_Port, KEY1_Pin, APP_KEY_ACTIVE_LEVEL);
App_Key_Init(&s_key2, KEY2_GPIO_Port, KEY2_Pin, APP_KEY_ACTIVE_LEVEL);
/* USER CODE END 2 */
c 复制代码
/* USER CODE BEGIN WHILE */
while (1)
{
    /* 跑两个状态机 */
    App_Key_Tick(&s_key1);
    App_Key_Tick(&s_key2);

    /* ====== KEY1:模式切换 ====== */
    App_KeyEvent e1 = App_Key_GetEvent(&s_key1);
    switch (e1) {
    case APP_KEY_EVENT_PRESS:
        /* 短按 KEY1 → 切到下一个模式 */
        s_mode            = (LedMode)((s_mode + 1) % MODE_COUNT);
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        break;

    case APP_KEY_EVENT_LONG_PRESS:
        /* 长按 KEY1 → 回到模式 0 */
        s_mode            = MODE_SWITCH;
        s_continuous_mode = false;
        s_sos_step        = 0;
        App_LED_Off();
        break;

    default: break;
    }

    /* ====== KEY2:在当前模式下执行操作 ====== */
    App_KeyEvent e2 = App_Key_GetEvent(&s_key2);

    switch (s_mode) {

    case MODE_SWITCH:
        if (e2 == APP_KEY_EVENT_PRESS) {
            App_LED_Toggle();   /* 短按 = 翻转 */
        }
        break;

    case MODE_FAST:
        if (e2 == APP_KEY_EVENT_PRESS) {
            s_continuous_mode = true;
            s_blink_tick      = HAL_GetTick();
        }
        if (e2 == APP_KEY_EVENT_LONG_PRESS) {
            s_continuous_mode = true;
            s_blink_tick      = HAL_GetTick();
        }
        if (e2 == APP_KEY_EVENT_RELEASE) {
            s_continuous_mode = false;
            App_LED_Off();
        }
        break;

    case MODE_SOS:
        if (e2 == APP_KEY_EVENT_PRESS) {
            s_continuous_mode = true;
            s_sos_step        = 0;
            s_sos_tick        = HAL_GetTick();
        }
        if (e2 == APP_KEY_EVENT_RELEASE) {
            s_continuous_mode = false;
            s_sos_step        = 0;
            App_LED_Off();
        }
        break;

    default: break;
    }

    /* ====== 持续行为:闪烁 / SOS ====== */
    if (s_continuous_mode) {
        uint32_t now = HAL_GetTick();

        if (s_mode == MODE_FAST) {
            /* KEY2 按着 = 50ms 爆闪;松开 = 100ms 快闪 */
            uint32_t interval = App_Key_IsPressed(&s_key2) ? 50u : 100u;
            if (now - s_blink_tick >= interval) {
                s_blink_tick = now;
                App_LED_Toggle();
            }
        }
        else if (s_mode == MODE_SOS) {
            /* SOS 摩尔斯码:... --- ... 以 100ms 为步进 */
            if (now - s_sos_tick >= 100) {
                s_sos_tick = now;

                static const uint8_t seq[] = {
                    1,0, 1,0, 1,0,                /* S S S(三短) */
                    2,2,2,0, 2,2,2,0, 2,2,2,0,    /* O O O(三长) */
                    1,0, 1,0, 1,0,                /* S S S(三短) */
                    0,0, 0,0, 0,0, 0,0,            /* 结尾停顿 */
                };

                uint8_t cmd = seq[s_sos_step];
                s_sos_step = (s_sos_step + 1) % (sizeof(seq));

                if (cmd == 1 || cmd == 2) App_LED_On();
                else                        App_LED_Off();
            }
        }
    }

    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */

    /* USER CODE END 3 */
}

代码解读

这段代码看起来长,但拆开看只有四个部分:

  1. 数据定义(PV 区): 两个 App_Key 变量、模式枚举、几个持续行为用的标志位和时间戳。和单按键版本的区别是:多了 s_key2,多了模式枚举,多了 SOS 序列状态。

  2. 状态机扫描(while 顶部): 每个 Tick 周期扫两个按键。两个 Tick 各跑各的,互不干扰。这就是多实例的好处------加一个按键只加一行 App_Key_Tick()

  3. 事件处理(中间两大段 switch):

    • KEY1 的事件很简单:短按切模式、长按回模式 0。
    • KEY2 的事件看当前模式:模式 0 最简单(翻转 LED),模式 1 区分短按和长按来控制闪烁频率,模式 2 启动 SOS 序列。
  4. 持续行为(底部 if 块): 闪烁和 SOS 是持续性的------不能放在事件响应里(事件只触发一次),必须在事件设好标志位后,每圈都检查、每圈都可能翻转 LED。

关键设计:s_continuous_mode 标志位。 它在 KEY2 PRESS 事件里被设为 true,在 RELEASE 事件里被设为 false(模式 0 除外,模式 0 的短按是瞬态动作不需要持续行为)。底部 if 块检查这个标志决定是否执行闪烁/SOS 循环。这是"事件触发瞬时动作 + 标志驱动持续行为"的典型模式。

SOS 信号怎么做的? SOS 是国际求救信号:三短、三长、三短(摩尔斯码 ... --- ...)。实现上用一个预定义的序列数组 seq[],以 100ms 为步进。1 表示这 100ms LED 亮(短),2 也表示亮但因为连续 3 个 2 所以实际亮 300ms(长),0 表示灭。s_sos_step 记录当前跑到序列的哪一步,每 100ms 前进一步,跑完一圈自动循环。

验证

编译下载后,按这个顺序测试:

步骤 操作 期望
1 按 KEY2 LED 翻转(你在模式 0)
2 按 KEY1 切换到模式 1,LED 灭
3 按住 KEY2 不放 LED 50ms 爆闪
4 松开 KEY2 切换到 100ms 快闪,然后灭
5 按 KEY1 切换到模式 2,LED 灭
6 短按 KEY2 一下 SOS 信号循环播放
7 松开 KEY2(如果还按着) SOS 停止
8 长按 KEY1 回到模式 0,LED 灭

所有操作都流畅,无卡顿------全程没有 HAL_Delay

移植到其他板子的修改点

要改的地方 为什么要改 在哪里改
按键引脚 每块板子按键接到不同 GPIO CubeMX Pinout,给新引脚设 User Label = KEY
上拉/下拉方向 按键另一端接 GND 还是 3.3V CubeMX GPIO 配置;代码 APP_KEY_ACTIVE_LEVEL
消抖时长 不同按键机械特性不同 app_key.c 顶部 APP_KEY_DEBOUNCE_MS(默认 20ms 适用大部分场景)
长按时长 不同产品需求不同 app_key.c 顶部 APP_KEY_LONG_PRESS_MS
多个按键 每个按键需要独立的 GPIO 和 User Label 在 CubeMX 为每个按键设独立的 User Label(如 KEY1, KEY2),App_Key_Init 时传入各自的引脚

移植的核心原则:只改配置,不改逻辑。 CubeMX 管引脚映射,#ifndef 宏管参数覆盖。状态机代码本身对新板子零修改。

常见问题排查

1. 按键完全没反应

优先检查 具体做法
引脚对不对 CubeMX 里确认按键 GPIO 设置正确,User Label = KEY
上下拉对不对 万用表测按键空闲时引脚电平:上拉接法应该读到 3.3V,下拉接法应该读到 0V
#error 编译提示 如果报 "KEY_GPIO_Port is not defined",去 CubeMX 设置 User Label
Makefile 漏了 app_key.c 检查 C_SOURCES,漏加会报 undefined reference
初始化是否调用 App_Key_Init() 是否在 MX_GPIO_Init() 之后调用了

2. 按一次触发好几次(消抖失效)

优先检查 具体做法
消抖时间太短 加大 APP_KEY_DEBOUNCE_MS(常见范围 15~50ms)
Tick 调用频率太低 主循环里有阻塞代码吗?如果有 HAL_Delay 或长时间操作,Tick 间隔太大会错过消抖窗口
APP_KEY_ACTIVE_LEVEL 反了 上拉接法应该用 GPIO_PIN_RESET,下拉用 GPIO_PIN_SET。配反了会导致"没按的时候状态机以为按了"
硬件滤波电容脱焊 检查原理图上按键两端的电容(如 100nF),确认没有虚焊

3. 长按不触发

优先检查 具体做法
时间阈值 APP_KEY_LONG_PRESS_MS 是否被意外改成了很大的值
是否产生了 PRESS 事件 先确认短按能正常触发 PRESS------如果消抖失败没进入 PRESSED 状态,长按永远不会触发
主循环 Tick 调用频率 如果两次 Tick 之间隔了太久,可能错过了长按检测窗口

4. 松开后 LED 还在闪

检查 main.c 里的 case APP_KEY_EVENT_RELEASE: 分支------是否写了 long_press_mode = false;。这个变量不清掉,闪烁循环会一直跑。

5. 编译报 "undefined reference to App_Key_xxx"

app_key.c 没有加入编译。在 MakefileC_SOURCES 里加上 Core/Src/app_key.c 。注意:

  • `` 后面不能有空格
  • 要加在 Makefile 中已有的 Core/Src/xxx.c 附近,保持格式一致

6. 上电时按键刚好被按着,触发了 PRESS 事件

App_Key_Init() 里已经处理了这种情况------如果初始化时检测到按键是按下的,直接进入 KS_PRESSED 状态,不会触发 PRESS 事件。如果你还是遇到了,确认 App_Key_Init()App_Key_Tick() 第一次被调用之前执行。

本篇小结

这一篇我们从一个看似简单的按键,引出了嵌入式编程里三个核心的工程概念:

收获 具体来说
状态 > 变量 不用一堆 static 变量东拼西凑,用 4 个清晰的状态描述按键的全部行为
事件驱动 状态机负责产生事件(PRESS / RELEASE / LONG_PRESS),主循环负责响应事件------各管各的,互不侵入
零阻塞 Tick 不等人,全程无 HAL_Delay。消抖的 20ms 和长按的 1000ms,都是用"每次来看看时间到了没"来实现的
可移植 CubeMX User Label + #ifndef 配置宏 + App_Key_ReadRaw 电平适配------换板子只改配置,不改逻辑
数据与逻辑分离 状态机逻辑(app_key.c)写一次,每个按键一个 App_Key struct 实例。加按键 = 加一行 App_Key_Tick()

这个状态机骨架在本系列会反复出现------定时器任务调度、串口协议解析、I2C 设备状态管理,本质上都是"状态机 + 事件驱动"。

这个状态机骨架在本系列会反复出现------定时器任务调度、串口协议解析、I2C 设备状态管理,本质上都是"状态机 + 事件驱动"。

如果你是刚开始学 STM32 的新手,建议把这篇文章的代码完整敲一遍。 不是因为你需要记住每一行------而是因为敲的过程中你会被迫思考:为什么是这四个状态?为什么 s_entry_tick 在长按触发后要重置?为什么 SetEvent 里要检查 s_event_taken?当你自己写一遍,这些设计决策会从"作者说的"变成"我理解了的"。

练习

基础练习(单按键):

  1. 改长按时间:APP_KEY_LONG_PRESS_MS 改成 2000(2 秒),重新编译下载,确认长按确实需要按更久才触发。
  2. 改消抖时间:APP_KEY_DEBOUNCE_MS 改成 5,下载看看短按会不会触发多次(人为制造消抖失败)。再改回 20。这个实验让你直观感受消抖时间的作用。

进阶练习(双按键):

  1. 加第四个模式:LedMode 里加一个 MODE_SLOW_BLINK------KEY2 短按进入 500ms 慢闪,再短按停止。提示:在 switch (s_mode) 里加一个 case,在 if (s_continuous_mode) 里加对应的闪烁逻辑。
  2. KEY1 双击检测: 在状态机里加一个 KS_WAIT_SECOND_CLICK 状态------KEY1 在第一次 PRESS 后的 500ms 内如果再次按下,就算双击,直接回到模式 0。提示:单按键版本的状态机需要加一个状态和对应的跳转逻辑。这个练习有难度,先画状态转移图再动手。

下一篇预告

按键搞定了,下一篇我们加上串口打印------把按键事件("短按了"、"长按了"、"松开了")输出到电脑上,顺便把 printf 重定向到串口,让开发板能"说话"。

串口加上之后,调试就不用靠 LED 闪了------想在电脑上看到什么变量、什么状态,printf 出来就行。这是整个系列的基建工程。

相关推荐
✎ ﹏梦醒͜ღ҉繁华落℘2 小时前
单片机基础知识---stm32单片机的优先级
stm32·单片机·mongodb
u152109648494 小时前
S.S.Audio PRO A2音频隔离器
嵌入式硬件·音视频·实时音视频·视频编解码·视频
zd8451015004 小时前
RS485 总线详解
单片机·嵌入式硬件
程序猿阿伟4 小时前
《Chrome离线扩展安装的底层逻辑与场景落地指南》
服务器·网络·chrome
之歆4 小时前
现代 HTTP 客户端深度解析:Fetch 与 Axios
chrome·网络协议·http
半条-咸鱼5 小时前
【STM32】I2C协议原理、HAL读写与OLED显示操作
嵌入式硬件·c·信息与通信
牛根生同志5 小时前
SPI数据收发的时候 TXE与RXNE标志位置位的时机
stm32·spi·transfer
wohoo_wangzi6 小时前
苏州晟雅泰电子:关于W25Q128JVSIQ这个芯片物料的参数,规格及应用领域
嵌入式硬件
ziyitty6 小时前
MiMoCode 配置 “Unrecognized key: mcpServers“ 问题解决方案
前端·chrome
goldenrolan7 小时前
学习型红外控制系统稳定性挂测工装专项总结
软件测试·python·stm32·嵌入式·红外