3、事件驱动架构基础:状态机理论入门、事件与状态的定义、事件队列的实现
好,我们正式开始事件驱动架构。这一章是整门课的基石,说白了,你后面所有低功耗设计的思路,都从这里长出来。
我记得刚入行那会儿,接手一个智能门锁项目。需求很简单:按键开锁、刷卡开锁、超时报警。我用了一个超级循环,里面堆了十几个 if-else 判断状态。结果呢?功耗高得离谱,而且加一个新功能就得改一大片代码。后来我才明白,问题出在「状态管理」上——我根本没有清晰地定义状态和事件,代码自然就成了一团乱麻。
3.1 状态机理论入门
状态机,英文叫 Finite State Machine (FSM)。名字听着唬人,其实核心就三个东西:状态、事件、转移。
- 状态:系统在某个时刻的稳定情形。比如门锁的「待机」、「开锁」、「报警」。
- 事件:触发状态变化的东西。比如「按键按下」、「刷卡成功」、「超时」。
- 转移:从一个状态到另一个状态的过程,通常伴随着动作。
你想想看,一个嵌入式系统本质上就是在不同状态之间跳来跳去。按键按下,从待机跳到开锁;开锁后 5 秒没操作,跳回待机。就这么简单。
核心原则:一个状态机在任意时刻,只能处于一个状态。这是确定性,也是我们调试的底气。
我在项目中遇到过一种情况:有人把状态机画得跟蜘蛛网一样,状态之间互相乱跳。结果代码写出来,自己都看不懂。所以,我建议你一开始就用 状态转移表 来约束设计。
下面是一个简单的门锁状态转移表:
| 当前状态 | 事件 | 下一状态 | 动作 |
|---|---|---|---|
| 待机 | 按键按下 | 开锁 | 驱动电机,点亮指示灯 |
| 待机 | 刷卡成功 | 开锁 | 驱动电机,点亮指示灯 |
| 开锁 | 超时 (5秒) | 待机 | 关闭电机,熄灭指示灯 |
| 开锁 | 按键按下 | 待机 | 关闭电机,熄灭指示灯 |
| 待机 | 连续错误 3 次 | 报警 | 启动蜂鸣器 |
| 报警 | 管理员复位 | 待机 | 关闭蜂鸣器 |
这张表就是你的设计契约。写代码之前,先把表画清楚。后面所有逻辑都按表来,不会乱。
3.2 事件与状态的定义
定义状态和事件,是状态机设计的第一步。这一步做不好,后面全是坑。
我个人习惯用枚举来定义。C 语言里,枚举就是干这个的。清晰、可读、编译器还能帮你检查。
// 状态枚举
typedef enum {
STATE_IDLE, // 待机
STATE_UNLOCK, // 开锁
STATE_ALARM, // 报警
STATE_MAX
} state_t;
// 事件枚举
typedef enum {
EVENT_BUTTON_PRESS, // 按键按下
EVENT_CARD_SUCCESS, // 刷卡成功
EVENT_TIMEOUT, // 超时
EVENT_ERROR_3_TIMES, // 连续错误3次
EVENT_ADMIN_RESET, // 管理员复位
EVENT_MAX
} event_t;
小技巧:在枚举末尾加一个 `_MAX` 成员,可以用来做数组边界检查,或者循环遍历。这个习惯我用了十年,很实用。
这里有个容易犯的错:不要把状态和事件混在一起。我曾经见过有人把「开锁中」既当状态又当事件,结果逻辑绕得跟迷宫一样。记住,状态是「现在是什么」,事件是「发生了什么」。两者是正交的。
避坑指南:我曾经在一个项目中,把事件定义成了 `uint8_t` 的宏,结果事件值冲突了,导致状态机跳到了错误的状态。从那以后,我坚持用枚举,并且每个事件都显式赋值,避免隐式冲突。
3.3 事件队列的实现
有了状态和事件,接下来就是怎么把事件「喂」给状态机。最直接的方式是函数调用,但这样会阻塞。在低功耗系统里,我们通常用 事件队列。
事件队列说白了就是一个 FIFO(先进先出)缓冲区。ISR(中断服务程序)把事件丢进去,主循环或者任务从里面取出来处理。这样 ISR 可以快速退出,系统功耗也更低。
下面是一个简单的环形缓冲区实现:
#define EVENT_QUEUE_SIZE 16
typedef struct {
event_t buffer[EVENT_QUEUE_SIZE];
uint8_t head;
uint8_t tail;
uint8_t count;
} event_queue_t;
static event_queue_t s_event_queue;
// 初始化队列
void event_queue_init(void) {
s_event_queue.head = 0;
s_event_queue.tail = 0;
s_event_queue.count = 0;
}
// 入队(通常在 ISR 中调用)
bool event_queue_push(event_t event) {
if (s_event_queue.count >= EVENT_QUEUE_SIZE) {
return false; // 队列满,丢事件
}
s_event_queue.buffer[s_event_queue.tail] = event;
s_event_queue.tail = (s_event_queue.tail + 1) % EVENT_QUEUE_SIZE;
s_event_queue.count++;
return true;
}
// 出队(在主循环中调用)
bool event_queue_pop(event_t *event) {
if (s_event_queue.count == 0) {
return false; // 队列空
}
*event = s_event_queue.buffer[s_event_queue.head];
s_event_queue.head = (s_event_queue.head + 1) % EVENT_QUEUE_SIZE;
s_event_queue.count--;
return true;
}
关键点:入队操作在 ISR 中执行,必须保证原子性。如果 MCU 不支持原子操作,需要关中断保护。出队操作在主循环中执行,通常不需要关中断,因为 ISR 不会修改 head 指针。
嗯,这里要注意:队列大小要合理。太小了容易丢事件,太大了浪费 RAM。我一般根据系统中最快的事件产生频率来估算。比如按键去抖后 50ms 产生一个事件,主循环 10ms 处理一次,那队列深度 4 就够用了。但为了安全,我通常会留一倍余量。
下面这张图展示了事件队列在整个系统中的位置:
主循环的典型处理逻辑是这样的:
void main_loop(void) {
event_t event;
while (1) {
// 进入低功耗模式前,检查队列
if (event_queue_pop(&event)) {
// 有事件,处理
state_machine_handle_event(event);
} else {
// 没有事件,进入低功耗模式
enter_sleep_mode();
}
}
}
低功耗关键:事件队列为空时,让 CPU 进入睡眠模式。中断来了,ISR 入队,然后唤醒 CPU 处理。这样 CPU 大部分时间都在睡觉,功耗自然就降下来了。这个模式,我几乎在每个低功耗项目里都用。
最后,我建议你把状态机处理函数也封装一下:
void state_machine_handle_event(event_t event) {
state_t next_state = s_current_state;
// 根据当前状态和事件,查表决定下一状态
switch (s_current_state) {
case STATE_IDLE:
if (event == EVENT_BUTTON_PRESS || event == EVENT_CARD_SUCCESS) {
next_state = STATE_UNLOCK;
// 执行动作:开锁
motor_drive(OPEN);
led_on();
} else if (event == EVENT_ERROR_3_TIMES) {
next_state = STATE_ALARM;
buzzer_on();
}
break;
case STATE_UNLOCK:
if (event == EVENT_TIMEOUT || event == EVENT_BUTTON_PRESS) {
next_state = STATE_IDLE;
motor_drive(CLOSE);
led_off();
}
break;
case STATE_ALARM:
if (event == EVENT_ADMIN_RESET) {
next_state = STATE_IDLE;
buzzer_off();
}
break;
default:
break;
}
s_current_state = next_state;
}
嗯,这一章的内容就到这里。事件驱动架构的核心就是这三板斧:状态机、事件定义、事件队列。你把这套东西练熟了,后面讲 RTOS 的任务通信、低功耗 Tickless 模式,都会觉得顺理成章。