按键状态机检测架构详解:消抖 / 单击 / 双击 / 长按全方案
定时器实现的按键检测状态机框架
1.按键检测状态机框架基本原理
1.1 按键操作种类
按键的操作,可以细分为以下几种:
- 长按:长时间按下按键,这里我们认为大于 1.2s 按下并释放即为长按
- 短按:短时间按下按键
- 单次点击:按一下按键松开,这里我们认为按下时间低于 1s 且按键释放后 500ms 内无再次按下为单击
- 双次点击:两次单击操作间隔小于 500ms,合并为双击
1.2 状态机基本概念
在讲解状态机之前,我们需要先了解什么是状态,状态是人或事物表现出来的形态。是指现实(或虚拟)事物处于生成、生存、发展、消亡时期或各转化临界点时的形态或事物态势。简单来说,就是一个对象在不同情况下对应的各种形态。
状态机(Finite State Machine,简称 FSM)则是一种用来描述对象在其生命周期内所经历的状态以及这些状态之间如何转移的数学模型。它由一组状态、一个初始状态、一组输入事件和一个状态转移函数组成。当接收到某个输入事件时,状态机会根据当前状态和输入事件来决定转移到哪个新的状态,并可能产生输出。 状态机在计算机科学中有广泛的应用,如编译器设计、网络协议分析、游戏 AI 等。
比方说一个小灯泡的开关,就是一个最基本的小型状态机:

状态机方便的地方是,如果现在我们只有两种状态,这两个状态在打开开关/关闭开关两个条件之间跳转,如果我们要加上一个新的条件,比方说我们设定了定时开关灯,多了一个跳转条件,如果灯泡开了超过 8 个小时就自动关闭,除非再次打开开关,否则灯泡一直处于关闭状态,这个状态图如下:

根据上面概述,我们可以看出状态机可归纳为现态、条件、动作、次态四个核心要素,其中 “现态” 与 “条件” 是 “因”,“动作” 与 “次态” 是 “果”,各要素详解如下:
- 现态:指当前所处的状态。
- 条件(又称 “事件”):当某一条件被满足时,会触发一个动作,或执行一次状态的迁移。
- 动作:条件满足后执行的操作;动作执行完毕,既可以迁移到新状态,也可以保持原状态(动作并非必需,条件满足后也可直接迁移至新状态)。
- 次态:条件满足后要迁往的新状态;“次态” 一旦被激活,就会转变为新的 “现态”。
因为一般的状态机的状态都是离散和可举的,即为有限,所以我们用到的状态机又叫有限状态机。
我们通常使用状态迁移图(STD)和状态迁移表来描述状态机的状态、事件和行为集合:

| 状态(现态) | 状态描述 | 条件 | 动作 | 后状态 | |
|---|---|---|---|---|---|
| 状态 1 | |||||
| …… | 条件 1 | - | 状态 2 | ||
| 条件 2 | 动作 1 | 状态 3 | |||
| 状态 2 | …… | 条件 3 | 附加条件 :满足 | - | 状态 4 |
| 附加条件:不满足 | - | 状态 3 | |||
| 条件 5 | - | 状态 1 | |||
| 条件 6 | 动作 2 | - | |||
| 状态 3 | …… | 条件 4 | - | 状态 4 | |
| 条件 5 | - | 状态 1 | |||
| 状态 4 | …… | 条件 5 | - | 状态 1 |
1.3 按键检测状态机的基本实现
简单来说,按键检测状态机的原理为:定时检测按键是否按下或释放(这里,我们称为按下动作和释放动作),根据按键动作和当前所处状态进行状态转换:
- 当按键处于单击状态并在规定时间释放释放后:完成一次单击操作,执行对应回调函数
- 当按键处于长按状态并释放后:完成一次长按操作,执行对应回调函数
- 当按键处于双击状态并在规定时间释放后:完成一次长按操作,执行对应回调函数
众所周知,按键可以进行两种动作:按下和释放。
我们首先对按键操作进行如下定义:
-
长按:长时间按下按键,这里我们认为大于 1.2s 按下并释放即为长按
-
短按:短时间按下按键
- 单次点击:按一下按键并释放,这里我们认为按下时间低于 1s 且按键释放后 500ms 内无再次按下为单击
- 双次点击:两次单击操作间隔小于 500ms,合并为双击
在 GPIO 的章节中,我们讲到过,按键按下时会有抖动,我们可以将按键分为如下状态:
-
释放状态:包括没有按下按键和按下按键后松开均为释放状态
-
消抖状态:按键按下动作发生后的 20ms 会产生抖动,为消抖状态
-
单击状态:消抖状态后在下一次的检测中按键仍处于按下状态并且按下时间小于 1.2s 则为单击状态
并且要求释放后 500ms 内没有按键再次按下 -
长按状态:当按键上一状态为单击状态且按下时间大于 1.2s 则为长按状态
-
等待状态:当按键上一状态为按下,释放后的 500ms 内在还没有按键按下事件这一期间为等待状态
-
双击状态:当按键上一状态为按下,释放后 500ms 内有按键再次按下为双击状态

