3、事件驱动模型:事件循环机制、事件类型分类(触摸、按键、定时器)、事件队列与分发
嵌入式GUI的核心,说白了就是“等事件、处理事件、再等下一个事件”。
我刚入行时,总想着用while大循环轮询所有外设——结果CPU占用率飙到90%,触摸屏还经常丢点。后来才明白,事件驱动才是嵌入式GUI的正解。
3.1 事件循环机制:GUI的“心脏”
事件循环,就是那个永不停止的while(1)。但它不是傻等,而是有策略地“休眠-唤醒”。
核心思想:CPU只在有事件发生时工作,没事就睡大觉。这对电池供电的设备尤其重要。
我习惯把事件循环拆成三步:
- 获取事件——从队列头部取出一个待处理事件
- 分发事件——根据事件类型,找到对应的处理函数
- 处理事件——执行回调,更新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 按键事件
物理按键虽然古老,但在工业设备里依然不可或缺。按键事件的核心问题是——去抖。
我常用的去抖策略:
- 硬件去抖——RC滤波 + 施密特触发器,一劳永逸
- 软件去抖——采样后延时10~20ms再确认状态
- 状态机去抖——用有限状态机处理按下、抖动、稳定、释放
// 按键状态机示例
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 事件队列与分发:消息的“快递系统”
事件队列,就是事件的中转站。生产者(中断、任务)往里放,消费者(事件循环)往外取。
我画了张图,帮你理解整个流程:
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 实战中的事件处理策略
理论说完了,聊聊实际项目中怎么用。
我做过一个智能家居面板,触摸、按键、定时器三种事件同时存在。我的处理策略是:
- 触摸事件优先级最高——用户交互必须秒级响应
- 按键事件次之——物理按键通常用于关键操作
- 定时器事件最低——动画、刷新可以稍微延迟
实现时,我用了一个技巧:事件队列里放的是“事件描述符”,而不是完整数据。触摸坐标、按键值这些大数据,放在共享内存里,队列里只放指针。这样队列操作更快,内存占用也更小。
嗯,最后说一句:事件驱动模型看着简单,但真正用好,需要你对系统实时性、内存管理、中断优先级都有深刻理解。别急,后面几章我会把这些坑一个个填上。