C 语言基础回炉第四天:环形缓冲区、FIFO 与 UART 接收
前言
前三天主要在练位运算、指针、数组和基础算法。今天开始进入一个更贴近嵌入式开发的结构:环形缓冲区。
环形缓冲区经常出现在 UART 接收、传感器采样、日志缓存等场景中。它不需要频繁移动数据,也不依赖动态内存,可以使用固定大小的数组持续接收和取出数据。
今天完成了四个函数:
RingBufferInitRingBufferAvailableRingBufferPushRingBufferPop
本文只记录当前代码和测试能够证明的内容。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,而是牺牲一个槽位,通过 head 和 tail 的关系区分空和满。两种方法都可以,但状态定义必须从一开始就统一。
三、初始化和可读数据量
初始化时没有必要清空整个数组,因为 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 个新字节会利用数组前面已经释放的位置。
逻辑顺序仍然是:
剩余旧数据 -> 后写入的新数据
即使物理存储已经从数组末尾回到开头,tail 和 count 仍能保证读取顺序正确。
当容量是 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,也只能阻止编译器省略访问,不能自动保证多个读改写步骤的原子性。
真实项目需要进一步明确:
- 是否严格限制为单生产者、单消费者。
- 哪些变量分别只由生产者或消费者写入。
count的更新是否需要临界区保护。- 缓冲区满时是丢弃新数据、覆盖旧数据,还是记录溢出计数。
- 是否需要用 DMA、FreeRTOS 队列或任务通知替代当前结构。
这些属于后续硬件和并发验证,不能因为主机测试通过就直接认为已经解决。
九、复杂度和资源占用
当前实现中:
- 单次写入时间复杂度:O(1)。
- 单次读取时间复杂度:O(1)。
- 获取可读数量:O(1)。
- 缓冲区空间复杂度:O(n),其中 n 是固定容量。
- 操作过程额外空间:O(1)。
它不需要在每次读取后移动剩余数据,也不需要动态申请内存。这种执行时间稳定、内存占用固定的结构很适合资源受限的 MCU。
今天的收获
今天主要确认了以下几点:
head是下一个写入位置,tail是下一个读取位置。- 使用
count可以直接判断空和满,并利用全部数组容量。 - 通过取模让下标在数组末尾回到开头。
- 环形缓冲区本质上是在固定数组上实现 FIFO。
- UART 中断和协议解析可以通过缓冲区解耦。
- 主机单线程测试通过,不代表真实 ISR 并发已经安全。
总结
Day 4 完成了一个容量为 8 字节的环形缓冲区,实现了初始化、可读数量查询、单字节写入和单字节读取,并通过了空、满、FIFO 和下标回绕测试。
更多推荐


所有评论(0)