UART不定长帧接收:DMA环形缓冲区中半满中断与空闲中断的竞争处理
👁 1 阅读 · 2026-08-27 · 嵌入式
在STM32等嵌入式平台上,使用DMA环形缓冲区接收UART不定长帧是常见做法,但半满中断与空闲中断的竞争常导致数据错乱或丢失。本文深入分析两者触发时机与竞争根源,给出基于状态机的精确处理方案,并附完整代码示例,帮助开发者稳健实现高速、可靠的串口接收。
# 用DMA环形缓冲区实现UART不定长帧接收:半满中断与空闲中断的竞争处理
## 一、问题背景
在嵌入式系统中,UART接收不定长帧(如AT指令、Modbus协议)时,常用DMA+环形缓冲区来降低CPU负载。DMA将数据连续写入内存,当缓冲区半满或发生总线空闲时触发中断,CPU再批量处理。但半满中断(HT)和空闲中断(IDLE)可能同时或先后触发,若处理不当,会造成数据重复、丢失或指针错乱。
## 二、原理剖析
### 1. DMA环形缓冲区机制
- 环形缓冲区:固定大小(如256字节),DMA写指针(NDTR)递减,读指针由软件维护。
- 半满中断(HT):当DMA传输达到缓冲区一半时触发,用于及时搬运前半部分数据。
- 传输完成中断(TC):整个缓冲区写满时触发,但环形模式下通常不启用,而是依赖HT和IDLE。
### 2. 空闲中断(IDLE)
- 当UART接收线上空闲(即无新数据)超过一个字节时间时触发,表示一帧数据可能结束。
- 在不定长帧中,IDLE是判断帧结束的关键信号。
### 3. 竞争根源
- 当数据长度恰好跨越半满点,且随后出现空闲,HT和IDLE几乎同时产生。
- 若在IDLE中断中直接读取DMA剩余计数(NDTR)来计算数据长度,而此时HT尚未处理,则可能漏掉前半部分数据。
- 反之,若HT先处理,但IDLE后到,则可能重复处理同一段数据。
## 三、解决方案:状态机+双缓冲处理
核心思想:将缓冲区分为两个半区(A和B),用状态机记录当前处理位置,确保每个字节只被处理一次。
### 1. 数据结构定义
```c
#define BUF_SIZE 256
#define HALF_SIZE (BUF_SIZE/2)
typedef struct {
uint8_t data[BUF_SIZE];
volatile uint8_t read_index; // 软件读指针(0~BUF_SIZE-1)
volatile uint8_t half_flag; // 半满中断标志
volatile uint8_t idle_flag; // 空闲中断标志
} UART_RingBuf;
UART_RingBuf uart_rb;
```
### 2. 中断服务函数(以STM32 HAL为例)
```c
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
// 半满中断回调(注意:HAL中HT和TC共用此回调,需判断)
if (huart->Instance->SR & USART_SR_TC) {
// 处理半满(此处为简化,实际需区分HT和TC)
uart_rb.half_flag = 1;
}
}
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {
// 空闲中断在HAL中通过ErrorCallback的UART_ERROR_IDLE触发
if (huart->ErrorCode & HAL_UART_ERROR_IDLE) {
uart_rb.idle_flag = 1;
__HAL_UART_CLEAR_IDLEFLAG(huart);
}
}
```
### 3. 主循环处理逻辑
```c
void ProcessUARTData(void) {
static uint8_t current_half = 0; // 0表示处理A半区,1表示B半区
uint16_t data_len;
uint8_t *data_ptr;
if (uart_rb.half_flag) {
uart_rb.half_flag = 0;
// 处理当前半区(由current_half决定)
if (current_half == 0) {
data_ptr = &uart_rb.data[0];
data_len = HALF_SIZE;
} else {
data_ptr = &uart_rb.data[HALF_SIZE];
data_len = HALF_SIZE;
}
// 处理数据(如解析帧)
ProcessFrame(data_ptr, data_len);
// 切换半区
current_half ^= 1;
}
if (uart_rb.idle_flag) {
uart_rb.idle_flag = 0;
// 计算剩余数据长度(DMA当前计数)
uint16_t remaining = __HAL_DMA_GET_COUNTER(huart.hdmatx); // 注意:这里应使用RX的DMA句柄
uint16_t total_received = BUF_SIZE - remaining;
// 确定未处理的数据区域(从当前半区到DMA写指针)
// 注意:此处需结合current_half和DMA写指针计算
uint8_t *start_ptr;
uint16_t len;
if (current_half == 0) {
// 如果当前处理A半区,则未处理数据可能在B半区或A半区后半部分
if (total_received < HALF_SIZE) {
// 数据都在A半区前半部分(但A半区可能已被处理过?需仔细判断)
// 简化处理:直接从头到尾扫描(实际应更精细)
start_ptr = &uart_rb.data[0];
len = total_received;
} else {
// 数据跨半区,但A半区已处理,只需处理B半区
start_ptr = &uart_rb.data[HALF_SIZE];
len = total_received - HALF_SIZE;
}
} else {
// 类似逻辑,但需考虑B半区已处理的情况
// ...
}
ProcessFrame(start_ptr, len);
// 重置DMA接收(重新启动)
HAL_UART_Receive_DMA(&huart, uart_rb.data, BUF_SIZE);
}
}
```
## 四、注意事项
- **中断优先级**:确保DMA的HT/TC中断优先级高于UART空闲中断,或反之,但必须保证处理顺序一致,建议HT高于IDLE,避免IDLE先处理导致数据未就绪。
- **临界区保护**:在中断中修改标志位时,主循环读取需关中断或使用原子操作。
- **DMA配置**:必须启用DMA循环模式(Circular),否则缓冲区写满后停止。
- **空闲中断清除**:在HAL中,空闲中断标志需手动清除,否则会反复触发。
- **边界情况**:当数据长度恰好等于半满或全满时,需特殊处理,避免重复或遗漏。
## 五、优化与扩展
- 使用双缓冲(乒乓缓冲)替代环形缓冲区,可简化逻辑,但内存占用翻倍。
- 结合超时定时器,在IDLE未触发时(如连续数据流)也能及时处理。
- 对于高波特率,可将处理逻辑移至中断中,但需注意中断耗时。
## 六、总结
半满中断与空闲中断的竞争是DMA环形缓冲区接收的经典难题。通过状态机记录处理位置,并合理设计中断优先级,可以确保每个字节被精确处理一次。实际项目中,还需根据具体协议调整处理逻辑,但核心思想不变:**明确每个中断的职责,用状态机避免重复或遗漏**。希望本文能帮助您构建更健壮的串口接收模块。