新手都能看懂的使用状态机实现非阻塞按键的单击双击长按功能

首先,我们得明白用一个按键实现单机,双击,长按的原理:

通过输入高电平实现控制引脚输出,这就是图中的l1单击,
在俩个高电平间会有间隔,我们定义其长度,让两次高电平时间控制在间隔时间内,执行双击,
对于长按来说,只要在一定时间内持续输出高电平,我们就可以认为是执行长按操作。

我们以图中l1-3举个例子,
l1 输出一个高电平代表按键的单击,l2则为俩个高电平加上其中的窗口期,如300ms,我们就可以得到理想状态下在300ms内再次点击按键即可实现双击操作,如果超过了300ms的窗口期后再按下按键,则回到l1实现单击,若持续按着按键不放达到一定时间,如800ms后,就执行长按操作。
通过控制高低电平持续与窗口期时间达到一个按键控制三种事件的操作。

但上述实现情况都是建立在理想状态下,在现实硬件中,会有一个抖动时间,抖动时间的长短由按键的机械特性决定,一般为5ms~10ms

在刚接触stm32的同学们最喜欢使用的就是delay延时函数进行消抖,这种方法有一个弊端,那就是会阻塞程序的运行,delay多少时间,程序就会在delay中执行多少时间的空循环,这样大大的降低了程序的效率,导致程序无法同时执行多个操作,为解决这种情况,我们使用状态机来实现,那问题来了,什么是状态机?

状态机:

状态机(Finite State Machine, FSM)是一种描述系统行为的数学模型,核心思想是:系统在任意时刻只能处于有限个状态中的一个,在事件触发下按规则转移到新状态

进入代码部分,首先我们在key.h文件中先定义并声明变量与状态机:

#ifndef __key_H #define __key_H #include "main.h" // Device header typedef enum{ KEY_IDLE = 0, //空闲状态 KEY_DEBOUNCE_DOWN, //按键按下消抖状态 KEY_PRESSED, //按键确认状态 KEY_DEBOUNCE_UP, //按键抬起消抖状态 KEY_WAIT_DOUBLE, //等待双击状态 KEY_LONG_PRESS //按键长按状态 }KEY_STATE; extern KEY_STATE keystates; //给KEY_STATE命名为keystates并声明为跨文件的全局变量 extern volatile uint8_t flag; //声明全局变量flag extern uint32_t now_time; //声明全局变量now_time void key_scan(void); //声明函数key_scan #endif

进入key.c文件

定义变量:

KEY_STATE keystates = KEY_IDLE; //设置keystates 的初始状态为KEY_IDLE volatile uint8_t flag; //定义变量 uint32_t now_time=0; //定义变量 uint32_t now_long_time =0; //定义变量 uint32_t now_double_time =0; //定义变量 uint8_t count ; //定义变量

其中我们的flag为标志位,因为本次代码将会在中断中执行,所以采用传输标志位的方式。
now_time=0; 为消抖的开始时间
uint32_t now_long_time =0;为长按的判定时间
uint32_t now_double_time =0; 双击窗口期的判定时间
uint8_t count ; 计数,来判断本次按下是否是双击

现在进行到最激动人心也是最复杂的按键扫码函数的编写,为方便解读说明,我将拆分开,逐段解释说明,最后会附上完整代码:

空闲状态:

//空闲状态 case KEY_IDLE: //判断按键是否按下 if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_RESET) { //将系统时间赋值给now_time now_time = HAL_GetTick(); //将系统时间赋值给now_long_time now_long_time = HAL_GetTick(); //初始化变量 flag =0; count =0; //转移状态为KEY_DEBOUNCE_DOWN,按下消抖 keystates = KEY_DEBOUNCE_DOWN; } break;

本状态用于初始化变量,进入下一层状态的条件为按键是否按下

按下消抖状态:

case KEY_DEBOUNCE_DOWN: if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_SET) { keystates =KEY_IDLE; } else if (HAL_GetTick() - now_time >=20) { now_time = HAL_GetTick(); keystates =KEY_PRESSED; } break;

本状态用于检测按键是否按下,如果为高电平则为误触,状态退回到空闲状态
在代码中我用了GetTick函数,此函数为为返回系统启动以来的时间戳,在空闲状态我已经获取了时间并放在now_time中,所以我现在用now_time减去当前的时间大于等于20的if句,来实现delay(20)的操作。如果满足条件,就再次记录时间,并将状态转移到KEY_PRESSED状态


按键确认状态:

case KEY_PRESSED: //判断按键是否松开,如果松开则跳转到抬起消抖状态 if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_SET) { now_time= HAL_GetTick(); keystates =KEY_DEBOUNCE_UP; } else if(HAL_GetTick() - now_long_time >= 800) { flag = 3; keystates =KEY_LONG_PRESS; } break;

本状态为确认按键是否按下与长按检测
若按键松开,恢复高电平,则进入下一层状态,按键释放消抖状态,在这里记录时间的作用和上一个状态一样,用于替代delay。
在空闲状态中,我们记录了第一次按键按下的时间,在这里我们可以利用它来检测是否长按时间是否达到,如果达到了就记录标志位跳入KEY_LONG_PRESS状态,

按键释放消抖状态:

case KEY_DEBOUNCE_UP:// 按键释放消抖状态, if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_RESET )//判断按键是否还是按下 { keystates =KEY_PRESSED;//是则表示未成功消抖跳回到按下状态 } else if(HAL_GetTick() - now_time >= 20) { count ++; if(count ==2) { count =0; flag =2; keystates = KEY_IDLE; } else { now_double_time = HAL_GetTick(); keystates = KEY_WAIT_DOUBLE; } } break;

