2. 嵌入式基础回顾

好,我们正式开始。上一章聊了为什么裸机也需要架构,这一章咱们把地基打牢。

事件驱动,说白了就是四个东西在转:事件、事件处理器、事件队列、事件循环。你把这四个概念吃透了,后面写代码就是搭积木。

1. 事件(Event)

什么是事件?我习惯这么定义:系统中发生的一件事,需要被处理。

按键按下,是一个事件。定时器溢出,是一个事件。串口收到一个字节,也是一个事件。甚至你踩到狗尾巴,狗叫了一声——对狗来说,这也是一个事件。

在嵌入式里,事件通常用一个结构体表示。我见过很多新手把事件定义成纯数字,比如 #define EVT_KEY_PRESS 1。嗯,这样也行,但不够灵活。

我个人推荐的事件结构体:

typedef struct {
    uint16_t id;       // 事件ID,比如 EVT_KEY_PRESS
    uint16_t param;    // 参数,比如按键编号
    uint32_t timestamp; // 时间戳,记录事件发生时刻
} Event_t;

为什么加 timestamp?我在一个项目中遇到过这样的坑:两个事件几乎同时发生,处理顺序全靠队列先进先出。但后来发现,有些事件需要按实际发生顺序处理,而不是入队顺序。加上时间戳,你就能做排序或超时判断。

小技巧:事件ID用枚举定义,别用宏。枚举在调试器里能直接看到名字,宏只能看到数字。

2. 事件处理器(Event Handler)

事件处理器,就是处理事件的函数。每个事件对应一个处理器,或者一类事件对应一个处理器。

我早期做项目时,喜欢写一个巨大的 switch-case:

void event_handler(Event_t *evt) {
    switch(evt->id) {
        case EVT_KEY_PRESS:  handle_key(evt); break;
        case EVT_TIMER:      handle_timer(evt); break;
        case EVT_UART_RX:    handle_uart(evt); break;
        // ... 50个case
    }
}

后来项目大了,这个函数变得又臭又长。你想想看,每次加一个新事件,都要改这个文件,编译时所有依赖这个文件的模块都得重编。烦不烦?

更好的做法是用函数指针表:

typedef void (*EventHandler)(Event_t *evt);

EventHandler handler_table[EVT_MAX] = {
    [EVT_KEY_PRESS]  = handle_key,
    [EVT_TIMER]      = handle_timer,
    [EVT_UART_RX]    = handle_uart,
    // 新增事件只需在这里加一行
};

这样,事件分发就变成了:

void dispatch(Event_t *evt) {
    if (evt->id < EVT_MAX && handler_table[evt->id]) {
        handler_table[evt->id](evt);
    }
}

干净、利落、可扩展。我曾经在一个项目里用这个模式,后期加了20多个新事件,没改过一行分发代码。

3. 事件队列(Event Queue)

事件队列,就是事件的缓冲区。为什么需要队列?因为事件产生和处理往往是异步的。

中断里产生事件,主循环里处理事件。如果中断里直接调用处理器,那中断服务程序会变得很长,影响系统实时性。所以,中断里只做一件事:把事件丢进队列。

队列的实现方式有很多种:

  • 环形缓冲区:最常用,固定大小,无动态内存分配
  • 链表队列:灵活,但需要内存管理,裸机上慎用
  • 双缓冲:一个写缓冲区,一个读缓冲区,适合高频场景

注意:中断里往队列写数据时,一定要考虑原子操作。如果队列是全局变量,中断和主循环可能同时访问它。我见过一个案例,中断写了一半,主循环读走了半个事件,系统直接崩溃。

一个简单的环形缓冲区队列实现:

#define QUEUE_SIZE 16

typedef struct {
    Event_t buffer[QUEUE_SIZE];
    volatile uint8_t head;
    volatile uint8_t tail;
} EventQueue_t;

bool queue_push(EventQueue_t *q, Event_t *evt) {
    uint8_t next = (q->head + 1) % QUEUE_SIZE;
    if (next == q->tail) return false; // 队列满
    q->buffer[q->head] = *evt;
    q->head = next;
    return true;
}

bool queue_pop(EventQueue_t *q, Event_t *evt) {
    if (q->head == q->tail) return false; // 队列空
    *evt = q->buffer[q->tail];
    q->tail = (q->tail + 1) % QUEUE_SIZE;
    return true;
}

注意 volatile 关键字。head 和 tail 会被中断和主循环同时访问,不加 volatile,编译器优化可能让你怀疑人生。

4. 事件循环(Event Loop)

事件循环,就是那个永远在跑的 while(1)。它的工作很简单:从队列里取事件,交给处理器。

void event_loop(void) {
    Event_t evt;
    while (1) {
        if (queue_pop(&g_event_queue, &evt)) {
            dispatch(&evt);
        } else {
            // 队列为空,可以进入低功耗模式
            __WFI(); // ARM 的等待中断指令
        }
    }
}

这里有个细节:队列为空时,别空转。我早期做的一个项目,CPU 在空转时功耗 50mA,加上 __WFI() 后降到 5mA。省下来的电,够设备多跑好几天。

事件循环的变体有很多:

  • 优先级循环:高优先级事件先处理
  • 时间片轮转:每个事件分配固定处理时间
  • 混合模式:紧急事件立即处理,普通事件排队

我个人习惯用最简单的先进先出。除非你明确知道需要优先级,否则别过度设计。你想想看,一个裸机系统,事件量通常不大,FIFO 完全够用。

5. 四个概念的关系

这四个东西怎么配合?我画了一张图,你看一眼就明白了。

事件驱动架构核心流程 事件源 中断/定时器/外设 事件队列 环形缓冲区 事件循环 while(1) 取事件 事件处理器 函数指针表 中断中入队 主循环出队 分发 💡 关键原则: 中断里只入队,不处理。主循环里只出队,不分发太久。每个处理器执行时间要短。

流程很清楚:事件源产生事件 → 入队 → 事件循环出队 → 分发到处理器。这个闭环,就是事件驱动架构的心脏。

6. 避坑指南

最后,分享几个我踩过的坑:

  • 队列大小设太小:中断密集时丢事件。我曾经设了8个,结果按键连发时丢了一半。后来改成32,再没丢过。
  • 处理器里做耗时操作:比如在事件处理器里调用延时函数。这会导致事件循环卡住,后续事件无法处理。记住:处理器里别阻塞。
  • 忘记关中断:在队列操作时,如果中断可能打断,一定要关中断或使用临界区。我见过一个同事,队列 push 写到一半被中断打断,pop 读走了脏数据。