4. FIFO基本操作:初始化、入队(push)、出队(pop)、查看队首(peek)

好,咱们接着聊。上一节我们把FIFO的接口和内部结构理清楚了,现在该动手了。这一节,我带你实现FIFO最核心的四个操作:初始化、入队、出队和查看队首。

说白了,这四个操作就是FIFO的“呼吸”和“心跳”。你只要掌握了它们,一个能用的FIFO基本就成型了。我个人习惯,写代码前先画个流程图,把逻辑走通,再动手敲键盘。

核心逻辑一句话: 用两个指针(写指针wp、读指针rp)在环形缓冲区里转圈圈。写数据时wp往前走,读数据时rp往前走。空和满的判断,就看这两个指针的关系。

FIFO 核心操作流程图 初始化 入队 (push) 出队 (pop) 查看队首 (peek) wp=0, rp=0 检查是否满 检查是否空 只读不删,指针不动 环形缓冲区 (数组实现)

4.1 初始化操作

初始化,就是把FIFO恢复到出厂状态。嗯,这里要注意,初始化不是把数据清零,而是把读写指针归零。

为什么?因为FIFO里的数据是“覆盖式”写入的。你只要把指针复位,下次写入时自然会把旧数据覆盖掉。我在项目中遇到过有人初始化时用memset把整个缓冲区清空,结果在256字节的FIFO上浪费了2ms。其实完全没必要。

// FIFO初始化
void fifo_init(fifo_t *fifo) {
    fifo->wp = 0;  // 写指针归零
    fifo->rp = 0;  // 读指针归零
    // 注意:不需要清空buffer数组!
    // 下次写入时会自动覆盖旧数据
}

小技巧: 如果你用的是硬件FIFO,初始化时记得把复位信号拉高至少一个时钟周期。我见过有人只拉高半个周期,结果指针没复位干净,数据全乱了。

4.2 入队操作(push)

入队,就是把数据塞进FIFO。你想想看,往一个杯子里倒水,得先看看杯子满了没有。入队也一样,第一步永远是检查是否已满。

判断满的条件:(wp + 1) % SIZE == rp。为什么是加1?因为我们要留一个“空位”来区分空和满。如果wp == rp表示空,那满的时候就不能让wp追上rp,得留一个间隔。

// 入队操作
int fifo_push(fifo_t *fifo, uint8_t data) {
    // 第一步:检查是否已满
    if (fifo_is_full(fifo)) {
        return -1;  // FIFO已满,入队失败
    }
    
    // 第二步:写入数据
    fifo->buffer[fifo->wp] = data;
    
    // 第三步:更新写指针(环形移动)
    fifo->wp = (fifo->wp + 1) % FIFO_SIZE;
    
    return 0;  // 入队成功
}

我曾经在调试一个串口驱动时,发现数据总是丢包。查了半天,原来是入队时忘了检查满标志。数据写进去,但指针没更新,后面的数据把前面的覆盖了。从那以后,我写push函数时都会在脑子里过一遍:先检查,再写入,最后更新指针。

避坑指南: 我曾经在中断服务函数里直接调用fifo_push,结果中断嵌套时把FIFO写爆了。后来我加了临界区保护,问题才解决。记住:多任务环境下,入队操作需要加锁。

4.3 出队操作(pop)

出队,就是把数据从FIFO里取出来。和入队相反,出队前得先看看队列是不是空的。

判断空的条件:wp == rp。这个简单,两个指针碰头了,说明没数据了。

// 出队操作
int fifo_pop(fifo_t *fifo, uint8_t *data) {
    // 第一步:检查是否为空
    if (fifo_is_empty(fifo)) {
        return -1;  // FIFO为空,出队失败
    }
    
    // 第二步:读取数据
    *data = fifo->buffer[fifo->rp];
    
    // 第三步:更新读指针(环形移动)
    fifo->rp = (fifo->rp + 1) % FIFO_SIZE;
    
    return 0;  // 出队成功
}

