嵌入式事件驱动状态机与实时任务调度 - 第2节
状态机基础理论:有限状态机(FSM)的四要素
各位同学,咱们今天聊聊状态机。说实话,我刚入行那会儿,觉得状态机就是个花架子——不就是 switch-case 吗?后来在一个多传感器融合的项目里,被各种乱七八糟的状态组合折磨得死去活来,才老老实实回去啃状态机理论。
有限状态机,英文叫 Finite State Machine,简称 FSM。说白了,就是描述一个系统在任意时刻只能处于有限个状态中的一个,然后根据外部事件在这些状态之间跳来跳去。你想想看,电梯、自动售货机、通信协议栈,甚至你手机里的触摸屏手势识别,底层全是这玩意儿。
状态(State)——系统在干什么
状态是 FSM 最核心的概念。它表示系统在某个时刻的“定格画面”。我习惯把状态理解成:系统正在等待某个特定事件发生的时刻。
- 确定性:同一时刻只能处于一个状态。不能又开门又关门。
- 有限性:状态总数是固定的,不能无限多。
- 可记忆性:状态本身隐含了历史信息——系统怎么走到这里的。
事件(Event)——发生了什么
事件是触发状态转移的“扳机”。没有事件,状态机就是死的。事件可以是外部输入(按键按下、数据包到达),也可以是内部信号(定时器超时、计算完成)。
这里有个坑,我踩过好几次:事件不能带“状态信息”。比如你定义了一个事件叫“温度过高”,这其实隐含了“当前温度 > 阈值”的判断。正确的做法是事件只叫“温度采样完成”,把判断逻辑放在转移条件里。否则你的事件会膨胀到无法维护。
转移(Transition)——怎么变
转移定义了系统从一个状态到另一个状态的路径。一个完整的转移包含三要素:
- 源状态:从哪出发
- 触发事件:什么条件下动
- 目标状态:到哪去
有时候还会带上守卫条件(Guard)——事件发生了,但还得满足某个条件才真的转移。比如“按键按下”事件,只有在“非锁定状态”下才触发转移。
动作(Action)——变的时候干什么
动作是转移发生时执行的代码。很多人把动作和状态搞混。记住一句话:状态是“等”,动作是“做”。
动作可以发生在三个时机:
- 进入动作(Entry Action):进入某个状态时执行一次
- 退出动作(Exit Action):离开某个状态时执行一次
- 转移动作(Transition Action):转移过程中执行
关键原则:动作不能阻塞状态机。我在一个电机控制项目里,把延时函数写进了进入动作,结果整个状态机卡死了。动作应该是轻量的、快速的。需要耗时操作?扔给任务调度器去处理。
状态转移图——一图胜千言
状态转移图是 FSM 最直观的表达方式。圆圈表示状态,箭头表示转移,箭头上的标签写“事件/动作”。
下面这张图展示了一个简单的按键消抖状态机。我当年做嵌入式键盘驱动时,就是靠这个结构搞定的。
状态转移表——工程师的“查字典”
图虽然直观,但代码实现时,我更喜欢用状态转移表。它把每个状态和每个事件交叉起来,一目了然。查表比追着箭头看快多了。
下面是对应上面按键消抖状态机的转移表:
| 当前状态 | 事件 | 守卫条件 | 动作 | 下一状态 |
|---|---|---|---|---|
| IDLE | 按键按下 | 无 | 启动消抖定时器 | DEBOUNCE |
| DEBOUNCE | 定时器超时 | 无 | 上报按键事件 | PRESSED |
| DEBOUNCE | 按键释放 | 无 | 取消定时器 | IDLE |
| PRESSED | 按键释放 | 无 | 触发单击回调 | IDLE |
| 任意 | 系统复位 | 无 | 初始化硬件 | IDLE |
注意:转移表里一定要包含“未定义转移”的处理。我曾经在一个通信协议栈里漏掉了“连接断开”事件在“数据发送中”状态的处理,结果系统直接挂死。建议在代码里为每个状态加一个默认的“事件未处理”分支,至少打印个日志。
FSM 的两种实现风格
代码实现上,我见过两种主流风格,各有千秋:
1. 嵌套 switch-case
最直接的方式。外层 switch 状态,内层 switch 事件。适合状态少于 10 个的简单场景。
void fsm_run(fsm_t *fsm, event_t evt) {
switch (fsm->state) {
case IDLE:
switch (evt) {
case BUTTON_PRESS:
start_timer(20);
fsm->state = DEBOUNCE;
break;
default:
break;
}
break;
case DEBOUNCE:
switch (evt) {
case TIMEOUT:
report_key_press();
fsm->state = PRESSED;
break;
case BUTTON_RELEASE:
cancel_timer();
fsm->state = IDLE;
break;
default:
break;
}
break;
// ... 其他状态
}
}
2. 查表法
把转移表定义成数据结构,运行时查表执行。可维护性更好,适合状态多的场景。
typedef struct {
state_t src_state;
event_t trigger;
state_t dst_state;
void (*action)(void *param);
} transition_t;
transition_t g_trans_table[] = {
{IDLE, BUTTON_PRESS, DEBOUNCE, action_start_timer},
{DEBOUNCE, TIMEOUT, PRESSED, action_report_key},
{DEBOUNCE, BUTTON_RELEASE, IDLE, action_cancel_timer},
{PRESSED, BUTTON_RELEASE, IDLE, action_trigger_click},
// 最后加一个哨兵
{STATE_MAX, EVENT_MAX, STATE_MAX, NULL},
};
void fsm_run(fsm_t *fsm, event_t evt) {
for (int i = 0; g_trans_table[i].action != NULL; i++) {
if (g_trans_table[i].src_state == fsm->state &&
g_trans_table[i].trigger == evt) {
g_trans_table[i].action(fsm->param);
fsm->state = g_trans_table[i].dst_state;
return;
}
}
// 未找到转移,记录错误
log_error("unhandled event %d in state %d", evt, fsm->state);
}
我个人的建议:产品代码里优先用查表法。为什么?因为转移表可以放在常量区(ROM),不占 RAM。而且加一个新状态或新事件,只需要改表,不用动逻辑代码。我在一个智能家居网关项目里,状态机有 20 多个状态,查表法让代码量减少了 60%。
小结
有限状态机没那么玄乎。记住四要素:状态、事件、转移、动作。画图用状态转移图,写代码用状态转移表。把这两个工具用好,大部分嵌入式状态逻辑都能搞定。