8、状态机与事件驱动:事件队列设计、事件分发机制、异步状态机
事件驱动,说白了就是状态机的「灵魂」。没有事件,状态机就是一潭死水。我在项目中见过太多人把状态机写成了轮询大循环——CPU 空转,功耗高,响应还慢。真正好用的状态机,一定是事件驱动的。
这一章,我们来聊聊事件队列怎么设计、事件怎么分发,以及异步状态机到底该怎么玩。
8.1 为什么需要事件队列?
你想想看,如果事件来了就立刻处理,那叫「同步」。但现实世界没那么简单——多个事件可能同时到达,或者某个事件处理到一半,新事件又来了。直接嵌套处理?那代码很快就变成一坨浆糊。
事件队列就是用来解决这个问题的。它像一个缓冲区,把所有事件先存起来,然后按顺序一个一个处理。这样状态机永远只处理当前事件,不会被新事件打断。
核心原则:事件队列让「事件产生」和「事件处理」解耦。生产者只管往队列里塞事件,消费者(状态机)只管从队列里取事件处理。
我在一个物联网网关项目里遇到过这种情况:传感器数据、网络报文、用户按键同时涌进来,如果不用队列,状态机直接被冲垮。后来加了事件队列,世界清净了。
8.2 事件队列的几种设计
事件队列的设计,说白了就三种路子:
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 环形缓冲区 | 固定大小,无动态分配,速度快 | 嵌入式裸机、RTOS 任务间通信 |
| 链表队列 | 动态分配,灵活但可能有碎片 | Linux 用户态、内存充足的系统 |
| 优先级队列 | 按事件优先级排序,高优先处理 | 实时性要求高的系统 |
我个人习惯在嵌入式场景下用环形缓冲区。为什么?因为它没有动态内存分配,不会产生碎片,也不会因为 malloc 失败而崩溃。你想想看,一个跑在单片机上的状态机,如果因为分配事件内存而挂掉,那多冤。
下面是一个简单的环形缓冲区事件队列实现:
#define EVENT_QUEUE_SIZE 16
typedef struct {
uint8_t event_id;
uint32_t param;
} event_t;
typedef struct {
event_t buffer[EVENT_QUEUE_SIZE];
volatile uint8_t head;
volatile uint8_t tail;
} event_queue_t;
int event_queue_push(event_queue_t *q, uint8_t id, uint32_t param) {
uint8_t next = (q->head + 1) % EVENT_QUEUE_SIZE;
if (next == q->tail) {
return -1; // 队列满了
}
q->buffer[q->head].event_id = id;
q->buffer[q->head].param = param;
q->head = next;
return 0;
}
int event_queue_pop(event_queue_t *q, event_t *evt) {
if (q->tail == q->head) {
return -1; // 队列空了
}
*evt = q->buffer[q->tail];
q->tail = (q->tail + 1) % EVENT_QUEUE_SIZE;
return 0;
}
小技巧:队列大小建议取 2 的幂次方,这样取模可以用位运算替代,速度更快。比如 size=16 时,用 (head + 1) & 0x0F 代替取模。
8.3 事件分发机制
事件从队列里取出来了,接下来怎么分发给状态机?这就涉及到事件分发机制。
最简单的做法是「查表法」——用一个函数指针数组,事件 ID 做索引,直接调用对应的处理函数。我在项目中一直用这个方式,因为它快,而且容易维护。
typedef void (*event_handler_t)(event_t *evt);
event_handler_t event_handlers[] = {
[EVT_BUTTON_PRESS] = handle_button_press,
[EVT_TIMER_EXPIRED] = handle_timer_expired,
[EVT_SENSOR_READY] = handle_sensor_ready,
[EVT_NETWORK_MSG] = handle_network_msg,
};
void event_dispatch(event_t *evt) {
if (evt->event_id < sizeof(event_handlers)/sizeof(event_handlers[0])) {
if (event_handlers[evt->event_id]) {
event_handlers[evt->event_id](evt);
}
}
}
嗯,这里要注意:事件 ID 必须从 0 开始连续编号,不然数组会浪费空间。如果事件 ID 不连续,可以用 switch-case,但查表法更快。
还有一种更灵活的方式——「订阅分发」。状态机可以动态注册自己关心的事件。这种方式适合多个状态机实例的场景,但实现起来稍微复杂一些。
8.4 异步状态机
同步状态机的问题在于:处理一个事件时,整个系统都在等。如果某个事件处理需要等待硬件操作(比如 I2C 读写、Flash 擦写),CPU 就白白空转了。
异步状态机的思路是:把耗时操作拆成多个步骤,每个步骤完成后触发一个新事件。状态机收到「完成事件」后,再继续下一步。
举个例子,一个「读取传感器数据」的状态:
// 同步写法——阻塞等待
state_t state_read_sensor(void) {
i2c_start();
while (!i2c_is_done()); // 死等!
uint8_t data = i2c_read();
return STATE_PROCESS_DATA;
}
// 异步写法——分两步
state_t state_start_read_sensor(event_t *evt) {
i2c_start_async(); // 启动 I2C 读取,不等待
return STATE_WAIT_SENSOR; // 切换到等待状态
}
state_t state_wait_sensor(event_t *evt) {
if (evt->event_id == EVT_I2C_DONE) {
uint8_t data = i2c_read();
return STATE_PROCESS_DATA;
}
return STATE_WAIT_SENSOR; // 还没完成,继续等
}
你看,异步状态机把「等待」变成了「状态切换」。CPU 在等待期间可以去处理其他事件,系统吞吐量大幅提升。
我曾经踩过一个坑:异步状态机里,同一个状态可能收到多个不同的事件。一定要在状态处理函数里判断事件类型,不要默认「收到的事件就是我想要的」。否则,一个乱入的事件可能让状态机跑飞。
8.5 异步状态机的完整框架
把事件队列、事件分发、异步状态机串起来,就是一个完整的异步事件驱动框架。下面这张图展示了核心流程:
这个框架的核心就是:主循环不断从队列取事件,分发给状态机,状态机执行动作。如果动作是异步的,启动后立即返回,等异步完成后再往队列里塞一个「完成事件」。这样整个系统就「活」起来了——没有死等,没有空转。
8.6 实战中的几个要点
最后,分享几个我在实战中总结的要点:
- 事件要轻量:事件结构体里不要放大量数据,放指针或 ID 就够了。数据另外存,事件只传递「发生了什么」。
- 队列要防溢出:嵌入式环境里,队列满了怎么办?我一般用「丢弃旧事件」策略,因为新事件通常更重要。
- 中断里只 push,不 pop:中断服务函数里只往队列里塞事件,不要在中断里处理事件。处理逻辑放到主循环里,避免中断嵌套导致死锁。
- 状态函数要快:状态处理函数里不要做耗时操作。如果必须做,拆成异步步骤,或者用定时器事件分段执行。
个人习惯:我会在事件结构体里加一个 timestamp 字段,记录事件产生的时间。调试的时候,这个字段能帮你快速定位「事件延迟」的问题。
事件驱动加上异步状态机,这套组合拳打好了,你的嵌入式系统会变得非常优雅。代码可读性高,响应快,还省电。下一节我们会聊聊状态机的代码生成——怎么用工具自动生成这些模板代码,让你从重复劳动中解放出来。
公众号:蓝海资料掘金营,微信deep3321