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 事件队列架构图
下面这张图展示了事件从产生到被处理的完整流程,以及两种队列在其中的位置。
4.5 如何选择?我的建议
说实话,没有银弹。选择哪种队列,取决于你的具体场景。
- 如果你的系统是裸机,事件类型简单(比如只有按键和定时器),用环形缓冲区就够了。它简单、高效、中断安全。
- 如果你的系统跑RTOS,事件类型多,或者需要优先级处理,用静态链表队列。节点池预分配,既灵活又安全。
- 如果你在做原型验证,或者内存足够大(比如Cortex-A系列),动态链表也可以,但记得加内存保护机制。
重要提醒:无论用哪种队列,一定要考虑“队列溢出”的情况。事件丢失比系统崩溃更可怕——至少崩溃了你还能看门狗复位,事件丢了用户可能按了按钮没反应,这种问题最难排查。我一般会在队列满时,要么丢弃最旧的事件(覆盖策略),要么直接丢弃新事件(丢弃策略),具体看业务需求。
4.6 实战中的一点经验
最后分享一个我踩过的坑。有一次做智能家居面板,触摸事件用环形缓冲区,但触摸芯片的中断频率很高,主循环处理不过来。结果缓冲区频繁溢出,触摸响应时好时坏。
后来怎么解决的?我做了两件事:
- 把环形缓冲区大小从16改成64,给了更多缓冲空间。
- 在中断里做了“去抖”和“合并”——短时间内同一个坐标的触摸事件,只保留最后一个。
嗯,从那以后,我再也不敢小看事件队列的设计了。有时候,问题不在队列本身,而在于你怎么用。