事件队列(Event Queue)
把"事情发生了"和"处理这件事"分开,中间用队列缓冲。
嵌入式里最常用的解耦手段之一。
一、为什么需要它
没有队列时
中断 / 回调
↓ 直接执行动作
问题①:执行时上下文不完整(不知道后续还有没有相关事件)
问题②:在中断里执行耗时操作,阻塞系统
问题③:事件来得太快,处理不过来,直接丢失
有队列时
中断 / 回调 主循环
↓ ↓
入队(快,几微秒) 出队 → 处理(慢,多久都行)
↓
立刻返回
两件事完全解耦,互不阻塞。
二、队列的两种形态
形态①:pending 数组(容量 = 1 帧)
就是我们在 ebtn 组合键里用的方案,适合"同一帧内积累、帧末统一派发"。
static struct {
uint8_t fired;
uint16_t click_cnt;
} pending[KEY_COUNT];
// 帧内:只存
void on_event(ebtn_btn_t *btn, ebtn_evt_t evt) {
if (evt == EBTN_EVT_ONCLICK)
pending[btn->key_id].fired = 1;
pending[btn->key_id].click_cnt = ebtn_click_get_count(btn);
}
// 帧末:统一派发
void dispatch(void) {
if (pending[COMBO].fired) {
handle_combo();
pending[BTN0].fired = pending[BTN1].fired = pending[COMBO].fired = 0;
return;
}
if (pending[BTN0].fired) { handle_btn0(); pending[BTN0].fired = 0; }
if (pending[BTN1].fired) { handle_btn1(); pending[BTN1].fired = 0; }
}
局限:每个槽只存 1 个事件,同一帧同一键触发两次会覆盖。
形态②:环形缓冲区(Ring Buffer)
可以跨帧积累,是"真正的"事件队列。
队列内存:[ e0 | e1 | e2 | e3 | e4 | e5 | e6 | e7 ]
↑head ↑tail
head → 下一个出队位置
tail → 下一个入队位置
空条件:head == tail
满条件:(tail + 1) % SIZE == head
实现:
#define QUEUE_SIZE 8 // 必须是 2 的幂,方便取模
typedef struct {
uint16_t key_id;
uint8_t click_cnt;
uint8_t evt;
} key_event_t;
static key_event_t queue[QUEUE_SIZE];
static uint8_t head = 0;
static uint8_t tail = 0;
// 队满判断
static inline uint8_t queue_full(void) {
return ((tail + 1) % QUEUE_SIZE) == head;
}
// 队空判断
static inline uint8_t queue_empty(void) {
return head == tail;
}
// 入队(在回调里调用,要求快速返回)
uint8_t enqueue(uint16_t key_id, uint8_t evt, uint8_t cnt) {
if (queue_full()) return 0; // 队满丢弃,返回失败
queue[tail].key_id = key_id;
queue[tail].evt = evt;
queue[tail].click_cnt = cnt;
tail = (tail + 1) % QUEUE_SIZE;
return 1;
}
// 出队(在主循环里调用)
uint8_t dequeue(key_event_t *out) {
if (queue_empty()) return 0;
*out = queue[head];
head = (head + 1) % QUEUE_SIZE;
return 1;
}
接入 ebtn:
void on_event(ebtn_btn_t *btn, ebtn_evt_t evt) {
enqueue(btn->key_id, evt, ebtn_click_get_count(btn));
// 回调立刻返回,不做任何业务逻辑
}
void app_process(void) {
key_event_t e;
while (dequeue(&e)) {
if (e.evt != EBTN_EVT_ONCLICK) continue;
switch (e.key_id) {
case BTN0: handle_btn0(e.click_cnt); break;
case BTN1: handle_btn1(e.click_cnt); break;
case COMBO: handle_combo(e.click_cnt); break;
}
}
}
// 主循环
while (1) {
ebtn_process(HAL_GetTick());
app_process();
HAL_Delay(5);
}
三、环形缓冲区图解
初始状态(空):
[ _ | _ | _ | _ | _ | _ | _ | _ ]
↑head=0
↑tail=0
入队 e0、e1、e2 后:
[ e0| e1| e2| _ | _ | _ | _ | _ ]
↑head=0 ↑tail=3
出队 e0 后:
[ -- | e1| e2| _ | _ | _ | _ | _ ]
↑head=1 ↑tail=3
入队到末尾,tail 回绕到开头(环形):
[ e7| e1| e2| e3| e4| e5| e6| _ ]
↑tail=1 ↑head=1
此时 tail==head,代表满(实际只用 SIZE-1 个槽防止歧义)
四、关键设计决策
队满时怎么办?
| 策略 | 适用场景 |
|---|---|
| 丢弃新事件(最常用) | 按键、UI 输入,偶发丢失可接受 |
| 覆盖最旧事件 | 传感器数据,只关心最新值 |
| 阻塞等待(RTOS 才用) | 不能丢数据,有任务调度支持 |
裸机嵌入式通常选丢弃新事件,简单可靠。
SIZE 为什么选 2 的幂?
取模运算 % SIZE 可以换成位与 & (SIZE-1),编译器优化成单条指令:
tail = (tail + 1) & (QUEUE_SIZE - 1); // 比 % 快
中断安全
如果入队在中断里调用,出队在主循环里,存在竞争。裸机的最小保护:
uint8_t enqueue(...) {
__disable_irq(); // 关中断
// ... 入队操作 ...
__enable_irq(); // 开中断
return 1;
}
或者把 head/tail 声明为 volatile,并确保读写是原子的(8位MCU 上 uint8_t 天然原子)。
五、与 pending 的对比
| pending 数组 | 环形缓冲区 | |
|---|---|---|
| 容量 | 每键 1 个事件 | N 个事件(跨帧) |
| 实现复杂度 | 极简 | 稍复杂 |
| 跨帧积累 | 不支持 | 支持 |
| 适用场景 | 同帧决策(如组合键) | 通用事件派发 |
按键场景两种都够用。系统越复杂(多模块、多事件源),越需要环形缓冲区。
六、在哪能看到这个 pattern
| 系统 | 实现位置 |
|---|---|
| LVGL | lv_indev,输入设备事件缓冲 |
| FreeRTOS | xQueueSend / xQueueReceive,消息队列 |
| Linux 内核 | kfifo,通用环形缓冲区 |
| Arduino | HardwareSerial 的 RX 缓冲区 |
| 本项目 ebtn | 自己加的 pending[] / queue[] |
核心结构完全一样,只是包装不同。