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_Port 和 KEY_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_pressed、press_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.hCore/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
这是本篇最核心的代码。代码不少,但结构很清楚:
- 可覆盖的配置宏
- 读取引脚(带电平适配)
- 状态机状态定义
- 内部变量
- SetEvent 机制
- 公开接口(Init、GetEvent、IsPressed)
- 状态机核心 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_RESET。App_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(按下方向消抖)
两种可能:
- 电平在 20ms 内又跳回去了 → 说明刚才只是抖动。回到
KS_IDLE,就当什么都没发生过。 - 电平稳稳维持了 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_Delay 和 HAL_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;
}
更进一步:多按键扩展
状态机的真正威力体现在多按键场景。延时消抖每加一个按键就是翻倍的 if 和 static 变量。状态机只需要把变量封装成结构体:
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
和单按键版本的区别只有两点:
- 所有状态变量(
state、entry_tick、event_taken、pending_event)从全局挪进了App_Keystruct。 - 每个函数多了一个
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 */
}
代码解读
这段代码看起来长,但拆开看只有四个部分:
-
数据定义(PV 区): 两个
App_Key变量、模式枚举、几个持续行为用的标志位和时间戳。和单按键版本的区别是:多了s_key2,多了模式枚举,多了 SOS 序列状态。 -
状态机扫描(while 顶部): 每个 Tick 周期扫两个按键。两个 Tick 各跑各的,互不干扰。这就是多实例的好处------加一个按键只加一行
App_Key_Tick()。 -
事件处理(中间两大段 switch):
- KEY1 的事件很简单:短按切模式、长按回模式 0。
- KEY2 的事件看当前模式:模式 0 最简单(翻转 LED),模式 1 区分短按和长按来控制闪烁频率,模式 2 启动 SOS 序列。
-
持续行为(底部 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 没有加入编译。在 Makefile 的 C_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?当你自己写一遍,这些设计决策会从"作者说的"变成"我理解了的"。
练习
基础练习(单按键):
- 改长按时间: 把
APP_KEY_LONG_PRESS_MS改成 2000(2 秒),重新编译下载,确认长按确实需要按更久才触发。 - 改消抖时间: 把
APP_KEY_DEBOUNCE_MS改成 5,下载看看短按会不会触发多次(人为制造消抖失败)。再改回 20。这个实验让你直观感受消抖时间的作用。
进阶练习(双按键):
- 加第四个模式: 在
LedMode里加一个MODE_SLOW_BLINK------KEY2 短按进入 500ms 慢闪,再短按停止。提示:在switch (s_mode)里加一个 case,在if (s_continuous_mode)里加对应的闪烁逻辑。 - KEY1 双击检测: 在状态机里加一个
KS_WAIT_SECOND_CLICK状态------KEY1 在第一次 PRESS 后的 500ms 内如果再次按下,就算双击,直接回到模式 0。提示:单按键版本的状态机需要加一个状态和对应的跳转逻辑。这个练习有难度,先画状态转移图再动手。
下一篇预告
按键搞定了,下一篇我们加上串口打印------把按键事件("短按了"、"长按了"、"松开了")输出到电脑上,顺便把 printf 重定向到串口,让开发板能"说话"。
串口加上之后,调试就不用靠 LED 闪了------想在电脑上看到什么变量、什么状态,printf 出来就行。这是整个系列的基建工程。