环形缓冲区(Ring Buffer)完全指南:从原理到实用实现
在嵌入式系统、串口通信、音频处理等高频数据场景中,数据生产者与消费者的速度往往不匹配。环形缓冲区(Ring Buffer)是一种经典的无锁数据结构,能够高效地解决这种速率适配问题。本文将从零开始,手把手实现一个高性能、可配置的环形缓冲区,并深入其设计原理。
1. 最简单的线性缓冲区
在处理串口通信读取的数据时,通常会遇到解析协议包、CRC校验、打印调试信息等相关操作,初学时习惯性的将这些函数放到串口中断里进行处理,这会出现当串口发完一帧数据时,中断并没有处理完成数据时,下一帧数据就已经提前到来了,导致数据丢失。因此,可以采用缓冲区来写入接收到的数据,使用时,通过读取缓冲区来处理接收到的数据,在串口中断中,只把接收到的数据**"压入"缓冲区,然后退出中断。在主循环**(或者task)里轮询缓冲区进行数据的解析处理。
首先,根据定义创建一个数据缓冲区uint8_t *buf;并初始化uint8_t buffer[16];
在进行数据的读写时采用读取数组下标的形式进行读写操作,因此这里需要设置写索引(wr_idx)和读索引(rd_idx),通过buffer[wr_idx++] = data;和data = buffer[rd_idx++];的形式进行数据的读写。
当写入一个数据的时候例如写入0xAA则变化为
写入一个字节:
buffer[wr_idx++] = data;
读取一个字节:data = buffer[rd_idx++];
但是运行一段时间后会出现问题,即wr_idx和rd_idx会不断增大,当它们达到BUFFER_SIZE时,就无法再写入或读取了。数组的内存空间在程序后面将不会再次被使用导致内存浪费,即不能重复使用。
2. 改进:让数组首尾相连
可以通过取模运算让指针在到达末尾后自动绕回到开头,这样数组在逻辑上变成了一个圆环。
| 索引 idx | 取模结果 (idx % BUFFER_SIZE) |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| 6 | 6 |
| … | … |
| 14 | 14 |
| 15 | 15 |
| 16 | 0 |
| 17 | 1 |
假设BUFFER_SIZE = 16
根据上述表格可知,当wr_idx和rd_idx不断增大时,即使越界仍然可以正常的指向buffer的下标区间即[0, BUFFER_SIZE-1]区间内,这就将线性缓冲区变成了环形缓冲区。
当wr_idx % BUFFER_SIZE == rd_idx% BUFFER_SIZE;根据取模运算结果表可以发现缓冲区存在两种可能,即wr_idx == 0 rd_idx == 0或wr_idx == 16 rd_idx == 16,这表示缓冲区可能是空,也可能是满(写指针追上了读指针)因此要想办法区分空和满。
这里可以通过留一个空位的方式来进行区分,即始终保留一个元素不使用,当(wr_idx+1)%BUFFER_SIZE == rd_idx%BUFFER_SIZE时为满,这种设置方式最为简单,但是会浪费一个存储单元。
3. 取模性能优化:使用位与运算
取模运算%在 CPU 上开销较大(几十个时钟周期)。但是如果限制缓冲区大小为2 的幂(如 64、128、1024),就可以用位与运算&代替取模,后者仅需 1 个时钟周期。
| idx | idx % 16 | idx & 15 | 是否相等 |
|---|---|---|---|
| 0 | 0 | 0 | 是 |
| 1 | 1 | 1 | 是 |
| 2 | 2 | 2 | 是 |
| 3 | 3 | 3 | 是 |
| 4 | 4 | 4 | 是 |
| 5 | 5 | 5 | 是 |
| 6 | 6 | 6 | 是 |
| 7 | 7 | 7 | 是 |
| 8 | 8 | 8 | 是 |
| 9 | 9 | 9 | 是 |
| 10 | 10 | 10 | 是 |
| 11 | 11 | 11 | 是 |
| 12 | 12 | 12 | 是 |
| 13 | 13 | 13 | 是 |
| 14 | 14 | 14 | 是 |
| 15 | 15 | 15 | 是 |
| 16 | 0 | 0 | 是 |
| 17 | 1 | 1 | 是 |
| 18 | 2 | 2 | 是 |
| 19 | 3 | 3 | 是 |
| 20 | 4 | 4 | 是 |
| 21 | 5 | 5 | 是 |
| 22 | 6 | 6 | 是 |
| 23 | 7 | 7 | 是 |
| 24 | 8 | 8 | 是 |
| 25 | 9 | 9 | 是 |
| 26 | 10 | 10 | 是 |
| 27 | 11 | 11 | 是 |
| 28 | 12 | 12 | 是 |
| 29 | 13 | 13 | 是 |
| 30 | 14 | 14 | 是 |
| 31 | 15 | 15 | 是 |
表1:BUFFER_SIZE = 16(2 的幂,BUFFER_SIZE-1 = 15= 0b1111)
当
BUFFER_SIZE为 2 的幂时,取模运算等价于位与运算,结果完全一致。
| idx | idx % 15 | idx & 14 | 是否相等 |
|---|---|---|---|
| 0 | 0 | 0 | 是 |
| 1 | 1 | 0 | 否 |
| 2 | 2 | 2 | 是 |
| 3 | 3 | 2 | 否 |
| 4 | 4 | 4 | 是 |
| 5 | 5 | 4 | 否 |
| 6 | 6 | 6 | 是 |
| 7 | 7 | 6 | 否 |
| 8 | 8 | 8 | 是 |
| 9 | 9 | 8 | 否 |
| 10 | 10 | 10 | 是 |
| 11 | 11 | 10 | 否 |
| 12 | 12 | 12 | 是 |
| 13 | 13 | 12 | 否 |
| 14 | 14 | 14 | 是 |
| 15 | 0 | 14 | 否 |
| 16 | 1 | 0 | 否 |
| 17 | 2 | 0 | 否 |
| 18 | 3 | 2 | 否 |
| 19 | 4 | 2 | 否 |
| 20 | 5 | 4 | 否 |
| 21 | 6 | 4 | 否 |
| 22 | 7 | 6 | 否 |
| 23 | 8 | 6 | 否 |
| 24 | 9 | 8 | 否 |
| 25 | 10 | 8 | 否 |
| 26 | 11 | 10 | 否 |
| 27 | 12 | 10 | 否 |
| 28 | 13 | 12 | 否 |
| 29 | 14 | 12 | 否 |
| 30 | 0 | 14 | 否 |
| 31 | 1 | 14 | 否 |
表2:BUFFER_SIZE = 15(非 2 的幂,BUFFER_SIZE-1 = 14= 0b1110)
当
BUFFER_SIZE不是 2 的幂时,位与运算只保留二进制低位(且最低位恒为 0),结果通常与取模不同,不可互换。仅在少数偶数索引上偶然相等(如 idx 为 0,2,4,6,8,10,12,14 时两者相等,但 idx=16 时取模 1 而位与 0 已不相等)。
通过取模运算和位与运算比较表可以得知,当缓冲区大小为2的幂时,完全可以通过位与运算替代取模运算。
这里建议设置大小时,设置为2的幂。
4. 写入策略
环形缓冲区写满后,后续写入行为有两种处理策略,需根据数据重要性与实时性选择。
拒绝模式(REJECT)
- 行为:缓冲区已满时,新写入操作失败,数据被丢弃,原有数据保持不变。
- 适用场景:命令传输、配置下发、事务日志等必须保证每条数据完整不丢失的场景。
- 原因:数据丢失可能导致状态错误或流程中断,拒绝写入能触发上层重试或流控,确保数据可达。
- 选择结论:数据完整性要求高于实时性时,采用REJECT。
覆盖模式(OVERWRITE)
- 行为:缓冲区已满时,新数据覆盖最旧的未读数据,始终保留最近写入的数据。
- 适用场景:传感器采样、音视频流、实时监控等数据量大、旧数据价值低的场景。
- 原因:最新数据比历史数据更有意义,允许丢弃旧数据以保证系统持续运行,避免阻塞生产者。
- 选择结论:数据新鲜度与持续写入能力优先时,采用OVERWRITE。
要完整性用拒绝,要实时性用覆盖。3. 数据结构定义
5. 环形缓冲区实现
5.1 定义结构体以及相关宏
创建ringbuf.h头文件,定义结构体及相关宏。
#ifndefRINGBUF_H#defineRINGBUF_H#include<stdint.h>#include<stdbool.h>// 写入模式配置#defineRINGBUF_MODE_REJECT0// 满时拒绝写入#defineRINGBUF_MODE_OVERWRITE1// 满时覆盖旧数据typedefstructringbuf{volatileuint32_twr_idx;// 写索引,生产者修改volatileuint32_trd_idx;// 读索引,消费者修改uint8_t*data;// 数据缓冲区(外部提供)uint32_tsize;// 缓冲区大小(建议为2的幂)uint32_tmask;// size - 1(当size为2的幂时使用)uint8_tmode;// 写入模式bool power_of_two;// 标记size是否为2的幂}ringbuf_t;/** * @brief 初始化环形缓冲区 * * @param rb 环形缓冲区对象指针 * @param buf 外部提供的字节数组,用作数据存储区 * @param size 缓冲区大小(字节数) * @param mode 写入模式:RINGBUF_MODE_REJECT 或 RINGBUF_MODE_OVERWRITE */voidringbuf_init(ringbuf_t*rb,uint8_t*buf,uint32_tsize,uint8_tmode);/** * @brief 向环形缓冲区写入一个字节 * * @param rb 环形缓冲区对象指针 * @param c 待写入的字节数据 * @return true 写入成功(拒绝模式下非满,或覆盖模式下总是成功) * @return false 写入失败(仅拒绝模式下缓冲区已满时返回false) */boolringbuf_write(ringbuf_t*rb,uint8_tc);/** * @brief 从环形缓冲区读取一个字节 * * @param rb 环形缓冲区对象指针 * @param c 输出指针,用于存储读取到的字节 * @return true 读取成功(缓冲区非空) * @return false 读取失败(缓冲区为空) */boolringbuf_read(ringbuf_t*rb,uint8_t*c);/** * @brief 获取环形缓冲区中当前有效数据的字节数 * * @param rb 环形缓冲区对象指针 * @return uint32_t 当前数据长度(0 ~ size) */uint32_tringbuf_count(constringbuf_t*rb);/** * @brief 判断环形缓冲区是否为空 * * @param rb 环形缓冲区对象指针 * @return true 缓冲区为空 * @return false 缓冲区非空 */boolringbuf_empty(constringbuf_t*rb);/** * @brief 判断环形缓冲区是否为满(仅对拒绝模式有意义) * * @param rb 环形缓冲区对象指针 * @return true 缓冲区已满(拒绝模式下无法再写入;覆盖模式下始终返回false,因为始终可写) * @return false 缓冲区未满 */boolringbuf_full(constringbuf_t*rb);/** * @brief 复位环形缓冲区(清空数据,重置读写索引) * * @param rb 环形缓冲区对象指针 */voidringbuf_reset(ringbuf_t*rb);#endif// RINGBUF_H关键点说明:
volatile关键字:防止编译器优化,确保多线程/中断中索引的可见性。data由外部提供:允许用户自定义内存分配策略(静态数组、malloc 等)。power_of_two:在初始化时自动检测,运行时用于选择最优取模方法。
5.2 初始化函数实现
在ringbuf.c中实现初始化逻辑。
#include"ringbuf.h"#include<stddef.h>#include<assert.h>// 内部取模函数:根据是否为2的幂选择位运算或取模staticinlineuint32_tringbuf_mod(constringbuf_t*rb,uint32_tval){if(rb->power_of_two)returnval&rb->mask;//使用按位与elsereturnval%rb->size;//使用取模符号}voidringbuf_init(ringbuf_t*rb,uint8_t*buf,uint32_tsize,uint8_tmode){assert(rb!=NULL&&buf!=NULL&&size>0);rb->wr_idx=0;rb->rd_idx=0;rb->data=buf;rb->size=size;rb->mode=mode;// 判断 size 是否为 2 的幂:size & (size-1) == 0rb->power_of_two=((size&(size-1))==0);rb->mask=rb->power_of_two?(size-1):0;}取模优化原理:
当size = 2^n时,size - 1的低 n 位全为 1。例如size = 8(二进制 1000),mask = 7(0111)。index & mask等价于index % 8,但位运算仅需 1 个 CPU 周期,而除法需要数十个周期。
5.3 写入字节:拒绝模式与覆盖模式
这是环形缓冲区的核心操作。根据配置的mode处理缓冲区满的情况。
boolringbuf_write(ringbuf_t*rb,uint8_tc){assert(rb!=NULL&&rb->data!=NULL);uint32_tnext_wr=rb->wr_idx+1;uint32_tcur_rd=rb->rd_idx;if(rb->mode==RINGBUF_MODE_REJECT){// 检查是否满(留一个空位)if(ringbuf_mod(rb,next_wr)==ringbuf_mod(rb,cur_rd))returnfalse;// 满,拒绝写入}else{// OVERWRITE 模式// 如果满,则移动读索引(丢弃最旧数据)if(ringbuf_mod(rb,next_wr)==ringbuf_mod(rb,cur_rd))rb->rd_idx++;// 单调递增,仅在访问数组时取模}// 数据写入当前写指针位置rb->data[ringbuf_mod(rb,rb->wr_idx)]=c;rb->wr_idx=next_wr;returntrue;}当
(wr_idx + 1) % size == rd_idx % size时视为满
5.4 读取字节
读取操作简单:检查是否为空,非空则取出数据并移动读指针。
boolringbuf_read(ringbuf_t*rb,uint8_t*c){assert(rb!=NULL&&c!=NULL);if(rb->rd_idx==rb->wr_idx)returnfalse;// 空*c=rb->data[ringbuf_mod(rb,rb->rd_idx)];rb->rd_idx++;returntrue;}注意:
rd_idx同样是单调递增的,允许溢出。由于我们使用无符号整数,溢出后的减法仍能得到正确差值(环形特性)。
5.5 辅助函数:计数、判空、判满
uint32_tringbuf_count(constringbuf_t*rb){assert(rb!=NULL);uint32_tdiff=rb->wr_idx-rb->rd_idx;if(rb->power_of_two)returndiff&rb->mask;// 位运算优化elsereturndiff%rb->size;}boolringbuf_empty(constringbuf_t*rb){assert(rb!=NULL);returnrb->rd_idx==rb->wr_idx;}boolringbuf_full(constringbuf_t*rb){assert(rb!=NULL);if(rb->mode==RINGBUF_MODE_OVERWRITE)returnfalse;// 覆盖模式下永远不会“满”// REJECT 模式:判断是否留一个空位returnringbuf_mod(rb,rb->wr_idx+1)==ringbuf_mod(rb,rb->rd_idx);}voidringbuf_reset(ringbuf_t*rb){assert(rb!=NULL);rb->wr_idx=0;rb->rd_idx=0;// 注意:不清空 data 缓冲区内容(通常不需要)}计数公式原理:
(wr_idx - rd_idx) % size能够正确处理索引回绕。例如:size = 8,wr_idx = 10(实际下标 2),rd_idx = 7(实际下标 7)。差值10-7=3,3%8=3,正确。当wr_idx溢出为 0 而rd_idx很大时,无符号减法自动得到补码差值,再取模依然正确。
5.6 使用示例(串口接收 + 主循环处理)
#include"ringbuf.h"#defineUART_BUF_SIZE64staticuint8_tuart_buffer[UART_BUF_SIZE];staticringbuf_tuart_rb;// 串口中断服务函数voidUART_IRQHandler(void){uint8_tdata=USART_ReceiveData();ringbuf_write(&uart_rb,data);// 根据需求选择覆盖或拒绝模式}// 主循环intmain(void){// 使用覆盖模式,防止中断阻塞ringbuf_init(&uart_rb,uart_buffer,UART_BUF_SIZE,RINGBUF_MODE_OVERWRITE);while(1){uint8_tbyte;while(ringbuf_read(&uart_rb,&byte)){process_byte(byte);// 协议解析、CRC等耗时操作}// 执行其他任务}}6. 总结
环形缓冲区是嵌入式系统中解决数据流速率不匹配问题的常见数据结构。本文从最简单的线性数组出发,逐步揭示了其无法复用内存的缺陷,进而引出索引回绕、取模运算、空/满状态区分(留空位法)以及性能优化(2 的幂 + 位与运算)。同时,根据业务需求给出了两种写入策略:拒绝模式保证数据完整性,覆盖模式确保数据新鲜度。
实现要点回顾:
- 使用单调递增的读写索引(允许溢出),仅在访问数组时取模。
- 通过“留一个空位”区分缓冲区空和满。
- 缓冲区大小建议设为 2 的幂,以便用位与运算替代取模,提升性能。
volatile关键字保证索引在中断/多线程中的可见性。- 代码适用于单生产者、单消费者(SPSC)场景,如“中断写入 + 主循环读取”。
注意事项:
- 若有多个中断源同时写入,或生产者在多任务环境下运行,需要额外增加临界区保护(如关中断、互斥锁)。
- 在弱内存模型架构(如 ARM、RISC-V)上,若生产者和消费者运行在不同优先级,可视情况插入内存屏障。
- 本文提供的代码已包含基本的参数断言(
assert),可在调试阶段帮助发现错误,正式发布时可定义NDEBUG关闭断言。
掌握环形缓冲区的基本原理和实现细节,能够帮助开发者高效应对串口通信、音频流、数据采集等各种嵌入式场景。希望本文成为您构建可靠嵌入式系统的一份实用参考。
6.1 完整代码分享
百度网盘