DMA环形缓冲区解析不定长串口协议:半满中断与空闲中断的竞争处理实战
👁 3 阅读 · 2026-08-27 · 嵌入式
在嵌入式系统中,使用DMA+环形缓冲区解析不定长串口协议是常见的高效方案,但半满中断与空闲中断的竞争常导致数据错乱或丢失。本文深入剖析两者触发时机与竞争根源,给出基于状态机的精准处理策略,并附完整STM32代码示例,帮助开发者彻底解决这一棘手问题。
# 用 DMA 环形缓冲区解析不定长串口协议:半满中断与空闲中断的竞争处理
## 一、问题背景与竞争根源
在STM32等MCU上,利用DMA接收串口数据并配合环形缓冲区,能大幅降低CPU负载。但处理不定长协议时,常同时启用DMA半满中断和串口空闲中断,以兼顾大数据块与短帧。然而,两者几乎同时触发时,若处理不当,会导致数据重复读取或丢失。
**竞争根源**:
- 半满中断在DMA传输达到缓冲区一半时触发,此时数据可能尚未完整到达;
- 空闲中断在总线空闲(即一帧结束)时触发,但DMA可能已越过半满点;
- 若在中断服务函数中直接读取DMA剩余计数并拷贝数据,两个中断可能操作同一批数据,造成竞态。
## 二、核心设计思路:状态机 + 双缓冲索引
解决竞争的关键是:**将数据读取与缓冲区索引更新原子化,并用状态机区分当前处理阶段**。
### 2.1 环形缓冲区结构
定义缓冲区大小为2的幂次(如256字节),便于索引回绕。
```c
#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 状态定义
```c
typedef enum {
IDLE, // 等待帧开始
RECEIVING, // 接收中
FRAME_READY // 一帧完整,待处理
} FrameState;
```
## 三、中断处理策略
### 3.1 半满中断处理
半满中断表示DMA已写入前一半缓冲区(或后一半,取决于方向)。此时更新`write_index`为当前DMA位置,并置位标志,但不立即解析数据,而是交由主循环处理。
```c
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`。
```c
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`的更新(如关中断)。
```c
// 主循环处理
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库简化):
```c
// 初始化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以上)下依然表现优异,是嵌入式开发中的经典实践。