嵌入式系统里的状态机:三种实现思路拆解
有同学问我为什么他的按键扫描程序总是不按预期跑按一下跳两下功能长按短按混在一起。我看了眼代码——一长串 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 状态机最直白的做法把状态转移画出来然后写进 switchtypedef 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 里翻过车