STM32按键状态机:从消抖到单击双击长按的完整实现
发布时间:2026/10/20 16:11:49 锦皓数字建站

简介这是一份基于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_PeriodElapsedCallbackvoid 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 灭长按超过 800msLED 亮。如果三个动作都符合预期说明状态机核心逻辑没问题。如果不正常优先检查按键引脚电平配置用示波器或万用表量一下按键空闲时电平是否为高按下是否为低这与 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 任务再阻塞读取队列。这样状态机本身不变只是事件传递方式从直接回调变成了队列解耦事件处理任务的优先级可以独立调整不会因为其中一个任务阻塞而丢掉按键响应。这套代码我看着虽然简单但真正把它调稳定并不容易。从一个连按下松手都分不清的初版到后来双击、长按、组合键全部可靠工作我最大的习惯变化就是每一次改完状态机都会把去抖、双击窗口、长按阈值这三个时间参数单独列成一个表格用示波器量真实按键波形去核对。从那以后我每次在嵌入式项目里碰按键都强制先画状态转移表再写代码少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。