3、事件驱动模型:事件循环机制、事件类型分类(触摸、按键、定时器)、事件队列与分发

嵌入式GUI的核心,说白了就是“等事件、处理事件、再等下一个事件”。

我刚入行时,总想着用while大循环轮询所有外设——结果CPU占用率飙到90%,触摸屏还经常丢点。后来才明白,事件驱动才是嵌入式GUI的正解。

3.1 事件循环机制:GUI的“心脏”

事件循环,就是那个永不停止的while(1)。但它不是傻等,而是有策略地“休眠-唤醒”。

核心思想:CPU只在有事件发生时工作,没事就睡大觉。这对电池供电的设备尤其重要。

我习惯把事件循环拆成三步:

  1. 获取事件——从队列头部取出一个待处理事件
  2. 分发事件——根据事件类型,找到对应的处理函数
  3. 处理事件——执行回调,更新UI状态

你看,就这么简单。但实际项目中,坑往往藏在细节里。

// 一个典型的事件循环骨架
void gui_event_loop(void) {
    event_t evt;
    while (1) {
        // 1. 从队列取事件(阻塞或非阻塞)
        if (event_queue_get(&evt, portMAX_DELAY) == OK) {
            // 2. 分发事件
            event_dispatch(&evt);
        }
        // 3. 空闲时做低优先级任务
        gui_idle_task();
    }
}

我的习惯:事件队列的等待时间不要设死。用FreeRTOS时,我一般设10ms超时,这样既能及时响应,又能给空闲任务留口气。

3.2 事件类型分类:触摸、按键、定时器

嵌入式GUI里,事件就三大类。我做了个表,方便你对照:

事件类型 触发源 典型数据 响应要求
触摸事件 触摸屏、触控板 坐标(x,y)、手势类型、压力值 < 20ms,否则卡顿感明显
按键事件 物理按键、矩阵键盘 键值、按下/释放、长按标志 < 50ms,去抖是关键
定时器事件 硬件定时器、软件定时器 定时器ID、超时次数 按周期精度要求,通常1ms~1s

3.2.1 触摸事件

触摸事件是GUI里最频繁、也最头疼的。我曾经在一个项目里,触摸屏的报点率只有60Hz,结果滑动列表时总是一跳一跳的。

触摸事件通常包含:

  • 按下(PRESS)——手指接触屏幕的瞬间
  • 移动(MOVE)——手指在屏幕上滑动
  • 释放(RELEASE)——手指离开屏幕
  • 长按(LONG_PRESS)——按下超过阈值时间

避坑指南:我曾经把触摸中断优先级设得太高,结果导致定时器事件频繁丢失。后来把触摸中断设为中等优先级,用DMA搬运坐标数据,问题才解决。

3.2.2 按键事件

物理按键虽然古老,但在工业设备里依然不可或缺。按键事件的核心问题是——去抖。

我常用的去抖策略:

  1. 硬件去抖——RC滤波 + 施密特触发器,一劳永逸
  2. 软件去抖——采样后延时10~20ms再确认状态
  3. 状态机去抖——用有限状态机处理按下、抖动、稳定、释放
// 按键状态机示例
typedef enum {
    KEY_IDLE,
    KEY_DEBOUNCE,
    KEY_PRESSED,
    KEY_RELEASE
} key_state_t;

key_state_t key_state = KEY_IDLE;

void key_scan(void) {
    uint8_t level = gpio_read(KEY_PIN);
    switch (key_state) {
        case KEY_IDLE:
            if (level == 0) key_state = KEY_DEBOUNCE;
            break;
        case KEY_DEBOUNCE:
            delay_ms(10);  // 等抖动过去
            if (level == 0) {
                key_state = KEY_PRESSED;
                event_queue_push(EVENT_KEY_PRESS, KEY_VALUE_OK);
            } else {
                key_state = KEY_IDLE;
            }
            break;
        // ... 后续状态
    }
}

3.2.3 定时器事件

定时器事件是GUI的“心跳”。动画刷新、光标闪烁、超时检测,都靠它。

