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;
我的习惯:事件 ID 用枚举,不要用魔数。枚举名要自解释,比如 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;
}
我曾经踩过的坑:队列长度设得太小。一个高频率中断(比如 1kHz 的编码器)连续投递,队列瞬间爆满,事件丢失。后来我加了一个“丢弃旧事件”的策略,并统计丢事件次数,方便调试。

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 三种核心角色:生产者、队列、消费者

事件驱动系统可以抽象为三个角色:

  1. 生产者:产生事件。通常是中断服务程序、定时器回调、底层驱动。
  2. 队列:缓冲事件。解耦生产者和消费者的时序。
  3. 消费者:处理事件。通常是主循环中的调度器或状态机。

它们的关系可以用一张图表示:

事件驱动系统三要素 生产者 中断 / 定时器 / 驱动 事件队列 环形缓冲区 / FIFO 消费者 调度器 / 状态机 关键约束 • 生产者不能阻塞等待队列空位 • 消费者必须在主循环或任务中运行,不能在中断中消费 • 队列长度必须覆盖最坏情况下的峰值事件数

4.5 事件驱动 vs 轮询:功耗对比

咱们用数据说话。假设一个系统每 10ms 检查一次按键,轮询方式下 CPU 一直跑,电流约 5mA。事件驱动方式下,按键中断唤醒,CPU 只在处理事件时工作,其余时间休眠,平均电流可以降到 50µA 以下。

对比项 轮询方式 事件驱动方式
CPU 利用率 持续 30%~100% 突发 1%~5%
平均功耗 高(mA 级) 低(µA 级)
响应延迟 取决于轮询周期 中断触发,微秒级
代码复杂度 简单直接 需要队列和调度
可扩展性 差,新增事件需改轮询逻辑 好,新增事件只需注册处理函数
我的建议:对于电池供电设备,优先选择事件驱动。对于对延迟不敏感的简单控制(比如 LED 闪烁),轮询也不是不能用。但一旦系统超过 3 个外设,事件驱动的优势就碾压轮询了。

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 投递新事件,结果导致递归调用,栈溢出。后来我改成“延迟投递”——先把事件暂存,等当前事件处理完再统一投递。这个细节很重要,尤其是在中断上下文和任务上下文混用的系统中。

好了,事件驱动编程的基础就这些。记住三个核心:事件是信号,队列是缓冲,处理器是调度。把这个三角关系理清了,你的嵌入式系统就能做到“该睡就睡,该醒就醒”,功耗自然就下来了。