3. 环形缓冲区实现:缓冲区初始化、写入操作、读取操作、溢出处理策略
环形缓冲区,说白了就是一个固定大小的数组,加上两个指针——一个写指针、一个读指针。数据写满了就绕回开头继续写,像个环一样转圈。我最早接触这东西是在做串口数据采集的时候,那时候数据量不大,但来得快,不用环形缓冲区根本扛不住。
你想想看,如果每次来一个数据就开一块新内存,那系统迟早崩掉。环形缓冲区的核心思想就是:复用内存,避免动态分配。这在嵌入式系统里是命根子。
3.1 缓冲区初始化
初始化其实很简单,就是把缓冲区清空,把读写指针归零。但我个人习惯在初始化时多做一件事——把整个缓冲区填成一个已知的“脏数据”模式,比如 0xAA 或 0x55。为什么?
我在项目中遇到过一个问题:调试时发现读出来的数据莫名其妙有残留值,排查了半天,原来是初始化时没清空缓冲区,旧数据混进来了。从那以后,我每次初始化都会把缓冲区刷一遍。
// 环形缓冲区结构体定义
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 写入操作
写入操作的核心逻辑就三步:
- 检查缓冲区是否已满
- 把数据写到
head位置 - 移动
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 画了一张环形缓冲区的结构图,帮你直观理解读写指针的移动过程。
这张图里,tail 指向索引 1(第一个有效数据),head 指向索引 4(下一个写入位置)。数据从 tail 到 head 之间是有效数据,其余是空闲空间。当 head 跑到末尾时,会绕回开头继续写。
3.6 避坑指南
最后,我把自己踩过的坑总结一下:
- 指针越界:一定要用位运算或取模保证指针在合法范围内。我见过有人直接用
head++,结果跑飞了。 - 多线程竞争:读写操作如果不在同一个线程,必须加锁。我建议用关中断的方式,开销最小。
- 缓冲区大小选择:太小容易溢出,太大浪费内存。我一般按最大数据量的 1.5 倍来定。
- 调试时打印指针:我曾经在调试时把
head和tail的值打印出来,一眼就看出问题在哪。这个习惯我一直保留着。
环形缓冲区这东西,看着简单,但用好了能解决很多实际问题。你只要把初始化、写入、读取、溢出处理这四个环节搞明白,基本上就能应对大部分嵌入式数据采集场景了。