引言

在嵌入式系统中,UART不定长接收是高频需求。传统逐字节中断在高速率下CPU负担重,而DMA+环形缓冲区能大幅降低中断频率,提升吞吐。然而,当启用DMA半满中断(HT)和帧超时定时器(如基于空闲中断或定时器)时,两者可能同时触发,形成竞态,导致数据被重复处理或遗漏。本文将从原理出发,剖析竞态,并给出可落地的解决方案。

一、基础架构与竞态根源

1. 典型架构

  • DMA:配置为循环模式,接收数据写入环形缓冲区(内存数组)。
  • 半满中断(HT):当DMA传输过半时触发,通知主循环处理前半部分数据。
  • 帧超时:利用定时器(如TIM)或UART空闲中断,检测到总线空闲一段时间(如3-5字节时间)认为一帧结束。

2. 竞态场景

假设环形缓冲区大小为BUF_SIZE,DMA写指针write_idx由硬件维护,软件读指针read_idx

  • 场景A:DMA刚触发HT中断(写指针到达BUF_SIZE/2),此时主循环开始处理前半段。若同时帧超时中断触发,软件可能误以为整帧数据已完整,而实际上后半段数据还在DMA中未处理。
  • 场景B:主循环处理完前半段后,更新read_idx,但DMA已写入后半段并再次触发HT,若帧超时处理逻辑未同步,可能重复处理或漏处理。

核心矛盾:HT中断表示“缓冲区半满”,不代表“一帧结束”;帧超时表示“总线空闲”,但不保证数据已全部搬移到缓冲区(可能还在DMA FIFO)。两者需协同,但时序交错导致状态不一致。

二、解决方案:状态机+双指针同步

1. 设计思路

  • 状态机:定义IDLERECEIVINGFRAME_DONE状态,由HT和超时事件驱动。
  • 双指针read_idx(软件读)和dma_write_idx(通过DMA寄存器读取)。
  • 超时处理:在超时中断中,不直接处理数据,而是置位标志,由主循环统一处理,避免中断嵌套竞态。

2. 具体步骤

  1. 初始化:配置DMA循环模式,使能HT和TC(传输完成)中断,但TC仅用于错误处理。启动一个基本定时器(如TIM6)用于超时,但仅在接收到首个字节后启动。
  2. 接收启动:UART空闲中断或外部事件触发时,启动DMA和定时器。
  3. HT中断处理
    • 读取当前DMA写指针dma_pos
    • 计算可读数据长度:len = dma_pos - read_idx(考虑回绕)。
    • len > 0,将数据拷贝到应用缓冲区(或直接处理),更新read_idx
    • 注意:HT触发时,dma_pos可能位于BUF_SIZE/2,但若之前有未处理数据,需先处理完。
  4. 超时中断处理
    • 停止定时器。
    • 读取dma_pos,计算剩余数据(从read_idxdma_pos),全部取出。
    • 置位frame_ready标志,通知主循环解析完整帧。
    • 重置状态机为IDLE
  5. 主循环:检测到frame_ready,处理帧数据,然后重新准备接收。

3. 竞态消除关键点

  • 互斥:HT和超时中断都操作read_idx,需用临界区保护(如关中断或使用原子操作)。
  • 顺序:超时中断中,先停止定时器,再读取DMA指针,确保没有新数据写入(因为总线空闲)。
  • 状态检查:在HT中,若状态为IDLE(即未在接收帧),则忽略,因为可能是残留数据。

三、代码示例(以STM32 HAL库为例)

// 环形缓冲区定义
#define BUF_SIZE 256
uint8_t dma_rx_buf[BUF_SIZE];
volatile uint16_t read_idx = 0;
volatile uint8_t frame_ready = 0;
volatile uint8_t rx_state = 0; // 0:IDLE, 1:RECEIVING

// DMA半满中断回调
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 进入临界区
        __disable_irq();
        uint16_t dma_pos = BUF_SIZE - huart->hdmarx->Instance->NDTR; // 当前写位置
        uint16_t len = (dma_pos >= read_idx) ? (dma_pos - read_idx) : (BUF_SIZE - read_idx + dma_pos);
        if (len > 0 && rx_state == 1) {
            // 处理前半段数据(此处仅示例,实际可拷贝到应用缓冲)
            process_data(&dma_rx_buf[read_idx], len);
            read_idx = dma_pos;
        }
        __enable_irq();
    }
}

// 超时定时器中断(假设TIM6)
void TIM6_IRQHandler(void)
{
    if (TIM6->SR & TIM_SR_UIF) {
        TIM6->SR = ~TIM_SR_UIF;
        // 停止定时器
        HAL_TIM_Base_Stop_IT(&htim6);
        
        __disable_irq();
        uint16_t dma_pos = BUF_SIZE - huart1.hdmarx->Instance->NDTR;
        uint16_t len = (dma_pos >= read_idx) ? (dma_pos - read_idx) : (BUF_SIZE - read_idx + dma_pos);
        if (len > 0) {
            process_data(&dma_rx_buf[read_idx], len);
            read_idx = dma_pos;
        }
        frame_ready = 1;
        rx_state = 0;
        __enable_irq();
    }
}

// UART空闲中断(用于启动帧接收)
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 启动DMA接收(循环模式)
        HAL_UART_Receive_DMA(&huart1, dma_rx_buf, BUF_SIZE);
        // 启动超时定时器(例如10ms)
        HAL_TIM_Base_Start_IT(&htim6);
        rx_state = 1;
        read_idx = 0;
    }
}

// 主循环
while (1) {
    if (frame_ready) {
        frame_ready = 0;
        // 解析完整帧
        parse_frame();
        // 重新准备接收(可重新开启空闲中断)
        __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    }
}

四、注意事项

  • DMA配置:务必使用循环模式(HAL_UART_Receive_DMA默认循环),否则半满后不再接收。
  • 超时时间选择:需大于最大帧间隔,通常取3-5个字节传输时间,波特率9600时约3ms,115200时约0.26ms,可用定时器分频实现。
  • 临界区保护read_idxdma_pos的读取需原子,否则可能读到不一致值。
  • 缓冲区大小:应为2的幂,便于回绕计算,且需大于最大帧长,避免覆盖。
  • 中断优先级:UART空闲中断应高于DMA HT和定时器,确保帧起始及时。

五、扩展与优化

  • 使用硬件空闲中断:部分MCU(如STM32F4)支持UART空闲中断,可替代定时器,但需注意空闲中断在DMA传输中也会触发,需结合状态判断。
  • 双缓冲:若帧长较大,可配置两个半缓冲区,交替处理,减少拷贝。
  • 调试技巧:在关键点翻转GPIO,用逻辑分析仪观察时序,验证竞态是否消除。

结语

通过状态机协调DMA半满中断与帧超时,并利用双指针同步,可有效消除竞态,实现稳定高效的不定长接收。本文方案已在STM32F4系列上验证,可移植至其他平台。掌握此技巧,你的嵌入式通信将更加健壮。