本状态最为复杂,if嵌套if的,但实际上很好理解,首先我们先判断按键是否按下没松开,为真则跳回上一状态,为假则表明已经松开,那这时我们再判断是否延时,真则表示我们已经完成了一次按键按下的操作,count就+1,此时我们再继续判断count是否等于2,若为真,则表明这是我们第二次按下,就计数器清零,标志位为2,跳转到空闲状态。如果不为2,那就表示我们只按了一次按键,就记录双击窗口期时间now_double_time,并跳入KEY_WAIT_DOUBLE中

等待双击状态:

case KEY_WAIT_DOUBLE: if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_RESET ) { now_time = HAL_GetTick(); keystates =KEY_DEBOUNCE_DOWN; } else if( HAL_GetTick() - now_double_time >=400) { flag =1; keystates = KEY_IDLE; } break;

本状态为双击等待状态,意思是我们已经完成了一次按键按下,等待在窗口期内会不会有第二次按键,如果有就跳转到按键按下消抖阶段,如果没有,那表明本次流程结束,得到想要的标志位flag值为1,状态回到空闲状态。

长按检测状态:

case KEY_LONG_PRESS: if(HAL_GPIO_ReadPin(GPIOA,GPIO_PIN_0) == GPIO_PIN_SET) { keystates = KEY_IDLE; } break;

本段状态特别简单,算是一个过渡状态,用于检测长按后是否松手,如果松手则返会空闲状态,否则继续检测,直至松开,在代码中,我们的长按是安到一定时间就执行命令,不用等到松手,但在这里我加入松手检测命令主要是为了让整个状态更完整,至于会不会有不检测松手会程序跑飞等问题暂未考虑,如果有同学在实操中发现问题,请分享与我,我们相互学习。

完整KEY.C代码

#include "main.h" // Device header #include "key.h" KEY_STATE keystates = KEY_IDLE; volatile uint8_t flag; uint32_t now_time = 0; uint32_t now_long_time = 0; uint32_t now_double_time = 0; uint8_t count; void key_scan() { switch (keystates) { case KEY_IDLE: if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { now_time = HAL_GetTick(); now_long_time = HAL_GetTick(); flag = 0; count = 0; keystates = KEY_DEBOUNCE_DOWN; } break; case KEY_DEBOUNCE_DOWN: if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) { keystates = KEY_IDLE; } else if (HAL_GetTick() - now_time >= 20) { now_time = HAL_GetTick(); keystates = KEY_PRESSED; } break; case KEY_PRESSED: if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) { now_time = HAL_GetTick(); keystates = KEY_DEBOUNCE_UP; } else if (HAL_GetTick() - now_long_time >= 800) { flag = 3; keystates = KEY_LONG_PRESS; } break; case KEY_DEBOUNCE_UP: if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { keystates = KEY_PRESSED; } else if (HAL_GetTick() - now_time >= 20) { count++; if (count == 2) { count = 0; flag = 2; keystates = KEY_IDLE; } else { now_double_time = HAL_GetTick(); keystates = KEY_WAIT_DOUBLE; } } break; case KEY_WAIT_DOUBLE: if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { now_time = HAL_GetTick(); keystates = KEY_DEBOUNCE_DOWN; } else if (HAL_GetTick() - now_double_time >= 400) { flag = 1; keystates = KEY_IDLE; } break; case KEY_LONG_PRESS: if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) { keystates = KEY_IDLE; } break; default: keystates = KEY_IDLE; break; } }

主文件调用

本次代码使用定时器中断,所以得先配置回调函数

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim ->Instance ==TIM2) { key_scan(); } }

在cubemx中开启tim2

开启内部时钟

配置参数

nvic配置

开启定时中断并定义变量key_flag 来存放flag值

HAL_TIM_Base_Start_IT(&htim2); uint8_t key_flag = 0;

最后激动人心的时候,while程序编写

while (1) { key_flag = flag; switch (key_flag) { case 1: HAL_GPIO_TogglePin(GPIOA,GPIO_PIN_1); flag =0; break; case 2: HAL_GPIO_TogglePin(GPIOA,GPIO_PIN_6); flag =0; break; case 3: HAL_GPIO_TogglePin(GPIOB,GPIO_PIN_11); flag =0; break; default: break; } }

这里我们把scan中得到的flag值赋给key_flag,用于避免竞态条件,防止每一次读取到不同的值
在主程序中,我们每执行完一个操作后都将flag值归零,以免读到相同的值

完整代码:

HAL_TIM_Base_Start_IT(&htim2); uint8_t key_flag = 0; while (1) { key_flag = flag; switch (key_flag) { case 1: HAL_GPIO_TogglePin(GPIOA,GPIO_PIN_1); flag =0; break; case 2: HAL_GPIO_TogglePin(GPIOA,GPIO_PIN_6); flag =0; break; case 3: HAL_GPIO_TogglePin(GPIOB,GPIO_PIN_11); flag =0; break; default: break; } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim ->Instance ==TIM2) { key_scan(); } }

注意,每次我们写的代码都需放这俩注释之间,

/* USER CODE BEGIN */ 中间填写我们的代码 /* USER CODE END */

结语

我也是个从0开始的小白,从delay不离手,到学习到状态机,再到应用,其中的每一步都折磨的我整晚整晚睡不着觉,在此感谢并推荐B站up主:江协科技,成电应电科协,铁头山羊以及在csdn分享技术心得的大佬们, 感谢他们让我学习到了学校外学不到的知识。

若发现内容有误,或表达的不准确的地方麻烦各位大佬们及时提出,为了千千万万个懵懂却又对嵌入式充满热情的同学们!