多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

单片机按键单击、双击、长按识别:状态机与去抖方案详解

单片机按键单击、双击、长按识别:状态机与去抖方案详解 搞单片机的人十有八九都被按键折磨过。刚入门那会儿写个“按键点亮LED”很简单无非是检测到低电平就翻转一下引脚。可一旦需求变成“单击做A操作、双击做B操作、长按做C操作”程序就开始失控单击偶尔变双击长按和双击互相打架松开按键才触发事件按下去却没反应……这些问题我过去都踩过而且踩得特别扎实。这篇文章就围绕“单击、双击、长按”这个经典功能从设计思路、核心原理、完整代码到排查经验把整条链路拆开来讲。用的平台是51单片机但思路完全可以直接迁移到STM32、Arduino或者其他任何MCU上。适合正在做电子钟、菜单按键、仪器参数设置或者任何带有人机交互功能项目的读者大家耐心看完应该能少走不少弯路。1. 为什么“单击、双击、长按”放在一起就那么难1.1 应用场景与需求拆解按键识别做得好不好直接影响一个产品的使用体验。电子时钟上的“调时键”就是典型场景单击切换时/分/秒双击切换24小时制/12小时制长按进入快速加减。这时候如果单击和双击识别不准用户按一次菜单却跳了两层心态直接就崩了。需求本身并不复杂核心其实就三件事识别一次“快速按下并松开”的短按用于单击操作识别在极短时间内连续两次短按合并为一个双击操作识别“一直按住不放”的状态触发长按操作通常还要区分长按触发和长按释放。这三个动作在时间维度上是有重叠区域的难点就在这儿。双击由两次单击组成长按的前半段看起来也像一次单击怎么在时间轴上把它们切开就是整个程序设计的核心。1.2 几种常规实现方案的对比我见过很多刚接触单片机的朋友解决这个问题的办法基本都是“逐个击破”先写一个延时去抖检测到按键按下后延时20ms再检测一次引脚电平如果还是按下就认为真按下了。然后死等按键松开松开后判断按下时间长度超过某个阈值就算长按否则算短按。这种做法解决单击和长按没问题但双击就完全没法区分了。你等第一个短按松开后再去等第二次按下用户手速快的第二次按下还没来程序已经认为第一次单击的流程结束了。想在“松开”这个点上继续等待第二个按下又会导致单击的响应延迟好几百毫秒交互很迟钝。另一种做法是用外部中断按键按下触发中断在中断里读取定时器值记录时间戳。这个思路方向是对的问题在于机械按键的抖动会让中断反复触发一次按下能进七八次中断去抖逻辑没写好时间和次数全都乱了。还有一部分人干脆用复杂的“定时器标志位”组合程序里堆了一堆flag各个flag之间互相影响调试的时候改一处崩三处。我自己早期也写过这种代码后来实在扛不住才彻底切到状态机方案。1.3 我的选型定时器扫描 状态机 时间窗口最终稳定下来的方案是三件套组合定时器扫描、状态机、时间窗口判断。定时器扫描负责给整个系统提供一个稳定的时间基准比如每1ms中断一次状态机负责管理按键从“空闲”到“按下确认”再到“等待双击”等各个阶段时间窗口用来决定在什么时长范围内算双击、超过多久算长按。这个方案的优势在于所有判断都是基于时间和状态而不是基于某个单一的标志位。逻辑树是平铺的每个状态下只处理该状态的事情不容易互相干扰。代码结构清晰出问题也好排查。这套架构我后来从51用到了STM32从单按键扩展到了矩阵键盘几乎没有大改过。2. 按键识别的核心原理去抖、边沿、时间窗口2.1 抖动从哪来去抖到底在消什么机械按键内部是金属弹片按下和松开的瞬间弹片会因为机械弹性产生一系列快速通断这个现象叫抖动。用示波器看引脚电平能看到在按下瞬间出现一串毛刺持续时间通常在5ms到15ms之间然后再稳定在低电平。如果不处理抖动程序可能在一次按下过程中检测到几十次电平变化。去抖的本质就是不让这些毛刺干扰逻辑判断。硬件上可以加RC滤波电路软件上最常用的就是“连续采样法”连续读到若干次相同电平才认为引脚状态真的改变了。我在代码里用的是带重试的计数法每一次扫描发现电平与上次记录的不同就把计数器清零等连续采样到足够多的相同电平才更新稳定电平。这样比单纯延时去抖更稳不会因为多按了几次导致延时叠加。核心代码如下这段逻辑是整个按键模块的地基static unsigned char read_key_pin(void) { // 按键接P3.2按下为低电平 return KEY_PIN ? 0 : 1; } static void key_scan(void) { unsigned char level; level read_key_pin(); if (level ! key_last_level) { // 电平发生变化清零计数器 debounce_cnt 0; key_last_level level; } else { // 电平与上次一致计数累加 if (debounce_cnt DEBOUNCE_LIMIT) { debounce_cnt; } // 连续DEBOUNCE_LIMIT次一致认为电平稳定 if (debounce_cnt DEBOUNCE_LIMIT) { key_state_machine(key_last_level); } } }这段代码里key_last_level记录的是上一次扫描到的原始电平debounce_cnt是稳定计数。每次扫描发现电平变了就清零必须连续20次对应20ms读到相同电平才去更新状态机。这个“连续一致”的判断能把抖动毛刺全部滤掉。2.2 状态机的关键状态状态机是整个模块的灵魂。我先说结论这套状态机一共5个状态分别是空闲、按下确认、等待双击、第二次按下、长按保持。每一个状态对应按键交互过程中一个稳定阶段。空闲ST_IDLE无按键操作只等第一次按下按下确认ST_PRESS按键已经被稳定识别为按下状态继续判断是松开还是超时长按等待双击ST_WAIT_DOUBLE第一次短按已经松开进入双击判决窗口窗口内再按下就算双击超时就算单击第二次按下ST_PRESS2双击窗口内第二次按下松开时确认双击长按保持ST_LONG按下时间超过长按阈值已触发过长按事件等待松开后复位。把交互过程拆成这5个状态之后整个判断逻辑就变成了一张清晰的表当前状态条件稳定电平时间动作下一状态空闲稳定电平为按下记录按下时间按下确认按下确认稳定电平为释放记录短按进入双击等待等待双击按下确认耗时超过长按阈值上报长按事件长按保持等待双击窗口内再次按下记录第二次按下时间第二次按下等待双击窗口超时上报单击事件空闲第二次按下稳定电平为释放上报双击事件空闲长按保持稳定电平为释放清除长按状态空闲这个表格写出来之后实现代码就顺理成章了本质上就是状态与条件的匹配。2.3 双击窗口和长按阈值的参数怎么定时间参数是整个方案的核心参数选得不好整套逻辑都会出问题。我实际项目里常用三个参数去抖时间20ms长按阈值800ms双击窗口300ms。去抖时间20ms很好理解机械按键的抖动一般在10ms以内20ms足够滤除。长按阈值800ms是人与人交互体验的平衡点太短容易误触成长按太长用户会没耐心。双击窗口300ms是人体连续点击的自然节奏普通人的双击间隔一般在100ms到250ms之间300ms能容忍较慢的动作又不至于误判。这三个参数我全部用宏定义写在文件最上面而不是散落在代码里#define DEBOUNCE_LIMIT 20 // 去抖采样次数1ms扫描一次即20ms #define KEY_LONG_MS 800 // 长按判定阈值单位ms #define KEY_DOUBLE_WINDOW_MS 300 // 双击判定窗口单位ms调参的时候只需要改这三个宏不用动状态机逻辑。如果一个项目用户手速普遍偏慢把KEY_DOUBLE_WINDOW_MS从300改到400就行。这里有一点需要说明长按阈值一定要大于双击窗口否则可能出现“按下一个键刚超过长按阈值又被等待窗口判定为单击”的冲突。800ms和300ms之间留了500ms的余量安全得很。3. 完整可运行代码51单片机按键模块3.1 电路与引脚示例电路非常简单一个按键、一个LED。按键一端接P3.2另一端接地P3.2内部或外部接上拉电阻到VCC按下时引脚被拉低LED接P2.0输出低电平点亮。单片机用经典的STC89C52RC或AT89C52都行晶振12MHz。这里有一个容易忽略的细节51单片机的P3口内部有上拉但如果P3.2悬空或者上拉强度不够按键线比较长或者环境电磁干扰比较严重的时候还是建议外部加一个10kΩ上拉电阻。我调试的时候遇到过按键在按下瞬间引脚电平跳变异常最后发现就是上拉电阻没接加上之后立刻稳定。3.2 key.h 接口设计接口设计的原则是“头文件只暴露需要暴露的东西”。外部模块只需要知道如何获取按键事件不需要知道内部状态机长什么样。我把接口收敛成三个函数#ifndef __KEY_H #define __KEY_H #include reg52.h // 按键引脚定义 sbit KEY_PIN P3^2; // 事件类型定义 #define KEY_EVENT_NONE 0x00 #define KEY_EVENT_SINGLE_CLICK 0x01 #define KEY_EVENT_DOUBLE_CLICK 0x02 #define KEY_EVENT_LONG_PRESS 0x03 // 对外接口 void key_init(void); void key_scan(void); // 建议在定时器中断中调用 unsigned char key_get_event(void); // 主循环中查询 #endifKEY_EVENT_NONE定义为0这样主循环在判断事件时天然兼容“无事件”的默认状态不会出现意外匹配。3.3 key.c 状态机实现核心实现文件是key.c完整代码如下每个函数都加了注释方便对照上面的状态表理解#include key.h #define DEBOUNCE_LIMIT 20 #define KEY_LONG_MS 800 #define KEY_DOUBLE_WINDOW_MS 300 // 按键状态枚举 #define ST_IDLE 0 #define ST_PRESS 1 #define ST_WAIT_DOUBLE 2 #define ST_PRESS2 3 #define ST_LONG 4 // 事件队列缓冲区 #define EVENT_QUEUE_SIZE 8 static unsigned char event_queue[EVENT_QUEUE_SIZE]; static unsigned char queue_head 0; static unsigned char queue_tail 0; // 系统运行时间由定时器中断累加 volatile unsigned int tick_ms 0; // 按键模块内部变量 static unsigned char key_state ST_IDLE; static unsigned char key_last_level 0; static unsigned char debounce_cnt 0; static unsigned int press_time 0; static unsigned int double_window_start 0; // 读引脚按下为1低电平有效 static unsigned char read_key_pin(void) { return KEY_PIN ? 0 : 1; } // 事件入队 static void enqueue_event(unsigned char ev) { unsigned char next (queue_head 1) % EVENT_QUEUE_SIZE; if (next ! queue_tail) { event_queue[queue_head] ev; queue_head next; } } // 事件出队 static unsigned char dequeue_event(void) { unsigned char ev KEY_EVENT_NONE; if (queue_head ! queue_tail) { ev event_queue[queue_tail]; queue_tail (queue_tail 1) % EVENT_QUEUE_SIZE; } return ev; } // 查询事件 unsigned char key_get_event(void) { return dequeue_event(); } // 初始化 void key_init(void) { key_state ST_IDLE; key_last_level 0; debounce_cnt 0; queue_head 0; queue_tail 0; tick_ms 0; } // 按键状态机核心逻辑 // 入参stable_level是去抖之后的稳定电平按下为1释放为0 static void key_state_machine(unsigned char stable_level) { unsigned int now; now tick_ms; switch (key_state) { case ST_IDLE: if (stable_level 1) { // 检测到第一次按下记录按下时刻 press_time now; key_state ST_PRESS; } break; case ST_PRESS: if (stable_level 0) { // 在长按阈值之前松开了视为一次短按 // 进入等待双击窗口 double_window_start now; key_state ST_WAIT_DOUBLE; } else if ((now - press_time) KEY_LONG_MS) { // 按住时间超过长按阈值触发长按 enqueue_event(KEY_EVENT_LONG_PRESS); key_state ST_LONG; } break; case ST_WAIT_DOUBLE: if (stable_level 1) { // 双击窗口内检测到第二次按下 press_time now; key_state ST_PRESS2; } else if ((now - double_window_start) KEY_DOUBLE_WINDOW_MS) { // 双击窗口超时确认是一次单击 enqueue_event(KEY_EVENT_SINGLE_CLICK); key_state ST_IDLE; } break; case ST_PRESS2: if (stable_level 0) { // 第二次按下后松开确认双击 enqueue_event(KEY_EVENT_DOUBLE_CLICK); key_state ST_IDLE; } break; case ST_LONG: if (stable_level 0) { // 长按松开回到空闲 key_state ST_IDLE; } break; default: key_state ST_IDLE; break; } } // 扫描函数读取引脚并去抖然后推进状态机 void key_scan(void) { unsigned char level; level read_key_pin(); if (level ! key_last_level) { debounce_cnt 0; key_last_level level; } else { if (debounce_cnt DEBOUNCE_LIMIT) { debounce_cnt; } if (debounce_cnt DEBOUNCE_LIMIT) { key_state_machine(key_last_level); } } }关键代码点有三个第一tick_ms用volatile修饰因为它在定时器中断里被修改在主循环里被读取不用volatile的话编译器优化后可能读到旧值这是一个很经典的坑。第二now - press_time这种写法利用了无符号整数的减法特性。即使tick_ms溢出回绕只要时间差不超过65535ms计算结果就是正确的。所以这个模块最长可以处理65秒的按键时长足够覆盖绝大多数场景。第三事件用环形队列缓存而不是单个变量。为什么因为用户可能在极短时间内连续操作比如快速按了两下键第一个单击事件还没被主循环取走第二个事件已经来了。用队列的好处是事件不会丢坏处是代码稍微多几行但这一点点的复杂度完全值得。3.4 main.c 使用示例主程序的写法非常简单定时器中断负责驱动时间基准和按键扫描主循环只关心事件到底是什么#include key.h // LED引脚定义 sbit LED_PIN P2^0; // 定时器0初始化12MHz晶振12T模式1ms中断一次 void timer0_init(void) { TMOD 0xF0; TMOD | 0x01; // 模式116位定时器 TH0 0xFC; // 定时器初值1ms TL0 0x18; ET0 1; TR0 1; } // 定时器0中断服务函数 void timer0_isr(void) interrupt 1 { TH0 0xFC; TL0 0x18; tick_ms; key_scan(); } void main(void) { unsigned char ev; key_init(); timer0_init(); EA 1; // 开总中断 LED_PIN 1; // 熄灭LED while (1) { ev key_get_event(); if (ev KEY_EVENT_SINGLE_CLICK) { // 单击LED闪烁一次 LED_PIN 0; // 这里可以加延时或者用非阻塞方式控制LED点亮时间 // 示例中简单处理 LED_PIN 1; } else if (ev KEY_EVENT_DOUBLE_CLICK) { // 双击LED翻转10次表示进入特殊模式 // 实际项目里可以打开设置菜单、切换显示等 LED_PIN 0; LED_PIN 1; } else if (ev KEY_EVENT_LONG_PRESS) { // 长按LED常亮进入连续调节模式 LED_PIN 0; } } }这个示例的逻辑很简单实际项目里在事件判断里插入对应功能代码就行。有一点要提醒不要在事件处理里写死循环延时比如用Delay(500)来让LED亮500ms因为tick_ms是在中断里更新的死循环期间按键扫描仍然在运行但主循环被卡住事件处理就会卡住甚至可能出现事件队列积压、按键反应延迟的问题。如果需要延时也要用“基于tick_ms的非阻塞延时”。4. 常见问题与调试实录双击变两次单击、单击变双击、长按乱触发4.1 现象一单击经常被识别成双击这是最让人抓狂的问题明明只按了一下程序却上报了双击。我排查过好几个项目根因大部分出在去抖不够彻底。按键弹片老化、接触不良、引脚接触电阻变大都会让抖动脉冲变长。原来20ms的去抖时间不够毛刺穿过去了状态机在ST_WAIT_DOUBLE阶段被同一抖动里的毛刺当成了第二次按下。解决方法是加长去抖时间或者用更稳定的“滤波窗口”方案。我实测下来把DEBOUNCE_LIMIT从20改到30甚至40对正常按键的手感影响不大但对误判的抑制作用很明显。另外如果用的是带内部上下拉的STM32等单片机要检查上下拉配置是否正确内部上拉太弱容易引入噪声。4.2 现象二双击被识别成两次单击这个问题恰好相反用户真的连按了两次但程序把两次分开报了。根因几乎都是双击窗口太短。不同人的双击速度差异很大我自己连按两次实测快的时候间隔100ms慢的时候能到280ms。如果KEY_DOUBLE_WINDOW_MS设成200ms手慢的用户就会吃亏。我建议这个参数至少设300ms起步如果应用对单击响应延迟不敏感甚至可以到400ms。这里有一个取舍要自己想清楚双击窗口越长单击事件的上报延迟就越大。因为单击必须等窗口超时才能确认窗口350ms意味着每次单击操作要等350ms才响应主界面会有“慢半拍”的感觉。解决方法通常是衡量这个应用里单击和双击哪个更重要单击更多就缩短窗口牺牲一点双击识别率双击更多就拉长窗口接受单击延迟。4.3 现象三长按期间误触发长按或者长按和双击冲突长按误触发一般有三个原因一是长按阈值设得太短比如设成300ms而双击窗口也是300ms用户只是想双击结果第一次短按松开后进入等待窗口第二次按下前不小心多摁了450ms触发了长按二是按键本身有粘连按下去回弹不彻底导致状态机认为一直处在按下状态三是定时器中断不稳定比如中断里放了太多耗时操作导致正常运行时的1ms时间基准变成了2ms、3ms时间被拉长长按判定提早出现。第三种情况隐蔽性很强排查它比较好的办法是用逻辑分析仪抓GPIO波形对比真实按住时间和MCU内部判定时间的差异。如果程序里没有明显掉进长中断的片段就要检查所有被调用的函数执行耗时是否可控。我自己在代码里处理长按和双击冲突的一个技巧是长按阈值始终比双击窗口大至少300ms。这是一个硬性原则。另外长按触发后不会再进入双击窗口这个机制在状态机设计里已经天然保证但扩展别的新功能时要记得守住这条边界不要让新逻辑破坏原有的时序。4.4 调试技巧与参数速查表调试按键识别程序我强烈推荐两个工具示波器或逻辑分析仪以及串口打印。示波器用来看真实的按键波形可以直观确认抖动时长串口打印用来输出每次按键事件和时间戳可以快速定位是状态机逻辑问题还是参数问题。51单片机速度慢串口打印简单的事件信息完全够用。下面是我整理的一个问题速查表也是我自己调试时会对照的清单现象常见原因排查方向解决建议单击变双击去抖不彻底毛刺穿过用示波器看按下波形增加DEBOUNCE_LIMIT单击变双击电松接触不良/上拉弱检查硬件电路加10k外部上拉更换按键双击变两次单击双击窗口太短打印两次按键的间隔拉大KEY_DOUBLE_WINDOW_MS长按误触发长按阈值过短检查参数与窗口的关系确保长按阈值双击窗口300ms长按误触发定时器基准不稳查中断耗时函数不要在中断里做耗时操作按键无响应去抖过程中引脚变化频繁示波器看是否一直在打抖加强滤波或换硬件去抖事件偶尔丢失事件变量被覆盖确认是否用了环形队列使用队列缓存事件这套排查方法在我做过的项目里屡试不爽。重要的事情说三遍都不算多先看波形再调参数最后才改逻辑。顺序反了只会越折腾越乱。5. 从按键模块到真实项目矩阵键盘、多按键与低功耗扩展5.1 矩阵键盘如何复用这套状态机很多项目用矩阵键盘而不是独立按键比如51单片机电子琴8个音符按键加功能按键就是典型的矩阵应用。矩阵键盘的核心区别在扫描方式而不是状态机。矩阵需要逐行或逐列扫描读回每个按键的状态之后每个按键仍然要走去抖和状态机这套流程。我的做法是先写一个matrix_scan()函数它把整个矩阵的按键状态读到一个二维数组里然后对每一个键位单独维护一套去抖计数和状态。因为状态机本身是纯逻辑模块不关心引脚怎么读只要把“稳定电平”传进去就行。矩阵扫描周期和定时器中断要匹配好确保每个键位的扫描间隔保持一致。这个方案有一个成本问题要注意矩阵键盘的键位数多每个键位都配一份独立的去抖计数器和状态变量RAM开销会明显涨。如果MCU资源紧张可以降低按键扫描频率或者只在矩阵扫描发现“有键按下/释放”的边沿时才跑状态机没有边沿变化时不做额外工作。5.2 多按键与事件调度的思路当系统同时有多个按键又有多个功能模块需要响应按键事件时单纯在main函数里写一堆if (ev xxx)就不好维护了。我推荐做一层简单的事件分发每个模块提前注册自己的按键事件处理函数主循环只要收到事件就按事件ID分发给对应的模块。比如说一个系统里有设置键、加键、减键、确认键设置键支持单击、双击、长按加键减键只需要长按连续加减。那么可以把“设置键长按”注册给菜单模块“加键长按”注册给参数调节模块。这种分发的代码量不大但结构会清晰非常多再加功能时只需要新增注册不用改动主循环。事件分发可以配合前面写的环形队列主循环每次取一个事件根据事件类型转发处理完再取下一个。5.3 低功耗场景下的按键唤醒与长按开机低功耗项目里按键识别的方案要做大调整。单片机进入掉电模式后定时器中断是停的靠按键唤醒只能用外部中断。这个时候“长按开机”就变成了一个很有意思的需求按键按下时单片机被唤醒但这时程序不能马上进入长按计时因为用户可能只是误碰到按键。我的做法是把“唤醒”和“长按确认”分开外部中断唤醒后先启动一个短延时稳定电源然后回到正常扫描逻辑开始计时长按。如果用户实际按下时长没达到长按阈值就认为是一次误触系统重新进入掉电模式。这样既保证功能又不费电。不过这个思路要配合具体芯片的低功耗模式和唤醒源来设计51的掉电唤醒和STM32的STOP模式唤醒在细节上差别很大移植时要仔细看数据手册。到这里单击、双击、长按的完整方案就落地了。我自己做这类程序有个习惯把时间参数全部集中在宏定义里调参只改这一处然后靠串口打印“按下、松开、单击、双击、长按”五类事件配合秒表人工测量事件间隔。实测确认逻辑对了再上真实功能基本一次过。按键识别看着基础但把基础稳扎稳打做好能省下后面一大半调试时间。
返回列表