用DMA环形缓冲区实现UART不定长接收:帧尾超时与半满中断竞争的精准处理

1. 背景与挑战

在嵌入式系统中,UART接收不定长数据帧是常见需求。传统中断逐字节接收在高速率下会频繁打断CPU,而DMA+环形缓冲区可大幅降低中断负载。但引入DMA后,两个异步事件——帧尾超时(IDLE中断或定时器)与半满中断(DMA传输过半)——可能同时或交错发生,若处理不当,会导致数据覆盖、重复读取或帧错位。

2. 原理剖析:竞争的本质

2.1 DMA环形缓冲区机制

  • 缓冲区大小设为2的幂次(如256字节),DMA在内存中循环写入。
  • 通过NDTR寄存器或DMA_GetCurrDataCounter()获取当前剩余计数,从而计算写指针位置。
  • 半满中断在传输达到缓冲区一半时触发,用于及时搬移数据,防止溢出。

2.2 帧尾超时机制

  • 常用方式:UART空闲中断(IDLE)或定时器超时(如10ms无新数据)。
  • 目的:判断一帧数据接收完毕,触发处理回调。

2.3 竞争场景

  • 场景A:DMA半满中断触发时,恰好收到帧尾(IDLE),两个中断几乎同时挂起。
  • 场景B:半满中断处理中,IDLE到来,但半满处理尚未完成,数据可能被覆盖。
  • 场景C:IDLE处理时,DMA继续写入,导致读指针追赶写指针。

核心矛盾:两个事件都试图更新读指针,但缺乏原子性保护

3. 解决方案:状态机+双标志位

3.1 设计思路

  • 引入rx_state状态机(IDLE、RECEIVING、FRAME_READY)。
  • 使用两个标志位:half_flag(半满待处理)和idle_flag(帧尾待处理)。
  • 中断中只置标志位,主循环或低优先级任务中统一处理,避免嵌套竞争。

3.2 状态转移

  • IDLE:等待起始字节,收到首个字节后进入RECEIVING。
  • RECEIVING:持续接收,若半满触发则置half_flag,若IDLE触发则置idle_flag并进入FRAME_READY。
  • FRAME_READY:主循环处理完整帧,然后回到IDLE。

3.3 竞争消解规则

  • 在中断服务函数中,仅更新标志位,不直接操作缓冲区读指针。
  • 主循环中,先检查idle_flag,若置位则处理整帧(此时半满标志可能已置位,但需先完成帧处理)。
  • half_flag置位且idle_flag未置位,则搬移半满数据到用户缓冲区,并更新读指针。
  • 关键:处理半满时,必须禁用UART的IDLE中断或使用临界区,防止IDLE打断

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

// 环形缓冲区定义
#define BUF_SIZE 256
#define HALF_SIZE (BUF_SIZE/2)

uint8_t dma_rx_buf[BUF_SIZE];
volatile uint16_t read_index = 0;
volatile uint8_t rx_state = 0; // 0:IDLE, 1:RECEIVING, 2:FRAME_READY
volatile uint8_t half_flag = 0;
volatile uint8_t idle_flag = 0;

// 用户帧缓冲区(假设最大帧长128)
uint8_t frame_buf[128];
uint16_t frame_len = 0;

// 初始化DMA和UART(略)

// UART IDLE中断回调(在HAL_UART_IRQHandler中调用)
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 计算当前写指针
        uint16_t write_index = BUF_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);
        // 置帧尾标志
        idle_flag = 1;
        // 保存当前帧长度(假设从read_index到write_index)
        frame_len = (write_index >= read_index) ? (write_index - read_index) : (BUF_SIZE - read_index + write_index);
        // 状态机转移
        rx_state = 2; // FRAME_READY
        // 注意:此时不立即处理,等待主循环
    }
}

