博客
返回文章列表
技术

按键组件编写思路

·19

嵌入式非阻塞按键组件开发笔记

一. 状态机 (State Machine) 核心思想

1. 核心思想

状态机(Finite State Machine, FSM)是借鉴了数字电路中的逻辑设计思想。在嵌入式开发中,它主要用于处理具有时间顺序多种逻辑阶段的任务。

其本质是:利用一个中间变量(状态变量)来维护当前的逻辑位置,根据输入条件实现状态的流转。


2. 基础应用:标志位同步

在简单的逻辑中,状态机可以表现为“标志位”。例如,通过按键控制 LED 灯的翻转,标志位 led_flag 充当了连接按键动作和灯光状态的桥梁。

c
void key_task() {
    if (key_read() == 1) {
        // 使用异或运算实现状态翻转:1 -> 0, 0 -> 1
        led1_flag ^= 1; 
    }
}

void main() {
    while (1) {
        if (led1_flag == 1) {
            // 执行 LED 点亮逻辑
        } else {
            // 执行 LED 熄灭逻辑
        }
    }
}

3. 高级应用:复杂按键逻辑

对于包含消抖、短按、长按等复杂判断的场景,简单的 if-else 会导致逻辑混乱或产生阻塞。此时使用 switch-case 结构的状态机是最优解。

状态机设计的“灵魂”

非阻塞执行:状态机的精髓在于每次调用只执行当前状态对应的逻辑。满足条件后仅修改状态值,而不立即跳转执行下一个状态的代码。这保证了 CPU 可以周期性地扫描其他任务。

代码实现示例:

c
unsigned char key_flag = 0;       // 记录哪一个按键被触发
unsigned char key_short_flag = 0; // 短按标志
unsigned char key_long_flag = 0;  // 长按标志

void key_scan() {
    unsigned char cur_key = 0;
    static unsigned char state = 0;   // 静态变量,保留上次调用后的状态
    static unsigned int key_time = 0; // 计时器
    
    cur_key = key_read(); // 读取当前物理键值

    switch (state) {
        case 0: // 状态 0:等待按下
            if (cur_key != 0) {
                key_flag = cur_key;  // 锁定当前按下的键值
                state = 1;           // 发现按下,进入状态 1
            }
            break;

        case 1: // 状态 1:确认按下(消抖阶段)
            if (cur_key == key_flag) {
                key_time = 0;        // 计时清零
                state = 2;           // 确认不是抖动,进入状态 2
            } else {
                state = 0;           // 抖动处理,回到初始态
            }
            break;

        case 2: // 状态 2:判断长短按
            if (cur_key != key_flag) { // 如果键值改变(松开)
                key_short_flag = key_flag; // 触发短按任务
                state = 0;                 // 回到初始态
            } else {
                key_time++;
                if (key_time >= 100) {     // 假设 1s (取决于扫描频率)
                    key_long_flag = key_flag; // 触发长按任务
                    state = 3;                // 进入等待松开状态
                }
            }
            break;

        case 3: // 状态 3:等待松开
            if (cur_key == 0) {
                state = 0; // 彻底松开后,重置状态机
            }
            break;
            
        default:
            state = 0;
            break;
    } 
}

二. 硬件解耦与注册表模式

为了实现底层硬件与按键逻辑的彻底解耦,组件层采用了面向对象注册表模式的设计。

1. 核心设计机制

  • 分离配置与状态:将按键属性分为静态配置(引脚、读取函数、回调函数、时间阈值)和运行状态(当前状态、时间戳、连击次数)。
  • 注册表管理:内部维护一个结构体指针数组,对外提供统一的 API,将不同硬件接口上的按键挂载到核心引擎集中管理。

2. 组件接口代码

c
// ==================== 私有全局变量 ====================
static key_base_t *key_base[KEY_MAX_NUM] = {NULL}; // 按键注册表
static uint32_t (*key_get_tick)(void) = NULL;      // 系统时间获取接口
static uint8_t key_num = 0;                        // 已注册按键数量

// ==================== 按键注册接口 ====================
bool bsp_key_register(key_base_t *base, uint32_t (*get_tick)(void))
{
    if ((base == NULL) || (get_tick == NULL) || (key_num >= KEY_MAX_NUM)) {
        return false;
    }

    // 防止重复注册
    for (uint8_t i = 0; i < key_num; i++) {
        if (key_base[i] == base) {
            return true; 
        }
    }

    key_get_tick = get_tick;
    key_base[key_num++] = base;
    return true;
}

