简介:这是一份基于STM32F103C8T6的按键状态机示例工程,面向嵌入式开发爱好者与单片机初学者,解决日常设备中短按、长按、双击等操作难以区分的问题。工程以定时器3中断为时基,通过状态机完成单按键单击(小于1秒)、双击(两次短按间隔小于500毫秒)与长按(大于1秒)的识别,并借助串口1打印输出,代码简洁规范、可读性强、易于移植。压缩包共79个文件,以C/H源码为主(33个头文件、32个源文件),另含启动汇编、Keil工程配置、HEX固件及说明文档,总大小仅182KB,目录划分清晰,便于直接编译与二次开发。资源已有6789人学习下载,学习参考价值较高。通过该工程可掌握定时器中断、按键消抖与多事件状态机的基本思路,并可直接套用到台灯亮度切换、多媒体控制等实际交互场景。
1. 为什么我在STM32上放弃了“松手再判断”的按键写法
之前在做一个工业仪表的人机交互面板,三个按键要承担“单击进入菜单、双击切换选项、长按返回”的完整操作。一开始我按最直白的思路写:检测到按键按下,记录时间,直到松手再去判断这算一次单击还是长按。结果在样机上一测就翻车,双击识别极不稳定,有人手速快被拆成两次单击,有人按得慢直接被当成两次单击加一次长按。后来我把整套按键逻辑重构成有限状态机,在STM32上用定时器周期性扫描按键,把去抖、按下确认、双击超时窗口、长按计时全部放进离散状态里,才终于让单击、双击、长按三种操作变得可靠。
这套项目就是一串可以在STM32裸机上直接跑的按键状态机代码,核心思路独立于具体芯片型号,只要是有GPIO和定时器的单片机都能移植。它解决的不只是“怎么判断一个按键”,而是怎么用状态机思维把时间维度的操作语义拆干净,让嵌入式硬件在产品里不再玄学。写固件的人可以直接抄状态表,搞应用的可以把回调事件接到菜单、音量、翻页等任意逻辑上。
2. 状态机按键的整体设计:文件结构、状态定义与事件接口
2.1 模块文件划分与职责
这套按键状态机严格按照“配置、底层驱动、逻辑判决、业务回调”四层拆开,不是把所有东西塞进一个 main.c 里。模块拆出来只有四个文件,小工程直接拷贝,大工程也可以当作基础驱动层。
| 文件 | 职责 | 关键内容 |
|---|---|---|
| key_config.h | 引脚、电平、扫描参数配置 | 按键引脚宏、有效电平、去抖次数、双击窗口、长按阈值 |
| key.h | 对外接口与事件枚举 | key_init、key_scan、事件回调注册 |
| key.c | 状态机核心实现 | 按键扫描、消抖、状态迁移、事件产生 |
| main.c | 示例业务层 | 初始化调用、TIM中断挂载、事件处理回调 |
这样拆的好处是,key.c 内部完全不知道按键接在哪个引脚,只通过 key_config.h 里的读写引脚宏来获取电平,换板子只改一份头文件。业务回调在 main.c 里写,按不同事件去做不同动作,后期不管是改成蜂鸣器提示还是 CAN 上报,都不需要动状态机本体。
2.2 状态定义:五个状态覆盖整个按压生命周期
状态机总共定义了五个关键状态,分别对应按键从空闲、按下、松开到等待双击的完整过程。下面是我在头文件里写的状态枚举:
typedef enum { KEY_STATE_IDLE = 0, // 空闲:没有按键按下,等待初始按下 KEY_STATE_DEBOUNCE_PRESS, // 按下消抖:连续N次读到按下才确认 KEY_STATE_PRESSED, // 已确认按下:开始累计长按时间 KEY_STATE_DEBOUNCE_RELEASE,// 松开消抖:连续N次读到松开才确认释放 KEY_STATE_DOUBLE_WAIT // 双击等待:等待第二个按下或超时触发单击 } key_state_t;IDLE 是起点,也是每一次操作结束后的归宿。DEBOUNCE_PRESS 和 DEBOUNCE_RELEASE 是两层“确认缓冲”,专门对付机械按键的抖动。PRESSED 状态里会做两件事,一个是持续累加按下次数,另一个是判断是否超过长按阈值。DOUBLE_WAIT 状态是整个状态机最精妙的地方,按下释放后不立刻上报单击,而是给一个双击窗口继续等第二个按下动作,窗口超时再补发单击事件。
从最终效果看,双击操作比单击优先,因为系统总有一个“等待确认”的延迟。这个延迟通常只有二百多毫秒,实际使用中体感不出来,但换来的是极高的识别准确率。长按则是在 PRESSED 状态里边计时边判断,一旦超过阈值就立刻触发,不需要等松手。
2.3 工作时序:10ms 扫描周期与双击窗口
整个状态机靠一个固定周期扫描来驱动。我习惯用 10ms 的软件定时器中断去调用一次 key_scan(),这个扫描周期对整个时序逻辑很关键。假设按键按下发生在某个时刻,状态机在下一个扫描周期检测到低电平,开始进入 DEBOUNCE_PRESS,连续两次扫描都为低才确认按下,也就是 20ms 去抖。
随后进入 PRESSED,每经过一个扫描周期就累加一次 elapsed_ms,当该值超过 KEY_LONG_PRESS_MS 后会触发长按事件并立刻返回 IDLE。如果没到长按阈值,用户就松手了,状态机先做 20ms 松开去抖,确认后进入 DOUBLE_WAIT。此时启动一个双击超时定时器,例如 250ms。在这个窗口内若再次扫描到按下,则进入第二次的 DEBOUNCE_PRESS 并最终上报“双击”;若窗口超时,则补发一次“单击”事件,回到 IDLE。
时序上还有一个细节:双击窗口内的第二次按下同样需要去抖,但第二次确认按下后不再继续双击等待,直接产生双击事件。长按如果发生,就优先消耗掉这次按下,窗口内剩余时间被清空,防止长按后再误触发一次单击。整个过程都在一个 10ms 的节拍里推进,不需要外部阻塞延时,状态机可以在中断上下文里安全运行。
2.4 对外接口:初始化、扫描、回调注册
为了让调用方只看头文件就能上手,接口设计得尽量精简。核心接口只有三个,一个初始化,一个周期扫描,一个事件处理钩子。
/* key.h */ void key_init(void); void key_scan(void); typedef enum { KEY_EVENT_SINGLE_CLICK = 0x01, KEY_EVENT_DOUBLE_CLICK = 0x02, KEY_EVENT_LONG_PRESS = 0x04 } key_event_t; extern void key_event_handler(key_event_t event);key_init 做两件事:初始化按键 GPIO 的时钟和输入模式,把状态机变量复位到 KEY_STATE_IDLE。key_scan 是状态机的执行引擎,必须在固定时间周期里被调用。key_event_handler 是一个弱函数,或者说是留给用户的回调接口,key.c 里检测到有效事件后直接调它,业务层决定事件最终怎么用。
这种写法让模块天然支持多实例化,如果业务里需要两个按键,可以把所有状态变量封装成一个结构体,key_scan 带一个按键对象指针传入。示例工程为了易读,先做成单键版本,实际项目里再扩展成数组。
3. 核心实现拆解:扫描、去抖、单击双击长按的完整判决链
3.1 扫描函数:每个节拍的输入采样与状态迁移
key_scan() 是整个状态机的核心入口,所有时序和判决都集中在这个函数里。我先贴出单键版本的完整实现,代码量不大,但注释尽量写清楚。
/* key.c 核心状态机 */ static key_state_t key_state; static uint16_t debounce_cnt; /* 连续去抖计数 */ static uint16_t pressed_time_ms; /* 按下持续时间累计 */ static uint16_t double_wait_ms; /* 双击等待计时 */ void key_scan(void) { uint8_t level = key_read_level(); /* 读取当前电平,按下为有效电平 */ switch (key_state) { case KEY_STATE_IDLE: if (level == KEY_PRESSED_LEVEL) { key_state = KEY_STATE_DEBOUNCE_PRESS; debounce_cnt = 0; pressed_time_ms = 0; double_wait_ms = 0; } break; case KEY_STATE_DEBOUNCE_PRESS: if (level == KEY_PRESSED_LEVEL) { if (++debounce_cnt >= KEY_DEBOUNCE_TICKS) { key_state = KEY_STATE_PRESSED; pressed_time_ms = 0; } } else { key_state = KEY_STATE_IDLE; /* 抖动导致的误触,直接回到空闲 */ debounce_cnt = 0; } break; case KEY_STATE_PRESSED: pressed_time_ms += KEY_SCAN_PERIOD_MS; if (level != KEY_PRESSED_LEVEL) { key_state = KEY_STATE_DEBOUNCE_RELEASE; debounce_cnt = 0; } else if (pressed_time_ms >= KEY_LONG_PRESS_MS) { key_event_handler(KEY_EVENT_LONG_PRESS); key_state = KEY_STATE_IDLE; double_wait_ms = 0; } break; case KEY_STATE_DEBOUNCE_RELEASE: if (level != KEY_PRESSED_LEVEL) { if (++debounce_cnt >= KEY_DEBOUNCE_TICKS) { key_state = KEY_STATE_DOUBLE_WAIT; double_wait_ms = 0; pressed_time_ms = 0; } } else { key_state = KEY_STATE_PRESSED; /* 松开过程有抖动,回到按下态 */ debounce_cnt = 0; } break; case KEY_STATE_DOUBLE_WAIT: double_wait_ms += KEY_SCAN_PERIOD_MS; if (level == KEY_PRESSED_LEVEL) { /* 第二个按下出现,直接进入第二次按下去抖 */ key_state = KEY_STATE_DEBOUNCE_PRESS; debounce_cnt = 0; } else if (double_wait_ms >= KEY_DOUBLE_CLICK_TIMEOUT_MS) { /* 等待窗口超时,补发单击事件 */ key_event_handler(KEY_EVENT_SINGLE_CLICK); key_state = KEY_STATE_IDLE; double_wait_ms = 0; } break; default: key_state = KEY_STATE_IDLE; break; } }这个函数的逻辑并不复杂,关键点在“去抖计数 + 时间累加”这两条线相交替。第一次按下时,必须连续读到有效电平,才能排除机械抖动。确认按下后,每拍累加 pressed_time_ms,一旦超过长按阈值立即上报长按并复位状态,这样长按不需要等待松手。松开后同样需要连续读到无效电平才确认释放,防止按下-松开过程里的弹跳被当成二次触发。
有一个常见的理解偏差:很多人看到 DEBOUNCE_PRESS 里出现无效电平就直接回 IDLE,会担心手抖一下导致检测失败。实际上,机械按键的抖动通常发生在按下瞬间的几毫秒,20ms 去抖窗口内如果电平回弹,往往意味着是干扰而不是真实松开,直接回到 IDLE 是更安全的选择,避免把一次按下拆成多次事件。
3.2 去抖参数与电平配置
去抖采用“连续计数”而不是“延时等待”,这充分利用了扫描周期。宏定义在 key_config.h 里,默认配置针对普通机械按键。
/* key_config.h */ #define KEY_SCAN_PERIOD_MS 10 #define KEY_DEBOUNCE_TICKS 2 #define KEY_LONG_PRESS_MS 800 #define KEY_DOUBLE_CLICK_TIMEOUT_MS 250 #define KEY_PRESSED_LEVEL 0KEY_SCAN_PERIOD_MS 是 key_scan 的调用周期,必须与实际使用的定时器周期保持一致。KEY_DEBOUNCE_TICKS 是去抖拍数,2 拍表示连续 2 次扫描都为按下才确认,实际消抖时间为 20ms。机械按键一般 10-20ms 就够,薄膜按键可能需要 30ms,对应的就是把 TICKS 改成 3。KEY_LONG_PRESS_MS 定义了长按阈值,这个参数要考虑用户手指停留的体感,800ms 比较合适,多媒体按键常用 1000ms。KEY_DOUBLE_CLICK_TIMEOUT_MS 是双击窗口,250ms 是一个平衡值,太短会忽略慢速双击,太长会把连续单击当成双击。
3.3 双击窗口内长按冲突的处理
我在调试过程中踩过最典型的坑是“长按之后又收到一次单击”。原因在于原版本里长按事件触发后,状态没有立刻回 IDLE,而是继续走 DOUBLE_WAIT,导致松开后被误判为一次单击。后来在 PRESSED 状态里长按触发后主动把 double_wait_ms 清零并回 IDLE,问题才消失。
反过来还有一种情况:先按下一个键不松手,超过长按阈值后立即上报长按。如果用户实际是想做“按住拖动”,那长按事件之后需要继续追踪当前是否仍按住,这就要再加一个“长按持续中”的状态,用于区分一次长按和长按松开。项目里如果不需要按住拖动,就用当前实现的版本,最简单,事件最少,也最容易测试。
3.4 事件回调如何与业务解耦
key.c 内部不包含任何用户业务逻辑,检测到事件后调用 key_event_handler。这个回调函数在业务层实现,示例代码中写在 main.c 里。更工程化的方式是做成一个弱函数:
/* key.c 内定义 */ __weak void key_event_handler(key_event_t event) { /* 默认空实现,用户可覆盖 */ }业务层在 main.c 中重新定义同名函数,即可获得事件。这样 key 模块可以被重复使用在多个项目里,不需要删回调代码。如果你要在同一个工程里支持多个按键,可以把 key_event_handler 扩展为带按键编号的版本,例如 key_event_handler(uint8_t key_id, key_event_t event)。
4. 工程集成与运行验证:CubeMX配置、中断挂载与LED观察
4.1 CubeMX 里的 GPIO 和定时器配置
拿到这套代码第一步不是看逻辑,而是先把工程环境搭对。我用 STM32CubeMX 生成基础工程,芯片型号以 STM32F103C8T6 为例,也完全适用于 F0/G0/L4 等全系列。按键引脚选 PA0,配置为 GPIO_INPUT 并打开上拉电阻,因为按键另一端接 GND,按下时为低电平,对应 KEY_PRESSED_LEVEL 配置为 0。LED 引脚选 PB0,配置为 GPIO_OUTPUT。
定时器选用 TIM6,这是基本定时器,专门用来产生固定周期中断。把 TIM6 的预分频和自动重载值算好,目标是 10ms 中断一次。如果时钟是 72MHz,预分频 7200-1,重载值 100-1,这样中断周期就是 10ms。在 NVIC 设置里使能 TIM6 全局中断,然后生成代码。
4.2 把模块加入工程
CubeMX 生成后,在工程目录下新建一个 key 文件夹,把 key.c、key.h、key_config.h 放进去。如果用的 MDK,在 Project Items 中新建一个组,里面添加 key.c 文件。如果用的 IAR,则在 Project 里点击 Add Existing File。头文件路径要配置到 C/C++ Compiler 的 Include Paths 中,否则编译会报找不到 key.h。最后检查勾选 C99 标准,因为代码里可能用到 for 循环内声明变量这类写法。
4.3 在定时器中断中挂载扫描函数
两种挂载方式都可以,一种是直接在中断回调里调用 key_scan,另一种是设置一个扫描标志,在主循环中轮询。我推荐中断里直接扫描,因为 key_scan 函数执行时间只有几条比较语句和简单赋值,占用十几微秒完全可以接受。在 stm32f1xx_it.c 的 TIM6_IRQHandler 中调用 HAL_TIM_IRQHandler,然后在 main.c 中重写 HAL_TIM_PeriodElapsedCallback:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { key_scan(); /* 每 10ms 扫描一次 */ } }业务回调放在 main 函数之前先用 key_init() 初始化按键,再启动 TIM6 中断,整个模块就跑起来了。主循环里不需要额外处理,如果项目里还有其他任务,千万不要在 while(1) 里使用 HAL_Delay,避免阻塞状态机的节拍。
4.4 用 LED 验证三种事件效果
验证阶段建议把每个事件对应一种明确的 LED 行为,观察结果直观。示例回调函数可以这样写:
void key_event_handler(key_event_t event) { switch (event) { case KEY_EVENT_SINGLE_CLICK: HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); /* 单击:翻转 LED */ break; case KEY_EVENT_DOUBLE_CLICK: HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); /* 双击:灭 */ break; case KEY_EVENT_LONG_PRESS: HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); /* 长按:亮 */ break; default: break; } }烧录后按一下按键,LED 状态翻转;快速按两下,LED 灭;长按超过 800ms,LED 亮。如果三个动作都符合预期,说明状态机核心逻辑没问题。如果不正常,优先检查按键引脚电平配置,用示波器或万用表量一下按键空闲时电平是否为高,按下是否为低,这与 KEY_PRESSED_LEVEL 宏必须完全一致,否则状态机一开始就进错分支。
5. 避坑记录:状态机按键调试中的几个常见翻车点
5.1 按下和松开瞬间的高低电平弄反
现象:单击事件完全没反应,反而偶尔出现长按事件,或者双击极度灵敏。原因:按键硬件接法不同,有的按键按下后引脚被拉低,有的被拉高,而 KEY_PRESSED_LEVEL 配置没有同步改。解决:先查硬件原理图,确定按键按下时读到的电平,然后把 key_config.h 里的 KEY_PRESSED_LEVEL 改成实际有效值。如果是按下为高,把 GPIO 改成下拉输入,宏值改为 1。
5.2 去抖时间太短,机械按键重复触发一个事件
现象:正常按一次键,输出两个单击事件,甚至偶发双击事件。原因:KEY_DEBOUNCE_TICKS 设得太短,1 拍相当于 10ms,机械银触点抖动没被完全覆盖。解决:把去抖拍数增加到 3 或 4,也就是 30-40ms。注意不要超过双击窗口的 30%,否则第二次按下的去抖还没完成,双击窗口就已经超时。
5.3 在事件回调里做耗时操作,把后面的事件全部吞掉
现象:单击事件发生后 LED 反应正常,但紧接着长按和双击全部失灵。原因:回调函数里写了 HAL_Delay 或者等待 Flash 写入,导致 key_scan 长时间无法被调用,状态机停在旧状态。解决:回调里只设置标志位或者发消息,真正的耗时处理放到主循环中执行。项目中我一般用一个 volatile uint8_t event_flag 记录最新事件,主循环里查询标志再做显示、保存参数等操作。
5.4 扫描定时器周期改过,但宏参数没同步
现象:双击变迟钝,单击经常识别成双击。原因:CubeMX 里把 TIM6 周期从 10ms 改到了 50ms,但 key_config.h 里 KEY_SCAN_PERIOD_MS 还是 10,状态机里所有时间累计都偏慢。解决:两处必须保持一致,建议只用 KEY_SCAN_PERIOD_MS 这一个宏,TIM6 重载值初始化直接用编译期运算,从源头避免不一致。也可以加一个断言,开机时用 DWT 计数器校准。
5.5 长按之后误触发一次单击
现象:按一次长按,LED 亮起,松手后 LED 又翻转一次,看起来像长按之后又来个单击。原因:老版本状态机在长按触发后进入 IDLE 前没有清空双击等待窗口,松开时把 DOUBLE_WAIT 当成正常释放,补发了一个单击事件。解决:在 PRESSED 状态触发长按事件的同时,强制把 double_wait_ms 清零并且直接置为 IDLE,不要经过 DEBOUNCE_RELEASE。修复后长按事件和单击事件就互斥了。
6. 二次开发扩展:组合键、三击事件与RTOS化改造
如果想在这套状态机上继续做功能,我建议不要继续往单键 switch 里堆状态,而是先把按键抽象成对象。我一般会把 key_state、pressed_time_ms、double_wait_ms 这些全局变量封装进一个结构体,然后把 key_scan 改成 key_scan(key_obj_t *key),这样就能支持多个按键实例。组合键的识别思路也清晰了:每个按键实例各自跑状态机,主循环定期检查按键数组里当前处于 PRESSED 状态的对象是否超过了两个,如果超过就触发“组合按下”事件。
增加三击事件也不复杂。现在的 DOUBLE_WAIT 状态负责在窗口内检测第二下按下,想要三击就得在第二次确认按下之后,把状态切到一个新的 TRIPLE_WAIT,继续等待第三下按下。这个新状态的实现方式和 DOUBLE_WAIT 几乎一样,只是超时后补发的不是单击而是双击。关键参数是第二下和第三下之间的窗口,通常沿用 KEY_DOUBLE_CLICK_TIMEOUT_MS 就可以。
如果工程里已经用了 FreeRTOS,按键扫描完全可以放到一个独立小任务里,省掉硬件定时器中断的上下文切换。比如创建一个 10ms 周期任务的 FreeRTOS 定时器,在回调里调用 key_scan,检测到事件后直接 xQueueSend 到消息队列,UI 任务再阻塞读取队列。这样状态机本身不变,只是事件传递方式从直接回调变成了队列解耦,事件处理任务的优先级可以独立调整,不会因为其中一个任务阻塞而丢掉按键响应。
这套代码我看着虽然简单,但真正把它调稳定并不容易。从一个连按下松手都分不清的初版,到后来双击、长按、组合键全部可靠工作,我最大的习惯变化就是:每一次改完状态机,都会把去抖、双击窗口、长按阈值这三个时间参数单独列成一个表格,用示波器量真实按键波形去核对。从那以后我每次在嵌入式项目里碰按键,都强制先画状态转移表再写代码,少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取