引言

在 STM32 等单片机上,UART 接收不定长数据时,DMA 环形缓冲区 + 空闲中断(IDLE)是常用组合。但引入半满中断(Half Transfer)后,两者可能同时触发,导致数据处理顺序混乱。本文从原理出发,分析竞争场景,并给出可落地的解决方案。

1. 基础原理与竞争根源

1.1 DMA 环形缓冲区工作模式

  • 环形缓冲区:DMA 在内存中循环写入,通过 NDTR(剩余传输计数)或回调判断读写位置。
  • 半满中断:当 DMA 传输达到缓冲区一半时触发,用于及时搬运数据,防止覆盖。
  • 空闲中断:UART 接收线空闲时触发,表示一帧数据结束。

1.2 竞争场景分析

假设缓冲区大小为 2N,DMA 写入位置为 write_index,应用读取位置为 read_index

  • 场景 A:数据量恰好达到半满(N 字节)时,半满中断触发,同时 UART 空闲(如数据包长度恰为 N),空闲中断也触发。若中断处理顺序不当,可能重复处理或漏处理数据。
  • 场景 B:半满中断处理中,空闲中断到来,但半满处理尚未完成,导致空闲中断的数据被半满逻辑覆盖。
  • 场景 C:空闲中断先触发,但半满中断在空闲处理期间触发,造成数据错位。

核心问题:两个中断共享同一缓冲区索引,且无同步机制。

2. 精准处理策略

2.1 设计原则

  • 使用状态机跟踪 DMA 当前处于“半满”还是“全满”阶段。
  • 利用标志位记录待处理事件,统一在安全点处理数据。
  • 优先处理空闲中断(因为一帧结束,数据完整),但需避免重复。

2.2 状态机定义

typedef enum {
    DMA_STATE_HALF = 0,  // 当前 DMA 写指针位于前半区
    DMA_STATE_FULL = 1   // 当前 DMA 写指针位于后半区
} dma_state_t;
  • 当半满中断触发时,切换状态,并设置 half_flag
  • 当全满中断(Transfer Complete)触发时,切换状态,并设置 full_flag
  • 空闲中断触发时,设置 idle_flag

2.3 中断处理逻辑

volatile uint8_t half_flag = 0;
volatile uint8_t full_flag = 0;
volatile uint8_t idle_flag = 0;
volatile dma_state_t dma_state = DMA_STATE_HALF;

void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) {
    half_flag = 1;
    dma_state = DMA_STATE_FULL;  // 下次写入将进入后半区
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    full_flag = 1;
    dma_state = DMA_STATE_HALF;
}

void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) {
    idle_flag = 1;
}

2.4 主循环处理(或低优先级任务)

void ProcessUartData(void) {
    // 先处理空闲事件(一帧结束,数据最完整)
    if (idle_flag) {
        idle_flag = 0;
        // 计算当前有效数据长度
        uint16_t len = GetValidDataLength();
        if (len > 0) {
            ProcessFrame(len);
        }
        // 重置状态,防止重复处理
        ClearFlags();
    }
    // 再处理半满/全满事件(用于长数据流)
    if (half_flag) {
        half_flag = 0;
        // 处理前半区数据
        ProcessBuffer(0, BUFFER_SIZE/2);
    }
    if (full_flag) {
        full_flag = 0;
        // 处理后半区数据
        ProcessBuffer(BUFFER_SIZE/2, BUFFER_SIZE/2);
    }
}

2.5 关键函数实现

uint16_t GetValidDataLength(void) {
    // 根据 DMA 当前写指针和状态计算
    uint16_t write_pos = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_uart_rx);
    if (dma_state == DMA_STATE_FULL) {
        // 写指针在后半区,有效数据从 read_index 到 write_pos
        return write_pos - read_index;
    } else {
        // 写指针在前半区,有效数据分为两段
        return (BUFFER_SIZE - read_index) + write_pos;
    }
}

void ProcessBuffer(uint16_t start, uint16_t len) {
    // 从缓冲区 start 位置读取 len 字节处理
    // 更新 read_index
    read_index = (start + len) % BUFFER_SIZE;
}

3. 完整代码示例(基于 STM32 HAL)

// 缓冲区定义
#define BUFFER_SIZE 256
uint8_t rx_buffer[BUFFER_SIZE];
volatile uint16_t read_index = 0;

// 初始化 DMA 接收
void UART_RX_Init(void) {
    HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

// 中断回调(在 UART 中断中调用)
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart) {
    // 处理空闲中断
    if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(huart);
        HAL_UART_IdleCpltCallback(huart);
    }
    HAL_UART_IRQHandler(huart);  // 调用 HAL 默认处理(会触发 DMA 回调)
}

// 主循环
while (1) {
    ProcessUartData();
    // 其他任务
}

4. 注意事项与优化

  • 中断优先级:建议将 UART 中断优先级设为高于 DMA 中断,但两者都应低于系统节拍。
  • 临界区保护:在修改 read_index 或标志位时,可短暂关闭中断或使用临界区,防止竞态。
  • 数据覆盖风险:若处理速度跟不上 DMA 写入,需增加缓冲区大小或采用双缓冲。
  • 空闲中断误触发:在连续数据流中,空闲中断可能不触发,此时依赖半满/全满中断保证数据不丢失。
  • 测试建议:使用串口调试助手发送不同长度数据(如 N-1、N、N+1 字节)验证逻辑。

结语

通过状态机与标志位分离事件处理,可有效消除半满中断与空闲中断的竞争。该方法已在实际项目验证,稳定可靠。掌握此策略,可显著提升 UART 接收的健壮性。