技术
按键组件编写思路
·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(); // 核心状态机扫描处理,或者放在调度器里面,或者放在定时器中断函数里面
// 执行其他非阻塞业务...
}
}