事件驱动编程基础:事件循环、队列、分发器与回调

各位好,欢迎来到第二章。上一章我们聊了嵌入式GUI的基本概念,今天要啃的这块骨头,是整个事件采集系统的灵魂——事件驱动编程。

说白了,事件驱动就是“有事才干活,没事就歇着”。你想想看,嵌入式设备大部分时间都在等用户操作——等触摸、等按键、等定时器。如果CPU一直轮询,那功耗和效率都扛不住。事件驱动就是解决这个问题的。

核心思想:系统不主动询问“你有没有事?”,而是等外部或内部事件来了,再响应处理。这就像你坐在工位上,不是每分钟问一遍老板有没有任务,而是等老板喊你才动。

事件循环机制

事件循环,英文叫 Event Loop。它是整个GUI系统的心脏,一个永不退出的 while 循环。

我刚开始做嵌入式GUI时,犯过一个低级错误——在事件循环里加了延时函数,结果整个界面卡死了。嗯,这里要注意:事件循环绝对不能阻塞。

一个典型的事件循环长这样:

while (1) {
    Event evt;
    
    // 1. 从队列取事件
    if (event_queue_get(&evt) == 0) {
        // 2. 分发事件
        event_dispatcher(&evt);
    }
    
    // 3. 处理空闲任务(可选)
    idle_task();
    
    // 4. 进入低功耗模式(可选)
    // WFI();  // 等待中断
}

看到没?循环里就干四件事:取事件、分发事件、干点杂活、睡觉。其中“睡觉”这一步很关键——没有事件时让CPU进入低功耗模式,靠中断唤醒。我在一个电池供电的智能家居面板项目里,靠这个技巧把待机功耗从50mA降到了2mA。

我的经验:事件循环的周期决定了GUI的响应速度。一般要求触摸到反馈不超过50ms,所以循环里不能有超过10ms的阻塞操作。如果遇到耗时任务,拆成多步或者扔到后台线程去。

事件队列

事件队列,说白了就是一个先进先出(FIFO)的缓冲区。所有产生的事件都先排队,事件循环再一个个取出来处理。

为什么要用队列?直接处理不行吗?

你想想看,如果用户在快速滑动屏幕,触摸控制器可能1ms就产生一个事件。如果事件循环正在处理一个耗时任务,新事件就丢了。队列就是用来解耦事件产生和事件处理的。

我常用的队列实现有两种:

类型 优点 缺点 适用场景
环形缓冲区 无动态内存分配,速度快 大小固定,可能溢出 资源紧张的MCU
链表队列 可动态扩展 需要内存管理,速度稍慢 内存充足的系统

我个人习惯在资源受限的MCU上用环形缓冲区。下面是一个简化版实现:

#define QUEUE_SIZE 16

typedef struct {
    Event buffer[QUEUE_SIZE];
    uint8_t head;  // 写位置
    uint8_t tail;  // 读位置
    uint8_t count; // 当前数量
} EventQueue;

int event_queue_put(EventQueue *q, const Event *evt) {
    if (q->count >= QUEUE_SIZE) {
        return -1;  // 队列满了
    }
    q->buffer[q->head] = *evt;
    q->head = (q->head + 1) % QUEUE_SIZE;
    q->count++;
    return 0;
}

int event_queue_get(EventQueue *q, Event *evt) {
    if (q->count == 0) {
        return -1;  // 队列空了
    }
    *evt = q->buffer[q->tail];
    q->tail = (q->tail + 1) % QUEUE_SIZE;
    q->count--;
    return 0;
}

避坑指南:我曾经在一个项目中把队列大小设成8,结果快速滑动时事件溢出,导致触摸轨迹断断续续。后来改成32才解决。建议队列大小至少是最大预期事件率的3倍以上。

事件分发器

事件分发器,就是那个根据事件类型,找到对应处理函数的中间人。它不干活,只负责“派活”。

