4. 事件队列实现:环形缓冲区队列、链表队列、静态与动态队列

事件队列,说白了就是事件的“中转站”。

GUI系统里,按键、触摸、定时器这些事件,都是在中断或不同任务里产生的。你不能在中断里直接处理GUI逻辑,那样太危险。所以,我们需要一个队列,先把事件存起来,等主循环空闲了再一个个拿出来处理。

我个人习惯把事件队列比作一个“水槽”。生产者往里倒水(投递事件),消费者从里面舀水(处理事件)。水槽怎么设计,直接决定了系统的实时性和稳定性。

4.1 环形缓冲区队列

这是嵌入式里最经典的实现方式。说白了,就是一个固定大小的数组,加上两个指针:读指针和写指针。

核心思想:数组是环形的,写指针追着读指针跑。当写指针追上读指针时,队列就满了。

我在项目中遇到过一个问题:一个工业控制面板,按键响应偶尔会“卡一下”。排查了半天,发现是环形队列的“满”和“空”判断条件写反了。嗯,这种低级错误,一旦出问题,定位起来还挺费劲的。

4.1.1 数据结构

typedef struct {
    uint8_t *buffer;    // 数据缓冲区
    uint16_t head;      // 读指针
    uint16_t tail;      // 写指针
    uint16_t size;      // 缓冲区大小(必须是2的幂)
    uint16_t count;     // 当前元素个数(可选)
} RingBuffer_t;

4.1.2 核心操作

操作 实现要点 时间复杂度
初始化 清空指针,设置size O(1)
入队 写数据到tail位置,tail++ O(1)
出队 从head位置读数据,head++ O(1)
判空 head == tail O(1)
判满 (tail + 1) % size == head O(1)

小技巧:如果size是2的幂,取模操作可以用位运算代替:(tail + 1) & (size - 1)。这样能省掉除法指令,在Cortex-M0这种没有硬件除法器的芯片上,性能提升很明显。

4.1.3 环形缓冲区实现代码

// 入队
int8_t RingBuf_Push(RingBuffer_t *rb, uint8_t data) {
    uint16_t next = (rb->tail + 1) & (rb->size - 1);
    if (next == rb->head) {
        return -1;  // 队列满
    }
    rb->buffer[rb->tail] = data;
    rb->tail = next;
    rb->count++;
    return 0;
}

// 出队
int8_t RingBuf_Pop(RingBuffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 队列空
    }
    *data = rb->buffer[rb->head];
    rb->head = (rb->head + 1) & (rb->size - 1);
    rb->count--;
    return 0;
}

注意:环形缓冲区有一个“坑”——你永远无法填满整个数组。因为判满条件需要保留一个空位来区分“满”和“空”。所以实际可用大小是size-1。我曾经因为这个原因,在调试一个触摸屏驱动时,发现事件莫名其妙丢失了,最后才发现是缓冲区大小算错了。

4.2 链表队列

环形缓冲区虽然快,但有个硬伤:大小固定。如果系统里事件类型多、优先级不同,或者你希望队列能动态伸缩,那就得上链表队列了。

你想想看,链表队列就像一串“糖葫芦”,每个节点是一个事件,想吃哪个摘哪个。插入和删除都很灵活。

4.2.1 数据结构

typedef struct EventNode {
    Event_t event;              // 事件数据
    struct EventNode *next;     // 指向下一个节点
} EventNode_t;

typedef struct {
    EventNode_t *head;          // 队首
    EventNode_t *tail;          // 队尾
    uint16_t count;             // 节点数量
} LinkedListQueue_t;

4.2.2 静态链表 vs 动态链表

特性 静态链表 动态链表
内存分配 预分配节点池 运行时malloc/free
内存碎片 无 有(长期运行风险)
最大节点数 固定 受堆大小限制
分配速度 O(1) 不确定(可能较慢)
适用场景 RTOS、安全关键系统 原型开发、内存充裕的系统

