3、事件队列:FIFO、优先级队列的实现原理与内存管理

事件队列,说白了就是整个GUI系统的「消息中转站」。所有按键、触摸、定时器到期,都得先扔进这个队列里,再由事件循环一个个取出来处理。我见过不少新手,上来就写全局变量传事件,结果项目一复杂,代码乱成一锅粥。嗯,队列这东西,看着简单,但用不好真的会翻车。

3.1 为什么需要事件队列?

你想想看,嵌入式系统里,中断服务程序(ISR)和主循环是异步的。ISR里不能做复杂操作,比如malloc、刷新屏幕——这些都得放到主循环里做。事件队列就是用来解耦的:ISR只管往队列里塞事件,主循环只管从队列里取事件并处理。

核心价值: 生产者(ISR/任务A)与消费者(主循环/任务B)通过队列解耦,互不阻塞。

我在一个智能家居面板项目里遇到过一个问题:触摸中断里直接调用了绘制函数,结果屏幕闪烁得厉害。后来改成中断里只压事件,主循环里统一处理,问题就解决了。说白了,队列就是给系统加了一层「缓冲垫」。

3.2 FIFO队列:先来后到,公平第一

FIFO(First In First Out)是最基础的事件队列。事件按到达顺序排队,先来的先处理。实现起来很简单,就是一个环形缓冲区。

3.2.1 环形缓冲区实现

// 事件结构体
typedef struct {
    uint8_t type;      // 事件类型:按键、触摸、定时器等
    uint8_t param;     // 参数:键值、坐标等
    uint32_t timestamp; // 时间戳,用于超时判断
} Event_t;