// ==================== 按键初始化接口 ====================
void bsp_key_init(key_base_t *base,
                  void *port,
                  uint32_t pin,
                  uint8_t (*read_fun)(void *, uint32_t),
                  void (*cb_fun)(void *, key_action_e),
                  uint32_t filter_ms,
                  uint32_t long_ms,
                  uint32_t double_ms,
                  uint32_t repeat_ms)
{
    if (base == NULL || read_fun == NULL) {
        return;
    }

    // 1. 填充静态配置
    base->key_static.port              = port;          
    base->key_static.pin               = pin;           
    base->key_static.read_fun          = read_fun;      
    base->key_static.cb_fun            = cb_fun;        
    base->key_static.filtering_time    = filter_ms;     
    base->key_static.long_press_time   = long_ms;       
    base->key_static.double_press_time = double_ms;     
    base->key_static.repeat_interval   = repeat_ms;     
    base->key_static.active_level      = 0U;            // 默认低电平有效

    // 2. 运行状态清零复位
    memset(&base->key_run, 0, sizeof(base->key_run));
    base->key_run.cur_state = KEY_STATE_IDLE;
}

三. 核心调度:全功能状态机扫描引擎

引入基于系统滴答定时器(Tick)的时间戳机制 (cur_time - last_time),使状态机无需依赖严格的定时器中断频率,可直接在 while(1) 裸机主循环或 RTOS 任务中健壮运行。

1. 状态流转说明

  • IDLE (空闲态):等待电平变化。
  • FILTER (消抖态):确认电平稳定,滤除杂波。
  • PRESS (按下态):判断是短按松手,还是持续按压触发长按。
  • WAIT_NEXT_CLICK (连击等待态):短按释放后开启时间窗口,等待后续点击以结算多击事件。
  • RELEASE (释放/连发态):长按触发后,等待彻底松手或周期性触发连发回调。

2. 状态机处理代码

c
void bsp_key_proc(void)
{
    // 未提供时间接口则直接退出
    if (key_get_tick == NULL) {
        return;
    }

    // 遍历注册表中的所有按键进行扫描
    for (uint8_t i = 0; i < key_num; i++) {
        key_base_t *cur_base = key_base[i];
        if (cur_base == NULL) {
            continue;
        }

        // 获取当前时间戳及流逝的时间差
        uint32_t cur_time = key_get_tick();
        uint32_t diff = cur_time - cur_base->key_run.last_time;

        // 读取当前引脚物理状态
        uint32_t cur_key = cur_base->key_static.read_fun(
            cur_base->key_static.port, cur_base->key_static.pin);

        // 状态机流转
        switch (cur_base->key_run.cur_state) {
            
        case KEY_STATE_IDLE:
            if (cur_key != 0U) {  // 检测到电平变化(疑似按下)
                cur_base->key_run.press_key = cur_key;
                cur_base->key_run.cur_state = KEY_STATE_FILTER; 
                cur_base->key_run.last_time = cur_time;         
            }
            break;

        case KEY_STATE_FILTER:
            if (cur_key == cur_base->key_run.press_key) {
                // 持续保持按下状态,判断是否达到消抖时间
                if (diff >= cur_base->key_static.filtering_time) {
                    cur_base->key_run.cur_state = KEY_STATE_PRESS; 
                    cur_base->key_run.last_time = cur_time;        
                }
            } else {
                // 期间状态改变,判定为抖动,回退到空闲态
                cur_base->key_run.cur_state = KEY_STATE_IDLE;
            }
            break;

        case KEY_STATE_PRESS:
            if (cur_key != cur_base->key_run.press_key) {  
                // 【情况A】提前松手(短按)
                cur_base->key_run.click_cnt++; 

                if (cur_base->key_static.double_press_time == 0U) {
                    // 若未配置连击时间,则立即触发单击事件
                    if (cur_base->key_static.cb_fun != NULL) {
                        cur_base->key_static.cb_fun(cur_base, KEY_ACTION_SINGLE_CLICK);
                    }
                    cur_base->key_run.click_cnt = 0U;
                    cur_base->key_run.cur_state = KEY_STATE_IDLE;
                } else {
                    // 若配置了连击,则进入连击等待窗口期
                    cur_base->key_run.cur_state = KEY_STATE_WAIT_NEXT_CLICK;
                    cur_base->key_run.last_time = cur_time;
                }
            } else {  
                // 【情况B】一直按住,判定长按
                if (diff >= cur_base->key_static.long_press_time) {
                    if (cur_base->key_static.cb_fun != NULL) {
                        cur_base->key_static.cb_fun(cur_base, KEY_ACTION_LONG_PRESS); 
                    }
                    cur_base->key_run.click_cnt = 0U;
                    cur_base->key_run.cur_state = KEY_STATE_RELEASE; 
                    cur_base->key_run.last_time = cur_time;          
                }
            }
            break;

        case KEY_STATE_WAIT_NEXT_CLICK:
            if (cur_key == cur_base->key_run.press_key) {  
                // 窗口期内再次按下(连击发生)
                cur_base->key_run.cur_state = KEY_STATE_FILTER; 
                cur_base->key_run.last_time = cur_time;
            } else if (diff >= cur_base->key_static.double_press_time) {
                // 窗口期超时,根据累计点击次数结算事件
                if (cur_base->key_static.cb_fun != NULL) {
                    if (cur_base->key_run.click_cnt == 1) {
                        cur_base->key_static.cb_fun(cur_base, KEY_ACTION_SINGLE_CLICK);
                    } else if (cur_base->key_run.click_cnt == 2) {
                        cur_base->key_static.cb_fun(cur_base, KEY_ACTION_DOUBLE_CLICK);
                    } else if (cur_base->key_run.click_cnt >= 3) {
                        cur_base->key_static.cb_fun(cur_base, KEY_ACTION_TRIPLE_CLICK); 
                    }
                }
                cur_base->key_run.click_cnt = 0U;
                cur_base->key_run.cur_state = KEY_STATE_IDLE;
            }
            break;

        case KEY_STATE_RELEASE:
            if (cur_key == 0U) {
                // 彻底松手
                cur_base->key_run.cur_state = KEY_STATE_IDLE;
            } else if (cur_base->key_static.repeat_interval > 0U) {
                // 一直未松手且开启了长按连发功能
                if (diff >= cur_base->key_static.repeat_interval) {
                    if (cur_base->key_static.cb_fun != NULL) {
                        cur_base->key_static.cb_fun(cur_base, KEY_ACTION_LONG_PRESS); 
                    }
                    cur_base->key_run.last_time = cur_time; 
                }
            }
            break;
            
        default:
            cur_base->key_run.cur_state = KEY_STATE_IDLE;
            break;
        }
    }
}