这里,我们首先定义了 Button 类和一系列在按键检测状态机中的类属性常量:
# 按键状态机框架
class ButtonFSM:
"""
ButtonFSM类,用于实现按键状态机,检测按键的按下、释放、长按、双击等事件。
该类封装了按键的状态机逻辑,支持按键的长按、单击、双击等操作的检测。通过定时器定期检测按键的状态变化,
并根据按键的状态进行相应的事件处理。用户可以通过回调函数获取按键动作。
Attributes:
LOW (int): 按键的低电平状态,表示按键未按下。
HIGH (int): 按键的高电平状态,表示按键被按下。
BtnPressMinTime (int): 按键长按的最小时间(单位:毫秒),当按键按下时间超过该值时认为是长按。
BtnDoubleClickMaxTime (int): 双击事件中两次点击的最大时间间隔(单位:毫秒)。
RELEASE_EVENT (int): 按键释放事件。
CLICK_EVENT (int): 按键点击事件。
RELEASE_STATE (int): 按键释放状态。
DEBOUNCE_STATE (int): 按键消抖状态。
CLICK_STATE (int): 按键点击状态。
WAIT_STATE (int): 按键等待第二次点击状态。
DOUBLE_CLICK_STATE (int): 按键双击状态。
PRESS_STATE (int): 按键长按状态。
Methods:
__init__(self, pin: Pin, timer: Timer, init_state: int,
press_callback: callable, click_callback: callable,
double_click_callback: callable, args: object = None) -> None:
初始化ButtonFSM类实例,设置按键引脚、定时器和回调函数。
detect(self, timer: Timer) -> None:
按键状态检测函数,根据当前按键状态和信号判断按键动作,更新按键状态。
get_action(self) -> None:
获取当前按键动作(如点击或释放),并更新按键状态。
"""
# 按键初始化状态,初始化情况下为低电平-0,按下时为高电平-1
LOW, HIGH = (0, 1)
# 按键长按最小确定时间:当短按时间大于该值时,认为按键被长按
BtnPressMinTime = 1200
# 双击中两次单击时间的最大间隔:当两次单击时间小于该值时,认为按键被双击
BtnDoubleClickMaxTime = 500
# 按键状态机相关定义
# 按键事件:释放或按下
RELEASE_EVENT,CLICK_EVENT = (0, 1)
# 按键状态:释放、消抖、单击/继续按下、等待第二次按下、双击、长按
RELEASE_STATE, DEBOUNCE_STATE, CLICK_STATE, WAIT_STATE, DOUBLE_CLICK_STATE, PRESS_STATE = (0, 1, 2, 3, 4, 5)
我们将按键事件和状态均定义为类属性,该部分变量在每个 Button 类的实例中均可用。
在 Button 类的初始化方法中,我们需要向其中传入按键使用的引脚、定时器对象、按键的初始状态(高电平或低电平)、长按回调函数、短按回调函数和双击回调函数:
def __init__(self, pin: Pin, timer: Timer, init_state: int,
press_callback: callable, click_callback: callable,
double_click_callback: callable, args: object = None) -> None:
"""
初始化按键,设置按键的状态、引脚、回调函数及定时器。
Args:
pin (machine.Pin): 按键连接的引脚对象。
timer (machine.Timer): 用于定时检测按键状态的定时器对象。
init_state (int): 按键初始化状态(低电平或高电平),可选值为 ButtonFSM.LOW 或 ButtonFSM.HIGH。
press_callback (callable): 长按触发时的回调函数。
click_callback (callable): 单击触发时的回调函数。
double_click_callback (callable): 双击触发时的回调函数。
args (object, optional): 回调函数的额外参数,默认值为 None。
Returns:
None
Description:
此方法将按键初始化为低电平或高电平,设置定时器定期调用 `detect` 方法以检测按键状态。
"""
self.pin = pin
self.timer = timer
self.init_state = init_state
self.press_callback = press_callback
self.click_callback = click_callback
self.double_click_callback = double_click_callback
# 初始化按键引脚
# 按键初始化状态为低电平-0,按下时为高电平-1
if self.init_state == ButtonFSM.LOW:
self.pin.init(self.pin.IN, self.pin.PULL_DOWN)
self.pin_signal = Signal(self.pin, invert=False)
# 按键初始化状态为高电平-0,按下时为低电平-1
elif self.init_state == ButtonFSM.HIGH:
self.pin.init(self.pin.IN, self.pin.PULL_UP)
self.pin_signal = Signal(self.pin, invert=True)
# 按键长按计数:通过长按计数*定时器周期判断按键是否被长按
self.press_count = 0
# 按键事件
self.event = ButtonFSM.RELEASE_EVENT
# 按键状态
self.state = ButtonFSM.RELEASE_STATE
# 定时器连续运行,周期为20ms,到达设置时间调用detect方法检测按键状态
self.run_period = 20
self.timer.init(period=self.run_period, mode=Timer.PERIODIC, callback=self.detect)
# 回调函数参数
self.args = args
这里,定时器设置为周期性触发,周期为 20ms。每次定时器触发时,都会调用 detect 方法来检测按键状态。
def detect(self, timer: Timer) -> None:
"""
按键按下状态检测函数,根据按键的当前状态和信号判断按键的动作。
Args:
timer (machine.Timer): 传入的定时器对象,用于定期调用检测函数。
Returns:
None: 该函数没有返回值,通过内部事件更新按键状态。
"""
# 获取按键动作
self.get_action()
# 状态:无动作
if self.state == ButtonFSM.RELEASE_STATE:
# 按键按下,进入消抖状态
if self.event == ButtonFSM.CLICK_EVENT:
self.state = ButtonFSM.DEBOUNCE_STATE
# 按键没有按下,进入释放状态
else:
self.state = ButtonFSM.RELEASE_STATE
# 状态:消抖
elif self.state == ButtonFSM.DEBOUNCE_STATE:
# 按键按下,进入单击状态
if self.event == ButtonFSM.CLICK_EVENT:
self.state = ButtonFSM.CLICK_STATE
# 按键没有按下,进入释放状态
else:
self.state = ButtonFSM.RELEASE_STATE
# 状态:单击/继续按下
elif self.state == ButtonFSM.CLICK_STATE:
# 按键仍然处于按下状态并且超过BtnPressMinTime按键长按最小确定时间
if self.event == ButtonFSM.CLICK_EVENT and self.press_count*self.run_period >= ButtonFSM.BtnPressMinTime:
# 按键为长按状态
self.state = ButtonFSM.PRESS_STATE
self.press_count = 0
# 按键仍然处于按下状态并且小于BtnPressMinTime按键长按最小确定时间
elif self.event == ButtonFSM.CLICK_EVENT and self.press_count*self.run_period < ButtonFSM.BtnPressMinTime:
# 继续计时
self.press_count = self.press_count + 1
# 保持单击状态
self.state = ButtonFSM.CLICK_STATE
# 短按后释放按键,进入等待第二次按下状态
else:
# 清除计数变量
self.press_count = 0
# 进入等待第二次按下状态
self.state = ButtonFSM.WAIT_STATE
# 状态:长按
elif self.state == ButtonFSM.PRESS_STATE:
# 仍然处于按下状态,等待按键释放后转换为长按事件
if self.event == ButtonFSM.CLICK_EVENT:
# 按键长按计数清零
self.press_count = 0
# 按键释放,进入释放状态
self.state = ButtonFSM.PRESS_STATE
else:
self.press_count = 0
self.state = ButtonFSM.RELEASE_STATE
# 执行长按回调函数
if self.press_callback is not None:
self.press_callback(self.args)
# 状态:等待第二次按下
elif self.state == ButtonFSM.WAIT_STATE:
# 第一次短按,且释放时间大于BtnDoubleClickMaxTime双击中两次单击时间的最大间隔
if self.event == ButtonFSM.RELEASE_EVENT and self.press_count*self.run_period >= ButtonFSM.BtnDoubleClickMaxTime:
self.press_count = 0
self.state = ButtonFSM.RELEASE_STATE
# 执行单击回调函数
if self.click_callback is not None:
self.click_callback(self.args)
# 第一次短按,且释放时间小于BtnDoubleClickMaxTime双击中两次单击时间的最大间隔
elif self.event == ButtonFSM.RELEASE_EVENT and self.press_count*self.run_period < ButtonFSM.BtnDoubleClickMaxTime:
# 继续等待
self.press_count = self.press_count + 1
self.state = ButtonFSM.WAIT_STATE
# 第一次短按,且还没到BtnDoubleClickMaxTime时间就第二次被按下
else:
self.press_count = 0
# 进入双击状态
self.state = ButtonFSM.DOUBLE_CLICK_STATE
# 状态:双击
elif self.state == ButtonFSM.DOUBLE_CLICK_STATE:
# 第二次按的时间大于BtnPressMinTime按键长按最小确定时间
if self.event == ButtonFSM.CLICK_EVENT and self.press_count*self.run_period >= ButtonFSM.BtnPressMinTime:
# 按键长按状态
self.state = ButtonFSM.PRESS_STATE
# 按键长按计数清零
self.press_count = 0
if self.click_callback is not None:
self.click_callback(self.args)
# 第二次按的时间小于BtnPressMinTime按键长按最小确定时间
elif self.event == ButtonFSM.CLICK_EVENT and self.press_count*self.run_period < ButtonFSM.BtnPressMinTime:
self.press_count = self.press_count + 1
# 保持双击状态
self.state = ButtonFSM.DOUBLE_CLICK_STATE
# 第二次按键按下后在BtnPressMinTime按键长按最小确定时间内释放
else :
self.press_count = 0
self.state = ButtonFSM.RELEASE_STATE
# 执行双击回调函数
if self.double_click_callback is not None:
self.double_click_callback(self.args)
return
同时,我们定义了一个 get_action 方法用于获取按键动作:
def get_action(self) -> None:
"""
获取按键动作并更新事件状态。
Args:
None
Returns:
None: 该函数没有返回值,仅更新内部事件状态。
"""
# 若信号无效,按键没有被按下
if self.pin_signal.value() == 0:
self.event = ButtonFSM.RELEASE_EVENT
# 信号有效,按键被按下
elif self.pin_signal.value() == 1:
self.event = ButtonFSM.CLICK_EVENT
然后,我们定义了 detect 方法,它会根据当前的按键状态和动作,更新按键的状态机:
- 通过调用 get_action() 方法获取当前的按键事件,包括按下、释放等
- 根据当前的状态和按键事件,代码会进入不同的状态处理分支:
- 无动作状态(RELEASE_STATE)
- 如果检测到按键按下事件,则进入消抖状态(DEBOUNCE_STATE)
- 如果检测到按键释放事件,则保持释放状态(RELEASE_STATE)
- 消抖状态(DEBOUNCE_STATE)
- 如果检测到按键按下事件,则进入单击状态(CLICK_STATE)
- 如果检测到按键释放事件,则进入释放状态(RELEASE_STATE)
- 单击/继续按下状态(CLICK_STATE)
- 如果检测到按键按下事件并且按键按下时间超过最小长按时间(BtnPressMinTime),则进入长按状态(PRESS_STATE)
- 如果检测到按键按下事件并且按键按下时间未超过最小长按时间,则继续计时并保持单击状态(CLICK_STATE)
- 如果检测到按键释放事件,则进入等待第二次按下状态(WAIT_STATE)
- 长按状态(PRESS_STATE)
- 如果检测到按键按下事件,则继续等待按键释放
- 如果检测到按键释放事件,则进入释放状态(RELEASE_STATE),确定为长按,并执行长按回调函数
- 等待第二次按下状态(WAIT_STATE)
- 如果检测到按键按下事件并且第一次按键释放时间到第二次按键按下的时间间隔超过最大双击时间(BtnDoubleClickMaxTime),则确定为单击,并执行单击回调函数
- 如果第一次按键释放时间未超过最大双击时间,则继续等待第二次按下
- 如果在等待期间检测到第二次按下,则进入双击状态(DOUBLE_CLICK_STATE)
- 双击状态(DOUBLE_CLICK_STATE)
- 如果第二次按键按下时间超过最小长按时间(BtnPressMinTime),进入长按状态(PRESS_STATE),响应为单击事件,执行单击回调函数
- 如果第二次按键按下时间未超过最小长按时间,则继续保持双击状态(DOUBLE_CLICK_STATE)
- 如果第二次按键在最小长按时间内释放,则响应为双击事先,执行双击回调并进入释放状态(RELEASE_STATE)