我个人习惯把定时器分成两类:

  • 硬件定时器——精度高,用于PWM、精确延时
  • 软件定时器——基于系统滴答,用于GUI周期性任务

嗯,这里要注意:软件定时器的回调函数里不要做耗时操作。我见过有人直接在定时器回调里刷新整个屏幕,结果系统直接卡死。

3.3 事件队列与分发:消息的“快递系统”

事件队列,就是事件的中转站。生产者(中断、任务)往里放,消费者(事件循环)往外取。

我画了张图,帮你理解整个流程:

事件驱动模型核心流程 事件源 触摸事件 按键事件 定时器事件 事件队列 FIFO 先进先出 最大长度: 32/64/128 事件分发器 查找回调表 调用处理函数 事件处理 更新UI / 执行动作 关键设计原则 1. 队列操作要加锁 2. 处理函数不能阻塞

3.3.1 队列的实现要点

事件队列,说白了就是个环形缓冲区。但有几个坑,我踩过:

  • 互斥保护——中断里放事件,主循环里取事件,不加锁会出乱子
  • 队列长度——太短丢事件,太长浪费内存。我一般设32~64个
  • 优先级——触摸事件应该比定时器事件优先处理
// 事件队列结构体
typedef struct {
    event_t buffer[QUEUE_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
    uint16_t count;          // 当前数量
    semaphore_t mutex;       // 互斥信号量
} event_queue_t;

// 放事件(可在中断中调用)
bool event_queue_push(event_queue_t *q, event_t *evt) {
    if (q->count >= QUEUE_SIZE) return false;  // 队列满
    q->buffer[q->head] = *evt;
    q->head = (q->head + 1) % QUEUE_SIZE;
    q->count++;
    return true;
}

我的经验:中断里放事件时,千万别用printf调试。我曾经在触摸中断里打印坐标,结果队列被打印任务占满,触摸事件全丢了。用GPIO翻转示波器看,比printf靠谱得多。

3.3.2 事件分发策略

事件分发,就是根据事件类型找到对应的处理函数。我常用的方式有两种:

方式 原理 优点 缺点
函数指针表 用事件类型做索引,直接调用 速度快,O(1)复杂度 扩展性差,新增类型要改表
注册回调 每个模块注册自己的处理函数 灵活,模块解耦 查找稍慢,管理复杂

我个人偏爱函数指针表——简单、可靠、容易调试。你看:

// 事件分发函数指针表
typedef void (*event_handler_t)(event_t *evt);

static const event_handler_t event_handlers[] = {
    [EVENT_TOUCH_PRESS]   = handle_touch_press,
    [EVENT_TOUCH_MOVE]    = handle_touch_move,
    [EVENT_TOUCH_RELEASE] = handle_touch_release,
    [EVENT_KEY_PRESS]     = handle_key_press,
    [EVENT_KEY_RELEASE]   = handle_key_release,
    [EVENT_TIMER_TICK]    = handle_timer_tick,
};

void event_dispatch(event_t *evt) {
    if (evt->type < EVENT_MAX && event_handlers[evt->type]) {
        event_handlers[evt->type](evt);
    }
}

避坑指南:我曾经在回调函数里直接调用了event_queue_push,结果形成了递归——处理事件A时又产生了事件B,事件B的处理又产生了事件A。最后栈溢出,系统复位。记住:回调里不要产生新事件,或者加个递归深度限制。

3.4 实战中的事件处理策略

理论说完了,聊聊实际项目中怎么用。

我做过一个智能家居面板,触摸、按键、定时器三种事件同时存在。我的处理策略是:

  1. 触摸事件优先级最高——用户交互必须秒级响应
  2. 按键事件次之——物理按键通常用于关键操作
  3. 定时器事件最低——动画、刷新可以稍微延迟

实现时,我用了一个技巧:事件队列里放的是“事件描述符”,而不是完整数据。触摸坐标、按键值这些大数据,放在共享内存里,队列里只放指针。这样队列操作更快,内存占用也更小。

嗯,最后说一句:事件驱动模型看着简单,但真正用好,需要你对系统实时性、内存管理、中断优先级都有深刻理解。别急,后面几章我会把这些坑一个个填上。