多功能按键实例讲解状态机

55 阅读14分钟

状态机是编程中很常用的一种思想,对于解决很多问题都有着很不错的·效果,因此学习状态机是很有必要的,下面我先简单介绍一下状态机,然后用一个多按键的例子来讲解状态机。

想象一下一个自动门:

  1. 平时它是关着的
  2. 有人走近(事件),它就打开
  3. 开门状态下,如果一段时间没人通过(事件),门就自动关上
  4. 在关门过程中,如果有人突然走近(事件),门就重新打开

这就是一个典型的有限状态机

状态机的核心思想是:

  1. 状态: ​ 系统在某个时刻所处的特定模式或情形​(如“门关着”、“门开着”)。
  2. 事件: ​ 发生的事情或输入​(如“有人走近”、“定时器超时”),它会触发状态机做出反应。
  3. 转移: ​ 当某个事件发生时,系统从当前状态切换到另一个状态
  4. 动作: ​ 在执行状态转移时(或在进入/离开某个状态时),系统可能执行相应的操作​(如“启动马达开门”、“停止马达”)。

状态机(尤其是有限状态机 - FSM)的特点和优势:

  • 有限性: ​ 只有预先定义好的、有限个状态
  • 事件驱动: ​ 行为由接收到的事件决定下一步怎么做。
  • 明确的行为: ​ 对于任何一个状态和接收到的事件,下一个状态或要执行的动作是明确且唯一定义的。这使得逻辑非常清晰。
  • 模型化复杂行为: ​ 它能有效且清晰地建模和实现那些行为依赖于其当前状态、并且对事件反应复杂的系统。
  • 可视化: ​ 通常用状态图​(带圆圈的状态和有箭头的事件/转移)来表示,非常直观。

两种主要类型(都属FSM):

  1. Moore 状态机: ​ ​动作/输出只与当前状态有关。当进入某个状态,它就执行固定的动作。
  2. Mealy 状态机: ​ ​动作/输出取决于当前状态触发转移的输入事件

为什么有用?

  • 简化逻辑设计: ​ 把复杂的、依赖于多种条件的逻辑分解成定义良好的状态和转移,思路更清晰。

  • 提高可维护性: ​ 状态图让系统行为一目了然,更容易理解和修改。

  • 避免错误: ​ 明确的状态转移规则有助于确保所有情况都被处理到。

  • 广泛应用:

    • 软件工程: ​ UI流程控制(如登录状态)、游戏角色AI、订单处理系统、协议解析(如TCP状态机)、编译器词法分析。
    • 硬件设计: ​ 数字电路设计(如序列检测器、控制器)。
    • 业务建模: ​ 工作流引擎。
    • 嵌入式系统: ​ 设备控制器。

总结来说:

状态机是管理和设计任何“其行为随其当前情形(状态)和发生事件而变化”的系统的强大工具。它像一个明确的流程图,定义了系统所有可能的状态、状态之间如何转换(在什么事件下)、以及在转换时或处于状态时要做什么。

好了简单的介绍完了,接下来是具体的多按键状态机示例。

假如说现在要你用一个按键来同时可以实现单击,双击,长按,长按保持这四个功能,你要怎么设计?我来讲讲我的思路,我会先把每个状态都列出来,按键一共有七种状态,首先是最基础的单击,双击,长按,长按保持四种要实现的状态,其次就是未按下,第一次按下,以及第一次按下后的等侯态。仔细想想以后我们可以画出这样一副图出来,最后所有状态都会回归到未按下的状态。

​编辑

现在来仔细讲讲这张图。对于这个多功能按键来说,它要进入各个状态的前提条件是什么?当然就是一个按下的动作,因此我们可以明确未按下的下一个状态是第一次按下。第一次按下以后,我们需要进行等待,因为这个时候可以有两种状态,一个是单击,一个是长按,我们需要通过按下维持的时间来判断下一个状态到底是什么。显而易见的,长按的结算状态就应该是长按与长按保持两种,单击就不一样,它除了结算成单击和进入长按状态以外还可以进入双击状态。通过松开时间和再次按下的时间差来判断是否进入双击,进入双击以后就也是跟之前一样,利用按下时间来判断是否进入长按。这样就可以简单地先连出一个状态机的图出来。

接下来是代码部分,我们先建立几个时间阈值还有键值与按键状态的枚举,枚举是状态机的好伙伴,可以帮助把有限的状态更好地囊括起来。建立完两个枚举就要开始建立按键的结构体,首先是名字与状态,为了获取按键按下维持的时间,我们需要它按时间,松开时间,多击就需要点击次数,有长按保持就添加一个长按保持计数,然后就是链表的下一个。接着就是注册两个回调函数,用来获取时间以及键值。