四. 组件使用指南 (User Guide)

以下是将本组件接入具体项目(例如基于 STM32 HAL 库)的标准流程:

1. 实例化按键对象

在用户业务层(如 app_key.c)中定义你的按键对象句柄:

c
key_base_t key_up;
key_base_t key_down;

2. 提供底层读取与事件回调函数

根据你的硬件平台,实现 GPIO 读取函数和业务回调函数:

c
// 底层读取函数 (STM32 HAL库示例)
// 返回 1 表示有效触发,0 表示未触发
uint8_t hardware_key_read(void *port, uint32_t pin) 
{
    GPIO_PinState state = HAL_GPIO_ReadPin((GPIO_TypeDef *)port, (uint16_t)pin);
    return (state == GPIO_PIN_RESET) ? 1 : 0; // 假设低电平按下
}

// 业务回调函数:当按键动作产生时由状态机调用
void app_key_callback(void *base, key_action_e action) 
{
    if (base == &key_up) {
        switch(action) {
            case KEY_ACTION_SINGLE_CLICK: /* 执行向上单击逻辑 */ break;
            case KEY_ACTION_LONG_PRESS:   /* 执行向上长按逻辑 */ break;
            default: break;
        }
    }
    // else if (base == &key_down) ...
}

3. 初始化并注册按键

main() 函数的硬件初始化完成后,配置按键参数并将其挂载到组件引擎中:

c
void app_key_init(void)
{
    // 初始化 KEY_UP:消抖20ms,长按1000ms,连击判定200ms,长按后每100ms连发一次
    bsp_key_init(&key_up, GPIOA, GPIO_PIN_0, 
                 hardware_key_read, app_key_callback, 
                 20, 1000, 200, 100);
                 
    // 初始化 KEY_DOWN:不需要双击(设为0),不需要长按连发(设为0)
    bsp_key_init(&key_down, GPIOB, GPIO_PIN_1, 
                 hardware_key_read, app_key_callback, 
                 20, 1000, 0, 0);

    // 注册按键到调度引擎,并传入系统的 Tick 接口(STM32中为 HAL_GetTick)
    bsp_key_register(&key_up, HAL_GetTick);
    bsp_key_register(&key_down, HAL_GetTick);
}

4. 在主循环中运行状态机

while(1) 或 RTOS 的专属任务中持续调用扫描函数:

c
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    app_key_init(); // 用户按键初始化

    while (1)
    {
        bsp_key_proc(); // 核心状态机扫描处理,或者放在调度器里面,或者放在定时器中断函数里面
        
        // 执行其他非阻塞业务...
    }
}

相关文章