用 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_STATEHALF_DONE 时处理,处理前半段数据,并将状态置为 HALF_DONE
  • 空闲中断:仅在 HALF_DONEIDLE_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_statelast_processed_pos 时,可临时关闭中断或使用临界区,防止嵌套中断破坏状态。
  • DMA 计数器读取__HAL_DMA_GET_COUNTER 返回剩余计数,需用 BUF_SIZE - counter 得到当前位置,注意在循环模式下 counter 会回绕。
  • 回绕处理:当 DMA 位置回绕到缓冲区开头时,必须分两段处理数据,否则会漏数据。
  • 测试覆盖:建议用串口助手发送不同长度(小于半满、等于半满、大于半满、跨回绕)的数据进行压力测试。

六、总结

通过状态机仲裁,我们解决了半满中断与空闲中断的竞争问题,确保每个数据块只被处理一次。该方案已在实际项目中验证,可稳定接收不定长帧。关键在于理解 DMA 的环形特性,并用状态机管理处理进度,而不是依赖中断触发的先后顺序。