资讯动态

STM32F103ZET6按键状态机:短按长按双击精准识别与避坑指南

发布时间:2026/10/26 13:32:34 来源:尧图企业网站定制
简介这份资源面向STM32嵌入式初学者与需要掌握按键多态识别的开发者围绕STM32F103ZET6实现长按、短按与双击三种按键动作的区分。方案采用外部中断捕获按键按下标志配合20ms定时器轮询状态机长按触发LED7亮灭并累加data值、同时串口输出短按控制LED6双击切换LED8可帮助读者理解中断与定时器协同处理按键抖动的思路。压缩包共142个文件以36个c源文件与37个h头文件为核心涵盖定时器、USART、GPIO等标准外设库模块另含Keil工程文件、编译中间产物与hex烧录文件整体约3.49MB工程结构完整可直接打开调试。目前已有5844人学习下载适合对照源码梳理状态机逻辑、复现实验现象并在此基础上扩展按键功能。1. 按键交互不是“读个电平”那么简单STM32F103ZET6 上做按键很多人第一版代码就是GPIO_ReadInputDataBit加一个delay_ms(20)消抖短按能用长按和双击一加进来立刻乱套长按松手被识别成短按、双击永远触发不了、连按两下变成两次单击。问题不在芯片在于把“电平采样”当成了“事件识别”。这套方案要解决的就是在 F103ZET6 上把短按、长按、双击三种事件稳定区分开适合正在做嵌入式课设、毕设里带人机交互面板的同学也适合要交付一台带按键操作设备、不想在客户现场被“按键不灵”投诉的从业者。下面按需求、状态机设计、定时器与扫描实现、避坑、验收的顺序讲透代码可以直接搬到工程里改。2. 需求拆解与状态机设计三种事件怎么互斥2.1 先把“按多久算长按”这类参数定死按键识别的本质是时间窗口划分参数不定后面代码怎么写都是玄学。我一般会在头文件里集中定义改需求只动这一处/* key_config.h —— 所有时间参数集中管理单位 ms */ #define KEY_SCAN_PERIOD_MS 10 /* 扫描周期定时器中断里调用 */ #define KEY_DEBOUNCE_MS 20 /* 消抖窗口连续稳定超过才算有效 */ #define KEY_LONG_PRESS_MS 800 /* 按住超过 800ms 判定长按 */ #define KEY_DOUBLE_GAP_MS 300 /* 两次按下间隔小于 300ms 判双击 */ #define KEY_RELEASE_CONFIRM_MS 30 /* 松手确认防止抖动误判抬起 */这几个数不是拍脑袋扫描周期 10ms 决定了识别精度消抖 20ms 覆盖机械按键典型抖动长按 800ms 是手感上“明显按住”的阈值双击间隔 300ms 符合人手连按节奏。参数之间有个隐含约束——KEY_DOUBLE_GAP_MS必须大于KEY_DEBOUNCE_MS否则第二次按下的抖动还没消完间隔窗口就关了双击永远进不去。这个约束后面避坑章节还会提。2.2 用状态机而不是 if-else 堆逻辑短按、长按、双击三者天然互斥又有时序依赖按下后要等松手才知道是短按还是长按松手后还要再等一个间隔窗口才知道是不是双击的第一击。用 if-else 嵌套写分支会爆炸且难维护。正确做法是有限状态机状态定义如下状态含义触发迁移的条件KEY_IDLE空闲等待按下检测到稳定按下 →KEY_PRESSEDKEY_PRESSED已按下计时中按住达长按阈值 →KEY_LONG提前松手 →KEY_RELEASEDKEY_LONG长按已触发松手 →KEY_IDLE长按只报一次KEY_RELEASED已松手等待第二击间隔窗口内再次按下 →KEY_PRESSED窗口超时 → 上报短按 →KEY_IDLE关键设计点短按事件不在松手瞬间上报而是延迟到双击间隔窗口结束才上报。这是双击能成立的前提——松手后必须留一个观察窗口看用户是不是还要按第二下。代价是短按有 300ms 左右的响应延迟对按键响应要求极高的场景可以把窗口调小但别小于消抖时间。2.3 事件与状态解耦输出统一事件码状态机只负责“现在是什么状态”对外统一吐事件码业务层拿事件码做业务不关心内部怎么判的typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_SHORT_PRESS, KEY_EVENT_LONG_PRESS, KEY_EVENT_DOUBLE_CLICK } KeyEvent_t; /* 每个按键一个上下文支持多按键并行 */ typedef struct { uint8_t state; /* 当前状态 */ uint16_t press_tick; /* 按下时刻的 tick */ uint16_t release_tick; /* 松手时刻的 tick */ uint8_t stable_level; /* 消抖后的稳定电平 */ uint8_t raw_level; /* 原始采样电平 */ uint16_t debounce_cnt; /* 消抖计数 */ } KeyCtx_t;这样设计的好处是业务层只写switch(event)加一个“三击”需求时只改状态机业务代码一行不动。多按键也好扩展一个KeyCtx_t数组搞定不用为每个键复制一套全局变量。3. 定时器扫描与消抖实现10ms 节拍怎么落地3.1 用 SysTick 或通用定时器产生扫描节拍按键扫描绝不能放在主循环里裸跑delay主循环一忙按键就丢。标准做法是用定时器中断产生固定节拍中断里只做采样和状态推进事件通过标志位或队列交给主循环处理。F103ZET6 上我用 TIM3 配 10ms 中断/* tim3 初始化72MHz / 7200 10kHz计 100 次 10ms */ void KeyTimer_Init(void) { TIM_TimeBaseInitTypeDef tb; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); tb.TIM_Prescaler 7200 - 1; tb.TIM_Period 100 - 1; tb.TIM_CounterMode TIM_CounterMode_Up; tb.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, tb); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); TIM_Cmd(TIM3, ENABLE); } /* 中断服务函数只做扫描不做业务 */ void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { Key_Scan(); /* 推进所有按键状态机 */ TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } }预分频 7200 把 72MHz 降到 10kHz周期 100 得到 10ms 中断和KEY_SCAN_PERIOD_MS严格对应。中断里只调Key_Scan()绝不放printf或业务逻辑——中断里做重活是另一个经典翻车点。3.2 消抖用“连续稳定计数”而不是 delay消抖的本质是“电平连续稳定 N 次才认”不是“等 20ms 再看一眼”。用计数器实现天然适配 10ms 节拍/* 读取消抖后的稳定电平返回 1按下0松开 */ static uint8_t Key_ReadStable(KeyCtx_t *k, uint8_t raw) { if (raw ! k-raw_level) { k-raw_level raw; k-debounce_cnt 0; /* 电平变了重新计数 */ } else if (k-debounce_cnt KEY_DEBOUNCE_MS / KEY_SCAN_PERIOD_MS) { k-debounce_cnt; if (k-debounce_cnt KEY_DEBOUNCE_MS / KEY_SCAN_PERIOD_MS) { k-stable_level raw; /* 连续稳定够久更新稳定电平 */ } } return k-stable_level; }KEY_DEBOUNCE_MS / KEY_SCAN_PERIOD_MS算出需要连续稳定的次数20/102 次电平一变就清零重计。这样抖动期间稳定电平不动只有真正稳定才翻转比delay_ms(20)可靠得多也不阻塞。3.3 状态机推进函数三种事件的判定全在这里核心扫描函数把消抖、计时、状态迁移串起来每个按键独立上下文void Key_Scan(void) { for (uint8_t i 0; i KEY_NUM; i) { KeyCtx_t *k g_keys[i]; uint8_t raw (GPIO_ReadInputDataBit(KEY_PORT, KEY_PIN[i]) Bit_RESET) ? 1 : 0; uint8_t lv Key_ReadStable(k, raw); /* 1按下 0松开 */ switch (k-state) { case KEY_IDLE: if (lv) { /* 稳定按下 */ k-state KEY_PRESSED; k-press_tick g_key_tick; /* 记录按下时刻 */ } break; case KEY_PRESSED: if (lv) { /* 仍按住判长按 */ if (g_key_tick - k-press_tick KEY_LONG_PRESS_MS) { k-state KEY_LONG; Key_PostEvent(i, KEY_EVENT_LONG_PRESS); /* 长按立即上报 */ } } else { /* 提前松手 */ k-state KEY_RELEASED; k-release_tick g_key_tick; } break; case KEY_LONG: if (!lv) k-state KEY_IDLE; /* 长按松手回空闲不重复上报 */ break; case KEY_RELEASED: if (lv) { /* 间隔窗口内又按下 → 双击 */ k-state KEY_PRESSED; k-press_tick g_key_tick; Key_PostEvent(i, KEY_EVENT_DOUBLE_CLICK); } else if (g_key_tick - k-release_tick KEY_DOUBLE_GAP_MS) { Key_PostEvent(i, KEY_EVENT_SHORT_PRESS); /* 窗口超时补报短按 */ k-state KEY_IDLE; } break; } } g_key_tick KEY_SCAN_PERIOD_MS; /* 全局节拍推进 */ }逐段说清楚KEY_IDLE只认稳定按下KEY_PRESSED里按住够久就报长按并切到KEY_LONG长按只报一次松手直接回空闲KEY_RELEASED是双击判定的关键——窗口内再按下报双击窗口超时才补报短按。g_key_tick是全局毫秒节拍每次扫描加 10所有时间比较都用它做差避免HAL_GetTick溢出比较的坑虽然 32 位 tick 溢出要 49 天但用相对差值写是习惯。注意双击上报后状态切回KEY_PRESSED意味着第二击如果一直按住还会再触发一次长按。如果业务上不允许“双击后长按”在KEY_PRESSED里加个标志位屏蔽即可这是需求取舍不是 bug。3.4 事件队列交给主循环消费中断里不能直接跑业务事件先入队主循环取/* 环形队列中断里写主循环读 */ static volatile uint8_t g_evt_head 0, g_evt_tail 0; static volatile KeyEvent_t g_evt_buf[16]; void Key_PostEvent(uint8_t key_id, KeyEvent_t evt) { uint8_t next (g_evt_head 1) % 16; if (next ! g_evt_tail) { /* 队列未满才写 */ g_evt_buf[g_evt_head] evt; g_evt_head next; } } /* 主循环里调用非阻塞取事件 */ uint8_t Key_GetEvent(KeyEvent_t *evt) { if (g_evt_tail g_evt_head) return 0; *evt g_evt_buf[g_evt_tail]; g_evt_tail (g_evt_tail 1) % 16; return 1; }队列长度 16 对按键事件足够满了丢新事件而不是覆盖旧事件保证已上报的事件不丢。主循环里while (Key_GetEvent(e)) { switch(e) {...} }消费业务和识别彻底解耦。4. 避坑与排查按键识别最容易翻车的五个点4.1 双击永远触发不了现象是连按两下只出两次短按双击事件一次没有。原因通常是KEY_DOUBLE_GAP_MS设得比消抖时间还小或者第二次按下的消抖还没完成窗口就关了。解决保证KEY_DOUBLE_GAP_MS KEY_DEBOUNCE_MS * 2300ms 对 20ms 消抖是安全的再检查KEY_RELEASED状态里判断的是消抖后的lv而不是原始电平用原始电平会被抖动骗进KEY_PRESSED又弹回来。4.2 长按松手后多报一次短按现象是按住两秒松手先出长按再出一个短按。原因是长按触发后状态没切走或者松手时又走了短按上报路径。解决长按上报后必须切到KEY_LONG状态该状态只等松手回KEY_IDLE不经过KEY_RELEASED短按上报路径根本进不去。检查KEY_PRESSED里长按判断和松手判断的先后顺序先判长按再判松手。4.3 主循环一忙按键就“失灵”现象是屏幕刷新或串口收发时按键没反应。原因是扫描放在主循环里主循环被阻塞时采样就断了。解决扫描必须放定时器中断且中断优先级要高于那些长耗时外设中断中断里只做采样和状态推进事件入队业务在主循环消费。这是血泪经验——曾经有个项目把扫描放主循环加了个 100ms 的 LCD 刷新后按键响应直接崩了。4.4 多按键互相干扰现象是按 A 键 B 键也触发。原因是多个按键共用一套全局状态变量或者g_key_tick在循环里被重复累加。解决每个按键独立KeyCtx_t上下文g_key_tick在Key_Scan末尾只加一次不在按键循环内部加。多按键的 GPIO 读取也要确认引脚和端口没配错F103ZET6 的 GPIO 分组多KEY_PORT和KEY_PIN要一一对应。4.5 上电瞬间误触发一次现象是刚上电没碰按键就报了个短按。原因是上电时 GPIO 电平未稳定stable_level初值没初始化第一次扫描就当成“从按下到松开”。解决初始化时把stable_level和raw_level都设成实际空闲电平上拉输入时空闲为高按下为低debounce_cnt清零状态置KEY_IDLE硬件上按键一端接 GPIO、一端接 GNDGPIO 配成上拉输入空闲电平确定误触发就没了。5. 验收与答辩怎么证明这套按键真的可靠验收别只看“按一下有反应”要按事件类型逐项过并且用可复现的步骤。我一般准备一张验收表答辩时直接展示测试记录测试项操作步骤预期结果判定短按按下 100ms 内松开约 300ms 后上报一次短按通过长按按住 1.5s 后松开按住满 800ms 时上报长按松手不再报短按通过双击300ms 内连按两下第二下按下瞬间上报双击无短按通过抖动快速轻触多次不产生任何事件或仅一次短按通过多键两键交替快速按各自事件独立无串扰通过上电上电后 2s 内不操作无任何事件上报通过测试时用示波器或逻辑分析仪抓 GPIO 波形和串口打印的事件时间戳对照比肉眼按更可信。答辩被问“为什么短按有延迟”就答“为双击判定预留观察窗口窗口 300ms 是手感与响应的折中可配置”被问“消抖为什么不用 delay”就答“阻塞式消抖会丢事件计数式消抖在定时器节拍里天然非阻塞”。这两个问题几乎必问提前把参数含义和取舍讲清楚比背代码强。还有一个容易被忽略的验收点边界值。按住刚好 799ms 和 801ms 各测一次连按间隔刚好 299ms 和 301ms 各测一次确认阈值切换干净、没有“既报短按又报长按”的中间态。我吃过这个亏——阈值附近状态机如果判断顺序写反会出现一次操作报两个事件现场调试时抓了半天才定位到是和的差别。从那以后我每次交付按键功能都强制把边界值测试和事件时间戳抓取走一遍不靠“按着感觉对”就签字。希望这套状态机和验收表能帮到你代码拿去改参数就能用验收表照着填就能交。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