一、为什么嵌入式系统需要环形缓冲区
有这么一个场景:
主控的一个串口,负责接收源源不断发过来的数据,而这份数据对系统运行十分重要,需要进行复杂的解析计算和处理。如果把数据放在ISR中处理,会产生两个问题:
1、中断回调函数长时间运行无法退出。复杂的逻辑计算会阻塞其它中断,导致系统实时性降低甚至崩溃。
2、造成数据丢失。如果主循环在忙着做其他事务处理,但新数据来了,主控来不及处理,也没地方存,那么数据就会丢失。
这两个问题是嵌入式开发中的经典问题。而环形缓冲区就是解决这两个问题的利器,它的作用就是解耦 。怎么理解解耦呢?其实就是解生产者 和消费者之间的耦!
1、生产者就像ISR,只需要把数据以最快速度扔进缓冲区。
2、消费者就像是主循环,有空时就从缓冲区取出数据做计算处理。
总结:它允许数据的写入和读取在时间上是不同的,异步的,避免数据丢失和系统阻塞。
二、环形缓冲区的工作原理
环形缓冲区本质上是一个线性数组,但我们通过逻辑上的操作,让它的"头"和"尾"相连,形成一个圆环。
核心构成:
数组buffer:实际存储数据的内存区域。
头指针head(也叫写指针):指向下一个写入数据的位置。
尾指针tail(也叫读指针):指向下一个读取数据的位置。
工作逻辑:
初始化时:head = tail = 0。
写入数据:将数据存入 head 指向的位置,head 向前移动一格。如果 head 到了数组末尾,它会自动跳回数组开头。
读取数据:读取 tail 指向的数据,tail 向前移动一格。同样,tail 到了末尾也会跳回开头。
环形缓冲区的**"环"**这一概念就来自于头指针和尾指针会重新回到开头,循环往复构成的一个环。
状态判断:
这个是一个关键点,因为空闲状态和满状态的都符合head == tail 这一条件,于是为 了区分,通常会留一个存储单元不用,满的状态使用**"(head + 1) % size == tail"** 来判断。(这里的size指的是数组的总容量,即物理长度)
如何理解这个"(head + 1) % size == tail"呢?我们定下一个规定:当缓冲区只剩下一个位置没被写入时,我们就认为它"满"了。永远不动最后那一个位置。这样满状态就定义就变成了:head的下一个位置就是tail。
牺牲这一个字节的意义在哪里呢?从算法与数据结构的角度上看,就是拿空间换时间。因为不使用这种方法的话,就需要引入一个计数变量Count,并在主循环和中断 并发的环境下,需要对其加锁维护,防止其被打断导致计算错误。而在嵌入式系统这 种有限资源的场景下,追求高效率,易维护,且head若只由一个生产者修改,tail只由一个消费者修改,牺牲这个一个字节的方法也便于在各类架构上的移植,提高效率。
但若是有多处写,多处读的情况,则必须加锁维护,如互斥量,保证系统运行安全。
三、代码逻辑实现
头文件:
cpp
#ifndef _RING_BUFFER_H
#define _RING_BUFFER_H
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#define RING_BUFFER_SIZE 128
typedef struct {
uint8_t buffer[RING_BUFFER_SIZE];
volatile uint16_t head; // 写入位置,使用volatile是为了防止被编译器优化
volatile uint16_t tail; // 读取位置
} RingBuffer_t;
void rbuffer_Init(RingBuffer_t* rb);
bool rbuffer_Write(RingBuffer_t* rb, uint8_t data);
bool rbuffer_Read(RingBuffer_t* rb, uint8_t* data);
bool rbuffer_IsEmpty(RingBuffer_t* rb);
bool rbuffer_IsFull(RingBuffer_t* rb);
uint16_t rbuffer_GetCount(RingBuffer_t* rb);
#endif
源文件:
cpp
#include "ring_buffer.h"
//环形缓冲区初始化
void rbuffer_Init(RingBuffer_t* rb) {
rb->head = 0;
rb->tail = 0;
memset(rb->buffer, 0, RING_BUFFER_SIZE);
}
//环形缓冲区是否满
bool rbuffer_IsFull(RingBuffer_t* rb) {
// 判满逻辑:Head的下一个位置是Tail
return ((rb->head + 1) % RING_BUFFER_SIZE) == rb->tail;
}
//环形缓冲区是否空
bool rbuffer_IsEmpty(RingBuffer_t* rb) {
return rb->head == rb->tail;
}
//数据写入环形缓冲区
bool rbuffer_Write(RingBuffer_t* rb, uint8_t data) {
if (rbuffer_IsFull(rb)) {
return false; // 缓冲区满,写入失败
}
rb->buffer[rb->head] = data;
rb->head = (rb->head + 1) % RING_BUFFER_SIZE; // 索引回绕
return true;
}
//从环形缓冲区读数据
bool rbuffer_Read(RingBuffer_t* rb, uint8_t* data) {
if (rbuffer_IsEmpty(rb)) {
return false; // 缓冲区空,读取失败
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE; // 索引回绕
return true;
}
//环形缓冲区中的数据个数
uint16_t rbuffer_GetCount(RingBuffer_t* rb) {
if (rb->head >= rb->tail) {
return rb->head - rb->tail;
} else {
return RING_BUFFER_SIZE - (rb->tail - rb->head);
}
}
代码都是很简单的逻辑,没有什么难以理解的地方。只需注意两点,一个是head和tail必须使用volatile修饰,告诉编译器我可能会被中断随时修改,不要优化我。
四、实际案例
结合实际场景体现环形缓冲区的作用:代码都是伪代码,只有思路。
1、UART 通信(中断接收,主循环处理)
cpp
RingBuffer_t uart_rb;
/* 生产者:UART接收中断 (ISR) */
void UART_ISR(void) {
// 硬件标志位判断
uint8_t ch = UART_GetByte();
// 关键点:快速写入,不做复杂逻辑,防止阻塞其他中断
rbuffer_Write(&uart_rb, ch);
}
/* 消费者:主循环 */
void Main_Task(void) {
uint8_t data;
while (1) {
// 检查缓冲区是否有数据
if (!rbuffer_IsEmpty(&uart_rb)) {
// 逐字节取出
if (rbuffer_Read(&uart_rb, &data)) {
// 进入解析数据
Protocol_data(data);
}
}
}
}
2、传感器采集(ADC高速采样,后台低速计算)
cpp
bool rbuffer_Write_16Bit(RingBuffer* rb, uint16_t data) {
// 检查剩余空间是否足够存2个字节
// Size - 1 是最大可用容量,GetCount是已用空间
uint16_t free_space = (RING_BUFFER_SIZE - 1) - RB_GetCount(rb);
if (free_space < 2) {
return false; // 空间不够,写入失败
}
// 小端模式:先存低位
uint8_t low_byte = data & 0xFF;
uint8_t high_byte = (data >> 8) & 0xFF;
// 复用基础的 Write 函数
RB_Write(rb, low_byte);
RB_Write(rb, high_byte);
return true;
}
bool rbuffer_Read_16Bit(RingBuffer* rb, uint16_t* data) {
// 检查是否有足够的数据(至少2个字节)
if (RB_GetCount(rb) < 2) {
return false; // 数据不足
}
uint8_t low_byte, high_byte;
// 依次读出
RB_Read(rb, &low_byte);
RB_Read(rb, &high_byte);
// 组合数据
*data = (uint16_t)low_byte | ((uint16_t)high_byte << 8);
return true;
}
cpp
#define BATCH_SIZE 50 // 每次处理50个采样点
RingBuffer adc_rb;
/* 生产者:定时器/ADC中断*/
void Timer_ADC_IRQHandler(void) {
uint16_t adc_val = ADC_Read();
rbuffer_Write_16Bit(&adc_rb, adc_val);
}
/* 消费者:后台算法任务 */
void Algorithm_Task(void) {
uint16_t samples[BATCH_SIZE];
while(1) {
// 只有当缓冲区里的数据够攒够一波时,才开始计算
if (rbuffer_GetCount(&adc_rb) >= BATCH_SIZE) {
// 批量读取
for(int i=0; i<BATCH_SIZE; i++) {
rbuffer_Read_16Bit(&adc_rb, &samples[i]);
}
// 滤波,计算求均值
averege(samples, BATCH_SIZE);
}
}
}
3、 DMA 使用(双缓冲区)
这是环形缓冲区的硬件形态。使用 DMA 的"半传输中断"和"传输完成中断",将一个线性数组在逻辑上切分为"前半段"和"后半段",形成一个由硬件自动维护的 2 级环形缓冲。
cpp
/* 定义双倍长度的缓冲区 */
#define CHUNK_SIZE 512
uint8_t dma_buffer[CHUNK_SIZE * 2];
/* 初始化 DMA,开启循环模式 (Circular Mode) */
void DMA_Audio_Init() {
// 配置DMA源为I2S/ADC,目标为 dma_buffer,长度为 CHUNK_SIZE * 2
HAL_DMA_Start_IT(&hdma_i2s_rx, (uint32_t)&I2S_DR, (uint32_t)dma_buffer, CHUNK_SIZE * 2);
}
/* 半传输完成中断 (Half Transfer) - DMA填满了前半段 */
void HAL_DMA_RxHalfCpltCallback(DMA_HandleTypeDef *hdma) {
// 此时硬件在写后半段,CPU安全地处理前半段
Process_Audio_Data(&dma_buffer[0], CHUNK_SIZE);
}
/* 传输完成中断 (Transfer Complete) - DMA填满了后半段,并回绕到开头 */
void HAL_DMA_RxCpltCallback(DMA_HandleTypeDef *hdma) {
// 此时硬件回绕去写前半段,CPU安全地处理后半段
Process_Audio_Data(&dma_buffer[CHUNK_SIZE], CHUNK_SIZE);
}
四、总结
环形缓冲区一句话总结就是类似一个蓄水池,起到削峰填谷的作用。使用时,要注意保护生产者和消费者的原子性和不要忘了加上volatile修饰变量。