指针与队列:用指针模拟循环队列
队列这东西,说白了就是个先进先出的数据结构。你想想看,排队买奶茶,先来的人先拿到,后来的人排后面——这就是队列。在嵌入式开发里,队列用得特别多,比如串口数据缓冲、按键事件缓存、任务调度等等。
我个人习惯用循环队列,而不是普通队列。为什么?因为普通队列用着用着,头指针往后移,前面的空间就浪费了。循环队列把数组的头尾连起来,像钟表一样转圈,空间利用率高得多。
队列的基本概念
队列有两个关键操作:入队(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 字段记录元素个数。
循环队列的状态判断
循环队列有四种状态,我画了张图帮你理解:
指针操作的核心技巧
用指针模拟队列,其实就是在操作数组下标。但如果你用真正的指针(指向数组元素的指针),写法会略有不同。我个人更喜欢用下标,因为可读性更好,尤其是在嵌入式调试时,看下标值比看地址值直观得多。
不过,如果你非要玩指针,也可以这样写:
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 *p,p + 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 的幂次。
好了,循环队列的核心就这些。说白了就是:一个数组,两个指针,一个取模运算。你想想看,是不是很简单?