用 DMA 环形缓冲区解析不定长串口协议:半满中断与空闲中断的竞争处理
一、问题背景与竞争根源
在STM32等MCU上,利用DMA接收串口数据并配合环形缓冲区,能大幅降低CPU负载。但处理不定长协议时,常同时启用DMA半满中断和串口空闲中断,以兼顾大数据块与短帧。然而,两者几乎同时触发时,若处理不当,会导致数据重复读取或丢失。
竞争根源:
- 半满中断在DMA传输达到缓冲区一半时触发,此时数据可能尚未完整到达;
- 空闲中断在总线空闲(即一帧结束)时触发,但DMA可能已越过半满点;
- 若在中断服务函数中直接读取DMA剩余计数并拷贝数据,两个中断可能操作同一批数据,造成竞态。
二、核心设计思路:状态机 + 双缓冲索引
解决竞争的关键是:将数据读取与缓冲区索引更新原子化,并用状态机区分当前处理阶段。
2.1 环形缓冲区结构
定义缓冲区大小为2的幂次(如256字节),便于索引回绕。
#define BUF_SIZE 256
#define BUF_MASK (BUF_SIZE - 1)
uint8_t dma_buf[BUF_SIZE];
volatile uint16_t read_index = 0; // 软件读取位置
volatile uint16_t write_index = 0; // DMA写入位置(由半满/空闲中断更新)
2.2 状态定义
typedef enum {
IDLE, // 等待帧开始
RECEIVING, // 接收中
FRAME_READY // 一帧完整,待处理
} FrameState;
三、中断处理策略
3.1 半满中断处理
半满中断表示DMA已写入前一半缓冲区(或后一半,取决于方向)。此时更新write_index为当前DMA位置,并置位标志,但不立即解析数据,而是交由主循环处理。
void DMA1_Channel4_IRQHandler(void) {
if (DMA_GetITStatus(DMA1_IT_HT4)) {
DMA_ClearITPendingBit(DMA1_IT_HT4);
// 更新写索引为当前DMA位置(即半满点)
write_index = (BUF_SIZE / 2) * (DMA_GetCurrentMemoryTarget(DMA1_Channel4) ? 1 : 0) + (BUF_SIZE / 2);
// 置位数据可用标志
data_ready = 1;
}
}
3.2 空闲中断处理
空闲中断在帧结束时触发,此时DMA可能已写入若干字节。需读取DMA剩余计数,计算实际接收长度,并更新write_index。
void USART1_IRQHandler(void) {
if (USART_GetITStatus(USART1_IT_IDLE)) {
USART_ClearITPendingBit(USART1_IT_IDLE);
// 读取DMA剩余计数
uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel4);
uint16_t received = BUF_SIZE - remain;
// 更新写索引(注意回绕)
write_index = (write_index + received) & BUF_MASK;
// 标记帧就绪
frame_ready = 1;
}
}
3.3 竞争消除:互斥与顺序
为避免半满和空闲同时操作write_index,采用以下措施:
- 在中断中仅更新
write_index,不读取数据; - 主循环中检测
frame_ready或data_ready,并一次性处理所有可用数据; - 使用临界区保护
write_index的更新(如关中断)。
// 主循环处理
while (1) {
if (frame_ready || data_ready) {
__disable_irq();
uint16_t current_write = write_index;
frame_ready = 0;
data_ready = 0;
__enable_irq();
// 解析从read_index到current_write之间的数据
while (read_index != current_write) {
uint8_t byte = dma_buf[read_index];
// 状态机解析协议
parse_byte(byte);
read_index = (read_index + 1) & BUF_MASK;
}
}
}
四、完整代码示例(STM32F1xx)
以下为初始化及中断配置示例(使用HAL库简化):
// 初始化DMA和串口
void UART_DMA_Init(void) {
// 使能时钟
__HAL_RCC_DMA1_CLK_ENABLE();
__HAL_RCC_USART1_CLK_ENABLE();
// DMA配置
DMA_HandleTypeDef hdma;
hdma.Instance = DMA1_Channel4;
hdma.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma.Init.PeriphInc = DMA_PINC_DISABLE;
hdma.Init.MemInc = DMA_MINC_ENABLE;
hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma.Init.Mode = DMA_CIRCULAR; // 循环模式
hdma.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma);
// 关联DMA到USART1
__HAL_LINKDMA(&huart1, hdmarx, hdma);
// 使能空闲中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
// 使能DMA半满/全满中断
HAL_DMA_Start_IT(&hdma, (uint32_t)&huart1.Instance->DR, (uint32_t)dma_buf, BUF_SIZE);
}
// 中断回调(HAL库)
void HAL_UART_IDLE_Callback(UART_HandleTypeDef *huart) {
if (huart->Instance == USART1) {
// 处理空闲中断,更新write_index
uint16_t remain = __HAL_DMA_GET_COUNTER(huart->hdmarx);
uint16_t received = BUF_SIZE - remain;
write_index = (write_index + received) & BUF_MASK;
frame_ready = 1;
}
}
五、注意事项与调试技巧
- 缓冲区大小必须为2的幂,否则回绕运算需取模,效率低且易错。
- 半满中断与空闲中断可能同时发生:在空闲中断中应清除DMA半满标志,避免重复处理。
-
主循环处理数据时,应关闭中断,防止
write_index被修改导致数据不一致。 - 协议解析建议采用状态机,如:等待帧头→接收长度→接收数据→校验,避免依赖中断时序。
- 调试时可用逻辑分析仪抓取串口波形,对比中断触发时刻,验证竞争处理逻辑。
六、总结
通过将数据读取与索引更新分离,利用状态机协调半满和空闲中断,可有效消除竞争,实现稳定可靠的不定长串口协议解析。此方案在高速通信(如115200bps以上)下依然表现优异,是嵌入式开发中的经典实践。