
简介针对STM32F103ZET6的按键处理工程核心实现长按、短按、双击三种交互模式。整体设计思路利用外部中断检测按键按下事件再配合20毫秒定时器轮询状态标志位根据按住时长精确区分不同操作分别控制LED6、LED7、LED8的亮灭并通过串口输出计数信息适合嵌入式初学者或需要多状态按键识别的开发者。压缩包内共包含142个文件以C源码、H头文件及Keil工程文件为主同时提供编译生成的hex、axf、链接脚本和清理脚本整体大小约3.49MB目录结构规整。目前已有5840人学习这套基于标准外设库的例程可以直接编译烧录也便于移植到其他STM32系列。通过阅读源码可以掌握外部中断与定时器协同工作的思路理解长按、短按、双击状态机的判定逻辑同时了解长按期间串口打印data值以及while循环可能带来的阻塞问题是一份实用性较强的STM32按键交互参考。 先纠正一个很多新手容易走入的误区按键处理不只是“读个GPIO电平”那么简单更不是靠HAL_Delay()消完抖就完事。按键按下、释放、再按下背后是一串有时间维度的信号长按、短按、双击的本质区别全都在时间参数上。我最初在STM32F103ZET6上做按键功能时也走过弯路用延时消抖加简单的电平判断写出来的代码测试时单按没问题一旦连按就乱套最终痛定思痛改成状态机实现才把长按、短按、双击彻底稳定下来。这篇文章就围绕STM32F103ZET6这颗芯片把按键的硬件电路、软件状态机设计、HAL库代码实现和实测调试经验完整讲一遍。无论你是在正点原子战舰开发板上做实验还是自己画板子做产品这套思路都可以直接迁移。1. 按键检测的本质你在处理的不是电平是时间先说一个反直觉的结论按键按下去引脚读到低电平只是“果”真正的“因”是机械触点从接触到稳定经历了大约5ms到20ms的抖动。抖动期间引脚电平会快速跳变几十次如果代码只盯着电平变化一次按键可能会被识别成十几次。这就是为什么接收按键必须做消抖。但消抖只是第一步。长按、短按、双击这三个动作靠的并不是电平本身而是三个时间窗口按下持续时间用来区分短按还是长按两次按下之间的间隔用来判断是不是双击释放后的等待窗口用来决定什么时候上报“短按”事件所以按键检测的完整链路是信号消抖 → 稳定电平 → 边沿检测 → 计时判断 → 事件上报。每一步都跟时间有关。我在实际项目里会把消抖周期设为10ms连续两次采样相同才认定电平稳定。为什么不是一次因为机械抖动通常不会超过20ms连续两次采样能过滤掉大部分毛刺。为什么不是更长的消抖时间因为消抖同时也会消耗响应速度按下去到程序感知到需要消抖时间消抖太长会让人觉得按键“迟钝”。另外一个关键认知双击不是一种独立的物理动作它就是“在双击窗口内发生了两次短按”。所以代码里不需要为双击单独设计一套物理检测只需要在第一次短按释放后留出一个等待窗口观察窗口内有没有第二次按下。这个思路看起来简单但实际代码里很少有人第一次写对。原因在于事件上报的时机。短按如果立即上报那第二次按下到来时第一次短按已经上报完了双击就没机会了。所以只要你的产品同时支持短按和双击短按事件就必须延迟一个双击窗口再上报。这是很多人容易忽略的设计取舍。1.1 为什么延时消抖在复杂按键场景里不适用很多入门例程里用的是这种写法检测到按键按下后延时20ms再读一次确认还是低电平就认为按下有效。这种写法只适合最基础的开关灯逻辑因为HAL_Delay()是阻塞延时延时期间CPU干不了别的。如果要在同一个系统里做长按检测延时期间你根本没法去计时要做双击检测两次按下之间的时间跨度可能长达300ms总不能全程阻塞等着。更糟糕的是如果按键扫描放在定时器中断里在中断里调用阻塞延时整个系统的实时性都会垮掉。所以正确的做法是使用一个周期性的时基信号比如SysTick的1ms tick或者定时器中断每次只做一次采样和状态更新在状态机内部通过记录时间戳来判断各种时间窗口是否超时。整个判断过程不阻塞、不等待每次扫描只执行几微秒剩下的CPU时间该干嘛干嘛。1.2 状态机为什么适合做按键逻辑按键状态机本质上是把按键从空闲到按下到释放再到等待的过程拆成几个明确的状态每个状态下只关心特定的事件和条件。状态之间通过时间参数转移最终产生不同的事件。用状态机的好处有三个逻辑清晰每个状态做什么一目了然便于扩展新增一种按键手势只需要加状态和转移条件没有阻塞调用所有判断基于时间戳和当前状态天然适合嵌入到主循环或中断里我下面的实现就是用三个主状态完成的空闲、按下、等待双击。加上消抖计数器的辅助状态整个逻辑非常紧凑。2. 硬件设计怎么做按键电路与引脚配置的坑STM32F103ZET6的按键硬件电路本身不复杂但对新手而言有几个容易踩的坑我先把电路设计讲清楚。2.1 高电平有效还是低电平有效最常用的接法是按键一端接GPIO另一端接GNDGPIO内部上拉或外部上拉。平时引脚被上拉到高电平按下后引脚被拉到低电平所以是低电平有效。为什么不推荐按键一端接3.3V、另一端接GPIO的高电平有效接法首先STM32的大多数GPIO内部下拉能力相对弱外部噪声更容易耦合进引脚其次很多开发板上按键就是按低电平有效画的和板载电路保持一致能省掉很多排查时间。2.2 上拉电阻选择内部上拉够用吗STM32F103ZET6的GPIO内部上拉电阻典型值在30kΩ到50kΩ之间对按键这种低速信号来说足够用。但如果你做了很长的按键引线或者使用环境电磁干扰比较强我建议在外部加一个10kΩ上拉电阻。10kΩ的值能让引脚在空闲时稳定在高电平同时按下时的灌电流只有0.33mA左右对MCU完全没有压力。如果你的开发板上按键已经带了外部上拉电阻配置GPIO时记得选GPIO_NOPULL不要选GPIO_PULLUP两路并联虽然不会坏但上拉强度变化会影响电平翻转速度极端情况下可能导致边沿检测异常。2.3 要不要加RC硬件滤波很多工控产品的按键电路里会在GPIO引脚并联一个0.1μF电容配合上拉电阻组成RC低通滤波器能有效过滤高频毛刺。软件消抖的效果已经很好了硬件RC滤波是可选项不是必选项。我自己做实验板的时候一般不焊这个电容因为软件消抖已经能覆盖绝大多数场景。只有在按键线长超过30cm、且系统对误触率要求很高的场合才在引脚和地之间并联0.1μF。需要特别注意的是加了电容之后电平翻转会有RC延时软件里的消抖时间可以适当放宽到30ms左右。2.4 GPIO配置代码在STM32F103ZET6上以PA0为例HAL库的初始化是这样GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_0; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);如果你用了外部10k上拉把GPIO_PULLUP改成GPIO_NOPULL即可。这里有一个细节STM32F103默认不只是GPIOA几乎所有的GPIO端口时钟在复位后都是关闭的忘了使能时钟读到的引脚电平永远是浮空这是最常见的低级错误。3. 状态机设计短按、长按、双击的判定规则状态机是整个按键实现的核心我先把判断规则列出来再解释为什么这样设计。三个时间参数参数我用的值说明消抖采样间隔10ms每次按键扫描的调用周期消抖次数2次连续2次采样相同才确认电平长按阈值800ms按下持续时间超过这个值判定为长按双击窗口300ms第一次释放后在这个时间内再次按下判定为双击这些值不是固定的需要根据产品实际手感调整。长按阈值太低容易被用户无意间触发长按太高则用户需要死按很久。800ms是我测试下来比较舒服的值双击窗口300ms则是大多数人对“快速连按”的感知边界。3.1 状态定义与转移按键模块使用三个主状态空闲态等待按下按下态已经检测到稳定按下的电平正在持续计时等待双击态第一次短按已经释放等待双击窗口结束或第二次按下转移规则如下空闲态检测到按下沿 → 记录按下起始时间进入按下态按下态检测到释放沿 → 根据按下时长判断小于长按阈值则视为一次短按第二次短按到达则上报双击大于等于长按阈值则视为长按按下态内按住时间累计超过长按阈值 → 上报长按事件等待双击态内收到新的按下沿 → 回到按下态标记为第二次按下等待双击态内双击窗口超时 → 补发短按事件回到空闲态这个状态机的关键点在于“短按延迟上报”。可以看到第一次短按释放后程序不会立刻上报短按事件而是进入等待双击态给对方30ms的时间窗口。窗口内没有第二次按下才把短按事件发出去。这样短按和双击就能可靠区分。3.2 长按与双击的优先级问题如果用户先快速按了一下然后又按住不放这时候怎么处理我的策略是第一次短按成立先补发一个短按事件然后按住超过长按阈值后再发长按事件。这样两个动作都不会丢。实现上我用了一个事件标志位而不是单一的事件变量因为一次扫描周期内可能出现两个事件需要上报。有人会问那如果第二次按下本身也是一次短按怎么区分是双击还是“短按加短按”靠时间窗口。双击窗口内到达就是双击窗口外到达就是两次独立短按。这个语义对用户来说完全是自然的快速连按两次就是双击慢慢按两下就是两个短按。3.3 为什么不用中断检测做全套按键可以接到EXTI外部中断引脚按下和释放都触发中断然后在中断里记录时间戳。这个方案可行但有一个比较麻烦的问题双击窗口超时判断不能只靠边沿中断完成你还需要一个定时中断或者时基在等待双击状态里做超时检查。也就是说单纯用边沿中断做按键最后还得再开一个定时器去扫描超时状态代码量没减少反而引入了中断并发问题。相比之下用定时器中断或SysTick时基定期扫描电平所有逻辑都写在一个函数里简单可靠太多对10ms级别的按键响应来说性能完全够。4. STM32F103ZET6 HAL库实现下面给出完整的实现代码。我把按键封装成结构体方便多个按键复用。4.1 数据结构定义/* button.h */ #ifndef __BUTTON_H #define __BUTTON_H #include main.h #define BTN_EVENT_NONE 0x00u #define BTN_EVENT_SHORT 0x01u #define BTN_EVENT_LONG 0x02u #define BTN_EVENT_DOUBLE 0x04u /* 时间参数单位ms */ #define BUTTON_LONG_MS 800 #define BUTTON_DOUBLE_WINDOW_MS 300 #define BUTTON_SCAN_INTERVAL_MS 10 #define BUTTON_DEBOUNCE_CNT 2 typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t active_level; /* 按下有效电平低电平有效填0 */ uint8_t raw_level; /* 最近一次采样电平 */ uint8_t filter_cnt; /* 消抖计数 */ uint8_t stable_level; /* 消抖后的稳定电平 */ uint8_t last_stable; /* 上一次稳定电平用于边沿检测 */ uint8_t state; /* 0空闲 1按下 2等待双击 */ uint8_t long_sent; /* 长按事件是否已发送 */ uint8_t double_wait; /* 当前在等待第二次按下 */ uint32_t press_start_ms; /* 按下起始时刻 */ uint32_t last_release_ms; /* 上次释放时刻 */ uint8_t event_flags; /* 待上报事件位 */ } Button_t; void Button_Init(Button_t *btn, GPIO_TypeDef *port, uint16_t pin, uint8_t active_level); void Button_Scan(Button_t *btn, uint32_t now_ms); uint8_t Button_GetEvents(Button_t *btn); #endif事件标志用位域而不是单一变量目的是让一次扫描周期内能同时缓存多个事件比如“短按长按”连续发生的情况。主程序通过Button_GetEvents()取走事件后直接按位判断。4.2 按键扫描与状态机代码/* button.c */ #include button.h #include main.h void Button_Init(Button_t *btn, GPIO_TypeDef *port, uint16_t pin, uint8_t active_level) { btn-port port; btn-pin pin; btn-active_level active_level; btn-raw_level 0; btn-filter_cnt 0; btn-stable_level 0; btn-last_stable 0; btn-state 0; btn-long_sent 0; btn-double_wait 0; btn-press_start_ms 0; btn-last_release_ms 0; btn-event_flags BTN_EVENT_NONE; } static uint8_t Button_ReadLevel(Button_t *btn) { return (HAL_GPIO_ReadPin(btn-port, btn-pin) btn-active_level) ? 1 : 0; } void Button_Scan(Button_t *btn, uint32_t now_ms) { uint8_t lv Button_ReadLevel(btn); /* 软件消抖 */ if (lv ! btn-raw_level) { btn-raw_level lv; btn-filter_cnt 0; } else { if (btn-filter_cnt BUTTON_DEBOUNCE_CNT) { btn-filter_cnt; if (btn-filter_cnt BUTTON_DEBOUNCE_CNT) { btn-stable_level lv; } } } /* 边沿检测 */ if (btn-stable_level ! btn-last_stable) { if (btn-stable_level 1) { /* 按下沿 */ if (btn-state 0) { btn-press_start_ms now_ms; btn-long_sent 0; btn-state 1; } else if (btn-state 2) { /* 双击窗口内第二次按下 */ btn-press_start_ms now_ms; btn-long_sent 0; btn-double_wait 1; btn-state 1; } } else { /* 释放沿 */ if (btn-state 1) { uint32_t press_dur now_ms - btn-press_start_ms; if (press_dur BUTTON_LONG_MS) { /* 长按释放只清状态 */ btn-state 0; btn-double_wait 0; } else { if (btn-double_wait) { /* 第二次短按释放 双击 */ btn-event_flags | BTN_EVENT_DOUBLE; btn-double_wait 0; btn-state 0; } else { /* 第一次短按释放进入等待双击窗口 */ btn-last_release_ms now_ms; btn-double_wait 1; btn-state 2; } } } } btn-last_stable btn-stable_level; } /* 按住超时触发长按 */ if (btn-state 1 !btn-long_sent) { if (now_ms - btn-press_start_ms BUTTON_LONG_MS) { btn-long_sent 1; if (btn-double_wait) { /* 第二次按下变长按先补发第一次短按 */ btn-event_flags | BTN_EVENT_SHORT; btn-double_wait 0; } btn-event_flags | BTN_EVENT_LONG; } } /* 双击窗口超时补发短按 */ if (btn-state 2) { if (now_ms - btn-last_release_ms BUTTON_DOUBLE_WINDOW_MS) { btn-event_flags | BTN_EVENT_SHORT; btn-double_wait 0; btn-state 0; } } } uint8_t Button_GetEvents(Button_t *btn) { uint8_t flags btn-event_flags; btn-event_flags BTN_EVENT_NONE; return flags; }这段代码有几个细节要强调一下。消抖部分用的是“连续两次采样相同”的方式每次扫描调用一次两次之间间隔由调用方保证10ms。采样不同时立刻重置计数这样的效果是电平变化必须稳定持续20ms以上才会被状态机接受。长按事件是在按住过程中实时上报的不需要等释放。好处是用户在长按到800ms的瞬间就能收到反馈而不是非得松开手才知道触发了长按。很多产品在这里会让LED灯闪烁或者蜂鸣器响一声作为长按生效的提示。双击事件是在第二次释放时上报的而不是在第二次按下时就上报。原因是我们要排除“第二次按下变成了长按”的情况。如果第二次按下还没释放就提前报双击结果用户其实是长按逻辑就乱了。牺牲一点响应速度换取正确性是值得的。4.3 在主循环中接入主循环里用SysTick做时基每10ms调用一次扫描函数Button_t key1; uint32_t last_scan 0; void main_task(void) { Button_Init(key1, GPIOA, GPIO_PIN_0, 0); last_scan HAL_GetTick(); while (1) { /* 10ms周期扫描按键 */ if (HAL_GetTick() - last_scan BUTTON_SCAN_INTERVAL_MS) { last_scan HAL_GetTick(); Button_Scan(key1, HAL_GetTick()); } /* 处理按键事件 */ uint8_t ev Button_GetEvents(key1); if (ev BTN_EVENT_SHORT) { /* 短按处理 */ HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } if (ev BTN_EVENT_LONG) { /* 长按处理 */ HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); } if (ev BTN_EVENT_DOUBLE) { /* 双击处理 */ /* 进入特殊菜单或切换模式 */ } } }事件处理放在event读取之后立刻判位执行。如果你有多个按键可以定义按键数组循环扫描但每个按键要有独立的时间戳注释。5. 实测中的坑双击识别失灵与长按误报代码写完不代表功能就稳定了我在调试中踩过三个比较有代表性的坑这里逐个说。5.1 双击被拆成两个短按现象快速按两下系统却上报了两次短按没有一次双击。排查链路先看结束状态机发现第一次按下释放后程序进入了等待双击态但很快又被拉回了空闲态。进一步追踪发现主循环里的扫描调用间隔不是稳定10ms某次因为处理耗时操作扫描间隔被拉长到50ms以上。此时按下的电平变化刚好落在两次扫描之间消抖逻辑直接漏掉了一次按下沿。解决办法扫描周期不要放在主循环里用HAL_GetTick()判断改成放到SysTick中断或定时器中断里保证严格10ms一次。事件读取仍然在主循环做扫描和事件处理分离。5.2 长按按完又触发双击现象长按到900ms释放后系统除了上报长按还附带了一个短按或双击事件。这个问题的根源在第一版代码里长按释放后没有把double_wait清零。当用户长按前曾经短按过一次时第一次短按的double_wait标记还挂着长按结束直接把这个标记带到了下一个周期。后面的代码我已经在释放分支里加了double_wait 0如果你自己实现时遇到“长按附带多余事件”优先检查释放分支的状态清理是否完整。5.3 在中断里用阻塞式延时做消抖这个我单独拉出来提一下因为太常见了。有些人实现按键消抖时会用下面的方式外部中断触发后在中断回调里调用延时20ms再读一次电平。按键少的时候看着能用但系统里同时有串口、ADC、PWM时20ms的阻塞延时会让所有其他中断挤压严重时系统响应直接劣化。正确做法是中断里只置一个标志位按键扫描逻辑放到时基中断或主循环里统一处理。我的实现里如果没有外部按键中断连中断都不需要开轮询扫描已经完全足够。6. 再进一步把按键框架扩展成可配置模块上面的代码已经能稳定工作我在实际项目中还做了几个扩展这里一并分享。第一多按键支持。把Button_Init里的参数抽象成一个配置结构体数组包括端口、引脚、有效电平然后循环扫描typedef struct { GPIO_TypeDef *port; uint16_t pin; uint8_t active_level; } ButtonConfig_t;这样硬件变更时只改配置表不用改逻辑代码。第二长按自动重复。长按除了触发一次事件还可以在按住期间周期性发送“长按重复”事件用于菜单翻页、音量调节这类场景。只需在长按触发之后每隔200ms再置一次扩展事件位比如BTN_EVENT_HOLD_REPEAT。第三长按自定义触发时间。同一个按键长按阈值可以做成可配置的比如300ms只用来快速唤出快捷菜单再按到1500ms进入深度设置。状态机结构不用变只需要在按下态里增加一个第二时间阈值在扫描时多一次判断。按键模块一旦做成了状态机框架后续换芯片、加手势都只是在原有骨架上加状态和时间参数的事。我自己从这版实现迁移到其他MCU平台时只需要重写Button_ReadLevel一个函数状态机代码完全复用。这也是我推荐用状态机处理按键的根本原因——可维护性和复用性远不是一堆if延时能比的。本文还有配套的精品资源点击获取