在嵌入式开发领域,串口通信是最基础也最常用的外设接口之一。无论是传感器数据采集、设备间通信还是调试信息输出,都离不开稳定可靠的串口传输。但在实际项目中,我们经常会遇到这样的困境:当大量数据需要快速收发时,传统的串口收发方式很容易因为处理不及时导致数据丢失。
这个问题的根源在于串口硬件缓冲区通常很小(比如STM32的串口缓冲区只有1字节),而CPU处理数据需要时间。当数据持续高速到达时,如果来不及处理,新数据就会覆盖旧数据,造成丢包。我在工业自动化项目中就曾遇到过因为串口丢包导致传感器数据不完整,最终影响控制精度的问题。
环形队列(Circular Buffer)正是解决这一痛点的经典方案。它通过在内存中开辟一块缓冲区,让数据像在环形跑道上一样循环写入和读取,从而实现了:
环形队列的核心是三个关键指针:
buffer: 指向存储区域的起始地址head: 指向下一个可写入位置tail: 指向下一个可读取位置当head和tail相等时,队列为空;当(head+1)%size == tail时,队列为满。这种设计使得队列空间可以循环利用,不会出现"假满"的情况。
c复制typedef struct {
uint8_t *buffer; // 数据缓冲区
uint16_t size; // 缓冲区总大小
uint16_t head; // 写指针
uint16_t tail; // 读指针
} CircularBuffer;
写入数据:
c复制bool CB_Write(CircularBuffer *cb, uint8_t data) {
uint16_t next_head = (cb->head + 1) % cb->size;
if(next_head == cb->tail) return false; // 队列满
cb->buffer[cb->head] = data;
cb->head = next_head;
return true;
}
读取数据:
c复制bool CB_Read(CircularBuffer *cb, uint8_t *data) {
if(cb->tail == cb->head) return false; // 队列空
*data = cb->buffer[cb->tail];
cb->tail = (cb->tail + 1) % cb->size;
return true;
}
注意:在多任务环境下操作环形队列时,必须考虑临界区保护。简单的做法是关中断或使用互斥锁,但会影响实时性。更优的方案是使用无锁队列设计。
以STM32F103为例,配置USART1为异步模式,波特率115200:
c复制void USART1_Init(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
USART_InitTypeDef USART_InitStruct = {0};
// 时钟使能
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
// TX(PA9)配置为复用推挽输出
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// RX(PA10)配置为浮空输入
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// USART参数配置
USART_InitStruct.USART_BaudRate = 115200;
USART_InitStruct.USART_WordLength = USART_WordLength_8b;
USART_InitStruct.USART_StopBits = USART_StopBits_1;
USART_InitStruct.USART_Parity = USART_Parity_No;
USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_Init(USART1, &USART_InitStruct);
// 使能接收中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
NVIC_EnableIRQ(USART1_IRQn);
USART_Cmd(USART1, ENABLE);
}
接收数据时使用中断模式,将数据快速存入环形队列:
c复制CircularBuffer rx_buffer;
void USART1_IRQHandler(void) {
if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {
uint8_t data = USART_ReceiveData(USART1);
CB_Write(&rx_buffer, data); // 写入接收队列
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
发送数据可以采用查询或DMA方式。查询方式简单但效率低,DMA方式更高效:
c复制void USART1_SendData_DMA(uint8_t *data, uint16_t len) {
DMA_InitTypeDef DMA_InitStruct;
// 配置DMA1 Channel4 (USART1_TX)
DMA_DeInit(DMA1_Channel4);
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)data;
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralDST;
DMA_InitStruct.DMA_BufferSize = len;
DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStruct.DMA_Mode = DMA_Mode_Normal;
DMA_InitStruct.DMA_Priority = DMA_Priority_High;
DMA_InitStruct.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel4, &DMA_InitStruct);
USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE);
DMA_Cmd(DMA1_Channel4, ENABLE);
}
缓冲区大小需要权衡内存占用和性能:
经验公式:
code复制缓冲区大小 ≥ (最大突发数据量 × 2) + 处理延迟时间 × 波特率/10
例如:115200波特率下,每秒最多11520字节。如果最大处理延迟10ms,则缓冲区至少需要115字节。
传统方式需要先将数据从队列复制到处理缓冲区,增加了开销。可以采用直接访问队列的方式:
c复制uint16_t CB_GetLinearRead(CircularBuffer *cb, uint8_t **data) {
if(cb->tail <= cb->head) {
*data = &cb->buffer[cb->tail];
return cb->head - cb->tail;
} else {
*data = &cb->buffer[cb->tail];
return cb->size - cb->tail;
}
}
void CB_CommitRead(CircularBuffer *cb, uint16_t len) {
cb->tail = (cb->tail + len) % cb->size;
}
当缓冲区快满时,可以通过硬件流控(RTS/CTS)或软件协议(XON/XOFF)通知发送方暂停:
c复制// 在接收中断中检查缓冲区余量
if(CB_FreeSpace(&rx_buffer) < threshold) {
Send_XOFF(); // 发送暂停命令
}
在工业Modbus RTU协议中,要求帧与帧之间至少有3.5个字符的静默时间。使用环形队列可以方便地实现帧分割:
c复制uint32_t last_char_time = 0;
#define FRAME_GAP_TICKS 35 // 3.5字符时间对应的定时器ticks
void ProcessUARTData(void) {
uint8_t data;
while(CB_Read(&rx_buffer, &data)) {
uint32_t now = GetTimerTicks();
if(now - last_char_time > FRAME_GAP_TICKS) {
// 新帧开始
StartNewFrame();
}
AddToFrame(data);
last_char_time = now;
}
}
通过串口连接无线模块(如ESP8266)时,经常需要处理大量突发数据。环形队列可以平滑数据流:
c复制void ESP8266_ReceiveHandler(void) {
uint8_t *data;
uint16_t len = CB_GetLinearRead(&rx_buffer, &data);
if(len > 0) {
// 直接发送给TCP连接
wifi_send(data, len);
CB_CommitRead(&rx_buffer, len);
}
}
现象:接收到的数据出现错位,比如原本连续的"123456"变成了"345612"。
原因:
解决方案:
c复制__disable_irq();
CB_Write(&buffer, data);
__enable_irq();
c复制head = (head + 1) % size; // 正确
head = (head + 1); if(head >= size) head = 0; // 也正确
现象:新数据无法写入,但实际数据量并不大。
原因:
解决方案:
现象:同时使用DMA发送和中断接收时,系统不稳定。
原因:
解决方案:
c复制NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStruct);
对于特别高速的数据流,可以采用多级缓冲:
c复制CircularBuffer quick_buffer; // 256字节
CircularBuffer main_buffer; // 2048字节
void TransferData(void) {
uint8_t data;
while(CB_Read(&quick_buffer, &data)) {
if(!CB_Write(&main_buffer, data)) {
// 主缓冲区满,触发处理
ProcessMainBuffer();
}
}
}
频繁动态分配内存会影响实时性,可以预分配内存池:
c复制#define POOL_SIZE 10
#define BUF_SIZE 256
typedef struct {
uint8_t buffer[BUF_SIZE];
uint16_t length;
bool in_use;
} BufferBlock;
BufferBlock mem_pool[POOL_SIZE];
BufferBlock *AllocBuffer(void) {
for(int i=0; i<POOL_SIZE; i++) {
if(!mem_pool[i].in_use) {
mem_pool[i].in_use = true;
mem_pool[i].length = 0;
return &mem_pool[i];
}
}
return NULL;
}
添加统计功能,监控队列使用情况:
c复制typedef struct {
CircularBuffer cb;
uint32_t max_usage;
uint32_t overflow_count;
} StatsBuffer;
void SB_Write(StatsBuffer *sb, uint8_t data) {
uint16_t usage = CB_Usage(&sb->cb);
if(usage > sb->max_usage) sb->max_usage = usage;
if(!CB_Write(&sb->cb, data)) {
sb->overflow_count++;
}
}
通过这些统计数据,可以优化缓冲区大小和数据处理策略。