#define SHORT_PRESS_TIME 50   // 单击时间阈值,单位为毫秒
#define MULTI_CLICK_TIME 300  // 多击时间间隔,单位为毫秒
#define LONG_PRESS_TIME 800   // 长按时间阈值,单位为毫秒
#define LONG_HOLD_TIME_MAX 2000  // 长按保持时间阈值,单位为毫秒
#define LONG_PRESS_RELEASE_TIME 500  // 长按与长按保持的时间间隔
#define LONG_HOLD_INTERVAL 200
#define MAX_CLICK_COUNT 2  // 最大点击次数

// 按键值定义
enum key_value {
    KEY_ON = 1,  // 按键按下
    KEY_OFF = 0,  // 按键松开
};

// 按键状态定义
enum key_status {
    OFF = -1,      // 无效状态
    WAITING,       // 等待按键按下
    CLICK,         // 单击
    MULTI_CLICK,   // 多击(双击、三击等)
    LONG_PRESS,    // 长按
    LONG_HOLD,     // 长按保持
};

// 按键事件结构体
typedef struct key_event_t {
    int id;                    // 按键ID
    enum key_status status;    // 按键状态
    uint32_t press_time;       // 按下时间
    uint32_t release_time;     // 松开时间
    uint16_t click_count;      // 点击次数
    uint16_t hold_count;       // 长按保持次数
    struct key_event_t *next;  // 下一个按键事件
} key_event_t;

// 回调函数类型定义
typedef uint32_t (*time_get_callback)(void);
typedef enum key_value (*key_value_get_callback)(int id);

接下来是定义全局变量,链表头以及两个函数,然后就是普通的链表添加,删除,查找操作

static key_event_t *head = NULL;
static time_get_callback global_time_get = NULL;
static key_value_get_callback global_key_value_get = NULL;


int key_event_init(time_get_callback time_get, key_value_get_callback key_value_get) {
    if (time_get == NULL || key_value_get == NULL) {
        return -1;
    }
    global_time_get = time_get;
    global_key_value_get = key_value_get;
    return 0;
}

static void deleteMyKey(uint32_t id){
    struct key_event_t *p = head;
    struct key_event_t *pre = NULL;
    while(p != NULL){
        if(p->id == id){
            if(pre == NULL){
                head = p->next;
            }else{
                pre->next = p->next;
            }
            free(p);
            return;
        }
        pre = p;
        p = p->next;
    }
}

static void addMyKey(uint32_t id){
    struct key_event_t *p = head;
    struct key_event_t *new = (struct key_event_t *)malloc(sizeof(struct key_event_t));
    if(new == NULL){
        return;
    }
    new->id = id;
    new->press_time = 0;
    new->release_time = 0;
    new->hold_count = 0;  // 初始按键保持次数为0
    new->next = NULL;
    if(p == NULL){
        head = new;
    }else{
        while(p->next != NULL){
            p = p->next;
        }
        p->next = new;
    }
}

key_event_t* findMyKey(int id)
{
    key_event_t *current = head;
    while(current != NULL && current->id != id)
    {
        current = current->next;  // 移动到下一个节点
    }
    if(current == NULL) {
        return NULL;  // 如果没有找到,返回NULL
    }
    return current;  // 返回找到的节点指针
}

uint16_t Key_GetClickCount(int id)
{
    key_event_t*p=findMyKey(id);
    if(p == NULL) {
        return 0; // 如果没有找到按键事件,返回0
    }
    else{
        return p->click_count; // 返回点击次数
    }
}

这里我也简单介绍一下链表以及链表的操作吧。链表顾名思义就是像链子一样的表,一个接着一个,像这样,每一节的末尾都是一个指向下一节的指针,一个连一个直到NULL结束。​编辑

添加操作也很好理解,就是如果链表头是NULL,就添加到链表头,然后把它的下一个变为NULL;如果链表头不是NULL,就一个个摸到链表尾的NULL,把它替换成新添加的,然后新添加的下一个就改为NULL。删除操作就是把想要删除的跳过,把它的前一个的next换成它的next也就是直接跳过它指向了下一个,用下面的来例子来讲,就是把1-next的值修改成2-next的值,让它可以直接指向3,这样就实现了跳过2,也就是删除的效果了。​编辑

接下来就是具体的状态变化:

/* 按键状态转换函数
 * 功能:根据按键ID和当前状态,计算并返回新的按键状态
 * 参数:id - 按键标识符
 * 返回值:enum key_status - 当前按键的最新状态
 * 状态机说明:
 *   OFF         : 初始状态/释放状态
 *   WAITING     : 按键按下但未达长按阈值
 *   CLICK       : 单击完成(按下后释放)
 *   MULTI_CLICK : 多击进行中(双击/三击等)
 *   LONG_PRESS  : 长按触发(超过长按时间阈值)
 *   LONG_HOLD   : 长按保持(持续按住时周期性触发)
 * 全局依赖:
 *   global_time_get      : 获取系统时间戳(毫秒级)
 *   global_key_value_get : 获取按键物理状态(KEY_ON/KEY_OFF)
 * 时间常量说明:
 *   LONG_PRESS_TIME     : 长按触发阈值(如500ms)
 *   MULTI_CLICK_TIME    : 多击时间窗口(如300ms)
 *   LONG_HOLD_INTERVAL  : 长按保持触发间隔(如1000ms)
 */
enum key_status Key_StatusChange(int id) {
    // 全局函数指针校验(防止未初始化导致崩溃)
    if (global_time_get == NULL || global_key_value_get == NULL) {
        return OFF;
    }
    
    // 按键事件对象管理
    key_event_t *p = findMyKey(id);       // 查找按键状态记录
    if (p == NULL) {                       // 首次检测到此按键
        addMyKey(id);                      // 创建新记录
        p = findMyKey(id);                 // 重新获取指针
        if (p == NULL) {                   // 创建失败处理
            return OFF;
        }
    }
    
    // 当前状态快照(避免多次调用全局函数)
    uint32_t current_time = global_time_get();
    enum key_value current_value = global_key_value_get(id);
    enum key_status status = p->status;    // 上次保存的状态
    uint32_t elapsed_time;                 // 时间差临时计算
    uint32_t press_elapsed = current_time - p->press_time; // 当前按下持续时间
    
    // 状态跃迁检测:从OFF到WAITING(立即响应新按下事件)
    if (current_value == KEY_ON && status == OFF) {
        p->press_time = current_time;      // 记录按下时刻
        p->click_count = 0;                // 重置点击计数
        p->release_time = 0;               // 清除释放时间
        p->status = WAITING;               // 进入等待状态
        return WAITING;                    // 立即返回新状态
    }
    
