环形缓冲区(Circular Buffer/Ring Buffer)是嵌入式系统中最基础、最常用的数据结构之一,它以固定大小的内存空间实现了高效的先进先出(FIFO)数据管理,完美解决了嵌入式系统中普遍存在的速度不匹配 、异步通信 和突发数据处理问题。
一、环形缓冲区在嵌入式系统中的核心作用
1. 解决速度不匹配问题
这是环形缓冲区最根本的作用。嵌入式系统中,外设与 CPU、不同模块之间的处理速度往往存在巨大差异:
- 高速 ADC 以 1MHz 采样,而 CPU 处理数据的速度可能只有 100kHz
- UART 以 115200bps 接收数据,而主循环可能需要几十毫秒才能处理一次
- 传感器以固定速率输出数据,而应用层可能需要批量处理
环形缓冲区作为 "蓄水池",可以暂时存储高速产生的数据,让低速处理模块能够从容地逐步读取。
2. 实现异步通信与解耦
环形缓冲区将数据的生产 和消费过程完全解耦:
- 生产者(如 UART 中断)只需要将数据写入缓冲区,不需要关心消费者何时处理
- 消费者(如主循环)只需要从缓冲区读取数据,不需要关心数据何时产生
- 两者可以在不同的时间、不同的优先级甚至不同的任务中运行
3. 减少中断服务程序 (ISR) 的执行时间
在嵌入式系统中,中断服务程序的执行时间必须尽可能短,否则会影响系统的实时性甚至导致数据丢失。使用环形缓冲区后:
- ISR 中只需要执行简单的 "数据写入缓冲区" 操作,耗时微秒级
- 复杂的数据解析、处理和业务逻辑全部放在主循环或低优先级任务中执行
- 这是嵌入式系统中断设计的最佳实践之一
4. 缓冲突发数据
许多嵌入式应用会遇到突发数据流量:
- 蓝牙模块一次接收一个完整的数据包(几十到几百字节)
- 传感器在触发后连续输出多个采样值
- 网络接口收到一个大的以太网帧
环形缓冲区可以吸收这些突发数据,避免在处理不及时时发生丢失。
5. 实现数据的流水线处理
通过多个环形缓冲区串联,可以构建数据处理流水线:
- 缓冲区 1:存储原始 ADC 采样数据
- 缓冲区 2:存储滤波后的数据
- 缓冲区 3:存储计算后的结果
- 每个处理阶段独立运行,提高系统整体吞吐量
二、环形缓冲区的主要应用领域
在嵌入式系统中,几乎所有涉及数据传输和处理的地方都能看到环形缓冲区的身影:
| 应用领域 | 典型使用场景 |
|---|---|
| 串行通信 | UART/USART、I2C、SPI、CAN、LIN、RS485 等接口的收发缓冲 |
| 传感器数据采集 | ADC 采样、加速度计、陀螺仪、温度传感器等高速数据采集 |
| 音频处理 | 麦克风输入、扬声器输出、音频编解码、语音识别 |
| 网络通信 | TCP/IP 协议栈、以太网、Wi-Fi、蓝牙、Zigbee 等无线通信 |
| 人机交互 | 按键扫描、触摸屏输入、LED 显示、LCD 刷屏 |
| 日志系统 | 调试日志、运行日志的缓存和输出 |
| 实时操作系统 | 任务间通信、消息队列的底层实现 |
| 工业控制 | PLC 数据采集、Modbus 通信、运动控制指令缓存 |
三、环形缓冲区的基本原理
环形缓冲区本质上是一个固定大小的数组,配合两个指针(或索引)来管理数据的读写:
- 写指针 (Write Pointer):指向缓冲区中下一个可写入的位置
- 读指针 (Read Pointer):指向缓冲区中下一个可读取的位置
当写指针到达数组末尾时,会自动绕回到数组开头,形成一个 "环形" 结构。
空满判断的核心问题
环形缓冲区实现中最关键的问题是如何区分 "缓冲区空" 和 "缓冲区满" 两种状态,因为这两种状态下读指针和写指针的值都是相等的。
常见的解决方案有三种:
1. 牺牲一个元素的空间(最常用)
- 当写指针的下一个位置等于读指针时,认为缓冲区满
- 当写指针等于读指针时,认为缓冲区空
- 优点:实现简单,不需要额外变量,速度快
- 缺点:缓冲区实际可用大小比定义的大小少 1
2. 使用一个计数器
- 增加一个变量记录缓冲区中当前元素的数量
- 当计数器等于缓冲区大小时,认为满
- 当计数器等于 0 时,认为空
- 优点:缓冲区可以完全利用
- 缺点:需要额外的变量,读写操作都需要修改计数器
3. 使用一个标志位
- 增加一个布尔变量标记缓冲区是否满
- 当写指针追上读指针时,将标志位置为真
- 当读指针追上写指针时,将标志位置为假
- 优点:缓冲区可以完全利用
- 缺点:逻辑相对复杂
在嵌入式系统中,牺牲一个元素空间的方法是最常用的,因为它实现最简单、执行效率最高,而牺牲一个字节的空间在大多数情况下完全可以接受。
四、STM32 上的环形缓冲区实现
下面以 STM32 单片机为例,提供一个通用、高效、中断安全的环形缓冲区实现,并展示其在 UART 通信中的典型应用。
1. 通用环形缓冲区的实现
我们采用牺牲一个元素空间的方法来实现,支持任意数据类型和任意缓冲区大小。
ring_buffer.h
cpp
#ifndef __RING_BUFFER_H
#define __RING_BUFFER_H
#include "stdint.h"
#include "string.h"
/* 环形缓冲区结构体 */
typedef struct {
uint8_t *buffer; /* 缓冲区数据存储区 */
uint16_t size; /* 缓冲区总大小 */
uint16_t write; /* 写指针 */
uint16_t read; /* 读指针 */
} ring_buffer_t;
/* 函数声明 */
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buffer, uint16_t size);
uint16_t ring_buffer_write(ring_buffer_t *rb, const uint8_t *data, uint16_t len);
uint16_t ring_buffer_read(ring_buffer_t *rb, uint8_t *data, uint16_t len);
uint16_t ring_buffer_get_used(ring_buffer_t *rb);
uint16_t ring_buffer_get_free(ring_buffer_t *rb);
uint8_t ring_buffer_is_empty(ring_buffer_t *rb);
uint8_t ring_buffer_is_full(ring_buffer_t *rb);
void ring_buffer_clear(ring_buffer_t *rb);
#endif /* __RING_BUFFER_H */
ring_buffer.c
cpp
#include "ring_buffer.h"
/**
* @brief 初始化环形缓冲区
* @param rb: 环形缓冲区结构体指针
* @param buffer: 用于存储数据的缓冲区
* @param size: 缓冲区总大小
* @retval None
*/
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buffer, uint16_t size)
{
rb->buffer = buffer;
rb->size = size;
rb->write = 0;
rb->read = 0;
}
/**
* @brief 向环形缓冲区写入数据
* @param rb: 环形缓冲区结构体指针
* @param data: 要写入的数据指针
* @param len: 要写入的数据长度
* @retval 实际写入的数据长度
*/
uint16_t ring_buffer_write(ring_buffer_t *rb, const uint8_t *data, uint16_t len)
{
uint16_t i;
uint16_t free_space = ring_buffer_get_free(rb);
/* 如果剩余空间不足,只写入能放下的部分 */
if (len > free_space) {
len = free_space;
}
for (i = 0; i < len; i++) {
rb->buffer[rb->write] = data[i];
rb->write = (rb->write + 1) % rb->size;
}
return len;
}
/**
* @brief 从环形缓冲区读取数据
* @param rb: 环形缓冲区结构体指针
* @param data: 存储读取数据的缓冲区
* @param len: 要读取的数据长度
* @retval 实际读取的数据长度
*/
uint16_t ring_buffer_read(ring_buffer_t *rb, uint8_t *data, uint16_t len)
{
uint16_t i;
uint16_t used_space = ring_buffer_get_used(rb);
/* 如果可用数据不足,只读取所有可用数据 */
if (len > used_space) {
len = used_space;
}
for (i = 0; i < len; i++) {
data[i] = rb->buffer[rb->read];
rb->read = (rb->read + 1) % rb->size;
}
return len;
}
/**
* @brief 获取环形缓冲区中已使用的空间大小
* @param rb: 环形缓冲区结构体指针
* @retval 已使用的字节数
*/
uint16_t ring_buffer_get_used(ring_buffer_t *rb)
{
return (rb->write - rb->read + rb->size) % rb->size;
}
/**
* @brief 获取环形缓冲区中剩余的空闲空间大小
* @param rb: 环形缓冲区结构体指针
* @retval 空闲的字节数
*/
uint16_t ring_buffer_get_free(ring_buffer_t *rb)
{
/* 牺牲一个元素空间 */
return (rb->read - rb->write - 1 + rb->size) % rb->size;
}
/**
* @brief 判断环形缓冲区是否为空
* @param rb: 环形缓冲区结构体指针
* @retval 1: 空 0: 非空
*/
uint8_t ring_buffer_is_empty(ring_buffer_t *rb)
{
return (rb->write == rb->read) ? 1 : 0;
}
/**
* @brief 判断环形缓冲区是否为满
* @param rb: 环形缓冲区结构体指针
* @retval 1: 满 0: 未满
*/
uint8_t ring_buffer_is_full(ring_buffer_t *rb)
{
return ((rb->write + 1) % rb->size == rb->read) ? 1 : 0;
}
/**
* @brief 清空环形缓冲区
* @param rb: 环形缓冲区结构体指针
* @retval None
*/
void ring_buffer_clear(ring_buffer_t *rb)
{
rb->write = 0;
rb->read = 0;
}
2. 在 STM32 UART 通信中的应用
这是环形缓冲区最经典的应用场景。我们将实现一个带双缓冲区(接收和发送)的 UART 驱动,支持中断方式的高效数据收发。
uart_ring_buffer.h
cpp
#ifndef __UART_RING_BUFFER_H
#define __UART_RING_BUFFER_H
#include "stm32f4xx_hal.h"
#include "ring_buffer.h"
/* 缓冲区大小定义 */
#define UART_RX_BUFFER_SIZE 256
#define UART_TX_BUFFER_SIZE 256
/* 全局变量声明 */
extern UART_HandleTypeDef huart1;
extern ring_buffer_t uart1_rx_rb;
extern ring_buffer_t uart1_tx_rb;
/* 函数声明 */
void uart_ring_buffer_init(void);
uint16_t uart_receive(uint8_t *data, uint16_t len);
uint16_t uart_send(const uint8_t *data, uint16_t len);
uint16_t uart_send_string(const char *str);
#endif /* __UART_RING_BUFFER_H */
uart_ring_buffer.c
cpp
#include "uart_ring_buffer.h"
/* 缓冲区存储区 */
static uint8_t uart1_rx_buffer[UART_RX_BUFFER_SIZE];
static uint8_t uart1_tx_buffer[UART_TX_BUFFER_SIZE];
/* 环形缓冲区结构体 */
ring_buffer_t uart1_rx_rb;
ring_buffer_t uart1_tx_rb;
/**
* @brief 初始化UART环形缓冲区
* @param None
* @retval None
*/
void uart_ring_buffer_init(void)
{
/* 初始化环形缓冲区 */
ring_buffer_init(&uart1_rx_rb, uart1_rx_buffer, UART_RX_BUFFER_SIZE);
ring_buffer_init(&uart1_tx_rb, uart1_tx_buffer, UART_TX_BUFFER_SIZE);
/* 使能UART接收中断 */
HAL_UART_Receive_IT(&huart1, (uint8_t *)&huart1.Instance->DR, 1);
}
/**
* @brief UART接收中断回调函数
* @param huart: UART句柄
* @retval None
*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
uint8_t data;
if (huart->Instance == USART1) {
/* 读取接收到的数据 */
data = (uint8_t)huart->Instance->DR;
/* 写入接收环形缓冲区 */
ring_buffer_write(&uart1_rx_rb, &data, 1);
/* 重新使能接收中断 */
HAL_UART_Receive_IT(huart, (uint8_t *)&huart->Instance->DR, 1);
}
}
/**
* @brief UART发送中断回调函数
* @param huart: UART句柄
* @retval None
*/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
uint8_t data;
if (huart->Instance == USART1) {
/* 从发送环形缓冲区读取一个字节 */
if (ring_buffer_read(&uart1_tx_rb, &data, 1) == 1) {
/* 发送数据 */
HAL_UART_Transmit_IT(huart, &data, 1);
}
}
}
/**
* @brief 从UART接收缓冲区读取数据
* @param data: 存储读取数据的缓冲区
* @param len: 要读取的数据长度
* @retval 实际读取的数据长度
*/
uint16_t uart_receive(uint8_t *data, uint16_t len)
{
return ring_buffer_read(&uart1_rx_rb, data, len);
}
/**
* @brief 通过UART发送数据(非阻塞)
* @param data: 要发送的数据指针
* @param len: 要发送的数据长度
* @retval 实际写入发送缓冲区的数据长度
*/
uint16_t uart_send(const uint8_t *data, uint16_t len)
{
uint16_t written;
uint8_t first_byte;
/* 写入发送环形缓冲区 */
written = ring_buffer_write(&uart1_tx_rb, data, len);
/* 如果发送器空闲,启动第一次发送 */
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TXE) && written > 0) {
ring_buffer_read(&uart1_tx_rb, &first_byte, 1);
HAL_UART_Transmit_IT(&huart1, &first_byte, 1);
}
return written;
}
/**
* @brief 通过UART发送字符串(非阻塞)
* @param str: 要发送的字符串
* @retval 实际写入发送缓冲区的数据长度
*/
uint16_t uart_send_string(const char *str)
{
return uart_send((const uint8_t *)str, strlen(str));
}
3. 主函数中的使用示例
cpp
#include "main.h"
#include "usart.h"
#include "gpio.h"
#include "uart_ring_buffer.h"
int main(void)
{
uint8_t rx_data[128];
uint16_t rx_len;
/* HAL库初始化 */
HAL_Init();
/* 系统时钟配置 */
SystemClock_Config();
/* 外设初始化 */
MX_GPIO_Init();
MX_USART1_UART_Init();
/* 初始化UART环形缓冲区 */
uart_ring_buffer_init();
/* 发送欢迎信息 */
uart_send_string("UART Ring Buffer Demo\r\n");
uart_send_string("Please enter some characters...\r\n");
while (1) {
/* 从接收缓冲区读取数据 */
rx_len = uart_receive(rx_data, sizeof(rx_data));
if (rx_len > 0) {
/* 回显接收到的数据 */
uart_send(rx_data, rx_len);
/* 处理接收到的数据 */
// process_data(rx_data, rx_len);
}
/* 其他任务 */
HAL_Delay(10);
}
}
五、关键注意事项和优化技巧
1. 中断安全问题
在上面的实现中,我们没有使用任何互斥机制,但它在中断环境下是安全的,原因是:
- 写操作只会修改写指针,读操作只会修改读指针
- 指针的修改是原子操作(在 32 位单片机上,16 位整数的赋值是单周期指令)
- 生产者和消费者分别在不同的上下文(中断和主循环)中运行
重要提示:如果有多个生产者或多个消费者同时访问同一个环形缓冲区,就必须添加互斥机制(如关中断、使用信号量等)。
2. 溢出处理策略
当缓冲区满时,上面的实现会丢弃新的数据。在实际应用中,你可以根据需求选择不同的溢出处理策略:
- 丢弃新数据:适用于实时性要求高的场景,如传感器数据采集
- 覆盖旧数据:适用于日志系统等场景,总是保留最新的数据
- 阻塞等待:适用于多任务系统,生产者等待缓冲区有空闲空间
3. 性能优化
- 使用指针操作代替数组索引:可以稍微提高访问速度
- 批量读写:尽量一次读写多个字节,减少函数调用开销
- 使用 DMA 配合环形缓冲区:对于高速数据传输,可以使用 DMA 将数据直接写入环形缓冲区,完全不需要 CPU 干预
4. FreeRTOS 环境下的使用
在使用 FreeRTOS 等实时操作系统时,你有两种选择:
- 使用上面的通用环形缓冲区实现,配合 FreeRTOS 的信号量实现阻塞读写
- 使用 FreeRTOS 提供的
xQueueCreate()等队列函数
自己实现的环形缓冲区通常比 FreeRTOS 队列更轻量、更高效,特别是对于字节流数据。
六、总结
环形缓冲区是嵌入式系统中不可或缺的基础组件,它以简单的实现解决了复杂的异步通信和速度匹配问题。在 STM32 单片机上,环形缓冲区广泛应用于 UART、SPI、I2C、CAN 等各种通信接口,以及传感器数据采集、音频处理等领域。
本文提供的实现是一个通用、高效、中断安全的版本,可以直接应用于大多数 STM32 项目。在实际使用中,你可以根据具体需求调整缓冲区大小、溢出处理策略和性能优化方法。