事件队列(Event Queue)

事件队列(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[]

核心结构完全一样,只是包装不同。