最简单的分发器就是一个 switch-case:

void event_dispatcher(const Event *evt) {
    switch (evt->type) {
        case EVENT_TOUCH_DOWN:
            touch_down_handler(evt->x, evt->y);
            break;
        case EVENT_TOUCH_MOVE:
            touch_move_handler(evt->x, evt->y);
            break;
        case EVENT_TOUCH_UP:
            touch_up_handler(evt->x, evt->y);
            break;
        case EVENT_KEY_PRESS:
            key_press_handler(evt->key_code);
            break;
        case EVENT_TIMER:
            timer_handler(evt->timer_id);
            break;
        default:
            // 未处理的事件
            break;
    }
}

这种方式简单直观,但事件类型多了以后,switch-case会变得又臭又长。我见过一个项目,事件类型超过50种,那个switch-case写了800多行……

更优雅的方式是用函数指针表:

typedef void (*EventHandler)(const Event *evt);

EventHandler handlers[EVENT_MAX] = {
    [EVENT_TOUCH_DOWN] = touch_down_handler,
    [EVENT_TOUCH_MOVE] = touch_move_handler,
    [EVENT_TOUCH_UP]   = touch_up_handler,
    [EVENT_KEY_PRESS]  = key_press_handler,
    [EVENT_TIMER]      = timer_handler,
};

void event_dispatcher(const Event *evt) {
    if (evt->type < EVENT_MAX && handlers[evt->type] != NULL) {
        handlers[evt->type](evt);
    }
}

这样扩展起来就方便多了——加一个新事件类型,只需要在数组里注册一个函数指针。

回调函数

回调函数,是事件驱动里最灵活的机制。说白了就是“你告诉我怎么做,到时候我喊你”。

在嵌入式GUI里,回调函数最常见的用法是控件事件通知。比如你按下一个按钮,按钮库不知道你要干啥,它通过回调函数通知你:

// 按钮控件结构体
typedef struct {
    int x, y, w, h;
    const char *label;
    // 回调函数指针
    void (*on_click)(void *user_data);
    void *user_data;
} Button;

// 按钮按下时的处理
void button_press(Button *btn) {
    // 绘制按下状态...
    // 触发回调
    if (btn->on_click != NULL) {
        btn->on_click(btn->user_data);
    }
}

// 用户代码:创建一个按钮并注册回调
void my_button_click_handler(void *data) {
    // 用户自己的处理逻辑
    printf("按钮被按下了!\n");
}

Button btn = {
    .x = 10, .y = 10, .w = 100, .h = 40,
    .label = "确定",
    .on_click = my_button_click_handler,
    .user_data = NULL,
};

你看,按钮库不需要知道用户要干什么,它只负责在事件发生时调用回调函数。这就是解耦的魅力。

我的习惯:回调函数里不要做耗时操作。如果必须做,用定时器或者工作队列延迟处理。否则整个事件循环会被卡住,界面就“冻”住了。

整体架构图

下面这张图把今天讲的四个概念串起来了。我建议你仔细看看,理解数据是怎么流动的:

事件驱动架构核心流程 事件源 触摸/按键/定时器 事件队列 FIFO 缓冲区 事件循环 while(1) 取事件 分发器 派发事件 回调函数表 触摸回调 按键回调 定时器回调 自定义回调 流程说明 1. 硬件中断或定时器产生事件 → 放入队列 2. 事件循环从队列取出事件 → 交给分发器 3. 分发器根据事件类型 → 调用对应的回调函数 4. 回调函数执行用户逻辑 → 更新界面或状态

总结一下

今天这四个概念,我建议你记住一个比喻:

  • 事件循环 = 餐厅的服务员,一直在转悠
  • 事件队列 = 服务员手里的点单本,先来后到
  • 事件分发器 = 服务员把单子递给不同的厨师
  • 回调函数 = 厨师按菜单做菜,做完喊“好了”