简介:EFSM事件驱动型有限状态机是一份基于事件驱动的C语言实现方案,主要面向嵌入式、物联网及边缘计算开发者,帮助解决复杂业务中状态与事件过多、状态管理难的问题,支持上百个状态、上千种事件,并能构建多重或层次状态机。压缩包约35KB,含22个文件,以8个C源文件、8个头文件为主体,覆盖核心机制与示例代码;另含3个TXT说明文件以及License、gitignore、CMake配置模板,方便了解许可、版本管理与构建方式。资源内配备examples演示目录,包含启动、在线、离线等典型状态模块,可对照核心源码逐行理解状态迁移、事件分发与状态机切换流程,同时提供CMake构建脚本,便于直接编译、测试、裁剪并移植进自身工程。已有225人学习下载,适合具备一定嵌入式或C语言基础、希望掌握轻量级事件驱动状态机设计方法的开发者参考使用。
1. EFSM事件驱动型有限状态机为什么在嵌入式系统中比传统状态机更适合复杂事件场景
嵌入式设备的业务逻辑往往由按键、定时器、串口数据、GPIO中断等多种异步事件推动。如果一个系统里有十几个状态,再用“状态标志位+switch-case”去组织逻辑,代码里会出现大量嵌套分支,新增一个状态往往要改动多处条件判断,稍有遗漏就产生难以复现的bug。EFSM事件驱动型有限状态机把“事件”作为状态转移的主要驱动源,将状态转移规则从业务代码中抽离成一张可查的表,状态机引擎只需要根据“当前状态+到达事件”查表并执行对应动作。这种方式天然契合嵌入式设备中中断频繁、资源受限、时序敏感的特点。本文会完整拆解EFSM的设计思路,并给出一个可直接移植到单片机上的C语言实现,以及嵌入式场景下事件接入、调试与状态覆盖验证的具体做法。适读人群包括正在做单片机裸机开发的嵌入式工程师、嵌入式Linux应用开发初学者,以及准备蓝桥杯嵌入式等竞赛并在寻找状态机工程化方案的读者。
2. EFSM的设计核心:事件、状态与转移表的组合关系
理解EFSM不能只停留在“状态机就是switch-case”的层面。普通有限状态机(FSM)关注的是“状态”和“迁移条件”,而EFSM将迁移条件明确封装为“事件”,并规定事件必须由统一的入口进入状态机,再由转移表决定后续动作。这个改变看似微小,却直接影响架构的可维护性。
2.1 状态机的两个模型:Mealy与Moore在EFSM中的取舍
经典FSM分为Mealy型和Moore型。Mealy型的输出由“当前状态和输入事件”共同决定,Moore型的输出只由当前状态决定。EFSM在实际工程中通常采用混合方式:状态转移后的下一状态由转移表决定,而动作函数内部既可以读取当前状态数据,也可以根据传入事件参数做分支处理。这样既保留了Moore型的状态清晰,又借助Mealy型的灵活性减少了状态总数。
我在此处做一个选择:建议状态表里只保存“当前状态+事件+动作+下一状态”四列,动作函数不做任何直接修改state变量的操作,全部状态更新统一由efsm_dispatch函数完成。这样做的收益是,状态值永远不会在动作内被意外修改,所有转移路径都可在表中查到,调试期看到一条日志就能完全复现系统走到这个状态的路径。
2.2 用转移表替代分支逻辑:一组规则描述所有合法路径
转移表的基本属性有四个:当前状态、触发事件、动作函数指针、下一状态。
表格示范如下(智能门锁密码输入的局部状态):
| 当前状态 | 事件 | 动作 | 下一状态 |
|---|---|---|---|
| STATE_IDLE | EVT_KEY_PRESS | action_start_key_timer | STATE_INPUT |
| STATE_INPUT | EVT_KEY_DIGIT | action_append_buffer | STATE_INPUT |
| STATE_INPUT | EVT_KEY_ENTER | action_verify_code | STATE_CHECKING |
| STATE_CHECKING | EVT_VERIFY_OK | action_open_lock | STATE_UNLOCKED |
| STATE_CHECKING | EVT_VERIFY_FAIL | action_decrease_try | STATE_INPUT |
| STATE_CHECKING | EVT_VERIFY_TIMEOUT | action_reset_input | STATE_IDLE |
这张表被定义成const数组后存放在Flash中,运行时不需要修改。表中每条记录定义了系统从哪个状态出发,在收到何种事件时,应该执行什么动作,以及新的状态是什么。没有出现在表中的组合会被引擎直接忽略或记录为未处理事件。这种设计下,添加一个“防撬报警”状态只需要新增一行表项,不需要打断已有的状态转移路径。
2.3 事件的生命周期:产生、排队、派发、消费
EFSM的事件在嵌入式中遵循四个阶段。首先是产生端,可能来自GPIO中断回调、定时器中断或串口接收解析。接着是排队,事件进入一个FIFO环形缓冲,这个缓冲长度设计决定了系统瞬时承受突发事件的容量。然后是派发,主循环或调度器从队列头部取出事件并交给状态机引擎。最后是消费,引擎结合当前状态查表,执行动作并更新状态。
在裸机环境中,我一般把事件队列放在中断里写入,在主循环中的efsm_poll_event中读出。事件定义采用uint8_t,事件号只占低六位,高两位可用来标记事件来源或者优先级,这样在不扩大结构体的前提下保留了扩展性。队列长度常用8到16,具体需要根据单次中断最多产生的事件数和主循环周期估算,如果采用16个事件仍频繁出现溢出,则需要优化中断里的丢事件策略,而不是一味加大缓冲区。
3. 在嵌入式C语言中实现EFSM框架:可复制的最小骨架
把理论映射到代码,核心是把状态表、事件队列、状态机执行引擎三个部分拆开。先给出一套能直接编译的C语言实现,代码刻意不引用具体芯片厂商的库函数,只依赖基础类型定义。
3.1 定义EFSM核心数据结构和接口
/* efsm.h */ #ifndef EFSM_H #define EFSM_H #include <stdint.h> #define EFSM_OK 0 #define EFSM_FULL -1 #define EFSM_NO_MATCH 1 typedef uint8_t efsm_state_t; typedef uint8_t efsm_event_t; typedef void (*efsm_action_t)(void); typedef struct { efsm_state_t current_state; efsm_event_t event; efsm_action_t action; efsm_state_t next_state; } efsm_trans_t; typedef struct { efsm_state_t state; const efsm_trans_t *table; uint16_t table_size; } efsm_machine_t; void efsm_init(efsm_machine_t *mach, const efsm_trans_t *table, uint16_t size, efsm_state_t init_state); int efsm_dispatch(efsm_machine_t *mach, efsm_event_t evt); #endif此处的接口设计有以下用意。efsm_state_t和efsm_event_t都限定为uint8_t,在资源紧张的Cortex-M0芯片上,枚举底层默认也是整型,显式使用uint8_t可以控制结构体对齐所消耗的空间,并且让整个状态表在编译后清晰可见。动作函数不接收参数,原因是嵌入式事件处理中,动作所需的数据往往存放在全局的结构体或缓冲区中,例如按键缓冲区、当前电压值,动作函数内部直接访问这些全局量,比通过参数传递更节省栈空间。
3.2 状态机引擎实现:查表与派发
/* efsm.c */ #include "efsm.h" void efsm_init(efsm_machine_t *mach, const efsm_trans_t *table, uint16_t size, efsm_state_t init_state) { mach->state = init_state; mach->table = table; mach->table_size = size; } int efsm_dispatch(efsm_machine_t *mach, efsm_event_t evt) { uint16_t i; const efsm_trans_t *rule = 0; for (i = 0; i < mach->table_size; i++) { if (mach->table[i].current_state == mach->state && mach->table[i].event == evt) { rule = &mach->table[i]; break; } } if (!rule) { return EFSM_NO_MATCH; } if (rule->action) { rule->action(); } mach->state = rule->next_state; return EFSM_OK; }代码逻辑并不复杂:从表头线性扫描到表尾,找出同时匹配当前状态和输入事件的首条规则。匹配成功后先执行动作,再更新状态。线性查表的时间复杂度是O(N),对于N小于200的转移规则表,在几十MHz主频下消耗仅在几微秒到十几微秒,完全可接受。
这里需要注意两个参数细节。第一个是动作执行和状态更新的顺序:先执行action,状态变量保持为旧值,因此动作函数内部可以安全读取旧状态,实现“离开状态的清理动作”;如果需要动作函数知道下一状态,可以改为先更新state再执行动作,两种方式对应不同语义,选定后就不要混用。第二个是非常重要的一点,第r条规则匹配生效后,不要在动作函数中再次调用efsm_dispatch处理同一状态机的其他事件,否则当前查得的rule指针在动作函数返回后依然有效,但状态mach->state已经被内层调用改写,外层还会继续覆盖state字段,造成记录与实际状态不一致。这就是EFSM实现中最容易碰到的重入问题。
3.3 事件队列:裸机环境下的FIFO骨架
#include "efsm.h" #define EVT_QUEUE_SIZE 16 static volatile uint8_t evt_queue[EVT_QUEUE_SIZE]; static volatile uint8_t q_head; static volatile uint8_t q_tail; static volatile uint16_t evt_dropped; void evt_queue_init(void) { q_head = 0; q_tail = 0; evt_dropped = 0; } int evt_post(uint8_t evt) { uint8_t next = (uint8_t)((q_head + 1) % EVT_QUEUE_SIZE); if (next == q_tail) { evt_dropped++; return EFSM_FULL; } evt_queue[q_head] = evt; q_head = next; return EFSM_OK; } int evt_poll(uint8_t *evt) { if (q_head == q_tail) { return 0; } *evt = evt_queue[q_tail]; q_tail = (uint8_t)((q_tail + 1) % EVT_QUEUE_SIZE); return 1; }队列采用环形缓冲区,写位置q_head和读位置q_tail相等时表示空,写位置推进后与读位置相等时表示满。队列满时丢弃新事件,并用一个无符号计数器evt_dropped记录丢事件总数。这里的EVT_QUEUE_SIZE=16适合大多数裸机事件场景,例如一个UART接收中断,一帧数据到达时可能产生1个EVT_FRAME_READY事件;按键扫描任务每10ms可能产生1个EVT_KEY事件。如果系统里存在高速ADC采样完成中断,采样率10kHz且每个样本都post,队列必然溢出。
处理高频事件更稳的办法不是在中断里直接post每个样本,而是先由中断把样本写入一个更长的数据FIFO,解析出一帧完整数据后再post一个事件。事件队列只承载业务级的离散事件,不承载原始数据流。
3.4 组装一个可运行的EFSM实例
typedef enum { STATE_POWER_OFF = 0, STATE_STARTUP, STATE_RUNNING, STATE_FAIL, STATE_STANDBY } app_state_t; typedef enum { EVT_POWER_ON = 0, EVT_INIT_OK, EVT_INIT_FAIL, EVT_STANDBY_REQ, EVT_RUNNING_ERR, EVT_RECOVER } app_event_t; static void act_start_init(void) { /* 初始化外设 */ } static void act_enter_run(void) { /* 启动控制输出 */ } static void act_fault_halt(void) { /* 记录错误并停止输出 */ } static void act_enter_standby(void) { /* 关闭外设电源 */ } static void act_recover_run(void) { /* 恢复运行前状态 */ } static const efsm_trans_t app_table[] = { { STATE_POWER_OFF, EVT_POWER_ON, act_start_init, STATE_STARTUP }, { STATE_STARTUP, EVT_INIT_OK, act_enter_run, STATE_RUNNING }, { STATE_STARTUP, EVT_INIT_FAIL, act_fault_halt, STATE_FAIL }, { STATE_RUNNING, EVT_STANDBY_REQ, act_enter_standby, STATE_STANDBY }, { STATE_RUNNING, EVT_RUNNING_ERR, act_fault_halt, STATE_FAIL }, { STATE_STANDBY, EVT_POWER_ON, act_recover_run, STATE_RUNNING }, }; static efsm_machine_t app_mach; void app_efsm_init(void) { efsm_init(&app_mach, app_table, sizeof(app_table) / sizeof(app_table[0]), STATE_POWER_OFF); }主循环里最小调用如下:
void main_loop(void) { uint8_t evt; while (1) { if (evt_poll(&evt)) { efsm_dispatch(&app_mach, evt); } /* 其他周期任务 */ } }这个框架已经具备一个真实EFSM的全部要素:事件队列、转移表、引擎、业务动作。后面要解决的是如何把定时器、串口、按键这些真实外设资源映射为事件。
4. 嵌入式中EFSM与具体外设的接入:定时器、按键与串口事件映射
EFSM引擎本身不关心事件来自哪里。工程里真正花时间的是设计“硬件中断”到“业务事件”的映射层。这一层做得不好,状态机会频繁收到同一类事件,导致无意义的转移和CPU浪费。
4.1 按键扫描与事件合并:消抖后只发一个有效事件
按键输入在嵌入式开发中最常见。如果用GPIO外部中断直接驱动,按下一次时机械抖动可能触发多次上升沿和下降沿,结果是队列里被灌入几十个EVT_KEY_PRESS。常见做法是每5ms到10ms扫描一次电平,连续两次读到稳定电平后,再投递一次按键事件。
一个简化的按键扫描状态如下:
static uint8_t key_scan_cnt = 0; static uint8_t key_last_level = 0; void key_scan_10ms(void) { uint8_t level = gpio_read(KEY_PIN); if (level == key_last_level) { if (key_scan_cnt < 3) { key_scan_cnt++; } else if (key_scan_cnt == 3) { evt_post(EVT_KEY_PRESS); key_scan_cnt = 4; } } else { key_scan_cnt = 0; key_last_level = level; } }这段逻辑把消抖和事件投递合并在一起,连续3次(即30ms)读到同一个电平均值后,才产生一个EVT_KEY_PRESS。为什么要降低按键事件的频率?因为EFSM中一条转移规则往往关联一项动作,如果事件每秒触发几十次,动作函数会被批量调用,时序难以把握。按键场景下把事件合并成“按下”“长按”“释放”三类,对应的状态机设计会更稳定。
4.2 定时器超时生成EVT_TIMEOUT:非阻塞延时的实现方式
状态机里不能使用delay_ms阻塞等待,因为阻塞期间事件队列无人消费,中断里post的事件堆积,导致逻辑错乱。正确的实现是记录一个到期截止时间,在周期时钟中检查。
static uint32_t timeout_deadline; void action_start_init(void) { timeout_deadline = sys_tick_get() + 3000; } void timer_tick_1ms(void) { if (app_mach.state == STATE_STARTUP) { if (sys_tick_get() >= timeout_deadline) { evt_post(EVT_INIT_TIMEOUT); } } }这里的EVT_INIT_TIMEOUT可以认为是一种特殊事件,它并不是由外部硬件直接产生,而是由时基管理器内部检查后产生。注意这里不要在中断里直接调用efsm_dispatch,否则中断可能打断主循环中正在执行的动作,产生重入风险。正确顺序是中断中只post到事件队列,主循环统一poll后dispatch。
4.3 串口数据帧完整到达事件:把字节流转换成业务事件
串口中断通常一字节一到,如果在每个字节中断里post事件,事件队列很快就会满。处理方式是在串口驱动层维护一个接收缓冲区,结合空闲中断或帧尾字符判断一帧结束,整帧完成后post一个EVT_FRAME_READY事件。
static uint8_t rx_buf[64]; static uint8_t rx_len = 0; void uart_byte_rx_isr(uint8_t byte) { if (rx_len < sizeof(rx_buf)) { rx_buf[rx_len++] = byte; } if (byte == '\n') { evt_post(EVT_FRAME_READY); } }动作函数中再取出rx_buf解析协议,例如:
static void act_parse_frame(void) { /* 假设第一字节是命令,第二字节是参数 */ if (rx_len >= 2) { if (rx_buf[0] == CMD_SET_PARAM) { param_value = rx_buf[1]; } } rx_len = 0; }这个接入方式的优点在于:协议解析动作只会在整帧到达后执行一次,而不是每个字节执行一次,DFSM状态机无需感知串口时序,只需要感知“一帧数据可读取”这个业务事件。注意动作函数应当执行快速非阻塞操作,如果一帧数据需要CRC校验、格式化存储等耗时操作,可把解析结果放入结果结构体,再通过另一个事件通知上层业务去消费。
4.4 多事件源优先级:中断里直接post还是统一入口
假设系统中有UART、按键、RTC三个事件源,都通过evt_post进入同一个队列,那么队列的行为是FIFO,先到先处理。这个模型在大多数嵌入式业务中是可接受的。
如果某些事件必须优先响应,比如电池过压保护EVT_OVP需要立刻停止输出,此时不能再等队列前面的低频事件处理完,需要引入优先级机制。嵌入式里的常见做法是采用两个队列:高优先级队列和普通队列,poll时先检查高优先级队列。也可以在事件编号编码上做一分,规定事件号为0到63的事件为普通事件,加上优先级位后放入两个不同队列。优先级事件会导致普通事件被延迟,延迟时间上界等于当前动作执行时间,因此在动作函数中不得执行大循环或长时间阻塞的延时。
5. EFSM在嵌入式系统中的调试技巧与覆盖验证思路
框架实现完成后,第一版代码往往能跑通,但一旦进入边界条件测试,问题就集中暴露在“某事件被忽略”或“状态进入错误分支”两类。EFSM提供了一种比其他逻辑更容易系统化调试的方式:在状态机引擎处统一打日志。
5.1 未命中事件与转移日志的输出方法
在efsm_dispatch的无匹配分支中,加入一条打印:
if (rule == 0) { debug_printf("EFSM: no rule state=%d evt=%d\n", (int)mach->state, (int)evt); return EFSM_NO_MATCH; }一条未命中日志能立刻告诉你两件事:第一,测试事件没有按要求设计对应的转移表项;第二,状态机因为某个不会被记录的路径进入了一个当前事件无法处理的状态。实际排查中,第二种情况占多数。我在调试时会把这条日志记录为“未处理事件”,并按事件号、状态号出现频率排序,找出高频的异常事件源。
转移成功时的日志可以这样写:
debug_printf("EFSM: S%d-[E%d]->S%d\n", (int)mach->state, (int)evt, (int)rule->next_state);建议在日志中不打印动作函数名,原因是动作函数地址回溯需要符号表映射,在裸机调试器下反而累赘。直接打印状态编号和事件编号足够还原完整行为轨迹,在系统联调时,比对实际打印的转移序列和设计文档中的路径,可以快速定位中断与动作函数之间的时序问题。
5.2 状态转移覆盖统计与自检:验证表完整性的手段
转移表是否完整,不是靠人眼检查出来的。我常用的方法是在编译期增加一个efsm_validate_table函数,在系统开机时遍历所有已定义的事件类型,对每个状态检查是否存在未定义路径。
int efsm_validate_table(const efsm_machine_t *mach, uint8_t max_state, uint8_t max_event, uint8_t *result_matrix) { uint8_t s, e, i; int missing = 0; for (s = 0; s < max_state; s++) { for (e = 0; e < max_event; e++) { for (i = 0; i < mach->table_size; i++) { if (mach->table[i].current_state == s && mach->table[i].event == e) { break; } } if (i == mach->table_size) { debug_printf("missing: state=%d event=%d\n", s, e); missing++; } } } return missing; }这里有一个重要取舍:不是所有(状态,事件)组合都必须定义。对于明确不处理的事件,通常建议在表中显式加入一条“空动作,状态不变”的规则,而不是依赖引擎的默认忽略机制。显式规则让阅读表的人知道“这个事件在这个状态下被有意忽略”,默认忽略则无法区分是漏写还是有意为之。对于真正不允许出现的事件组合,则统计未命中日志,放在系统联调阶段作为一个异常监控项。
5.3 内存占用与运行效率的权衡参数
转移表存Flash中,每条记录的结构体在4字节对齐时占用4+1+4+1后补齐到12字节。100条规则共1200字节。事件队列的RAM占用为EVT_QUEUE_SIZE字节,一般16字节即可。
如果芯片Flash紧张,可以把动作函数指针从4字节压缩为uint8_t的action_index,在动作索引表中再查一次函数地址。这样每条记录从12字节降到4+1+1+1即7字节,以100条规则计算节省约500字节Flash。但会增加一次查表,轻微降低派发速度。对于Cortex-M0系列Flash为16KB到64KB的芯片,这种优化值得做。
时间方面,线性扫描100条规则,循环体内两次比较和一次指针载入,在48MHz主频下大约5微秒内完成。即使事件率达到每秒1000个,状态引擎消耗也为5毫秒左右,完全不影响主循环。
5.4 EFSM时动作函数内的阻塞问题
最后提醒一个在嵌入式中最容易踩的性能坑:即便状态机引擎本身很快,如果动作函数内有阻塞调用比如读取Flash扇区、等待I2C从设备应答、delay延时,整个事件循环就会被卡住。使用EFSM后事件的产生仍在中断里,但消费动作被阻塞,队列可能被填满,系统表现为“状态不跳变”。
处理方法是将阻塞操作异步化:动作函数只负责发起操作,并登记一个“操作完成前保持当前状态”的条件;完成中断或DMA回调再post一个完成事件,把业务逻辑继续往下推。这样做之后,状态机的确定性会明显提高,系统响应也更容易进行时间预算测算。在定位线上问题时,打开EFSM日志与中断时间戳打印,就能看到每个状态停留的精确时长,从而判断某个动作是否占用了过多时间。
本文还有配套的精品资源,点击获取