我个人习惯在正式产品里用静态链表。为什么?因为malloc在嵌入式里是个“定时炸弹”。你永远不知道它什么时候会返回NULL,或者产生内存碎片。我曾经在一个需要连续运行几个月的设备上,因为用了动态链表,结果三个月后系统崩溃了——堆碎片导致malloc失败。

4.2.3 静态链表实现(节点池方式)

#define MAX_EVENT_NODES  32

static EventNode_t node_pool[MAX_EVENT_NODES];
static EventNode_t *free_list = NULL;

// 初始化节点池
void NodePool_Init(void) {
    for (int i = 0; i < MAX_EVENT_NODES - 1; i++) {
        node_pool[i].next = &node_pool[i + 1];
    }
    node_pool[MAX_EVENT_NODES - 1].next = NULL;
    free_list = &node_pool[0];
}

// 从池中分配节点
EventNode_t* Node_Alloc(void) {
    if (free_list == NULL) return NULL;
    EventNode_t *node = free_list;
    free_list = free_list->next;
    node->next = NULL;
    return node;
}

// 释放节点回池
void Node_Free(EventNode_t *node) {
    node->next = free_list;
    free_list = node;
}

避坑指南:我曾经在一个项目里,节点池大小设成了16,结果系统在高负载下事件溢出。后来我加了一个“溢出计数器”,当节点分配失败时,记录丢失的事件数量。这样调试时就能知道是不是池子太小了。

4.3 环形缓冲区 vs 链表队列 对比

对比维度 环形缓冲区 链表队列
内存占用 固定,连续 分散,有指针开销
入队/出队速度 极快(O(1)) 较快(O(1))
动态扩展 不支持 支持
中断安全性 好(无锁可实现) 一般(需临界区保护)
实现复杂度 低 中
典型应用 按键、触摸事件 多优先级、复杂事件

4.4 事件队列架构图

下面这张图展示了事件从产生到被处理的完整流程,以及两种队列在其中的位置。

事件队列架构图 按键事件 触摸事件 定时器事件 系统事件 事件队列 环形缓冲区 或 链表队列 (静态/动态) FIFO 先进先出 事件分发器 Event Dispatcher 主循环中轮询 事件处理函数 说明: 1. 中断或任务中产生的事件,通过入队接口存入队列 2. 主循环(或专用任务)从队列中取出事件,交给分发器处理 3. 环形缓冲区适合高频、固定大小的事件;链表队列适合复杂、可变大小的事件

4.5 如何选择?我的建议

说实话,没有银弹。选择哪种队列,取决于你的具体场景。

  • 如果你的系统是裸机,事件类型简单(比如只有按键和定时器),用环形缓冲区就够了。它简单、高效、中断安全。
  • 如果你的系统跑RTOS,事件类型多,或者需要优先级处理,用静态链表队列。节点池预分配,既灵活又安全。
  • 如果你在做原型验证,或者内存足够大(比如Cortex-A系列),动态链表也可以,但记得加内存保护机制。

重要提醒:无论用哪种队列,一定要考虑“队列溢出”的情况。事件丢失比系统崩溃更可怕——至少崩溃了你还能看门狗复位,事件丢了用户可能按了按钮没反应,这种问题最难排查。我一般会在队列满时,要么丢弃最旧的事件(覆盖策略),要么直接丢弃新事件(丢弃策略),具体看业务需求。

4.6 实战中的一点经验

最后分享一个我踩过的坑。有一次做智能家居面板,触摸事件用环形缓冲区,但触摸芯片的中断频率很高,主循环处理不过来。结果缓冲区频繁溢出,触摸响应时好时坏。

后来怎么解决的?我做了两件事:

  1. 把环形缓冲区大小从16改成64,给了更多缓冲空间。
  2. 在中断里做了“去抖”和“合并”——短时间内同一个坐标的触摸事件,只保留最后一个。

嗯,从那以后,我再也不敢小看事件队列的设计了。有时候,问题不在队列本身,而在于你怎么用。