3. 环形缓冲区实现:缓冲区初始化、写入操作、读取操作、溢出处理策略

环形缓冲区,说白了就是一个固定大小的数组,加上两个指针——一个写指针、一个读指针。数据写满了就绕回开头继续写,像个环一样转圈。我最早接触这东西是在做串口数据采集的时候,那时候数据量不大,但来得快,不用环形缓冲区根本扛不住。

你想想看,如果每次来一个数据就开一块新内存,那系统迟早崩掉。环形缓冲区的核心思想就是:复用内存,避免动态分配。这在嵌入式系统里是命根子。

3.1 缓冲区初始化

初始化其实很简单,就是把缓冲区清空,把读写指针归零。但我个人习惯在初始化时多做一件事——把整个缓冲区填成一个已知的“脏数据”模式,比如 0xAA0x55。为什么?

我在项目中遇到过一个问题:调试时发现读出来的数据莫名其妙有残留值,排查了半天,原来是初始化时没清空缓冲区,旧数据混进来了。从那以后,我每次初始化都会把缓冲区刷一遍。

核心要点:初始化时务必清空缓冲区,并记录缓冲区大小和元素大小。
// 环形缓冲区结构体定义
typedef struct {
    uint8_t *buffer;    // 数据缓冲区
    uint32_t size;      // 缓冲区大小(元素个数)
    uint32_t head;      // 写指针
    uint32_t tail;      // 读指针
    uint32_t count;     // 当前数据个数
} ring_buffer_t;

// 初始化函数
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buf, uint32_t size) {
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
    
    // 我个人习惯:用0xAA填充整个缓冲区
    memset(buf, 0xAA, size);
}

嗯,这里要注意:size 最好是 2 的幂次方。为什么?因为可以用位运算代替取模,速度更快。比如 head = (head + 1) & (size - 1),比 head = (head + 1) % size 快一个数量级。

3.2 写入操作

写入操作的核心逻辑就三步:

  1. 检查缓冲区是否已满
  2. 把数据写到 head 位置
  3. 移动 head 指针

但这里有个坑——满的判断。我见过不少新手用 head == tail 来判断空和满,结果空和满的状态完全一样,根本分不清。我常用的做法是维护一个 count 变量,记录当前数据个数。

// 写入单个字节
int ring_buffer_write(ring_buffer_t *rb, uint8_t data) {
    // 检查是否已满
    if (rb->count >= rb->size) {
        return -1;  // 缓冲区已满
    }
    
    // 写入数据
    rb->buffer[rb->head] = data;
    
    // 移动写指针(利用位运算加速)
    rb->head = (rb->head + 1) & (rb->size - 1);
    
    // 更新计数
    rb->count++;
    
    return 0;
}

我曾经在一个项目中,写入操作频繁触发中断,导致 count 更新不及时。后来加了临界区保护,问题才解决。所以如果你在多线程或中断环境下使用,记得加锁或关中断。

小技巧:批量写入时,可以一次性写入多个字节,减少函数调用开销。比如用 memcpy 一次拷贝一段数据。

3.3 读取操作

读取操作和写入是对称的。从 tail 位置读数据,然后移动 tail 指针。同样要检查缓冲区是否为空。

// 读取单个字节
int ring_buffer_read(ring_buffer_t *rb, uint8_t *data) {
    // 检查是否为空
    if (rb->count == 0) {
        return -1;  // 缓冲区为空
    }
    
    // 读取数据
    *data = rb->buffer[rb->tail];
    
    // 移动读指针
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    
    // 更新计数
    rb->count--;
    
    return 0;
}

你可能会问:为什么不直接用 head == tail 来判断空?嗯,我刚才说了,如果缓冲区满的时候 head 也等于 tail,那就没法区分了。用 count 是最稳妥的办法。

另外,我建议读取操作也支持批量读取。比如一次读出一帧数据,而不是一个字节一个字节地读。这样效率高很多。

3.4 溢出处理策略

缓冲区溢出了怎么办?这得看你的应用场景。我总结了几种常见策略:

策略 做法 适用场景
丢弃新数据 写入时如果满了,直接丢弃新数据 数据可靠性要求高,不能丢失旧数据
覆盖旧数据 写入时如果满了,覆盖最旧的数据 实时性要求高,旧数据可以丢弃
阻塞等待 写入时如果满了,等待读取线程消费 多线程同步场景
动态扩容 满了就重新分配更大的缓冲区 内存充足,数据量不可预测

我个人最常用的是覆盖旧数据策略。为什么?因为嵌入式系统里,数据往往是流式的,旧数据过时了就没用了。比如传感器数据,你关心的是最新的温度值,而不是十分钟前的。

注意:覆盖旧数据时,读指针 tail 也要跟着移动,否则读指针会指向被覆盖的位置,导致数据错乱。
// 带溢出覆盖的写入
int ring_buffer_write_overwrite(ring_buffer_t *rb, uint8_t data) {
    // 写入数据
    rb->buffer[rb->head] = data;
    
    // 移动写指针
    rb->head = (rb->head + 1) & (rb->size - 1);
    
    // 如果缓冲区已满,读指针也要跟着移动
    if (rb->count >= rb->size) {
        rb->tail = (rb->tail + 1) & (rb->size - 1);
    } else {
        rb->count++;
    }
    
    return 0;
}

你看,这里的关键是:当 count 已经等于 size 时,再写入数据,count 不变,但 tail 要前进一位,相当于丢弃了最旧的数据。

3.5 环形缓冲区结构图

下面我用 SVG 画了一张环形缓冲区的结构图,帮你直观理解读写指针的移动过程。

环形缓冲区结构图 0 1 2 3 4 5 6 7 head (写指针) tail (读指针) 数据从 tail 到 head 依次填充 绿色格子:已写入数据 白色格子:空闲空间

这张图里,tail 指向索引 1(第一个有效数据),head 指向索引 4(下一个写入位置)。数据从 tailhead 之间是有效数据,其余是空闲空间。当 head 跑到末尾时,会绕回开头继续写。

3.6 避坑指南

最后,我把自己踩过的坑总结一下:

  • 指针越界:一定要用位运算或取模保证指针在合法范围内。我见过有人直接用 head++,结果跑飞了。
  • 多线程竞争:读写操作如果不在同一个线程,必须加锁。我建议用关中断的方式,开销最小。
  • 缓冲区大小选择:太小容易溢出,太大浪费内存。我一般按最大数据量的 1.5 倍来定。
  • 调试时打印指针:我曾经在调试时把 headtail 的值打印出来,一眼就看出问题在哪。这个习惯我一直保留着。

环形缓冲区这东西,看着简单,但用好了能解决很多实际问题。你只要把初始化、写入、读取、溢出处理这四个环节搞明白,基本上就能应对大部分嵌入式数据采集场景了。

专注资料整理