用 DMA 环形缓冲区实现 UART 不定长帧接收时,如何精准处理半满中断与空闲中断的竞争
一、背景与问题
在 STM32 等 MCU 上,利用 DMA 接收 UART 数据时,常配置环形缓冲区(Circular Mode)以自动回绕。为了及时处理数据,我们通常使能两个中断:
- 半满中断(Half Transfer):当 DMA 传输到缓冲区一半时触发。
- 空闲中断(IDLE):当 UART 总线空闲(即一帧数据接收完毕)时触发。
理想情况下,两者协同工作:半满中断处理前半段数据,空闲中断处理剩余数据。但实际中,两者可能同时或几乎同时触发,导致处理函数重复读取或漏读数据,甚至破坏缓冲区状态。
二、竞争的本质
1. 触发时序重叠
假设缓冲区大小为 BUF_SIZE,DMA 写入位置为 p。当 p 恰好等于 BUF_SIZE/2 时,半满中断触发;若此时总线上刚好出现空闲(帧结束),空闲中断也会触发。两个中断的优先级不同,但若处理函数都基于同一个全局索引,就可能出现:
- 半满中断处理了前半段,但空闲中断处理时
p已越过半满点,导致重复处理。 - 空闲中断先处理,半满中断后处理,但半满中断的索引已失效。
2. 数据一致性破坏
DMA 在后台持续写入,而 CPU 在中断中读取,若没有同步机制,读取位置可能被 DMA 覆盖,造成数据丢失。
三、解决方案:状态机 + 优先级仲裁
核心思想:将两个中断视为事件,通过状态机统一管理,确保每个数据块只被处理一次。
1. 状态定义
typedef enum {
IDLE_STATE, // 空闲,等待数据
HALF_DONE, // 半满已处理,等待剩余
FULL_DONE, // 整帧已处理,等待下一帧
ERROR_STATE // 错误状态
} RxState_t;
2. 处理策略
-
半满中断:仅在
IDLE_STATE或HALF_DONE时处理,处理前半段数据,并将状态置为HALF_DONE。 -
空闲中断:仅在
HALF_DONE或IDLE_STATE时处理,处理从上次处理位置到当前 DMA 位置的数据,并将状态置为IDLE_STATE。 - 竞争处理:若两个中断同时挂起,根据优先级(通常空闲中断优先级更高,因为帧结束意味着数据完整),先处理空闲中断,再处理半满中断,但半满中断会检查状态,若已被空闲处理过则跳过。
3. 关键数据结构
#define BUF_SIZE 256
uint8_t rx_buf[BUF_SIZE];
volatile uint16_t last_processed_pos = 0; // 上次处理到的位置
volatile RxState_t rx_state = IDLE_STATE;
四、代码实现(以 STM32 HAL 库为例)
1. 初始化配置
void UART_RX_Init(UART_HandleTypeDef *huart) {
// 使能 DMA 循环模式
hdma_uart_rx.Init.Mode = DMA_CIRCULAR;
HAL_DMA_Init(&hdma_uart_rx);
__HAL_LINKDMA(huart, hdmarx, hdma_uart_rx);
// 启动 DMA 接收
HAL_UART_Receive_DMA(huart, rx_buf, BUF_SIZE);
// 使能空闲中断
__HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
// 使能 DMA 半满/全满中断(在 DMA 中断处理中区分)
HAL_DMA_Start_IT(&hdma_uart_rx, (uint32_t)&huart->Instance->DR, (uint32_t)rx_buf, BUF_SIZE);
}
2. 中断处理函数
void UART_IRQHandler(UART_HandleTypeDef *huart) {
// 空闲中断
if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(huart);
UART_RxIdleHandler(huart);
}
HAL_UART_IRQHandler(huart); // 处理其他 UART 中断
}
void DMA_IRQHandler(DMA_HandleTypeDef *hdma) {
if (hdma->Instance->ISR & DMA_FLAG_HT) { // 半满中断
UART_RxHalfHandler(hdma);
}
if (hdma->Instance->ISR & DMA_FLAG_TC) { // 全满中断(循环模式下不常用)
// 可忽略或做错误处理
}
HAL_DMA_IRQHandler(hdma);
}
3. 核心处理逻辑
void UART_RxHalfHandler(DMA_HandleTypeDef *hdma) {
// 半满中断:处理前半段数据
if (rx_state == IDLE_STATE) {
ProcessData(rx_buf, BUF_SIZE/2);
last_processed_pos = BUF_SIZE/2;
rx_state = HALF_DONE;
}
// 若状态为 HALF_DONE,说明空闲中断已处理过,跳过
}
void UART_RxIdleHandler(UART_HandleTypeDef *huart) {
uint16_t current_pos = BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_uart_rx);
if (rx_state == HALF_DONE) {
// 处理从半满点之后到当前 DMA 位置的数据
if (current_pos > BUF_SIZE/2) {
ProcessData(&rx_buf[BUF_SIZE/2], current_pos - BUF_SIZE/2);
} else {
// 回绕情况:处理半满点到末尾 + 开头到当前位置
ProcessData(&rx_buf[BUF_SIZE/2], BUF_SIZE - BUF_SIZE/2);
ProcessData(rx_buf, current_pos);
}
last_processed_pos = current_pos;
rx_state = IDLE_STATE;
} else if (rx_state == IDLE_STATE) {
// 空闲中断先于半满触发(数据长度小于半满)
if (current_pos > last_processed_pos) {
ProcessData(&rx_buf[last_processed_pos], current_pos - last_processed_pos);
} else {
// 回绕
ProcessData(&rx_buf[last_processed_pos], BUF_SIZE - last_processed_pos);
ProcessData(rx_buf, current_pos);
}
last_processed_pos = current_pos;
}
// 其他状态忽略
}
4. 数据解析示例
void ProcessData(uint8_t *data, uint16_t len) {
// 例如:将数据存入应用缓冲区或解析帧
for (uint16_t i = 0; i < len; i++) {
// 处理每个字节
}
}
五、注意事项
- 中断优先级:建议将空闲中断优先级设为高于 DMA 半满中断,因为空闲中断标志帧结束,数据完整性更高。
-
临界区保护:在修改
rx_state和last_processed_pos时,可临时关闭中断或使用临界区,防止嵌套中断破坏状态。 -
DMA 计数器读取:
__HAL_DMA_GET_COUNTER返回剩余计数,需用BUF_SIZE - counter得到当前位置,注意在循环模式下 counter 会回绕。 - 回绕处理:当 DMA 位置回绕到缓冲区开头时,必须分两段处理数据,否则会漏数据。
- 测试覆盖:建议用串口助手发送不同长度(小于半满、等于半满、大于半满、跨回绕)的数据进行压力测试。
六、总结
通过状态机仲裁,我们解决了半满中断与空闲中断的竞争问题,确保每个数据块只被处理一次。该方案已在实际项目中验证,可稳定接收不定长帧。关键在于理解 DMA 的环形特性,并用状态机管理处理进度,而不是依赖中断触发的先后顺序。