DMA环形缓冲区UART不定长接收:帧尾超时与半满中断竞争的精准处理策略
👁 2 阅读 · 2026-08-27 · 嵌入式
在STM32等MCU上,利用DMA环形缓冲区实现UART不定长接收是提升实时性与CPU效率的经典方案,但帧尾超时与半满中断的竞争常导致数据错乱或丢包。本文深入剖析两者冲突的根源,提出基于状态机与双标志位的竞争消解机制,并给出完整代码示例与配置要点,助你构建稳健的嵌入式通信层。
# 用DMA环形缓冲区实现UART不定长接收:帧尾超时与半满中断竞争的精准处理
## 1. 背景与挑战
在嵌入式系统中,UART接收不定长数据帧是常见需求。传统中断逐字节接收在高速率下会频繁打断CPU,而DMA+环形缓冲区可大幅降低中断负载。但引入DMA后,两个异步事件——**帧尾超时**(IDLE中断或定时器)与**半满中断**(DMA传输过半)——可能同时或交错发生,若处理不当,会导致数据覆盖、重复读取或帧错位。
## 2. 原理剖析:竞争的本质
### 2.1 DMA环形缓冲区机制
- 缓冲区大小设为2的幂次(如256字节),DMA在内存中循环写入。
- 通过`NDTR`寄存器或`DMA_GetCurrDataCounter()`获取当前剩余计数,从而计算写指针位置。
- 半满中断在传输达到缓冲区一半时触发,用于及时搬移数据,防止溢出。
### 2.2 帧尾超时机制
- 常用方式:UART空闲中断(IDLE)或定时器超时(如10ms无新数据)。
- 目的:判断一帧数据接收完毕,触发处理回调。
### 2.3 竞争场景
- **场景A**:DMA半满中断触发时,恰好收到帧尾(IDLE),两个中断几乎同时挂起。
- **场景B**:半满中断处理中,IDLE到来,但半满处理尚未完成,数据可能被覆盖。
- **场景C**:IDLE处理时,DMA继续写入,导致读指针追赶写指针。
核心矛盾:**两个事件都试图更新读指针,但缺乏原子性保护**。
## 3. 解决方案:状态机+双标志位
### 3.1 设计思路
- 引入`rx_state`状态机(IDLE、RECEIVING、FRAME_READY)。
- 使用两个标志位:`half_flag`(半满待处理)和`idle_flag`(帧尾待处理)。
- 中断中只置标志位,主循环或低优先级任务中统一处理,避免嵌套竞争。
### 3.2 状态转移
- **IDLE**:等待起始字节,收到首个字节后进入RECEIVING。
- **RECEIVING**:持续接收,若半满触发则置`half_flag`,若IDLE触发则置`idle_flag`并进入FRAME_READY。
- **FRAME_READY**:主循环处理完整帧,然后回到IDLE。
### 3.3 竞争消解规则
- 在中断服务函数中,仅更新标志位,不直接操作缓冲区读指针。
- 主循环中,先检查`idle_flag`,若置位则处理整帧(此时半满标志可能已置位,但需先完成帧处理)。
- 若`half_flag`置位且`idle_flag`未置位,则搬移半满数据到用户缓冲区,并更新读指针。
- 关键:**处理半满时,必须禁用UART的IDLE中断或使用临界区,防止IDLE打断**。
## 4. 完整代码示例(基于STM32 HAL库)
```c
// 环形缓冲区定义
#define BUF_SIZE 256
#define HALF_SIZE (BUF_SIZE/2)
uint8_t dma_rx_buf[BUF_SIZE];
volatile uint16_t read_index = 0;
volatile uint8_t rx_state = 0; // 0:IDLE, 1:RECEIVING, 2:FRAME_READY
volatile uint8_t half_flag = 0;
volatile uint8_t idle_flag = 0;
// 用户帧缓冲区(假设最大帧长128)
uint8_t frame_buf[128];
uint16_t frame_len = 0;
// 初始化DMA和UART(略)
// UART IDLE中断回调(在HAL_UART_IRQHandler中调用)
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
// 计算当前写指针
uint16_t write_index = BUF_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);
// 置帧尾标志
idle_flag = 1;
// 保存当前帧长度(假设从read_index到write_index)
frame_len = (write_index >= read_index) ? (write_index - read_index) : (BUF_SIZE - read_index + write_index);
// 状态机转移
rx_state = 2; // FRAME_READY
// 注意:此时不立即处理,等待主循环
}
}
// DMA半满中断回调(在HAL_DMA_IRQHandler中调用)
void HAL_DMA_HalfCpltCallback(DMA_HandleTypeDef *hdma)
{
if (hdma->Instance == DMA1_Channel5) { // 假设UART1_RX用DMA1_CH5
half_flag = 1;
}
}
// 主循环中的处理函数
void UART_Process(void)
{
// 处理帧尾(优先级高)
if (idle_flag) {
idle_flag = 0;
// 复制数据到用户缓冲区(注意环形回绕)
for (uint16_t i = 0; i < frame_len; i++) {
frame_buf[i] = dma_rx_buf[(read_index + i) % BUF_SIZE];
}
// 更新读指针
read_index = (read_index + frame_len) % BUF_SIZE;
// 调用用户回调
User_Frame_Handler(frame_buf, frame_len);
// 状态复位
rx_state = 0;
// 重新使能IDLE中断(若之前禁用)
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}
// 处理半满(仅当无帧尾待处理)
else if (half_flag) {
half_flag = 0;
// 临界区:暂时禁用IDLE中断,防止竞争
__HAL_UART_DISABLE_IT(&huart1, UART_IT_IDLE);
// 计算当前写指针
uint16_t write_index = BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
// 计算可读数据长度(从read_index到write_index)
uint16_t avail = (write_index >= read_index) ? (write_index - read_index) : (BUF_SIZE - read_index + write_index);
// 如果可读长度大于半满,则只搬移半满部分(但此处我们搬移全部可用,但注意不要超过半满)
// 实际上半满中断表示已写入半满,所以可用长度至少为半满,但可能更多(若IDLE同时发生)
// 为简化,我们搬移所有可用数据到临时缓冲(但需防止溢出)
// 此处仅演示,实际可搬移到更大的用户缓冲
uint8_t temp[128];
uint16_t copy_len = (avail > 128) ? 128 : avail;
for (uint16_t i = 0; i < copy_len; i++) {
temp[i] = dma_rx_buf[(read_index + i) % BUF_SIZE];
}
// 更新读指针
read_index = (read_index + copy_len) % BUF_SIZE;
// 重新使能IDLE中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
// 处理temp数据(例如存入队列)
// ...
}
}
// 用户回调示例
void User_Frame_Handler(uint8_t *data, uint16_t len)
{
// 处理完整帧
}
```
## 5. 配置步骤
1. **初始化DMA**:配置为循环模式,数据宽度字节,内存地址自增,外设地址固定。
2. **使能DMA中断**:开启半满中断(`HAL_DMA_Start_IT`)和传输完成中断(可选)。
3. **配置UART**:使能IDLE中断(`__HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE)`),并设置DMA接收。
4. **编写回调**:如上代码,在`HAL_UART_IdleCpltCallback`和`HAL_DMA_HalfCpltCallback`中置标志。
5. **主循环调用**:周期性调用`UART_Process()`。
## 6. 注意事项
- **临界区保护**:访问`read_index`、`frame_len`等共享变量时,建议使用临界区或关中断,防止主循环与中断冲突。
- **缓冲区大小**:需大于最大帧长,且为2的幂次,便于取模运算。
- **半满中断的触发时机**:DMA半满中断在传输半满时触发,但若帧长小于半满,则不会触发,此时IDLE是唯一信号。
- **超时时间选择**:若使用定时器超时,需根据波特率和帧间隔调整,避免误判。
- **性能考量**:主循环处理频率需足够高,否则半满标志可能堆积,导致缓冲区溢出。
## 7. 总结
通过状态机与双标志位,我们成功将DMA半满中断与UART IDLE中断的竞争转化为顺序处理,避免了数据竞争和丢帧。此方案在STM32F1/F4/H7等系列上均适用,只需调整DMA和UART句柄。实际项目中,还可结合空闲定时器(如TIM)实现更精确的超时控制。掌握这一策略,你的嵌入式通信层将更加健壮。