栈与队列模块:栈的数组实现与链表实现、队列的环形缓冲区实现

说实话,栈和队列这两个数据结构,是嵌入式系统里最基础也最实用的东西。我做了十几年嵌入式开发,几乎每个项目都离不开它们。今天咱们就把它们彻底讲透。

栈:后进先出的艺术

栈这个东西,说白了就是一个「后进先出」的容器。你想想看,就像一摞盘子,你最后放上去的,总是最先被拿走。在嵌入式里,函数调用、中断嵌套、表达式求值,全都要靠它。

栈的数组实现

数组实现栈,是最直接的方式。我个人的习惯是,只要栈的最大深度能提前确定,就用数组。为什么?因为简单、高效、没有动态内存分配的开销。

#include <stdint.h>
#include <stdbool.h>

#define STACK_MAX_SIZE 64

typedef struct {
    int32_t data[STACK_MAX_SIZE];
    int32_t top;  // 栈顶索引,-1 表示空栈
} Stack_Array;

void stack_init(Stack_Array *s) {
    s->top = -1;
}

bool stack_push(Stack_Array *s, int32_t value) {
    if (s->top >= STACK_MAX_SIZE - 1) {
        return false;  // 栈满
    }
    s->data[++s->top] = value;
    return true;
}

bool stack_pop(Stack_Array *s, int32_t *value) {
    if (s->top < 0) {
        return false;  // 栈空
    }
    *value = s->data[s->top--];
    return true;
}

bool stack_peek(Stack_Array *s, int32_t *value) {
    if (s->top < 0) {
        return false;
    }
    *value = s->data[s->top];
    return true;
}

bool stack_is_empty(Stack_Array *s) {
    return s->top == -1;
}

我的经验:数组实现栈时,top 初始化为 -1 还是 0,很多人搞混。我习惯用 -1,这样 push 时先 ++top 再赋值,逻辑上更自然。曾经有个同事用 0 初始化,结果 push 第一个元素时直接覆盖了 data[0],排查了半天。

栈的链表实现

如果栈的最大深度不确定,或者你需要频繁地创建和销毁栈,那就用链表实现。不过要注意,链表有动态内存分配的开销,在资源紧张的 MCU 上要慎用。

#include <stdlib.h>

typedef struct Node {
    int32_t data;
    struct Node *next;
} Node;

typedef struct {
    Node *top;  // 栈顶指针
} Stack_List;

void stack_list_init(Stack_List *s) {
    s->top = NULL;
}

bool stack_list_push(Stack_List *s, int32_t value) {
    Node *new_node = (Node *)malloc(sizeof(Node));
    if (!new_node) {
        return false;  // 内存分配失败
    }
    new_node->data = value;
    new_node->next = s->top;
    s->top = new_node;
    return true;
}

bool stack_list_pop(Stack_List *s, int32_t *value) {
    if (!s->top) {
        return false;  // 栈空
    }
    Node *temp = s->top;
    *value = temp->data;
    s->top = temp->next;
    free(temp);
    return true;
}

注意:链表实现栈时,每次 push 都要 malloc,每次 pop 都要 free。在中断服务函数里千万别这么干——malloc 不是线程安全的,而且可能引起内存碎片。我曾经在一个实时系统里吃过这个亏,后来全部改成了数组实现。

队列:先进先出的哲学

队列和栈正好相反,它是「先进先出」的。就像排队买票,先来的人先买到。在嵌入式里,任务调度、数据缓冲、消息传递,队列无处不在。

环形缓冲区:最优雅的队列实现

用数组实现队列,最头疼的就是「假溢出」问题——明明数组前面还有空位,但 rear 已经走到头了。环形缓冲区完美解决了这个问题。说白了,就是把数组的头尾连起来,形成一个环。

#define QUEUE_SIZE 16

typedef struct {
    int32_t buffer[QUEUE_SIZE];
    uint32_t head;  // 读指针
    uint32_t tail;  // 写指针
    uint32_t count; // 当前元素个数
} RingBuffer;