// DMA半满中断回调(在HAL_DMA_IRQHandler中调用)
void HAL_DMA_HalfCpltCallback(DMA_HandleTypeDef *hdma)
{
    if (hdma->Instance == DMA1_Channel5) { // 假设UART1_RX用DMA1_CH5
        half_flag = 1;
    }
}

// 主循环中的处理函数
void UART_Process(void)
{
    // 处理帧尾(优先级高)
    if (idle_flag) {
        idle_flag = 0;
        // 复制数据到用户缓冲区(注意环形回绕)
        for (uint16_t i = 0; i < frame_len; i++) {
            frame_buf[i] = dma_rx_buf[(read_index + i) % BUF_SIZE];
        }
        // 更新读指针
        read_index = (read_index + frame_len) % BUF_SIZE;
        // 调用用户回调
        User_Frame_Handler(frame_buf, frame_len);
        // 状态复位
        rx_state = 0;
        // 重新使能IDLE中断(若之前禁用)
        __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    }
    // 处理半满(仅当无帧尾待处理)
    else if (half_flag) {
        half_flag = 0;
        // 临界区:暂时禁用IDLE中断,防止竞争
        __HAL_UART_DISABLE_IT(&huart1, UART_IT_IDLE);
        // 计算当前写指针
        uint16_t write_index = BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        // 计算可读数据长度(从read_index到write_index)
        uint16_t avail = (write_index >= read_index) ? (write_index - read_index) : (BUF_SIZE - read_index + write_index);
        // 如果可读长度大于半满,则只搬移半满部分(但此处我们搬移全部可用,但注意不要超过半满)
        // 实际上半满中断表示已写入半满,所以可用长度至少为半满,但可能更多(若IDLE同时发生)
        // 为简化,我们搬移所有可用数据到临时缓冲(但需防止溢出)
        // 此处仅演示,实际可搬移到更大的用户缓冲
        uint8_t temp[128];
        uint16_t copy_len = (avail > 128) ? 128 : avail;
        for (uint16_t i = 0; i < copy_len; i++) {
            temp[i] = dma_rx_buf[(read_index + i) % BUF_SIZE];
        }
        // 更新读指针
        read_index = (read_index + copy_len) % BUF_SIZE;
        // 重新使能IDLE中断
        __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
        // 处理temp数据(例如存入队列)
        // ...
    }
}

// 用户回调示例
void User_Frame_Handler(uint8_t *data, uint16_t len)
{
    // 处理完整帧
}

5. 配置步骤

  1. 初始化DMA:配置为循环模式,数据宽度字节,内存地址自增,外设地址固定。
  2. 使能DMA中断:开启半满中断(HAL_DMA_Start_IT)和传输完成中断(可选)。
  3. 配置UART:使能IDLE中断(__HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE)),并设置DMA接收。
  4. 编写回调:如上代码,在HAL_UART_IdleCpltCallbackHAL_DMA_HalfCpltCallback中置标志。
  5. 主循环调用:周期性调用UART_Process()

6. 注意事项

  • 临界区保护:访问read_indexframe_len等共享变量时,建议使用临界区或关中断,防止主循环与中断冲突。
  • 缓冲区大小:需大于最大帧长,且为2的幂次,便于取模运算。
  • 半满中断的触发时机:DMA半满中断在传输半满时触发,但若帧长小于半满,则不会触发,此时IDLE是唯一信号。
  • 超时时间选择:若使用定时器超时,需根据波特率和帧间隔调整,避免误判。
  • 性能考量:主循环处理频率需足够高,否则半满标志可能堆积,导致缓冲区溢出。

7. 总结

通过状态机与双标志位,我们成功将DMA半满中断与UART IDLE中断的竞争转化为顺序处理,避免了数据竞争和丢帧。此方案在STM32F1/F4/H7等系列上均适用,只需调整DMA和UART句柄。实际项目中,还可结合空闲定时器(如TIM)实现更精确的超时控制。掌握这一策略,你的嵌入式通信层将更加健壮。