并且,根据当前的按键事件,函数会更新相应的状态变量(self.state)。如果检测到相应的按键操作(单击、双击、长按),则会执行对应的回调函数。
需要注意的是,有以下三种特殊情况:
- 当进行一次短按后在 500ms 内进行长按,则响应一次单击和长按操作,不响应双击操作

- 进行连续的偶数 2n 次时间间隔小于 500ms 的短按操作后,响应其为 n 次双击操作

- 进行连续的奇数 2n+1 次时间间隔小于 500ms 的短按操作后,响应为 n 次双击操作和 1 次单击操作

这里建议,在进行完一次操作(单击、双击或长按后)过 500ms 再进行下一次操作,不然可能导致误操作。
2.触摸按键检测实验
这里,我们使用电容触摸按键完成按键的单击、双击和长按检测。
电容触摸按键的工作原理是电容容量的变化,在周围环境,不变的情况下,按键和大地之间的电容是一个很微小的固定值,而人体和大地之间也存在着电容。当手指靠近触摸按键时,就相当于触摸按键与大地之间的电容并联了人体与大地之间的电容,从而使总电容容量变大:

我们通常使用一个电源串联一个电阻与电容触摸按键并联,在上电时,电源会给电容进行充电,而当我们当手指靠近触摸按键时总电容容量变大,电容充电电路的 RC 时间常数也会改变,通过测量电容充电时间即可检测按键的状态。

