指针与队列:用指针模拟循环队列

队列这东西,说白了就是个先进先出的数据结构。你想想看,排队买奶茶,先来的人先拿到,后来的人排后面——这就是队列。在嵌入式开发里,队列用得特别多,比如串口数据缓冲、按键事件缓存、任务调度等等。

我个人习惯用循环队列,而不是普通队列。为什么?因为普通队列用着用着,头指针往后移,前面的空间就浪费了。循环队列把数组的头尾连起来,像钟表一样转圈,空间利用率高得多。

队列的基本概念

队列有两个关键操作:入队(enqueue)和出队(dequeue)。入队就是在队尾加数据,出队就是从队头取数据。我们需要维护两个指针:

  • 头指针(front):指向队头元素
  • 尾指针(rear):指向队尾的下一个位置

用数组实现时,这两个指针其实是数组下标。循环队列的关键在于:当指针走到数组末尾时,让它绕回开头。

核心公式(rear + 1) % MAX_SIZE 就是尾指针的下一个位置。

这个取模运算,就是循环的精髓。

用指针实现循环队列

我习惯把队列封装成一个结构体,这样用起来清爽。来看代码:

#define MAX_QUEUE_SIZE 10

typedef struct {
    int data[MAX_QUEUE_SIZE];
    int front;  // 队头下标
    int rear;   // 队尾下标(指向下一个空位)
} CircularQueue;

// 初始化队列
void initQueue(CircularQueue *q) {
    q->front = 0;
    q->rear = 0;
}

// 判断队列是否为空
int isEmpty(CircularQueue *q) {
    return q->front == q->rear;
}

// 判断队列是否已满
int isFull(CircularQueue *q) {
    return (q->rear + 1) % MAX_QUEUE_SIZE == q->front;
}

// 入队
int enqueue(CircularQueue *q, int value) {
    if (isFull(q)) {
        return -1;  // 队列满了
    }
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
    return 0;
}

// 出队
int dequeue(CircularQueue *q, int *value) {
    if (isEmpty(q)) {
        return -1;  // 队列空了
    }
    *value = q->data[q->front];
    q->front = (q->front + 1) % MAX_QUEUE_SIZE;
    return 0;
}

注意看,这里我故意留了一个空位。也就是说,数组最多存 MAX_QUEUE_SIZE - 1 个元素。为什么?因为如果存满,rear 绕一圈追上 front,那就和空队列的状态一样了,没法区分。

避坑指南:我曾经在项目里犯过这个错——把数组填满了,结果队列空和满的状态判断混在一起,调试了一下午才发现。后来我学乖了,要么留一个空位,要么加一个 size 字段记录元素个数。

循环队列的状态判断

循环队列有四种状态,我画了张图帮你理解:

循环队列四种状态 空队列 [ ] [ ] [ ] [ ] [ ] front → rear → 有元素(未满) [A] [B] [C] [ ] [ ] front → rear → 满队列(留空位) [A] [B] [C] [D] [ ] front → rear → 循环绕回 [E] [ ] [C] [D] [ ] front → rear → 判断条件: 空:front == rear 满:(rear + 1) % MAX_SIZE == front 循环队列的指针移动 索引0 索引1 索引2 索引3 索引4 索引5 索引6 指针绕圈,取模运算实现循环

指针操作的核心技巧

用指针模拟队列,其实就是在操作数组下标。但如果你用真正的指针(指向数组元素的指针),写法会略有不同。我个人更喜欢用下标,因为可读性更好,尤其是在嵌入式调试时,看下标值比看地址值直观得多。

不过,如果你非要玩指针,也可以这样写:

typedef struct {
    int data[MAX_QUEUE_SIZE];
    int *front;  // 指向队头元素
    int *rear;   // 指向队尾下一个位置
} QueuePtr;

void initQueuePtr(QueuePtr *q) {
    q->front = q->data;
    q->rear = q->data;
}

int enqueuePtr(QueuePtr *q, int value) {
    // 判断是否满:rear + 1 是否绕回 front
    int *nextRear = q->rear + 1;
    if (nextRear >= q->data + MAX_QUEUE_SIZE) {
        nextRear = q->data;  // 绕回开头
    }
    if (nextRear == q->front) {
        return -1;  // 满了
    }
    *(q->rear) = value;
    q->rear = nextRear;
    return 0;
}

嗯,这里要注意:指针加减时,编译器会自动根据类型计算偏移量。比如 int *pp + 1 实际地址加的是 4 字节(假设 int 是 4 字节)。这个特性用好了很方便,但搞错了也容易出 bug。

我的建议:在嵌入式开发中,如果队列大小不大(比如几十个元素),用下标法就够了。指针法虽然看起来更「高级」,但调试时多了一层间接性,反而容易晕。我一般在写通用库时才会用指针法。

实际应用场景

循环队列在嵌入式里最常见的应用就是串口接收缓冲。我记得有一次做物联网项目,MCU 通过 UART 接收传感器数据,每秒来几百个字节。如果用普通队列,头指针一直往后移,很快数组就满了,但前面的数据其实已经处理完了——这就是典型的空间浪费。

换成循环队列后,数据来了就往后存,处理完了头指针往前移,空间循环利用。配合中断,入队操作在中断里完成,出队操作在主循环里完成,完美解耦。

场景 队列大小 入队频率 出队频率
串口接收缓冲 64~256 字节 中断触发(随机) 主循环轮询
按键事件队列 10~20 个事件 按键中断 任务处理
任务调度队列 取决于任务数 任务创建时 调度器运行时

你看,队列大小其实不需要很大。循环队列的好处就是:只要平均处理速度跟得上入队速度,队列就不会满。这就像你家的垃圾桶,只要每天倒一次,桶再小也够用。

避坑指南:我曾经在项目里把队列大小设成 2 的幂次(比如 16、32、64),然后用位运算代替取模——(rear + 1) & (SIZE - 1)。这样确实快一点,但前提是 SIZE 必须是 2 的幂次。如果你随便设个 10、20,位运算就失效了。所以,要么老老实实用取模,要么确保 SIZE 是 2 的幂次。

好了,循环队列的核心就这些。说白了就是:一个数组,两个指针,一个取模运算。你想想看,是不是很简单?

公众号:蓝海资料掘金营,微信deep3321