状态机编程:有限状态机的C语言实现,事件驱动与状态转移表
状态机这东西,说白了就是让程序“记住自己到了哪一步”。
我刚开始做嵌入式那会儿,写控制逻辑全靠if-else堆。一个按键处理函数能写两百行,后来自己都看不懂了。直到有一次做通信协议解析,状态一多,bug满天飞。嗯,从那以后,我彻底转向了状态机。
什么是有限状态机?
有限状态机(FSM)是一种数学模型。它包含:
- 状态:系统当前所处的模式
- 事件:触发状态变化的条件
- 动作:进入或离开状态时执行的操作
- 转移:从一个状态到另一个状态的路径
你想想看,一个LED灯,无非就是“亮”和“灭”两种状态。按一下按键,状态翻转。这就是最简单的状态机。
核心思想:程序的行为由当前状态决定,而不是由代码的执行顺序决定。
状态机的三种实现方式
我在项目中用过三种方式,各有优劣。我一个个说。
1. switch-case 实现
最直观的方式。适合状态少、逻辑简单的场景。
typedef enum {
STATE_IDLE,
STATE_RUNNING,
STATE_PAUSE,
STATE_STOP
} State_t;
State_t currentState = STATE_IDLE;
void stateMachine(Event_t event) {
switch (currentState) {
case STATE_IDLE:
if (event == EVT_START) {
currentState = STATE_RUNNING;
motorStart();
}
break;
case STATE_RUNNING:
if (event == EVT_PAUSE) {
currentState = STATE_PAUSE;
motorPause();
} else if (event == EVT_STOP) {
currentState = STATE_STOP;
motorStop();
}
break;
// 其他状态...
}
}
这种写法,我早期项目里用得最多。简单直接,但状态一多,case块就膨胀得厉害。我曾经维护过一个项目,一个switch-case函数写了800行,改一个状态要翻半天。
我的习惯:状态少于5个,用switch-case。超过5个,赶紧换方案。
2. 函数指针表实现
每个状态对应一个处理函数。用函数指针数组来管理。
typedef void (*StateHandler_t)(Event_t);
StateHandler_t stateTable[] = {
[STATE_IDLE] = idleHandler,
[STATE_RUNNING] = runningHandler,
[STATE_PAUSE] = pauseHandler,
[STATE_STOP] = stopHandler
};
void stateMachine(Event_t event) {
stateTable[currentState](event);
}
你看,主循环就一行代码。每个状态的处理逻辑被隔离到独立的函数里。改一个状态,不会影响其他状态。
我记得有一次做智能家居网关,有12个状态。用函数指针表,代码清晰得像教科书。后来同事接手,说这是他见过最好维护的代码。
3. 状态转移表实现
这是我最推荐的方式,尤其是状态多、事件复杂的时候。
状态转移表是一个二维数组。行是当前状态,列是事件。单元格里存的是下一个状态和要执行的动作。
typedef struct {
State_t nextState;
Action_t action;
} Transition_t;
// 状态转移表
Transition_t stateTable[STATE_MAX][EVT_MAX] = {
// STATE_IDLE
[STATE_IDLE] = {
[EVT_START] = {STATE_RUNNING, ACTION_START_MOTOR},
[EVT_STOP] = {STATE_IDLE, ACTION_NONE},
[EVT_ERROR] = {STATE_ERROR, ACTION_LOG_ERROR}
},
// STATE_RUNNING
[STATE_RUNNING] = {
[EVT_PAUSE] = {STATE_PAUSE, ACTION_PAUSE_MOTOR},
[EVT_STOP] = {STATE_STOP, ACTION_STOP_MOTOR},
[EVT_ERROR] = {STATE_ERROR, ACTION_EMERGENCY_STOP}
},
// 其他状态...
};
void stateMachine(Event_t event) {
Transition_t *t = &stateTable[currentState][event];
if (t->action != ACTION_NONE) {
executeAction(t->action);
}
currentState = t->nextState;
}
为什么我偏爱转移表?
- 状态和事件的关系一目了然
- 新增状态或事件,只需要扩展表格
- 逻辑集中,不容易遗漏转移路径
- 可以用工具自动生成表格代码
事件驱动机制
状态机不能自己跑,得有东西推它一把。这个“推手”就是事件。
事件从哪里来?
- 硬件中断(按键按下、定时器超时)
- 消息队列(其他任务发来的指令)
- 内部定时器(超时事件)
- 传感器数据变化
我一般会做一个事件队列。外部中断或任务把事件塞进队列,主循环从队列里取事件,喂给状态机。
typedef struct {
Event_t events[QUEUE_SIZE];
int head;
int tail;
} EventQueue_t;
void postEvent(Event_t evt) {
// 把事件放入队列
queue.events[queue.tail] = evt;
queue.tail = (queue.tail + 1) % QUEUE_SIZE;
}
void mainLoop(void) {
while (1) {
if (!isQueueEmpty()) {
Event_t evt = getEvent();
stateMachine(evt);
}
// 其他任务...
}
}
我曾经踩过的坑:事件队列溢出。有一次做工业控制器,中断频率太高,事件生产速度大于消费速度,队列满了,事件丢失。后来我加了队列满时的处理策略——要么丢弃旧事件,要么阻塞生产者,要么报警。具体选哪种,看业务容忍度。
状态机设计要点
做了这么多年,我总结了几条铁律:
- 状态要正交:两个状态不能同时成立。如果出现“既在运行又在暂停”,说明状态划分有问题。
- 事件要完整:每个状态对每个事件都要有处理。哪怕只是忽略,也要显式写出来。空转移也是一种设计。
- 动作要原子:一个动作只做一件事。不要在动作里嵌套状态转移。
- 状态图先画:写代码之前,先在纸上画状态转移图。画清楚了,代码就是翻译工作。
我的小技巧:用枚举定义状态和事件时,把“无效”或“空”值放在0位置。这样初始化时默认就是无效状态,调试时容易发现问题。
状态转移图
下面这张图,展示了一个典型的状态机结构。我建议你把它打印出来贴在工位上。
实际项目中的状态机
我参与过一个智能门锁项目。状态包括:待机、验证中、已开锁、报警、低电量。事件有:按键输入、指纹匹配、超时、电量检测。
用状态转移表实现后,整个逻辑只有不到100行。测试覆盖率轻松做到100%。后来客户要加一个“防撬报警”状态,我花了10分钟改表,5分钟测试,搞定。
状态机的价值:它把混乱的逻辑变成了清晰的表格。你不再需要关心“现在该执行哪段代码”,只需要关心“当前状态 + 来了什么事件 = 下一步做什么”。
避坑指南
最后,分享几个我踩过的坑:
- 状态爆炸:状态太多时,考虑用嵌套状态机或层次状态机。不要把100个状态平铺在一张表里。
- 事件丢失:中断里直接调用状态机?别这么做。中断应该只负责投递事件,状态机在主循环里跑。
- 状态不一致:多任务环境下,状态机要加锁。我见过两个任务同时修改状态,结果状态变成了一个非法值。
- 死循环:状态A收到事件X转到状态B,状态B收到事件Y又转回状态A。如果事件X和Y交替触发,系统就卡死了。设计时要注意避免这种循环。
我曾经犯过的错:在一个通信协议栈里,状态机没有处理“非法事件”。结果收到一个损坏的数据包,状态机跑到了一个未定义的状态,整个协议栈崩溃。从那以后,我每个状态都加了一个default分支,专门处理意外事件。
状态机编程,说白了就是“以不变应万变”。你把状态和转移规则定好了,剩下的就是事件驱动。代码稳定、可读、好维护。嗯,这大概就是嵌入式工程师追求的“优雅”吧。
公众号:蓝海资料掘金营,微信deep3321