用 DMA 环形缓冲区实现 UART 不定长帧接收:半满与全满中断的边界竞态精确处理

一、背景与问题定义

在 STM32 等 MCU 上,利用 DMA 的循环模式接收 UART 数据,可大幅降低 CPU 负载。通常配置 DMA 传输长度(如 256 字节),并开启半传输中断(HT)和传输完成中断(TC)。当缓冲区半满或全满时,CPU 在中断中搬运数据到应用缓冲区,形成环形缓冲。

然而,边界竞态发生在以下场景:

  • 数据恰好落在半满点(如第 128 字节)时,HT 中断触发,但此时 DMA 可能已写入第 129 字节。
  • 数据跨越半满点后,TC 中断触发,但 CPU 尚未处理完 HT 中断的数据,导致重复读取或漏读。
  • 更隐蔽的是,当数据长度恰好等于半满或全满时,中断标志可能同时或先后置位,若处理不当,会丢失边界字节。

二、竞态根源分析

DMA 的 HT 和 TC 中断基于传输计数(DMA_CNDTR)判断。在循环模式下,CNDTR 从初值递减到半值或零时触发中断。但中断响应有延迟,且 CPU 读取 CNDTR 时可能已变化,导致判断不准确。

核心矛盾:中断触发点与数据实际写入点不同步。例如:

  • 配置 DMA 长度为 256,半满点为 128。当 CNDTR 从 128 减到 127 时,HT 中断置位,但此时 DMA 已写入第 129 个字节(索引 128)。若 CPU 在中断中只处理前 128 字节,则第 129 字节会被下一次处理覆盖。
  • 同理,TC 中断在 CNDTR 从 1 减到 0 时触发,但 DMA 已写入第 256 字节,且可能已回绕到缓冲区开头写入新数据。

三、解决方案:基于状态机的双缓冲处理

3.1 设计思路

采用双缓冲索引 + 状态机,避免依赖中断标志的精确性,而是通过记录 DMA 当前写位置(CNDTR 反推)和上次处理位置,计算可安全处理的数据量。

核心变量:

  • dma_buffer:DMA 循环缓冲区,大小 BUF_SIZE(2 的幂,便于取模)。
  • last_pos:上次处理到的位置(字节索引)。
  • state:当前状态(IDLE、HALF、FULL)。

3.2 中断处理逻辑

在 HT 和 TC 中断中,不直接处理数据,而是设置标志位,并在主循环或中断尾部调用统一处理函数。

#define BUF_SIZE 256
#define HALF_SIZE (BUF_SIZE/2)

volatile uint8_t dma_buffer[BUF_SIZE];
volatile uint32_t last_pos = 0;
volatile uint8_t state = 0; // 0:IDLE, 1:HALF, 2:FULL

void DMA_IRQHandler(void) {
    if (DMA_GetITStatus(DMA1_IT_HT)) {
        DMA_ClearITPendingBit(DMA1_IT_HT);
        state = 1; // 标记半满
    }
    if (DMA_GetITStatus(DMA1_IT_TC)) {
        DMA_ClearITPendingBit(DMA1_IT_TC);
        state = 2; // 标记全满
    }
}

void Process_DMA_Buffer(void) {
    uint32_t current_pos = BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel4);
    uint32_t available = 0;
    
    if (state == 1) {
        // 半满:处理从 last_pos 到 HALF_SIZE 的数据
        available = HALF_SIZE - last_pos;
        if (available > 0) {
            HandleData(&dma_buffer[last_pos], available);
            last_pos = HALF_SIZE;
        }
        state = 0;
    } else if (state == 2) {
        // 全满:处理从 last_pos 到 BUF_SIZE,再处理从 0 到 current_pos(可能已回绕)
        available = BUF_SIZE - last_pos;
        if (available > 0) {
            HandleData(&dma_buffer[last_pos], available);
        }
        // 处理回绕部分(从 0 到 current_pos)
        if (current_pos > 0) {
            HandleData(&dma_buffer[0], current_pos);
        }
        last_pos = current_pos;
        state = 0;
    }
}

3.3 消除竞态的关键点

  • 使用 current_pos 而非中断标志:通过 DMA_GetCurrDataCounter() 获取当前写位置,确保处理的数据量准确。
  • 状态机防止重复处理:每次中断只处理一次,且 last_pos 更新后,下次中断基于新位置计算。
  • 处理回绕:全满时,数据可能已回绕到缓冲区开头,需分段处理。

四、完整代码示例(STM32F1 系列)

// 初始化 DMA
void UART_DMA_Init(void) {
    DMA_InitTypeDef DMA_InitStructure;
    USART_InitTypeDef USART_InitStructure;
    
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
    
    // USART 配置(略)
    
    DMA_DeInit(DMA1_Channel4);
    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
    DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)dma_buffer;
    DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
    DMA_InitStructure.DMA_BufferSize = BUF_SIZE;
    DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
    DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
    DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;
    DMA_InitStructure.DMA_Priority = DMA_Priority_High;
    DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
    DMA_Init(DMA1_Channel4, &DMA_InitStructure);
    
    // 开启 HT 和 TC 中断
    DMA_ITConfig(DMA1_IT_HT4, ENABLE);
    DMA_ITConfig(DMA1_IT_TC4, ENABLE);
    
    // 使能 DMA 和 USART
    DMA_Cmd(DMA1_Channel4, ENABLE);
    USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE);
    
    // 配置 NVIC(略)
}

// 数据处理函数(用户实现)
void HandleData(uint8_t *data, uint32_t len) {
    // 例如:解析帧、存储到环形队列等
}

// 主循环中调用
int main(void) {
    // 初始化...
    while (1) {
        if (state != 0) {
            Process_DMA_Buffer();
        }
        // 其他任务
    }
}

五、注意事项

  • 缓冲区大小应为 2 的幂,便于取模运算,提高效率。
  • 中断优先级:确保 DMA 中断优先级高于其他可能长时间阻塞的中断,避免数据覆盖。
  • 临界区保护:在更新 last_posstate 时,可暂时关闭中断或使用临界区,防止主循环与中断冲突。
  • 测试边界情况:重点测试数据长度恰好为 128、256、129 等边界值,验证无数据丢失。
  • 性能考量:若数据速率极高,可在中断中直接处理,但需保证处理时间短于 DMA 写入一个字节的时间。

六、总结

通过状态机与 DMA 当前计数器的结合,我们精确地解决了半满/全满中断的边界竞态。该方法不依赖中断标志的精确时序,而是基于实际写位置计算可处理数据量,从根本上避免了重复或遗漏。此模式可广泛应用于各种需要 DMA 接收不定长帧的嵌入式场景,如串口通信、传感器数据采集等。