前言

前三天主要在练位运算、指针、数组和基础算法。今天开始进入一个更贴近嵌入式开发的结构:环形缓冲区。

环形缓冲区经常出现在 UART 接收、传感器采样、日志缓存等场景中。它不需要频繁移动数据,也不依赖动态内存,可以使用固定大小的数组持续接收和取出数据。

今天完成了四个函数:

  • RingBufferInit
  • RingBufferAvailable
  • RingBufferPush
  • RingBufferPop

本文只记录当前代码和测试能够证明的内容。Day 4 没有单独保存训练日志,因此不会补写没有证据的调试过程。

一、今天的验证结果

我先单独编译并运行环形缓冲区练习:

gcc -std=c99 -Wall -Wextra -O0 02_ring_buffer.c -o 02_ring_buffer.exe
.\02_ring_buffer.exe

输出为:

02_ring_buffer passed

然后运行完整的 week02 脚本,关键结果为:

01_array_tools passed
02_ring_buffer passed
FAIL ...03_uart_frame_parser.c:79 expression: ParseFirstFrame(...) == 1
1 exercise(s) failed

这说明数组工具和环形缓冲区已经通过。完整脚本仍然失败,是因为 Day 5 的 UART 帧解析函数还没有实现,不能把这个失败算到今天的环形缓冲区上。

二、环形缓冲区保存了什么状态

今天使用的结构体如下:

#define RING_BUFFER_CAPACITY 8u

typedef struct {
    uint8_t data[RING_BUFFER_CAPACITY];
    uint16_t head;
    uint16_t tail;
    uint16_t count;
} RingBuffer;

我的理解是:

  • data 是真正存放字节的固定数组。
  • head 指向下一个写入位置。
  • tail 指向下一个读取位置。
  • count 表示当前可读字节数。

这三个状态变量必须始终保持一致。如果只移动 head 却忘记增加 count,缓冲区里虽然写入了数据,读取端仍然会认为它是空的。

使用 count 后,空和满的判断比较直接:

空:count == 0
满:count == RING_BUFFER_CAPACITY

这种方案可以使用数组中的全部 8 个位置。另一种常见实现不保存 count,而是牺牲一个槽位,通过 headtail 的关系区分空和满。两种方法都可以,但状态定义必须从一开始就统一。

三、初始化和可读数据量

初始化时没有必要清空整个数组,因为 count == 0 已经表示没有有效数据。真正需要复位的是三个状态变量:

static void RingBufferInit(RingBuffer *rb)
{
    rb->head = 0;
    rb->tail = 0;
    rb->count = 0;
}

获取可读字节数只需要返回 count

static uint16_t RingBufferAvailable(const RingBuffer *rb)
{
    return rb->count;
}

这里也体现了“容量”和“有效长度”的区别。数组容量始终是 8,但当前有效数据可能是 0 到 8 个字节。处理串口缓冲区时,不能因为数组里还残留着旧值,就把它们当成有效数据。

四、写入操作 RingBufferPush

写入前先判断缓冲区是否已满。如果未满,就把数据写入 head 指向的位置,然后更新下一个写入位置和有效数量:

static int RingBufferPush(RingBuffer *rb, uint8_t byte)
{
    if (rb->count < RING_BUFFER_CAPACITY) {
        rb->data[rb->head] = byte;
        rb->head = (rb->head + 1) % RING_BUFFER_CAPACITY;
        rb->count++;
        return 0;
    }

    return -1;
}

返回值的含义是:

  • 0:写入成功。
  • -1:缓冲区已满,没有覆盖旧数据。

“满了以后怎么办”属于接口策略。当前实现选择拒绝新数据并返回错误,这样不会静默破坏还没有读取的旧数据。实际项目也可以选择覆盖最旧数据,但必须明确记录丢包策略,不能无声覆盖。

五、读取操作 RingBufferPop

读取前先判断是否为空。存在数据时,从 tail 指向的位置取出一个字节,再移动读取位置并减少有效数量:

static int RingBufferPop(RingBuffer *rb, uint8_t *byte)
{
    if (rb->count > 0) {
        *byte = rb->data[rb->tail];
        rb->tail = (rb->tail + 1) % RING_BUFFER_CAPACITY;
        rb->count--;
        return 0;
    }

    return -1;
}

