1. 事件驱动架构基础

各位同学,咱们今天聊点实在的。事件驱动架构,说白了就是一套「等通知、再干活」的编程思想。我做了十几年嵌入式,见过太多系统因为架构选型不对,后期改得痛不欲生。这一章,咱们先把地基打牢。

什么是事件驱动架构

想象一下你坐在办公室里。轮询模式就像你每隔5分钟跑去问领导:「有活吗?有活吗?」——领导烦,你也累。事件驱动模式呢?你坐在工位上喝茶,领导有活了喊你一声:「小王,来一下!」你才起身去处理。

在嵌入式世界里,事件驱动架构就是这种「被动响应」的机制。系统不主动去查状态,而是等外部或内部发生了什么事,再触发对应的处理逻辑。CPU不用空转,功耗低,响应也快。

我记得有一次做智能家居网关,客户要求电池续航一年。用轮询写传感器数据采集,三天就没电了。改成事件驱动——传感器主动上报变化,MCU大部分时间睡大觉——续航直接飙到14个月。这就是差距。

核心概念拆解

事件驱动架构有四个核心角色,我一个个说清楚。

事件(Event)

事件就是「发生了某件事」这个信息本身。它可以是按键按下、CAN总线来了一帧数据、定时器溢出、或者一个软件标志被置位。事件里通常带着数据,比如按键的键值、ADC的采样结果。

关键点:事件是不可变的。一旦产生,它的内容就不该被修改。我见过有人直接在事件处理函数里改事件数据,结果多核系统上跑出了各种诡异bug。记住:事件是只读的。

事件源(Event Source)

谁产生事件,谁就是事件源。中断服务程序、定时器回调、网络协议栈、传感器驱动——这些都是事件源。一个系统里通常有多个事件源,它们并发地往系统里扔事件。

事件处理器(Event Handler)

事件处理器就是「接到事件后干活的那段代码」。每个事件类型对应一个或多个处理器。处理器里不能做阻塞操作,比如delay(1000)这种——你阻塞了,后面的事件就排着队等你,系统就卡死了。

避坑指南:我曾经在一个项目里,事件处理器里调了printf,结果printf内部用了互斥锁,锁超时直接死锁。事件处理器里不要做任何可能阻塞的操作。实在要打印,把日志扔到队列里,让后台任务去处理。

事件队列(Event Queue)

事件队列是连接事件源和事件处理器的缓冲区。事件源把事件塞进队列,事件处理器从队列里取出来执行。队列的存在,让生产者和消费者解耦了——生产得快、消费得慢,队列能缓冲;反过来也一样。

队列的实现方式有很多种:环形缓冲区、链表队列、甚至直接用一个数组加头尾指针。选哪种?看你的场景。我一般用固定大小的环形缓冲区,没有动态内存分配,不会碎片化。

事件驱动 vs 轮询架构

咱们直接上对比表,一目了然。

对比维度 事件驱动架构 轮询架构
CPU利用率 空闲时几乎为0,有事件才跑 一直在跑,哪怕没事干也在查
响应延迟 事件产生后立即入队处理 取决于轮询间隔,平均延迟一半周期
功耗 低,适合电池设备 高,CPU无法深度休眠
代码复杂度 中等,需要设计事件类型和队列 简单,一个while循环搞定
扩展性 好,加新事件不影响现有逻辑 差,加一个新轮询项要改主循环
适用场景 多任务、低功耗、高实时性 简单控制、单任务、对功耗不敏感

你想想看,如果你的系统只有两个按键、一个LED,轮询完全够用。但如果你在做车载ECU、工业控制器、智能穿戴——事件驱动几乎是唯一的选择。

一个简单的例子

咱们用伪代码展示一下事件驱动的核心流程。假设系统有两个事件源:按键和定时器。

// 事件类型定义
typedef enum {
    EVENT_BUTTON_PRESS,
    EVENT_TIMER_TICK,
    EVENT_SENSOR_DATA_READY
} event_type_t;

// 事件结构体
typedef struct {
    event_type_t type;
    uint32_t data;      // 附加数据,比如按键键值
    uint64_t timestamp; // 事件发生的时间戳
} event_t;

// 事件队列(环形缓冲区)
#define QUEUE_SIZE 16
static event_t queue[QUEUE_SIZE];
static uint8_t head = 0, tail = 0;

// 事件源:中断中入队
void button_isr(void) {
    event_t ev = {EVENT_BUTTON_PRESS, 0x01, get_tick()};
    queue[head] = ev;
    head = (head + 1) % QUEUE_SIZE;
}

// 事件处理器
void handle_event(event_t *ev) {
    switch (ev->type) {
        case EVENT_BUTTON_PRESS:
            toggle_led();
            break;
        case EVENT_TIMER_TICK:
            update_display();
            break;
        // 其他事件...
    }
}

// 主循环:从队列取事件并处理
void main_loop(void) {
    while (1) {
        if (head != tail) {
            event_t ev = queue[tail];
            tail = (tail + 1) % QUEUE_SIZE;
            handle_event(&ev);
        } else {
            // 队列为空,进入低功耗模式
            __WFI(); // Wait For Interrupt
        }
    }
}

看到没?主循环里没事干就睡大觉,有事件才醒。这就是事件驱动最核心的「无事休眠,有事响应」。

事件驱动架构的核心流程图

下面这张图展示了事件从产生到被消费的完整路径。我建议你仔细看看,理解每个环节的作用。

事件驱动架构核心流程 事件源 1 按键中断 定时器溢出 事件源 2 CAN接收中断 传感器就绪 事件源 N 网络数据到达 DMA传输完成 事件队列 FIFO / 环形缓冲区 事件分发器 处理器 A 处理器 B 多个事件源并发产生事件 → 事件队列缓冲 → 分发器按类型派发 → 对应处理器执行

我的经验:事件队列的深度一定要留余量。我一般按理论最大事件数的2倍来设。曾经有个项目,队列深度设小了,高负载下事件丢失,设备偶发死机。查了三天才找到原因——队列溢出。从那以后,我都在队列满时加一个「丢弃最旧事件」的兜底策略,至少保证系统不崩。

什么时候该用事件驱动?

不是所有场景都适合事件驱动。我总结了几条判断标准:

  • 系统有多个并发事件源——按键、传感器、通信接口同时工作,用轮询会顾此失彼。
  • 对功耗有要求——电池供电的设备,CPU大部分时间必须休眠。
  • 响应时间要求不一致——有些事件要立即响应(比如急停按钮),有些可以排队慢慢处理。
  • 系统需要扩展——后期可能要加新功能,事件驱动加一个新事件类型就行,不用改主循环。

反过来,如果你的系统就一个循环任务,比如每隔100ms读一次温度、显示一下,轮询反而更简单直接。别为了用事件驱动而用事件驱动,架构选型要务实。