    // 状态机核心处理(基于当前状态分支)
    switch (status) {
        case OFF:
            // OFF状态下检测到按下:初始化参数并进入等待状态
            if (current_value == KEY_ON) {
                p->press_time = current_time;
                p->click_count = 0;
                p->release_time = 0;
                p->status = WAITING;
                return WAITING;
            }
            break;
            
        case WAITING:
            /* 等待状态处理逻辑:
             * 1. 若释放:记录释放时间,标记为单击(click_count=1)
             * 2. 若持续按下且超长按阈值:进入长按状态
             * 注意:未满足条件时保持WAITING状态
             */
            if (current_value == KEY_OFF) {
                p->release_time = current_time;
                p->click_count = 1;        // 首次单击计数
                p->status = CLICK;
                return CLICK;
            } 
            else if (press_elapsed >= LONG_PRESS_TIME) {
                p->status = LONG_PRESS;     // 触发长按
                p->hold_count = 0;          // 初始化长按保持计数
                return LONG_PRESS;
            }
            return WAITING;                 // 未达条件,保持等待
            break;
            
        case CLICK:
            /* 单击完成后的处理:
             * 1. 若再次按下:检测是否在多击时间窗内
             *   是 → 进入多击状态(增加点击计数)
             *   否 → 返回单击状态并重置为OFF
             * 2. 若超多击时间窗:自动重置为OFF状态
             */
            if (current_value == KEY_ON) {
                elapsed_time = current_time - p->release_time;
                if (elapsed_time <= MULTI_CLICK_TIME) {
                    p->status = MULTI_CLICK;
                    p->click_count++;       // 增加点击计数(双击/三击)
                    p->press_time = current_time;
                    p->release_time = 0;    // 重置释放时间(因再次按下)
                    p->hold_count = 0;
                    return MULTI_CLICK;
                } 
                else {                      // 超过多击时间窗
                    p->status = OFF;        // 结束单击周期
                    return CLICK;           // 仍返回本次单击
                }
            } 
            else if (current_time - p->release_time > MULTI_CLICK_TIME) {
                p->status = OFF;            // 超时自动重置
                return CLICK;
            }
            return CLICK;                   // 保持单击状态
            break;
            
        case MULTI_CLICK:
            /* 多击状态处理:
             * 释放时:
             *   - 若按下时间超窗:退出多击状态(判定为无效多击)
             * 按下时:
             *   - 若超长按阈值:转长按状态
             *   - 若在时间窗内:增加点击计数(等待下一次释放)
             */
            if (current_value == KEY_OFF) {
                p->release_time = current_time;
                // 释放时检测:若本次按下时间过长,判定多击失败
                if (current_time - p->press_time > MULTI_CLICK_TIME) {
                    p->status = OFF;        // 退出多击状态
                    p->click_count = 0;     // 重置计数
                    p->hold_count = 0;
                    return OFF;
                }
                return MULTI_CLICK;
            } 
            else {
                // 按下持续期间检测长按
                if (press_elapsed >= LONG_PRESS_TIME) {
                    p->status = LONG_PRESS; // 转长按状态(覆盖多击)
                    p->hold_count = 0;
                    return LONG_PRESS;
                }
                // 快速连按检测:在上次释放后规定时间内再次按下
                if (p->release_time > 0) {  // 存在前次释放记录
                    elapsed_time = current_time - p->release_time;
                    if (elapsed_time <= MULTI_CLICK_TIME && p->click_count < MAX_CLICK_COUNT) {
                        p->click_count++;   // 增加有效点击计数
                        p->press_time = current_time;
                        p->release_time = 0; // 清除释放标记(因再次按下)
                    }
                }
                return MULTI_CLICK;
            }
            break;
            
        case LONG_PRESS:
            /* 长按状态处理:
             * 释放 → 立即返回OFF
             * 持续按下 → 检测是否达到保持触发间隔
             */
            if (current_value == KEY_OFF) {
                p->status = OFF;            // 释放即重置
                return OFF;
            } 
            else {
                elapsed_time = press_elapsed - LONG_PRESS_TIME;
                // 达到长按保持间隔:升级为LONG_HOLD状态
                if (elapsed_time >= LONG_HOLD_INTERVAL) {
                    p->status = LONG_HOLD;
                    p->hold_count = 1;      // 初始化保持计数
                    return LONG_HOLD;
                }
            }
            return LONG_PRESS;              // 保持长按状态
            break;
            
        case LONG_HOLD:
            /* 长按保持状态:
             * 释放 → 重置为OFF
             * 持续按下 → 按保持间隔周期性触发
             */
            if (current_value == KEY_OFF) {
                p->status = OFF;
            } 
            else {
                elapsed_time = press_elapsed - LONG_PRESS_TIME;
                // 周期性触发:每达到整数倍间隔时更新计数
                if (elapsed_time >= (p->hold_count + 1) * LONG_HOLD_INTERVAL) {
                    p->hold_count++;        // 增加保持计数
                    return LONG_HOLD;        // 触发新事件
                }
            }
            return LONG_HOLD;               // 无事件时保持状态
            break;
    }
    
    return OFF; // 默认返回OFF状态(异常处理)
}

总结

状态机,作为一种将复杂行为分解为清晰状态与可控转换的编程范式,为我们设计响应式系统提供了强大的理论支撑和实践工具。其核心构成:​状态、事件、转移和动作

而多功能按键检测的使用状态机将看似简单的“按键按下”过程分解为多个互斥状态(OFFWAITINGCLICKMULTI_CLICKLONG_PRESSLONG_HOLD),并通过严格定义的时间阈值​(SHORT_PRESS_TIMEMULTI_CLICK_TIME等)和按键释放/按下事件来驱动状态间的有序流转。

代码实现的关键在于:

  1. 精确定义状态枚举​:清晰描述所有可能的系统“模式”。
  2. 维护按键上下文结构体​:记录状态、时间戳、计数等关键信息,它们是状态判断的基础。
  3. 事件驱动的状态转移函数​:在核心的Key_StatusChange函数中,通过switch-case结构,根据当前状态新事件(按键值变化 + 时间推移)​,精确地判断下一状态并执行相应动作或返回结果。
  4. 时间管理​:通过global_time_get回调获取可靠的时间戳,计算按键时长和间隔是区分单击、双击、长按及长按保持的核心依据。

状态机的魅力就在于它能将散乱的条件判断(if...else)逻辑,转换成一张清晰可见、易于维护的“状态图”。显著提升代码的可读性、可维护性和健壮性, 是一种解决“行为随状态而变”这类问题的通用编程利器。理解并实践它,将拥有设计和实现更优雅、更健壮软件系统的能力。