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是寸土寸金。事件队列的内存管理,我总结了几条铁律:
- 静态分配,绝不动态:队列缓冲区在编译时就定好大小,绝不用malloc。中断里malloc?那是找死。
- 事件结构体要精简:能用一个uint8_t传的参数,就别用结构体。我见过有人把整个触摸坐标结构体(几十个字节)塞进事件里,其实只需要x和y两个short。
- 队列深度要算好:根据系统最大事件产生速率和处理速率来算。一般GUI系统,32~64个事件深度就够用了。
| 场景 | 推荐队列深度 | 说明 |
|---|---|---|
| 简单按键菜单 | 8~16 | 用户操作频率低 |
| 触摸屏+动画 | 32~64 | 触摸事件+定时器事件较多 |
| 多任务+外设中断 | 64~128 | 多个事件源同时产生 |
我曾经踩过的坑: 在一个智能手表项目里,我把队列深度设成了128,每个事件结构体32字节,结果队列占了4KB RAM。对于只有64KB RAM的MCU来说,这太奢侈了。后来我把事件结构体压缩到8字节,队列深度降到32,只用了256字节。嗯,够用就好,别贪多。
3.5 事件队列的核心流程
下面这张图展示了事件从产生到被处理的完整路径:
3.6 避坑指南
- 队列溢出怎么办? 我习惯的做法是:如果队列满了,直接丢弃最旧的事件(覆盖tail位置),而不是丢弃新事件。因为旧事件可能已经过时了,比如一个过期的按键事件。
- 中断里操作队列要小心:如果中断和主循环同时访问队列,一定要关中断或使用原子操作。否则会出现「读了一半,被中断写乱了」的情况。
- 优先级反转:在多级FIFO中,如果高优先级事件处理函数依赖低优先级事件的结果,就会死锁。我曾经在一个项目里遇到过,后来加了个超时机制才解决。
最后说一句: 事件队列是GUI系统的「心脏」,选FIFO还是优先级队列,取决于你的实时性要求。对于大多数消费类产品,3级FIFO就足够了。别为了炫技用堆排序,简单才是王道。