这里有个细节:出队时,我们不需要清除buffer里的旧数据。因为下次写入时会自动覆盖。我刚开始写代码时,总喜欢把读过的数据清零,觉得这样“干净”。后来发现这完全是画蛇添足,还多了一次内存写操作。

操作 检查条件 指针变化 数据变化
入队 (push) 检查是否满 wp 增加 写入新数据
出队 (pop) 检查是否空 rp 增加 读取旧数据
查看队首 (peek) 检查是否空 指针不变 只读不删

4.4 查看队首(peek)

peek操作,说白了就是“只看不拿”。它和pop的区别在于:pop会删除数据,peek不会。

这个操作在协议解析时特别有用。比如你收到一个数据包,想先看看包头判断是什么类型,但又不想把数据取走。这时候peek就派上用场了。

// 查看队首元素
int fifo_peek(fifo_t *fifo, uint8_t *data) {
    // 检查是否为空
    if (fifo_is_empty(fifo)) {
        return -1;  // FIFO为空,无法查看
    }
    
    // 读取数据,但不移动读指针
    *data = fifo->buffer[fifo->rp];
    
    return 0;  // 查看成功
}

重点: peek操作不移动读指针,所以可以多次调用peek,每次都会返回同一个数据。直到你调用pop,数据才会被“消费”掉。

我记得有一次做网络协议栈,需要先peek一下看是不是ARP包,是的话直接处理,不是的话再pop出来继续解析。如果没有peek,就得先把数据pop出来,判断完再塞回去——那多麻烦啊。

4.5 完整示例:一个简单的FIFO驱动

好了,四个基本操作都讲完了。咱们把它们拼起来,就是一个完整的FIFO驱动。我习惯把代码写得规整一点,这样以后维护起来省心。

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

#define FIFO_SIZE 16

typedef struct {
    uint8_t buffer[FIFO_SIZE];
    uint8_t wp;  // 写指针
    uint8_t rp;  // 读指针
} fifo_t;

// 初始化
void fifo_init(fifo_t *fifo) {
    fifo->wp = 0;
    fifo->rp = 0;
}

// 判断是否为空
bool fifo_is_empty(fifo_t *fifo) {
    return fifo->wp == fifo->rp;
}

// 判断是否为满
bool fifo_is_full(fifo_t *fifo) {
    return (fifo->wp + 1) % FIFO_SIZE == fifo->rp;
}

// 入队
int fifo_push(fifo_t *fifo, uint8_t data) {
    if (fifo_is_full(fifo)) return -1;
    fifo->buffer[fifo->wp] = data;
    fifo->wp = (fifo->wp + 1) % FIFO_SIZE;
    return 0;
}

// 出队
int fifo_pop(fifo_t *fifo, uint8_t *data) {
    if (fifo_is_empty(fifo)) return -1;
    *data = fifo->buffer[fifo->rp];
    fifo->rp = (fifo->rp + 1) % FIFO_SIZE;
    return 0;
}

// 查看队首
int fifo_peek(fifo_t *fifo, uint8_t *data) {
    if (fifo_is_empty(fifo)) return -1;
    *data = fifo->buffer[fifo->rp];
    return 0;
}

嗯,代码写完了。你可能会问:为什么FIFO_SIZE是16,但最多只能存15个数据?因为我们要留一个空位来区分空和满。如果你非要存满16个,那就得用另一种判断方式——比如加一个计数器。这个我们后面会讲到。

这一节的内容就到这里。四个操作,说难不难,但细节不少。你写代码的时候,多想想边界情况:满了怎么办?空了怎么办?指针绕圈了怎么办?把这些想清楚,FIFO这块你就拿下了。

我的经验: 刚开始学FIFO时,我总在纸上画格子,模拟指针移动。画个十遍八遍,逻辑就通了。你也试试?