C语言进阶:从零实现一个可靠的环形缓冲区,吃透回绕、边界与并发

C语言进阶|环形缓冲区到底怎么"转"?从 5 个格子开始讲明白

很多人第一次看到环形缓冲区代码,会同时遇到这些名字:headtailsizecapacity、取模、回绕、空和满。

每个单词似乎都认识,放在一起却很容易乱:

  • head 到底指向已经读过的位置,还是下一次要读的位置?
  • tail 指向最后写入的数据,还是下一次准备写入的空位?
  • 为什么 head == tail 有时表示空,有时又表示满?
  • 数组明明是一条直线,怎么就"环"起来了?
  • 读走的数据为什么还留在数组里?

这篇文章不从复杂的批量复制、无锁并发或 DMA 开始。我们先拿一个只有 5 个格子的数组,把每一步写入和读取都走一遍;等 headtailsize 真正变得直观,再把单字节版本升级成工程中常用的批量版本。

读完后,你应该能够自己回答三个问题:

  1. 环形缓冲区为什么能重复利用固定数组?
  2. 写入、读取、回绕、空和满分别怎样判断?
  3. 怎样用 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 也不是"最后一个已经写入的数据",而是:

下一次写入应该使用哪个下标。

这一点是初学者最容易记反的地方。可以把 headtail 都理解为"下一步的位置"。

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 时:

  1. 把 A 放入 data[tail],也就是 data[0]
  2. tail 向前走一格,从 0 变成 1;
  3. 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 时:

  1. data[head],也就是 data[0] 取出 A;
  2. head 向前走一格,从 0 变成 1;
  3. 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 并没有被清零。

这是正常的。读取操作只需要改变 headsize,没有必要额外擦除旧字节。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

因此,只有 headtail 两个下标时,无法仅凭相等关系区分空和满。

本文增加 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。

初始化不需要清空整个数组。空和满由元数据决定,初始状态只需要让 headtailsize 都等于 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;
}

按执行顺序阅读:

  1. 参数无效或缓冲区已满,返回 false
  2. 把数据写入 tail 当前指向的格子;
  3. tail 指向下一个可写位置;
  4. 有效数据数量加 1;
  5. 返回 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;
}

它和写入几乎完全对称:

  1. 参数无效或缓冲区为空,返回 false
  2. 通过输出参数 value 把字节交给调用者;
  3. head 指向下一个待读位置;
  4. 有效数据数量减 1;
  5. 返回 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() 很适合解释 headtail 与回绕,但真实程序通常一次收到一段数据:串口驱动可能交付 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 == 0readable == 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

这会用到三个新能力:

  1. rb_peek():查看某个逻辑位置的字节,但不推进 head
  2. rb_find():寻找换行符或协议帧头;
  3. 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)

解析器通常按状态推进:

  1. 查找帧头,丢弃帧头前的噪声;
  2. 数据不足以读取固定头部时等待;
  3. 查看长度字段,计算完整帧长度;
  4. 完整帧尚未到达时等待;
  5. 完整到达后批量读出并校验;
  6. 校验失败时重新同步帧头,成功时交给业务层。

这里会自然复习 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 与真实系统边界

当前实现不是线程安全的。dataheadtailsize 必须作为一个一致状态更新;给字段加 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

警告不是"能运行就先不管"的提示。指针与整数混用、符号类型比较和越界下标,往往正是缓冲区错误的前兆。

十七、推荐的实践路线

接下来可以按这个顺序扩展:

  1. 完成并测试 rb_write()rb_read()rb_write_exact()
  2. 加入 rb_peek() 和按换行符读取命令;
  3. 实现"帧头 + 长度 + 负载 + 校验"的二进制协议解析器;
  4. 增加溢出计数、最大占用水位和错误统计;
  5. 在明确的线程模型下增加同步;
  6. 用性能测量决定是否实现 span/consume 或 DMA 接口。

这条路线会把环形缓冲区从一个数组练习,逐步变成串口接收、网络流处理、日志管道、音频缓存和生产者---消费者通信的基础组件。

十八、最后记住四个工程结论

  1. 批量读写没有改变环形模型,只是把逻辑连续数据拆成最多两个物理连续区域;
  2. 返回实际处理长度,是流式 API 最重要的契约;
  3. 字节流与完整消息必须使用不同的失败语义;
  4. 环形缓冲区负责暂存数据,协议解析、溢出策略和并发规则必须由更高层明确决定。
相关推荐
CodeHackerBhx1 小时前
Spring Boot 4 防重复提交:从接口幂等到 Redis 分布式锁的完整实践
java·数据库·spring boot·redis·分布式
八角.。1 小时前
方法参数与Debug按键
java·开发语言·jvm
BerryS3N2 小时前
Java在人工智能与大模型时代的深度演进:从工程落地、高性能计算到企业级Agent与RAG架构实战指南
java·人工智能·架构
蔬菜_2 小时前
前端转全栈-day2(修饰符)
java
fīɡЙtīиɡ ℡2 小时前
怎么是用JWT
java·状态模式
唐青枫2 小时前
Java JCommander 实战详解:用注解解析命令行参数
java
一次旅行2 小时前
RLHF全链路深度解析:Reward Model数学推导+PPO完整实战,对比GRPO轻量化方案
人工智能·算法·机器学习
Wang's Blog2 小时前
AI Agent白手起家29: Few Shot 提示词工程实战
人工智能·算法
步行cgn2 小时前
carList.forEach(System.out::println)
java·开发语言·mybatis
plainGeekDev3 小时前
JUnit 4 → JUnit 5 + Kotlin
android·java·kotlin