这里,我们无需通过模数转换器测量电容充电时间从而判断按键是否按下,我们使用专用的 4 路触摸按键芯片 TTP224 辅助我们进行判断,TTP224 通过内部的传感器振动电路、计时器和触摸检测电路等,可以在电容按键按下通过内部电路处理直接输出高低电平,使得我们可以像使用机械按键一样使用电容触摸按键。

这里,我们设计的四路触摸按键模块中在 OUT 输出端还连接了 LED 灯指示其按键按下情况,当电容按键没有按下时 OUT 输出端为低电平,LED 灯不亮;而当电容按键按下时,OUT 输出端为高电平,LED 灯点亮。
在下面实验中,我们需要将风雅一号板-通用兼容扩展板插入到风雅一号板-七彩触控扩展板上,同时打开风雅一号板-七彩触控扩展板上 SWITCH1 拨码开关所有选项(TB_OUTx):

这里,四路触摸按键接口与树莓派 Pico 连线如下所示:

以下实验中所有代码开源在我们提供的资料包中 elegance-devkit v1\Demo\22 TIMER_ButtonFSM 文件夹找到。
接下来,开始实验代码讲解,我们首先将按键检测状态机框架 Button 类代码放到 ButtonDetect.py 文件中,然后在我们的 main.py 文件中,首先导入必要的模块:
# Python env : MicroPython v1.23.0
# -*- coding: utf-8 -*-
# @Time : 2024/7/9 下午11:03
# @Author : 李清水
# @File : main.py
# @Description : 定时器实验,使用定时器完成按键短按和长按检测功能
# ======================================== 导入相关模块 ========================================
# 导入硬件相关的模块
from machine import Pin, Timer
# 导入按键检测框架
from ButtonDetect import ButtonFSM
# 时间相关的模块
import time
这里,我们定义了按键在三个不同操作后的回调函数,回调函数指示按键操作和按键编号:
# ======================================== 功能函数 ============================================
# 按键长按回调函数
def press_func(arg: int) -> None:
"""
按键长按回调函数,当按键被长按时触发。
Args:
arg (int): 按键编号或其他标识信息。
Returns:
None: 该函数没有返回值,仅执行打印操作。
"""
print('button %d is pressed' % arg)
# 按键短按回调函数
def click_func(arg: int) -> None:
"""
按键短按回调函数,当按键被短按时触发。
Args:
arg (int): 按键编号或其他标识信息。
Returns:
None: 该函数没有返回值,仅执行打印操作。
"""
print('button %d is clicked' % arg)
# 按键双击回调函数
def double_click_func(arg: int) -> None:
"""
按键双击回调函数,当按键被双击时触发。
Args:
arg (int): 按键编号或其他标识信息。
Returns:
None: 该函数没有返回值,仅执行打印操作。
"""
print('button %d is double clicked' % arg)
在初始化配置中,我们定义触摸按键使用的引脚和定时器,并创建 Button 实例:
# ======================================== 初始化配置 ==========================================
# 延时等待设备初始化
time.sleep(3)
# 打印调试信息
print("FreakStudio : Using ButtonFSM to detect the status of button")
# 定义按键引脚
button_1_pin = Pin(10)
button_2_pin = Pin(11)
button_3_pin = Pin(12)
button_4_pin = Pin(13)
# 定义定时器对象
timer_1 = Timer(-1)
timer_2 = Timer(-1)
timer_3 = Timer(-1)
timer_4 = Timer(-1)
# 创建4个按键实例
button_1 = ButtonFSM(button_1_pin, timer_1, ButtonFSM.LOW, press_func, click_func, double_click_func,1)
button_2 = ButtonFSM(button_2_pin, timer_2, ButtonFSM.LOW, press_func, click_func, double_click_func,2)
button_3 = ButtonFSM(button_3_pin, timer_3, ButtonFSM.LOW, press_func, click_func, double_click_func,3)
button_4 = ButtonFSM(button_4_pin, timer_4, ButtonFSM.LOW, press_func, click_func, double_click_func,4)
接着,我们创建一个 while 死循环,主要是为了 main.py 文件不会直接退出:
# ======================================== 主程序 ============================================
while True:
pass
烧录程序,通过 mpremote 连接到树莓派 Pico,按下按键时可以看到终端输出对应操作:



浙公网安备 33010602011771号