// FIFO队列结构
typedef struct {
    Event_t buffer[EVENT_QUEUE_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
    uint16_t count;          // 当前事件数(可选)
} FIFO_Queue_t;

// 压入事件
int8_t FIFO_Push(FIFO_Queue_t *q, Event_t *ev) {
    uint16_t next = (q->head + 1) % EVENT_QUEUE_SIZE;
    if (next == q->tail) {
        return -1; // 队列满
    }
    q->buffer[q->head] = *ev;
    q->head = next;
    q->count++;
    return 0;
}

// 取出事件
int8_t FIFO_Pop(FIFO_Queue_t *q, Event_t *ev) {
    if (q->head == q->tail) {
        return -1; // 队列空
    }
    *ev = q->buffer[q->tail];
    q->tail = (q->tail + 1) % EVENT_QUEUE_SIZE;
    q->count--;
    return 0;
}

我的习惯: head和tail一定要加volatile,因为中断和主循环会同时访问它们。另外,队列大小建议用2的幂次方,这样取模可以用位运算加速。

3.2.2 FIFO的优缺点

优点 缺点
实现简单,代码量少 无法处理紧急事件
内存占用固定,无碎片 高优先级事件会被低优先级事件阻塞
入队/出队时间复杂度O(1) 不适合实时性要求高的场景

我曾经在一个工业控制器的GUI里只用FIFO,结果发现「急停」按钮按下后,要等前面十几个普通事件处理完才响应。嗯,这显然不行。所以就有了优先级队列。

3.3 优先级队列:紧急事件插队

优先级队列允许高优先级事件「插队」到前面。实现方式有很多种,我挑两种最常用的讲:多级FIFO和堆排序队列。

3.3.1 多级FIFO(最实用)

说白了,就是准备多个FIFO队列,每个队列对应一个优先级。处理时,先看高优先级队列有没有事件,有就处理,没有再看下一级。

// 优先级定义
#define PRIO_HIGH   0
#define PRIO_MED    1
#define PRIO_LOW    2
#define PRIO_COUNT  3

// 多级队列结构
typedef struct {
    FIFO_Queue_t queues[PRIO_COUNT];
} PrioQueue_t;

// 压入事件(带优先级)
int8_t PrioQueue_Push(PrioQueue_t *pq, Event_t *ev, uint8_t prio) {
    if (prio >= PRIO_COUNT) return -1;
    return FIFO_Push(&pq->queues[prio], ev);
}

// 取出事件(按优先级)
int8_t PrioQueue_Pop(PrioQueue_t *pq, Event_t *ev) {
    for (int i = 0; i < PRIO_COUNT; i++) {
        if (FIFO_Pop(&pq->queues[i], ev) == 0) {
            return 0; // 从最高优先级队列取到事件
        }
    }
    return -1; // 所有队列都空
}

注意: 多级FIFO有个坑——如果高优先级事件源源不断,低优先级事件可能永远得不到处理,这叫「饥饿」问题。我建议在高优先级队列里加一个计数限制,比如连续处理10个高优先级事件后,强制处理一个低优先级事件。

3.3.2 堆排序队列(适合事件量大的场景)

如果优先级等级很多(比如16级、32级),多级FIFO就不太合适了,因为要轮询所有队列。这时候可以用最小堆(Min-Heap)来实现优先级队列。每个事件带一个优先级数值,堆顶永远是优先级最高的事件。

// 堆节点
typedef struct {
    Event_t event;
    uint8_t priority; // 值越小优先级越高
} HeapNode_t;

// 堆队列
typedef struct {
    HeapNode_t nodes[HEAP_MAX_SIZE];
    uint16_t size;
} HeapQueue_t;

// 入堆(上浮操作)
void Heap_Push(HeapQueue_t *hq, Event_t *ev, uint8_t prio) {
    uint16_t i = hq->size++;
    hq->nodes[i].event = *ev;
    hq->nodes[i].priority = prio;
    // 上浮:与父节点比较,如果优先级更高则交换
    while (i > 0) {
        uint16_t parent = (i - 1) / 2;
        if (hq->nodes[i].priority >= hq->nodes[parent].priority) break;
        // 交换
        HeapNode_t tmp = hq->nodes[i];
        hq->nodes[i] = hq->nodes[parent];
        hq->nodes[parent] = tmp;
        i = parent;
    }
}

// 出堆(下沉操作)
int8_t Heap_Pop(HeapQueue_t *hq, Event_t *ev) {
    if (hq->size == 0) return -1;
    *ev = hq->nodes[0].event;
    hq->nodes[0] = hq->nodes[--hq->size];
    // 下沉:与子节点中优先级更高的交换
    uint16_t i = 0;
    while (1) {
        uint16_t smallest = i;
        uint16_t left = 2 * i + 1;
        uint16_t right = 2 * i + 2;
        if (left < hq->size && hq->nodes[left].priority < hq->nodes[smallest].priority)
            smallest = left;
        if (right < hq->size && hq->nodes[right].priority < hq->nodes[smallest].priority)
            smallest = right;
        if (smallest == i) break;
        HeapNode_t tmp = hq->nodes[i];
        hq->nodes[i] = hq->nodes[smallest];
        hq->nodes[smallest] = tmp;
        i = smallest;
    }
    return 0;
}

我的建议: 对于大多数嵌入式GUI,3级优先级(高、中、低)就够用了。堆排序虽然灵活,但入队/出队是O(log n),而且代码量大,内存占用也高。除非你的系统有10级以上优先级,否则用多级FIFO更划算。

3.4 内存管理:别让队列吃掉你的RAM

嵌入式系统的RAM是寸土寸金。事件队列的内存管理,我总结了几条铁律:

  1. 静态分配,绝不动态:队列缓冲区在编译时就定好大小,绝不用malloc。中断里malloc?那是找死。
  2. 事件结构体要精简:能用一个uint8_t传的参数,就别用结构体。我见过有人把整个触摸坐标结构体(几十个字节)塞进事件里,其实只需要x和y两个short。
  3. 队列深度要算好:根据系统最大事件产生速率和处理速率来算。一般GUI系统,32~64个事件深度就够用了。
场景 推荐队列深度 说明
简单按键菜单 8~16 用户操作频率低
触摸屏+动画 32~64 触摸事件+定时器事件较多
多任务+外设中断 64~128 多个事件源同时产生

我曾经踩过的坑: 在一个智能手表项目里,我把队列深度设成了128,每个事件结构体32字节,结果队列占了4KB RAM。对于只有64KB RAM的MCU来说,这太奢侈了。后来我把事件结构体压缩到8字节,队列深度降到32,只用了256字节。嗯,够用就好,别贪多。

3.5 事件队列的核心流程

下面这张图展示了事件从产生到被处理的完整路径:

事件队列核心流程图 事件队列核心流程 事件源 ISR / 任务 事件队列 FIFO / 优先级队列 事件循环 主循环中轮询 事件处理函数 说明: 事件源在ISR或任务中产生事件, 通过队列传递给主循环,实现异步解耦。

3.6 避坑指南

  • 队列溢出怎么办? 我习惯的做法是:如果队列满了,直接丢弃最旧的事件(覆盖tail位置),而不是丢弃新事件。因为旧事件可能已经过时了,比如一个过期的按键事件。
  • 中断里操作队列要小心:如果中断和主循环同时访问队列,一定要关中断或使用原子操作。否则会出现「读了一半,被中断写乱了」的情况。
  • 优先级反转:在多级FIFO中,如果高优先级事件处理函数依赖低优先级事件的结果,就会死锁。我曾经在一个项目里遇到过,后来加了个超时机制才解决。

最后说一句: 事件队列是GUI系统的「心脏」,选FIFO还是优先级队列,取决于你的实时性要求。对于大多数消费类产品,3级FIFO就足够了。别为了炫技用堆排序,简单才是王道。