它同样通过返回值区分成功和失败:

  • 0:成功读出一个字节。
  • -1:缓冲区为空。

当前练习使用的是 FIFO,也就是先进先出。先写入的 0xA0 必须先于后写入的 0xA1 被读出。

六、为什么下标需要回绕

环形缓冲区的关键不是数组真的变成了圆,而是下标走到末尾后重新回到 0:

rb->head = (rb->head + 1) % RING_BUFFER_CAPACITY;
rb->tail = (rb->tail + 1) % RING_BUFFER_CAPACITY;

测试用例先写满 8 个字节,再读出 3 个,随后重新写入 3 个字节。这 3 个新字节会利用数组前面已经释放的位置。

逻辑顺序仍然是:

剩余旧数据 -> 后写入的新数据

即使物理存储已经从数组末尾回到开头,tailcount 仍能保证读取顺序正确。

当容量是 2 的幂时,还可以使用按位与代替取模:

next = (index + 1u) & (RING_BUFFER_CAPACITY - 1u);

但这种写法依赖容量必须是 2 的幂。当前版本使用 % 更直观,也没有把这个额外约束隐藏在代码中。

七、它为什么适合 UART 接收

UART 接收和数据处理的速度往往不同:

  • UART 中断负责快速取得新字节。
  • 主循环或任务负责解析命令、校验数据和执行业务逻辑。

环形缓冲区可以把这两个阶段分开:

UART RX 中断 -> 写入环形缓冲区 -> 主循环或任务读取 -> 协议解析

这对应生产者—消费者模型。UART 接收端是生产者,协议解析端是消费者,中间的环形缓冲区临时保存速度差异产生的数据。

ISR 中不适合直接做完整协议解析。查找帧头、校验 checksum、处理 payload 都可能耗费较长时间。中断执行越久,越容易影响其他中断和实时任务。因此更合理的分工是:ISR 只读取字节、清除标志并快速入队,复杂逻辑放在主循环或 FreeRTOS 任务中。

八、当前实现还不能证明什么

这次测试是在主机上以单线程方式运行的,它证明了:

  • 初始化状态正确。
  • 空和满的边界能够识别。
  • 写入、读取和回绕顺序符合 FIFO。
  • 满时拒绝写入,空时拒绝读取。

但它还不能证明真实 STM32 中断并发下是安全的。

如果 ISR 调用 RingBufferPush,主循环调用 RingBufferPop,双方都会修改 count。即使把变量声明为 volatile,也只能阻止编译器省略访问,不能自动保证多个读改写步骤的原子性。

真实项目需要进一步明确:

  1. 是否严格限制为单生产者、单消费者。
  2. 哪些变量分别只由生产者或消费者写入。
  3. count 的更新是否需要临界区保护。
  4. 缓冲区满时是丢弃新数据、覆盖旧数据,还是记录溢出计数。
  5. 是否需要用 DMA、FreeRTOS 队列或任务通知替代当前结构。

这些属于后续硬件和并发验证,不能因为主机测试通过就直接认为已经解决。

九、复杂度和资源占用

当前实现中:

  • 单次写入时间复杂度:O(1)。
  • 单次读取时间复杂度:O(1)。
  • 获取可读数量:O(1)。
  • 缓冲区空间复杂度:O(n),其中 n 是固定容量。
  • 操作过程额外空间:O(1)。

它不需要在每次读取后移动剩余数据,也不需要动态申请内存。这种执行时间稳定、内存占用固定的结构很适合资源受限的 MCU。

今天的收获

今天主要确认了以下几点:

  1. head 是下一个写入位置,tail 是下一个读取位置。
  2. 使用 count 可以直接判断空和满,并利用全部数组容量。
  3. 通过取模让下标在数组末尾回到开头。
  4. 环形缓冲区本质上是在固定数组上实现 FIFO。
  5. UART 中断和协议解析可以通过缓冲区解耦。
  6. 主机单线程测试通过,不代表真实 ISR 并发已经安全。

总结

Day 4 完成了一个容量为 8 字节的环形缓冲区,实现了初始化、可读数量查询、单字节写入和单字节读取,并通过了空、满、FIFO 和下标回绕测试。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