void rb_init(RingBuffer *q) {
    q->head = 0;
    q->tail = 0;
    q->count = 0;
}

bool rb_enqueue(RingBuffer *q, int32_t value) {
    if (q->count >= QUEUE_SIZE) {
        return false;  // 队列满
    }
    q->buffer[q->tail] = value;
    q->tail = (q->tail + 1) % QUEUE_SIZE;
    q->count++;
    return true;
}

bool rb_dequeue(RingBuffer *q, int32_t *value) {
    if (q->count == 0) {
        return false;  // 队列空
    }
    *value = q->buffer[q->head];
    q->head = (q->head + 1) % QUEUE_SIZE;
    q->count--;
    return true;
}

bool rb_is_full(RingBuffer *q) {
    return q->count == QUEUE_SIZE;
}

bool rb_is_empty(RingBuffer *q) {
    return q->count == 0;
}

关键点:环形缓冲区里,head 和 tail 的移动都用取模运算 (index + 1) % SIZE。这样当指针走到数组末尾时,自动回到开头。我见过有人用 if 判断来重置指针,其实取模运算更简洁,而且现代编译器会优化成位运算,效率很高。

实战:设计一个任务调度队列

好了,理论讲完了,咱们来点实战。假设你要设计一个简单的任务调度器,任务以函数指针的形式存在,按顺序执行。这就是队列的典型应用。

#include <stdint.h>
#include <stdbool.h>

#define TASK_QUEUE_SIZE 32

// 任务类型:无参数、无返回值的函数指针
typedef void (*TaskFunc)(void);

typedef struct {
    TaskFunc tasks[TASK_QUEUE_SIZE];
    uint32_t head;
    uint32_t tail;
    uint32_t count;
} TaskQueue;

void task_queue_init(TaskQueue *q) {
    q->head = 0;
    q->tail = 0;
    q->count = 0;
}

bool task_queue_add(TaskQueue *q, TaskFunc task) {
    if (q->count >= TASK_QUEUE_SIZE) {
        return false;  // 任务队列满了
    }
    q->tasks[q->tail] = task;
    q->tail = (q->tail + 1) % TASK_QUEUE_SIZE;
    q->count++;
    return true;
}

bool task_queue_execute_next(TaskQueue *q) {
    if (q->count == 0) {
        return false;  // 没有待执行的任务
    }
    TaskFunc task = q->tasks[q->head];
    q->head = (q->head + 1) % TASK_QUEUE_SIZE;
    q->count--;
    
    if (task) {
        task();  // 执行任务
    }
    return true;
}

// 调度主循环:不断从队列中取出任务执行
void task_scheduler_run(TaskQueue *q) {
    while (1) {
        if (!task_queue_execute_next(q)) {
            // 队列空了,可以进入低功耗模式或做其他事情
            break;
        }
    }
}

避坑指南:我曾经在一个物联网网关项目里,用这个模式来调度网络数据包的处理任务。一开始没加队列满的保护,结果某个传感器疯狂发数据,直接把任务队列撑爆了,系统死机。从那以后,我所有的队列实现都加了满/空检查,而且会在队列快满时触发告警。

知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以看到栈和队列的两种实现方式,以及它们在任务调度中的具体应用。

栈与队列知识体系 栈(后进先出) 数组实现 固定大小 无动态分配 链表实现 动态大小 需malloc/free 队列(先进先出) 环形缓冲区实现 head/tail指针 + 取模运算 解决假溢出问题 实战:任务调度队列 任务入队 task_queue_add() 任务出队执行 task_queue_execute_next() 调度主循环 task_scheduler_run()

嗯,到这里,栈和队列的核心内容就讲完了。数组实现和链表实现各有优劣,环形缓冲区是队列的最佳实践。任务调度队列这个例子,你可以在很多嵌入式 RTOS 里看到类似的影子。下次写代码时,不妨想想:这个场景用栈还是队列?用数组还是链表?选对了,代码会干净很多。