4. 事件驱动编程基础:事件、事件队列、事件处理器的概念
好,咱们进入事件驱动编程。说实话,这是嵌入式低功耗设计的核心思维之一。你想想看,一个单片机大部分时间在干嘛?不是在算数,而是在等——等按键按下,等数据包到达,等定时器溢出。如果它一直轮询这些条件,CPU 就闲不下来,功耗自然就上去了。
事件驱动编程,说白了就是让系统“有事才干活,没事就睡觉”。我在一个智能门锁项目里吃过轮询的亏——电池撑不过两周。后来改成事件驱动,直接干到半年。差距就这么大。
4.1 什么是事件?
事件,就是“发生了某件值得关注的事”。它不是一个函数调用,也不是一个变量值,而是一个语义化的信号。
| 事件类型 | 典型来源 | 举例 |
|---|---|---|
| 外部事件 | GPIO 中断、按键、传感器触发 | 按键按下、门磁打开 |
| 内部事件 | 定时器、软件定时、任务超时 | 100ms 心跳、看门狗喂狗 |
| 通信事件 | UART/SPI/I2C 接收完成 | 收到一帧数据、CRC 校验通过 |
| 系统事件 | 任务切换、错误、低电量 | 堆栈溢出、电压低于阈值 |
事件本身通常是一个结构体,包含事件 ID 和参数。我习惯这样定义:
typedef struct {
uint16_t event_id; // 事件编号
uint32_t timestamp; // 发生时刻(可选)
union {
uint32_t value; // 简单数值
void *ptr; // 复杂数据指针
} param;
} event_t;
EVT_BUTTON_PRESS、EVT_UART_RX_DONE。这样调试时看日志就知道发生了什么。
4.2 事件队列:缓冲与解耦
事件产生后,不能直接丢给处理器。为什么?因为产生事件的上下文(比如中断服务程序)和处理器上下文(比如主循环)往往不同步。直接调用处理器函数,轻则破坏优先级,重则导致死锁。
事件队列就是中间的缓冲区。它像一个邮箱:生产者往里投信,消费者取信处理。
队列的实现方式有很多种。在资源受限的 MCU 上,我推荐环形缓冲区:
#define EVENT_QUEUE_SIZE 16
static event_t queue[EVENT_QUEUE_SIZE];
static volatile uint8_t head = 0;
static volatile uint8_t tail = 0;
bool event_post(event_t *evt) {
uint8_t next = (head + 1) % EVENT_QUEUE_SIZE;
if (next == tail) return false; // 队列满
queue[head] = *evt;
head = next;
return true;
}
bool event_get(event_t *evt) {
if (head == tail) return false; // 队列空
*evt = queue[tail];
tail = (tail + 1) % EVENT_QUEUE_SIZE;
return true;
}
4.3 事件处理器:调度与分发
事件处理器是系统的“大脑”。它从队列中取出事件,根据事件 ID 调用对应的处理函数。最简单的形式就是一个 switch-case:
void event_dispatcher(void) {
event_t evt;
while (event_get(&evt)) {
switch (evt.event_id) {
case EVT_BUTTON_PRESS:
handle_button(evt.param.value);
break;
case EVT_UART_RX_DONE:
handle_uart_data(evt.param.ptr);
break;
case EVT_TIMER_TICK:
handle_timer_tick();
break;
default:
// 未注册事件,记录日志
break;
}
}
}
这个函数通常放在主循环的 idle 部分:
void main(void) {
system_init();
while (1) {
event_dispatcher(); // 处理所有待办事件
system_sleep(); // 无事可做,进入休眠
}
}
你看,这个结构天然支持低功耗——事件处理完就睡,有事件才醒。
4.4 三种核心角色:生产者、队列、消费者
事件驱动系统可以抽象为三个角色:
- 生产者:产生事件。通常是中断服务程序、定时器回调、底层驱动。
- 队列:缓冲事件。解耦生产者和消费者的时序。
- 消费者:处理事件。通常是主循环中的调度器或状态机。
它们的关系可以用一张图表示:
4.5 事件驱动 vs 轮询:功耗对比
咱们用数据说话。假设一个系统每 10ms 检查一次按键,轮询方式下 CPU 一直跑,电流约 5mA。事件驱动方式下,按键中断唤醒,CPU 只在处理事件时工作,其余时间休眠,平均电流可以降到 50µA 以下。
| 对比项 | 轮询方式 | 事件驱动方式 |
|---|---|---|
| CPU 利用率 | 持续 30%~100% | 突发 1%~5% |
| 平均功耗 | 高(mA 级) | 低(µA 级) |
| 响应延迟 | 取决于轮询周期 | 中断触发,微秒级 |
| 代码复杂度 | 简单直接 | 需要队列和调度 |
| 可扩展性 | 差,新增事件需改轮询逻辑 | 好,新增事件只需注册处理函数 |
4.6 一个完整的例子:按键事件驱动
咱们把前面讲的东西串起来。一个按键按下,经过消抖、事件投递、队列缓冲、调度处理,最终点亮 LED。整个过程如下:
// 中断服务程序(生产者)
void EXTI0_IRQHandler(void) {
if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
event_t evt = { .event_id = EVT_BUTTON_PRESS, .param.value = 1 };
event_post(&evt); // 投递事件,不做其他事
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
// 事件处理函数(消费者的一部分)
void handle_button(uint32_t press_count) {
if (press_count & 1) {
GPIO_SetBits(LED_PORT, LED_PIN); // 奇数次按下点亮
} else {
GPIO_ResetBits(LED_PORT, LED_PIN); // 偶数次按下熄灭
}
}
// 主循环(消费者)
void main(void) {
system_init();
while (1) {
event_dispatcher(); // 处理事件
__WFI(); // 等待中断,进入休眠
}
}
你看,中断里只做了一件事——投递事件。主循环处理完所有事件后,直接进入休眠。按键按下时,中断唤醒 CPU,处理完又睡回去。功耗就是这么降下来的。
event_post 和 event_get 里加 GPIO 翻转,用示波器看队列的负载情况。我曾经用这个方法发现一个中断每秒投递 2000 次事件,队列根本扛不住——后来发现是消抖没做好。
4.7 事件驱动与状态机的结合
事件驱动和状态机是天作之合。事件驱动负责“什么时候做什么事”,状态机负责“当前状态下该怎么做”。下一章咱们会深入讲这个组合,但这里先给个概念:
void event_dispatcher(void) {
event_t evt;
while (event_get(&evt)) {
state_machine_run(current_state, &evt);
}
}
状态机根据当前状态和事件,决定下一步动作和下一个状态。这种结构让代码逻辑清晰,调试时只需要关注“状态 + 事件”的组合,而不是一堆 if-else 嵌套。
event_post 投递新事件,结果导致递归调用,栈溢出。后来我改成“延迟投递”——先把事件暂存,等当前事件处理完再统一投递。这个细节很重要,尤其是在中断上下文和任务上下文混用的系统中。
好了,事件驱动编程的基础就这些。记住三个核心:事件是信号,队列是缓冲,处理器是调度。把这个三角关系理清了,你的嵌入式系统就能做到“该睡就睡,该醒就醒”,功耗自然就下来了。