先看一段代码。一个按键消抖的典型实现:
c
typedef enum {
KEY_IDLE,
KEY_DEBOUNCE,
KEY_PRESSED,
KEY_RELEASE_WAIT
} KeyState;
KeyState state = KEY_IDLE;
void key_scan(void) {
uint8_t level = GPIO_ReadPin(KEY_PIN);
switch(state) {
case KEY_IDLE:
if(level == 0) state = KEY_DEBOUNCE;
break;
case KEY_DEBOUNCE:
if(level == 0) state = KEY_PRESSED;
else state = KEY_IDLE;
break;
case KEY_PRESSED:
key_event = KEY_DOWN;
state = KEY_RELEASE_WAIT;
break;
case KEY_RELEASE_WAIT:
if(level == 1) state = KEY_IDLE;
break;
}
}
这段代码很多单片机工程师都写过。用switch-case管理几个状态,逻辑清晰,跑起来也没问题。
但随着状态增多------比如一个通信协议解析器,可能有十多个状态,再加上状态内的子条件判断------switch-case就会变得又长又散,维护起来像是在翻一本没有目录的手册。
设计模式在这里不是理论,是刚需。
从状态表到状态机引擎
一个有意思的思路是:把状态和动作解耦,用查表代替分支判断。
我们设计一个状态表结构:
c
typedef struct {
uint8_t current_state;
uint8_t event;
uint8_t next_state;
void (*action)(void);
} StateTableEntry;
每一行定义:当前在什么状态,收到什么事件,执行什么动作,然后跳转到什么状态。协议栈、UI菜单、通信链路管理......凡是状态流转清晰的场景,都能用这张表来驱动。
来看看怎么用。假设有一个串口AT指令解析器,状态包括"等待AT"、"接收参数"、"校验"、"响应":
c
StateTableEntry at_parser_table[] = {
{ST_WAIT_AT, EVT_RX_A, ST_RECV_PARAM, at_send_ok},
{ST_WAIT_AT, EVT_RX_OTHER, ST_WAIT_AT, at_send_error},
{ST_RECV_PARAM, EVT_CR, ST_CHECKSUM, NULL},
{ST_CHECKSUM, EVT_OK, ST_WAIT_AT, at_exec_cmd},
{ST_CHECKSUM, EVT_FAIL, ST_WAIT_AT, at_send_error},
// ... 更多条目
};
然后用一个统一的引擎函数来驱动:
c
uint8_t state_machine_run(StateTableEntry *table, uint8_t table_size,
uint8_t current_state, uint8_t event) {
for(uint8_t i = 0; i < table_size; i++) {
if(table[i].current_state == current_state &&
table[i].event == event) {
if(table[i].action) table[i].action();
return table[i].next_state;
}
}
return current_state; // 未匹配,保持状态
}
核心逻辑浓缩到十几行代码里。要加一个新状态或新事件,不用动这个引擎------只需要往表里加一行。这就是典型的策略模式在状态机上的应用,只不过我们用C语言实现得足够轻量。
为什么这套模式在嵌入式场景中特别有价值
状态表驱动的好处,不只是代码好看。
第一,可读性飞跃。 一个同事接手你的代码,看几百行的switch-case和看一张结构清晰的状态表,完全是两种体验。表格本身就是文档------每一行明确表达"在XX状态下收到XX事件就做XX然后跳转到XX状态"。
第二,可测试性。 写单元测试的时候,我们可以直接遍历状态表,构造每个状态+事件的组合,验证跳转是否正确。switch-case的测试覆盖要人工走读,状态表可以做到自动化覆盖。
第三,资源占用可控。 很多人担心查表比直接switch慢。我们来算一笔账:一张50条的状态表,用线性查找最多50次比较。在72MHz的STM32上,这连1微秒都用不到。如果状态表再按状态分组、用二分查找,查表次数可以降到log2(n)级别。对于绝大多数嵌入式场景,查表的开销比一个GPIO翻转还小。
一个常见的误区是认为状态机只适合简单场景。恰恰相反------状态越多,状态表方案的优势越明显。一个正在运行的MQTT客户端协议栈,状态数可能有20~30个,事件类型也在10个以上,用switch-case写出来的嵌套深度和代码行数都会让人头疼。
另一种思路:分层状态机
上面的状态表还有一个扩展方向。有些场景里,状态之间存在"继承"关系。比如一个设备有"正常运行"和"异常处理"两个大状态,而"正常运行"下又分"采集"、"发送"、"休眠"子状态。如果把所有状态平铺在一张表里,每个子状态都要重复处理"异常事件"的跳转逻辑。
分层状态机的想法是:子状态处理不了的事件,向上抛给父状态处理。这样父状态只写一次异常处理逻辑,所有子状态共享。
c
// 子状态表只关心自己的业务事件
StateTableEntry child_table[] = {
{ST_COLLECT, EVT_DATA_READY, ST_SEND, sensor_read},
{ST_SEND, EVT_TX_DONE, ST_SLEEP, radio_send},
{ST_SLEEP, EVT_WAKEUP, ST_COLLECT, wakeup_init},
};
// 父状态表统一处理公共事件,包括异常
StateTableEntry parent_table[] = {
{ST_RUNNING, EVT_ERROR, ST_ERROR_HANDLE, log_error},
{ST_RUNNING, EVT_LOW_BATTERY, ST_SLEEP, power_save},
};
引擎运行时先查子状态表,如果没匹配到,再查父状态表。这个机制用C语言实现只需要几十行,带来的状态管理便利性却很大。尤其在工业设备、无人机飞控这类对异常处理要求严格的系统里,分层状态机几乎成了标配。
回调函数指针和状态表的组合,本质上是在C语言里模拟了面向对象的多态能力。不夸张地说,理解了这套模式,嵌入式代码的结构层次会上一个台阶。
不妨想想你手头正在做的项目------LED灯效切换、菜单导航、通信协议解析、电源管理......是不是都能用状态表来重新组织?一个有意思的练习是:把你现有一个用switch-case实现的状态机,改写成状态表驱动,然后对比两种实现的代码行数和维护成本。结果可能会让你重新思考"设计模式"这个词在嵌入式开发中的意义。欢迎在实践中遇到具体问题时来讨论------每个协议、每个设备的状态转换逻辑,都有值得优化的空间。