有同学问我,为什么他的按键扫描程序总是不按预期跑?按一下跳两下功能,长按短按混在一起。我看了眼代码——一长串 if-else 层层嵌套,全局变量满天飞。这大概是每个嵌入式开发者都踩过的坑:状态一多,逻辑就成一团乱麻。
状态机,或者说 finite state machine,就是专门解决这个问题的。它不是什么高深的理论,在嵌入式系统里它更像一个思维工具,帮我们把"什么时候该做什么事"理清楚。今天不扯远的,就对比三种实现方式,从最简单的手写 switch-case 到最灵活的表驱动法。
先看这个场景
一个简单的长按/短按检测,输入是个 GPIO 引脚,输出是两种事件:SINGLE_CLICK(按下< 500ms 松开)和 LONG_PRESS(按住超过 1s)。
如果用裸奔的 if 做,大概长这样:
uint32_t press_time = 0; uint8_t is_pressed = 0; void button_scan(void) { uint8_t pin = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN); if (pin == 0 && is_pressed == 0) { press_time = get_tick_ms(); is_pressed = 1; } if (pin == 0 && is_pressed == 1) { if (get_tick_ms() - press_time > 1000) { trigger_event(LONG_PRESS); is_pressed = 2; // 防重复触发 } } if (pin == 1 && is_pressed == 1) { if (get_tick_ms() - press_time < 500) { trigger_event(SINGLE_CLICK); } is_pressed = 0; } if (pin == 1 && is_pressed == 2) { is_pressed = 0; } }这段代码能跑,但有一个致命问题:is_pressed 的三个状态(0=空闲, 1=按下, 2=长按已触发)散落在四五个 if 分支里,你很难一眼看出完整的逻辑边界。加一个新功能——比如双击——就意味着要在这一堆 if 里再塞逻辑,改着改着就改出 bug 了。
第一种:switch-case 状态机
最直白的做法,把状态转移画出来,然后写进 switch:
typedef enum { ST_IDLE, ST_PRESSED, ST_LONG_HELD } btn_state_t; btn_state_t state = ST_IDLE; void btn_fsm(void) { uint8_t pin = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN); uint32_t now = get_tick_ms(); switch (state) { case ST_IDLE: if (pin == 0) { press_time = now; state = ST_PRESSED; } break; case ST_PRESSED: if (pin == 1) { if (now - press_time < 500) trigger_event(SINGLE_CLICK); state = ST_IDLE; } else if (now - press_time > 1000) { trigger_event(LONG_PRESS); state = ST_LONG_HELD; } break; case ST_LONG_HELD: if (pin == 1) state = ST_IDLE; break; } }是不是清楚多了?每个状态一个 case,入口条件、出口条件一目了然。新加一个双击状态,就在 enum 里加 ST_DOUBLE_WAIT,再加一个 case 就完事了。这个简单直白的做法其实覆盖了嵌入式里至少 80% 的状态机场景。我看到很多产品量产代码就是用这个结构跑的,稳定、没毛病。
它的局限在于:当状态成倍增长,比如一个通信协议解析器有十几个状态、每个状态处理多种事件时,switch-case 的代码行数会膨胀到难以维护。
第二种:状态表(查表法)
把状态转移关系抽成一张表,好处是逻辑和数据分离。先定义表的结构:
typedef struct { btn_state_t curr_state; uint8_t event; // EVENT_PRESS, EVENT_RELEASE, EVENT_TIMEOUT btn_state_t next_state; void (*action)(void); } trans_t; static void do_nothing(void) {} static void on_press(void) { press_time = get_tick_ms(); } static void on_click(void) { trigger_event(SINGLE_CLICK); } static void on_long(void) { trigger_event(LONG_PRESS); } const trans_t fsm_table[] = { {ST_IDLE, EVENT_PRESS, ST_PRESSED, on_press}, {ST_IDLE, EVENT_RELEASE, ST_IDLE, do_nothing}, {ST_PRESSED, EVENT_RELEASE, ST_IDLE, on_click}, {ST_PRESSED, EVENT_TIMEOUT, ST_LONG_HELD, on_long}, {ST_LONG_HELD,EVENT_RELEASE, ST_IDLE, do_nothing}, };然后一个通用的调度引擎:
btn_state_t cur = ST_IDLE; void fsm_run(uint8_t event) { for (int i = 0; i < sizeof(fsm_table)/sizeof(trans_t); i++) { if (fsm_table[i].curr_state == cur && fsm_table[i].event == event) { fsm_table[i].action(); cur = fsm_table[i].next_state; return; } } }这个思路的巧妙之处在于:如果你要新增一个"双击"状态,不用改动调度代码,只用在表里加两行记录。配合 const 放到 flash 里,查表时间也是 O(n),对几十条级别的表来说完全可接受。缺点是事件提取的逻辑(按键按下/松开 -> 转换成事件枚举)还得在外面写一层。
第三种:分层状态机(Hierarchical State Machine)
当系统状态有继承关系时——比如"通信中"状态下面有"正在发送"和"正在接收"两个子状态——平铺的表就不够优雅了。HSM 的思想是子状态继承父状态的转移规则,只覆盖不同的部分。
C 语言实现 HSM 稍微有点抽象,核心是利用函数指针:
typedef btn_state_t (*state_handler_t)(void); btn_state_t st_idle(void) { uint8_t pin = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN); if (pin == 0) return ST_PRESSED; return ST_IDLE; } btn_state_t st_pressed(void) { uint8_t pin = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN); uint32_t now = get_tick_ms(); if (pin == 1) { if (now - press_time < 500) trigger_event(SINGLE_CLICK); return ST_IDLE; } if (now - press_time > 1000) { trigger_event(LONG_PRESS); return ST_LONG_HELD; } return ST_PRESSED; } state_handler_t current_handler = st_idle; void fsm_step(void) { current_handler = current_handler(); }每个状态是一个函数,函数的返回值决定跳转到哪里。这其实已经有了 HSM 的雏形——如果你让 st_pressed 在某种条件下调用 st_idle 的公共处理逻辑(比如超时复位),就是简单的 state inheritance。不过纯 C 做 HSM 语法上不够优雅,很多项目会引入 QP/C 这样的框架。
实际项目中怎么选?
没有银弹。我自己倾向的一个判断标准:状态数少于 8 个,用 switch-case,简单直观,同事不需要查文档就能改。8~20 个状态,尤其当事件种类也多的时候,表驱动法更划算——可维护性远超 switch-case。如果要写一个带 GUI 菜单的系统(子菜单层层嵌套),HSM 几乎是唯一能保持代码整洁的方式。
话说回来,比选择哪种实现方式更重要的,是先把状态图画清楚。我见过有人对着键盘直接开写,写到一半发现漏了一个状态转移,又回去改结构——纸笔花五分钟画个圈圈箭头,比 debug 两小时值多了。
关于状态机的话题就先聊到这儿。你们的按键逻辑都是怎么组织的?有没有在 switch-case 里翻过车?