C语言进阶|环形缓冲区到底怎么"转"?从 5 个格子开始讲明白
很多人第一次看到环形缓冲区代码,会同时遇到这些名字:head、tail、size、capacity、取模、回绕、空和满。
每个单词似乎都认识,放在一起却很容易乱:
head到底指向已经读过的位置,还是下一次要读的位置?tail指向最后写入的数据,还是下一次准备写入的空位?- 为什么
head == tail有时表示空,有时又表示满? - 数组明明是一条直线,怎么就"环"起来了?
- 读走的数据为什么还留在数组里?
这篇文章不从复杂的批量复制、无锁并发或 DMA 开始。我们先拿一个只有 5 个格子的数组,把每一步写入和读取都走一遍;等 head、tail 和 size 真正变得直观,再把单字节版本升级成工程中常用的批量版本。
读完后,你应该能够自己回答三个问题:
- 环形缓冲区为什么能重复利用固定数组?
- 写入、读取、回绕、空和满分别怎样判断?
- 怎样用 C 语言写出一个可运行、边界清楚的实现?
建议按下面的路线阅读:
- 第一次接触环形缓冲区:先读到"新人版 Demo"并亲自运行;
- 已经理解单字节读写:继续学习批量
memcpy; - 准备放进真实项目:再阅读边界、并发和测试部分。
不要急着一次记住所有代码。先始终抓住三个问题:下一次从哪里读、下一次向哪里写、当前还有多少有效数据。
一、先别想"环",先想一支排队的队伍
假设程序不断收到字符,它们必须按照到达顺序被处理:
text
A 先到,B 后到,C 最后到
那么读取顺序也必须是:
text
A -> B -> C
这种"先进入的数据先出去"的规则叫 FIFO,也就是 First In, First Out,中文常说"先进先出"。普通队列、消息队列和环形缓冲区都遵循这个基本顺序。
最直接的实现是使用数组:
c
uint8_t data[5];
写入 A、B、C 后,数组可以想象成:
text
下标: 0 1 2 3 4
数组: [A] [B] [C] [ ] [ ]
现在读取 A。如果每读一个元素,就把后面的 B、C 全部向左搬一格,当然能够继续使用数组前面的空间:
text
下标: 0 1 2 3 4
数组: [B] [C] [ ] [ ] [ ]
但这种做法每读一次都可能搬动大量数据。数组越大,代价越高。
更好的办法是:数据不搬,只移动"下一次从哪里读"的位置。
读取 A 后,我们只要记住下次应该从下标 1 读取。读取 B 后,再记住下次应该从下标 2 读取。数组里的旧字符可以暂时保留,因为程序只承认有效范围内的数据。
这就是环形缓冲区的第一条核心思想:
数据通常不搬家,移动的是读写位置。
二、数组并没有真的变成圆形
物理内存仍然是一段普通的连续数组:
text
[0] [1] [2] [3] [4]
所谓"环形",只是下标走到最后一个格子以后,下一步重新回到 0:
text
0 -> 1 -> 2 -> 3 -> 4 -> 0 -> 1 -> ...
可以把它想象成时钟:数字 12 后面不是 13,而是重新回到 1。环形缓冲区也是一样,下标 capacity - 1 的下一个位置是 0。
在代码里,我们先写一个只负责"向前走一格"的函数:
c
static size_t rb_next(size_t index, size_t capacity)
{
index++;
if (index == capacity) {
index = 0U;
}
return index;
}
假设容量是 5:
| 当前下标 | 调用 rb_next() 后 |
|---|---|
| 0 | 1 |
| 1 | 2 |
| 2 | 3 |
| 3 | 4 |
| 4 | 0 |
最后一行就是"回绕"。没有神秘的圆形内存,只是下标回到了数组开头。
三、必须记住的 5 个字段
我们用下面的结构体描述一个按字节存储的环形缓冲区:
c
typedef struct {
uint8_t *data;
size_t capacity;
size_t head;
size_t tail;
size_t size;
} ring_buffer_t;
逐个解释。
1. data:数组在哪里
c
uint8_t *data;
它指向真正保存数据的数组。结构体本身只保存管理信息,存储空间由调用者提供。
例如:
c
uint8_t storage[5];
ring_buffer_t rb;
rb.data = storage;
这样做的好处是:缓冲区不必在内部调用 malloc()。在 MCU、驱动或实时任务中,固定内存通常更容易控制。
2. capacity:一共有多少个格子
c
size_t capacity;
如果数组是 uint8_t storage[5],容量就是 5。
这里使用 size_t,因为它是 C 语言专门用于表示对象大小和数组长度的无符号整数类型,也是 sizeof 的返回类型。
3. head:下一次从哪里读取
c
size_t head;
请特别注意:head 不是"上次读到哪里",而是:
下一次读取应该访问哪个下标。
4. tail:下一次向哪里写入
c
size_t tail;
tail 也不是"最后一个已经写入的数据",而是:
下一次写入应该使用哪个下标。
这一点是初学者最容易记反的地方。可以把 head 和 tail 都理解为"下一步的位置"。
5. size:当前有多少个有效字节
c
size_t size;
size 不关心数组里残留了什么,只记录当前有多少个字节还没有被读取。
由它可以直接得到:
c
可读字节数 = size
可写字节数 = capacity - size
也可以判断:
c
size == 0 -> 空
size == capacity -> 满
四、用 5 个格子完整走一遍
下面所有例子都使用容量为 5 的缓冲区。
第 1 步:初始化
初始化后:
text
下标: 0 1 2 3 4
数组: [ ] [ ] [ ] [ ] [ ]
head = 0 下一次从 0 读取
tail = 0 下一次向 0 写入
size = 0 当前没有有效数据
此时 head == tail,并且 size == 0,所以缓冲区为空。
第 2 步:依次写入 A、B、C
写入 A 时:
- 把 A 放入
data[tail],也就是data[0]; tail向前走一格,从 0 变成 1;size加 1,从 0 变成 1。
继续写入 B、C 后:
text
下标: 0 1 2 3 4
数组: [A] [B] [C] [ ] [ ]
head = 0 下一次读取 A
tail = 3 下一次写入下标 3
size = 3 有效数据是 A、B、C
逻辑读取顺序是:
text
A -> B -> C
第 3 步:读取 A、B
读取 A 时:
- 从
data[head],也就是data[0]取出 A; head向前走一格,从 0 变成 1;size减 1,从 3 变成 2。
再读取 B 后:
text
下标: 0 1 2 3 4
数组: [A] [B] [C] [ ] [ ]
head = 2 下一次读取 C
tail = 3 下一次写入下标 3
size = 1 只有 C 仍然有效
你可能注意到了:数组里的 A、B 并没有被清零。
这是正常的。读取操作只需要改变 head 和 size,没有必要额外擦除旧字节。A、B 虽然还留在物理内存里,但它们已经不属于缓冲区的逻辑有效数据。
如果保存的是密码、密钥等敏感信息,可以在更高层明确执行安全擦除;普通 FIFO 为了"看起来干净"而每次清零,通常只是增加无用开销。
第 4 步:写入 D、E
现在 tail == 3。
写入 D 后,tail 从 3 变成 4;写入 E 后,tail 从 4 回到 0:
text
下标: 0 1 2 3 4
数组: [A] [B] [C] [D] [E]
head = 2
tail = 0 已经发生回绕
size = 3
此时物理数组看起来是 ABCDE,但 A、B 已经失效。真正的逻辑数据只有:
text
C -> D -> E
逻辑顺序从 head == 2 开始,走到下标 4 后再绕回 0。
第 5 步:继续写入 F、G,缓冲区变满
tail == 0,所以 F 覆盖下标 0 中已经失效的 A;随后 G 覆盖下标 1 中已经失效的 B。
text
下标: 0 1 2 3 4
数组: [F] [G] [C] [D] [E]
head = 2
tail = 2
size = 5
物理数组是:
text
F G C D E
但从 head == 2 开始读取,逻辑顺序仍然是:
text
C -> D -> E -> F -> G
这正是环形缓冲区最关键的区别:
物理排列可以被切成两段,逻辑顺序仍然是一条连续的 FIFO 数据流。
把整个过程放进一张表
表格中的 · 表示从未写入或不关心的内容。数组里已经读走的旧字符可能仍然存在,但不再属于逻辑数据。
| 操作完成后 | 物理数组 [0..4] |
head | tail | size | 逻辑有效数据 |
|---|---|---|---|---|---|
| 初始化 | · · · · · |
0 | 0 | 0 | 空 |
| 写 A | A · · · · |
0 | 1 | 1 | A |
| 写 B | A B · · · |
0 | 2 | 2 | A B |
| 写 C | A B C · · |
0 | 3 | 3 | A B C |
| 读 A | A B C · · |
1 | 3 | 2 | B C |
| 读 B | A B C · · |
2 | 3 | 1 | C |
| 写 D | A B C D · |
2 | 4 | 2 | C D |
| 写 E | A B C D E |
2 | 0 | 3 | C D E |
| 写 F | F B C D E |
2 | 1 | 4 | C D E F |
| 写 G | F G C D E |
2 | 2 | 5 | C D E F G |
五、为什么 head == tail 既可能是空,也可能是满
回头看两个状态。
刚初始化时:
text
head = 0
tail = 0
size = 0
缓冲区是空的。
写满以后:
text
head = 2
tail = 2
size = 5
缓冲区是满的。
它们都满足:
text
head == tail
因此,只有 head 和 tail 两个下标时,无法仅凭相等关系区分空和满。
本文增加 size 来消除歧义:
c
if (rb->size == 0U) {
/* 空 */
}
if (rb->size == rb->capacity) {
/* 满 */
}
另一种常见方法是故意永远空出一个格子,让 head == tail 只表示空,但容量为 N 时最多只能存 N-1 个元素。两种方案都能使用,关键是必须选择一种,并让整个实现始终遵守同一规则。
对于第一次实现环形缓冲区的人,显式保存 size 更直观。
六、开始写代码:先实现单字节版本
单字节版本只有两个核心动作:
text
put:放入一个字节
get:取出一个字节
先把这两个动作写对,再升级到一次读写多个字节。
1. 初始化
c
static bool rb_init(ring_buffer_t *rb,
uint8_t *storage,
size_t capacity)
{
if (rb == NULL || storage == NULL || capacity == 0U) {
return false;
}
rb->data = storage;
rb->capacity = capacity;
rb->head = 0U;
rb->tail = 0U;
rb->size = 0U;
return true;
}
这里先检查三个非法情况:
- 管理结构体指针为空;
- 存储数组指针为空;
- 容量为 0。
初始化不需要清空整个数组。空和满由元数据决定,初始状态只需要让 head、tail、size 都等于 0。
2. 判断空和满
c
static bool rb_is_empty(const ring_buffer_t *rb)
{
return rb != NULL && rb->size == 0U;
}
static bool rb_is_full(const ring_buffer_t *rb)
{
return rb != NULL &&
rb->capacity > 0U &&
rb->size == rb->capacity;
}
这里返回 bool,调用处可以直接写:
c
if (rb_is_full(&rb)) {
/* 暂时不能继续写 */
}
3. 写入一个字节
c
static bool rb_put(ring_buffer_t *rb, uint8_t value)
{
if (rb == NULL || rb->data == NULL || rb->capacity == 0U ||
rb_is_full(rb)) {
return false;
}
rb->data[rb->tail] = value;
rb->tail = rb_next(rb->tail, rb->capacity);
rb->size++;
return true;
}
按执行顺序阅读:
- 参数无效或缓冲区已满,返回
false; - 把数据写入
tail当前指向的格子; - 让
tail指向下一个可写位置; - 有效数据数量加 1;
- 返回
true。
为什么要"先写数据,再更新 tail"?因为 tail 的含义是下一次写入的位置。只有当前字节已经写好,才能让它前进。
4. 读取一个字节
c
static bool rb_get(ring_buffer_t *rb, uint8_t *value)
{
if (rb == NULL || value == NULL || rb->data == NULL ||
rb->capacity == 0U || rb_is_empty(rb)) {
return false;
}
*value = rb->data[rb->head];
rb->head = rb_next(rb->head, rb->capacity);
rb->size--;
return true;
}
它和写入几乎完全对称:
- 参数无效或缓冲区为空,返回
false; - 通过输出参数
value把字节交给调用者; - 让
head指向下一个待读位置; - 有效数据数量减 1;
- 返回
true。
value 是指针,因为函数既需要返回成功或失败,又需要把读取到的字节交出去。bool 返回操作结果,*value 返回数据,这是一种常见的 C 接口设计。
七、完整的新人版 Demo
下面的代码可以直接编译运行。它会重现前面的 5 格示例,并使用 assert() 检查每一步是否符合预期。
c
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
typedef struct {
uint8_t *data; /* 真正保存数据的数组 */
size_t capacity; /* 数组一共有多少个格子 */
size_t head; /* 下一次从哪个格子读取 */
size_t tail; /* 下一次向哪个格子写入 */
size_t size; /* 当前保存了多少个有效字节 */
} ring_buffer_t;
/*
* 把下标向前移动一格。
* 到达数组末尾以后回到 0,这就是"环形"的来源。
*/
static size_t rb_next(size_t index, size_t capacity)
{
index++;
if (index == capacity) {
index = 0U;
}
return index;
}
static bool rb_init(ring_buffer_t *rb, uint8_t *storage, size_t capacity)
{
if (rb == NULL || storage == NULL || capacity == 0U) {
return false;
}
rb->data = storage;
rb->capacity = capacity;
rb->head = 0U;
rb->tail = 0U;
rb->size = 0U;
return true;
}
static bool rb_is_empty(const ring_buffer_t *rb)
{
return rb != NULL && rb->size == 0U;
}
static bool rb_is_full(const ring_buffer_t *rb)
{
return rb != NULL && rb->capacity > 0U && rb->size == rb->capacity;
}
/*
* 放入一个字节。
* 成功返回 true;缓冲区已满或参数无效时返回 false。
*/
static bool rb_put(ring_buffer_t *rb, uint8_t value)
{
if (rb == NULL || rb->data == NULL || rb->capacity == 0U ||
rb_is_full(rb)) {
return false;
}
/* 先写数据,再移动 tail。 */
rb->data[rb->tail] = value;
rb->tail = rb_next(rb->tail, rb->capacity);
rb->size++;
return true;
}
/*
* 取出一个字节。
* 成功返回 true,并通过 value 返回数据;缓冲区为空时返回 false。
*/
static bool rb_get(ring_buffer_t *rb, uint8_t *value)
{
if (rb == NULL || value == NULL || rb->data == NULL ||
rb->capacity == 0U || rb_is_empty(rb)) {
return false;
}
/* 先读数据,再移动 head。 */
*value = rb->data[rb->head];
rb->head = rb_next(rb->head, rb->capacity);
rb->size--;
return true;
}
/* 只用于演示:按照逻辑读取顺序打印有效数据。 */
static void rb_print_logical(const ring_buffer_t *rb)
{
if (rb == NULL || rb->data == NULL || rb->capacity == 0U) {
return;
}
size_t index = rb->head;
putchar('[');
for (size_t i = 0U; i < rb->size; ++i) {
if (i > 0U) {
putchar(' ');
}
putchar((int)rb->data[index]);
index = rb_next(index, rb->capacity);
}
putchar(']');
}
static void rb_print_state(const char *label, const ring_buffer_t *rb)
{
printf("%-18s head=%zu tail=%zu size=%zu logical=",
label, rb->head, rb->tail, rb->size);
rb_print_logical(rb);
putchar('\n');
}
int main(void)
{
uint8_t storage[5] = {0};
uint8_t value = 0U;
ring_buffer_t rb;
assert(rb_init(&rb, storage, sizeof storage));
rb_print_state("1. init", &rb);
assert(rb_put(&rb, (uint8_t)'A'));
assert(rb_put(&rb, (uint8_t)'B'));
assert(rb_put(&rb, (uint8_t)'C'));
rb_print_state("2. put A B C", &rb);
assert(rb_get(&rb, &value) && value == (uint8_t)'A');
printf("get: %c\n", (int)value);
assert(rb_get(&rb, &value) && value == (uint8_t)'B');
printf("get: %c\n", (int)value);
rb_print_state("3. get A B", &rb);
assert(rb_put(&rb, (uint8_t)'D'));
assert(rb_put(&rb, (uint8_t)'E'));
rb_print_state("4. put D E", &rb);
/* 下面两次写入会从数组末尾绕回数组开头。 */
assert(rb_put(&rb, (uint8_t)'F'));
assert(rb_put(&rb, (uint8_t)'G'));
rb_print_state("5. put F G", &rb);
assert(rb_is_full(&rb));
/* 已满时拒绝写入 H,旧数据不会被覆盖。 */
assert(!rb_put(&rb, (uint8_t)'H'));
printf("drain: ");
while (rb_get(&rb, &value)) {
putchar((int)value);
}
putchar('\n');
rb_print_state("6. after drain", &rb);
assert(rb_is_empty(&rb));
assert(!rb_get(&rb, &value));
puts("all checks passed");
return 0;
}
使用 GCC 或 Clang 编译:
bash
gcc circle_buffer_demo.c -o demo
./demo
运行结果:

这段输出再次说明:
- 第 4 步
tail从数组末尾回到了 0; - 第 5 步
head == tail == 2,但size == 5,因此缓冲区是满的; - 全部读取完成后仍然
head == tail == 2,但size == 0,因此缓冲区是空的; - 无论物理数组怎样回绕,读出的顺序始终是
CDEFG。
八、为什么工程代码需要批量读写
单字节 rb_put() 和 rb_get() 很适合解释 head、tail 与回绕,但真实程序通常一次收到一段数据:串口驱动可能交付 16 个字节,网络接口可能交付几百个字节,音频回调也常按一帧处理。
如果写入 1000 个字节时调用 1000 次 rb_put(),就会重复执行参数检查、空满判断和函数调用。批量接口把这些工作集中起来:
c
size_t rb_write(ring_buffer_t *rb, const void *src, size_t len);
size_t rb_read(ring_buffer_t *rb, void *dst, size_t len);
返回 size_t 很重要:它表示实际处理的字节数。返回 0 不一定是程序出错,也可能只是当前没有空间或没有数据。调用者必须检查返回值。
写入前最多能处理:
c
size_t writable = min_size(len, rb->capacity - rb->size);
读取前最多能处理:
c
size_t readable = min_size(len, rb->size);
这两个式子把 API 的边界说得很清楚:绝不写过剩余空间,也绝不读过有效数据。
九、一次批量操作为什么最多拆成两段
假设容量为 8,tail == 6,现在要写入 ABCDEF。物理数组尾部只剩下下标 6 和 7,因此逻辑上连续的 6 个字节会分布成:
text
第一段:A B -> data[6], data[7]
第二段:C D E F -> data[0] ... data[3]
分段长度可以统一计算:
c
size_t first = min_size(writable, rb->capacity - rb->tail);
size_t second = writable - first;
capacity - tail是当前位置到物理数组末尾的连续空间;first是不回绕就能复制的部分;second是绕回下标 0 后还需复制的部分;- 没有发生回绕时,
second == 0。
所以批量写入并不需要逐字节循环,只需最多两次 memcpy():
c
memcpy(&rb->data[rb->tail], input, first);
if (second > 0U) {
memcpy(rb->data, &input[first], second);
}
批量读取完全对称:起点换成 head,复制方向反过来,最后推进 head 并减少 size。
十、完整的批量读写实现
先准备两个辅助函数:
c
static size_t min_size(size_t a, size_t b)
{
return a < b ? a : b;
}
/* 前提:index < capacity,并且 step <= capacity。 */
static size_t rb_advance(size_t index, size_t step, size_t capacity)
{
size_t to_end = capacity - index;
return step < to_end ? index + step : step - to_end;
}
然后加入 <string.h> 并实现写入:
c
size_t rb_write(ring_buffer_t *rb, const void *src, size_t len)
{
if (len == 0U) return 0U;
if (rb == NULL || src == NULL || rb->data == NULL ||
rb->capacity == 0U || rb->head >= rb->capacity ||
rb->tail >= rb->capacity || rb->size > rb->capacity) return 0U;
const uint8_t *input = src;
size_t writable = min_size(len, rb->capacity - rb->size);
size_t first = min_size(writable, rb->capacity - rb->tail);
size_t second = writable - first;
memcpy(&rb->data[rb->tail], input, first);
if (second > 0U) memcpy(rb->data, &input[first], second);
rb->tail = rb_advance(rb->tail, writable, rb->capacity);
rb->size += writable;
return writable;
}
读取函数只改变数据方向和状态更新方向:
c
size_t rb_read(ring_buffer_t *rb, void *dst, size_t len)
{
if (len == 0U) return 0U;
if (rb == NULL || dst == NULL || rb->data == NULL ||
rb->capacity == 0U || rb->head >= rb->capacity ||
rb->tail >= rb->capacity || rb->size > rb->capacity) return 0U;
uint8_t *output = dst;
size_t readable = min_size(len, rb->size);
size_t first = min_size(readable, rb->capacity - rb->head);
size_t second = readable - first;
memcpy(output, &rb->data[rb->head], first);
if (second > 0U) memcpy(&output[first], rb->data, second);
rb->head = rb_advance(rb->head, readable, rb->capacity);
rb->size -= readable;
return readable;
}
这里有一个容易忽略的细节:即使 writable == 0 或 readable == 0,上述计算仍然安全,状态也不会变化。不过把零长度情况提前返回,通常更容易阅读和调试。
十一、先确定语义:字节流还是完整消息
"写不下时怎么办"不是实现细节,而是接口契约。
1. 字节流:允许部分处理
日志、TCP 数据、音频 PCM 都可以先处理一部分,再保存偏移量继续:
c
size_t offset = 0U;
while (offset < len) {
size_t n = rb_write(&rb, &data[offset], len - offset);
if (n == 0U) break;
offset += n;
}
2. 完整消息:要么全写,要么不写
如果 10 字节协议帧只写进去 6 字节,解析器可能把下一帧也误当成它的一部分。此时应提供原子语义:
c
bool rb_write_exact(ring_buffer_t *rb, const void *src, size_t len)
{
if (rb == NULL || rb->data == NULL || rb->size > rb->capacity)
return false;
if (len > rb->capacity - rb->size)
return false;
return len == 0U || rb_write(rb, src, len) == len;
}
选择原则可以概括为:连续流允许部分进度,有边界的消息必须保护边界。
十二、下一步功能:用环形缓冲区接收并解析串口命令
学完批量读写后,最合适的练习不是继续堆辅助函数,而是建立一条真正的数据通道。假设设备通过 UART 收到文本命令:
text
LED ON\n
SET SPEED 120\n
STATUS\n
接收端可以分成三层:
text
驱动/中断收到一批字节
↓ rb_write
环形缓冲区保存尚未处理的数据
↓ 找到 '\n' 后读取完整一行
命令解析器处理 LED、SPEED、STATUS
这会用到三个新能力:
rb_peek():查看某个逻辑位置的字节,但不推进head;rb_find():寻找换行符或协议帧头;rb_read_line():只有发现完整一行时才消费数据。
peek 可以这样实现:
c
bool rb_peek(const ring_buffer_t *rb, size_t offset, uint8_t *value)
{
if (rb == NULL || value == NULL || rb->data == NULL ||
rb->capacity == 0U || offset >= rb->size) return false;
size_t index = rb_advance(rb->head, offset, rb->capacity);
*value = rb->data[index];
return true;
}
行读取的关键是"先确认完整,再修改状态":
c
size_t rb_read_line(ring_buffer_t *rb, char *dst, size_t dst_size)
{
size_t line_len = 0U;
uint8_t ch;
if (dst == NULL || dst_size == 0U) return 0U;
while (line_len < rb->size) {
if (!rb_peek(rb, line_len, &ch)) return 0U;
line_len++;
if (ch == (uint8_t)'\n') break;
}
if (line_len == 0U || ch != (uint8_t)'\n') return 0U;
if (line_len >= dst_size) return 0U;
if (rb_read(rb, dst, line_len) != line_len) return 0U;
dst[line_len] = '\0';
return line_len;
}
这段设计体现了一个重要习惯:解析器不能因为"暂时还没收完整"就破坏已有数据。只有检测到换行符并确认输出空间足够后,才真正调用 rb_read()。
十三、从文本行继续升级到二进制协议
完成行协议后,可以把消息格式升级为:
text
帧头(2) | 长度(1) | 命令(1) | 负载(N) | 校验(1)
解析器通常按状态推进:
- 查找帧头,丢弃帧头前的噪声;
- 数据不足以读取固定头部时等待;
- 查看长度字段,计算完整帧长度;
- 完整帧尚未到达时等待;
- 完整到达后批量读出并校验;
- 校验失败时重新同步帧头,成功时交给业务层。
这里会自然复习 peek、批量读取、完整消息语义和回绕。还会引出新的工程问题:长度字段是否可信、最大帧长是多少、校验失败后丢几个字节、缓冲区满时记录什么统计信息。
十四、再进一步:减少复制,而不是盲目追求"零拷贝"
普通 rb_read() 会把数据从环形缓冲区复制到调用者数组。数据量较大时,可以提供"取得连续可读区域"和"确认消费"的两阶段接口:
c
size_t rb_read_span(const ring_buffer_t *rb, const uint8_t **ptr);
void rb_consume(ring_buffer_t *rb, size_t len);
第一次只能返回从 head 到数组末尾的连续区域;如果逻辑数据发生回绕,调用者消费第一段后再获取第二段。这种接口适合音频、文件发送和 DMA,但责任也更大:在 consume 前不能覆盖该区域,指针不能长期保存,并发访问必须有明确约束。
因此学习顺序建议是:先把两次 memcpy 版本写对并测全,再根据性能数据决定是否增加 span 接口。
十五、并发、volatile 与真实系统边界
当前实现不是线程安全的。data、head、tail 和 size 必须作为一个一致状态更新;给字段加 volatile 既不能提供互斥,也不能让多步操作变成原子操作。
- 单线程事件循环:当前版本可以直接使用;
- 中断写、主循环读:使用平台认可的临界区,或专门设计 SPSC 队列;
- 多线程读写:先用互斥锁保护完整操作;
- RTOS 项目:先确认系统自带 stream buffer、message buffer 或 queue 是否已经满足需求。
如果生产速度长期大于消费速度,再精巧的环形缓冲区也终会填满。缓冲区只能吸收短时抖动,不能解决长期吞吐不平衡。
十六、必须覆盖的测试
批量版本至少应验证:
- 长度为 0 时状态不变;
- 空读、满写与部分读写;
- 不回绕的一段复制;
- 写入回绕和读取回绕;
- 请求长度恰好等于可用长度;
write_exact失败时所有状态和原数据不变;- 容量为 1 和 2;
- 多次回绕后的 FIFO 顺序;
- 随机操作与一个简单参考队列的结果一致。
编译时打开警告和运行期检查:
bash
gcc -std=c11 -Wall -Wextra -Wpedantic \
-fsanitize=address,undefined -g \
circle_buffer_demo.c -o demo
./demo
警告不是"能运行就先不管"的提示。指针与整数混用、符号类型比较和越界下标,往往正是缓冲区错误的前兆。
十七、推荐的实践路线
接下来可以按这个顺序扩展:
- 完成并测试
rb_write()、rb_read()、rb_write_exact(); - 加入
rb_peek()和按换行符读取命令; - 实现"帧头 + 长度 + 负载 + 校验"的二进制协议解析器;
- 增加溢出计数、最大占用水位和错误统计;
- 在明确的线程模型下增加同步;
- 用性能测量决定是否实现 span/consume 或 DMA 接口。
这条路线会把环形缓冲区从一个数组练习,逐步变成串口接收、网络流处理、日志管道、音频缓存和生产者---消费者通信的基础组件。
十八、最后记住四个工程结论
- 批量读写没有改变环形模型,只是把逻辑连续数据拆成最多两个物理连续区域;
- 返回实际处理长度,是流式 API 最重要的契约;
- 字节流与完整消息必须使用不同的失败语义;
- 环形缓冲区负责暂存数据,协议解析、溢出策略和并发规则必须由更高层明确决定。