4. FIFO基本操作:初始化、入队(push)、出队(pop)、查看队首(peek)
好,咱们接着聊。上一节我们把FIFO的接口和内部结构理清楚了,现在该动手了。这一节,我带你实现FIFO最核心的四个操作:初始化、入队、出队和查看队首。
说白了,这四个操作就是FIFO的“呼吸”和“心跳”。你只要掌握了它们,一个能用的FIFO基本就成型了。我个人习惯,写代码前先画个流程图,把逻辑走通,再动手敲键盘。
核心逻辑一句话: 用两个指针(写指针wp、读指针rp)在环形缓冲区里转圈圈。写数据时wp往前走,读数据时rp往前走。空和满的判断,就看这两个指针的关系。
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时,我总在纸上画格子,模拟指针移动。画个十遍八遍,逻辑就通了。你也试试?