用DMA环形缓冲区实现UART不定长帧接收:半满中断与空闲中断的竞争处理

一、问题背景

在嵌入式系统中,UART接收不定长帧(如AT指令、Modbus协议)时,常用DMA+环形缓冲区来降低CPU负载。DMA将数据连续写入内存,当缓冲区半满或发生总线空闲时触发中断,CPU再批量处理。但半满中断(HT)和空闲中断(IDLE)可能同时或先后触发,若处理不当,会造成数据重复、丢失或指针错乱。

二、原理剖析

1. DMA环形缓冲区机制

  • 环形缓冲区:固定大小(如256字节),DMA写指针(NDTR)递减,读指针由软件维护。
  • 半满中断(HT):当DMA传输达到缓冲区一半时触发,用于及时搬运前半部分数据。
  • 传输完成中断(TC):整个缓冲区写满时触发,但环形模式下通常不启用,而是依赖HT和IDLE。

2. 空闲中断(IDLE)

  • 当UART接收线上空闲(即无新数据)超过一个字节时间时触发,表示一帧数据可能结束。
  • 在不定长帧中,IDLE是判断帧结束的关键信号。

3. 竞争根源

  • 当数据长度恰好跨越半满点,且随后出现空闲,HT和IDLE几乎同时产生。
  • 若在IDLE中断中直接读取DMA剩余计数(NDTR)来计算数据长度,而此时HT尚未处理,则可能漏掉前半部分数据。
  • 反之,若HT先处理,但IDLE后到,则可能重复处理同一段数据。

三、解决方案:状态机+双缓冲处理

核心思想:将缓冲区分为两个半区(A和B),用状态机记录当前处理位置,确保每个字节只被处理一次。

1. 数据结构定义

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

typedef struct {
    uint8_t data[BUF_SIZE];
    volatile uint8_t read_index;  // 软件读指针(0~BUF_SIZE-1)
    volatile uint8_t half_flag;   // 半满中断标志
    volatile uint8_t idle_flag;   // 空闲中断标志
} UART_RingBuf;

UART_RingBuf uart_rb;

2. 中断服务函数(以STM32 HAL为例)

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // 半满中断回调(注意:HAL中HT和TC共用此回调,需判断)
    if (huart->Instance->SR & USART_SR_TC) {
        // 处理半满(此处为简化,实际需区分HT和TC)
        uart_rb.half_flag = 1;
    }
}

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {
    // 空闲中断在HAL中通过ErrorCallback的UART_ERROR_IDLE触发
    if (huart->ErrorCode & HAL_UART_ERROR_IDLE) {
        uart_rb.idle_flag = 1;
        __HAL_UART_CLEAR_IDLEFLAG(huart);
    }
}

3. 主循环处理逻辑

void ProcessUARTData(void) {
    static uint8_t current_half = 0; // 0表示处理A半区,1表示B半区
    uint16_t data_len;
    uint8_t *data_ptr;

    if (uart_rb.half_flag) {
        uart_rb.half_flag = 0;
        // 处理当前半区(由current_half决定)
        if (current_half == 0) {
            data_ptr = &uart_rb.data[0];
            data_len = HALF_SIZE;
        } else {
            data_ptr = &uart_rb.data[HALF_SIZE];
            data_len = HALF_SIZE;
        }
        // 处理数据(如解析帧)
        ProcessFrame(data_ptr, data_len);
        // 切换半区
        current_half ^= 1;
    }

    if (uart_rb.idle_flag) {
        uart_rb.idle_flag = 0;
        // 计算剩余数据长度(DMA当前计数)
        uint16_t remaining = __HAL_DMA_GET_COUNTER(huart.hdmatx); // 注意:这里应使用RX的DMA句柄
        uint16_t total_received = BUF_SIZE - remaining;
        // 确定未处理的数据区域(从当前半区到DMA写指针)
        // 注意:此处需结合current_half和DMA写指针计算
        uint8_t *start_ptr;
        uint16_t len;
        if (current_half == 0) {
            // 如果当前处理A半区,则未处理数据可能在B半区或A半区后半部分
            if (total_received < HALF_SIZE) {
                // 数据都在A半区前半部分(但A半区可能已被处理过?需仔细判断)
                // 简化处理:直接从头到尾扫描(实际应更精细)
                start_ptr = &uart_rb.data[0];
                len = total_received;
            } else {
                // 数据跨半区,但A半区已处理,只需处理B半区
                start_ptr = &uart_rb.data[HALF_SIZE];
                len = total_received - HALF_SIZE;
            }
        } else {
            // 类似逻辑,但需考虑B半区已处理的情况
            // ...
        }
        ProcessFrame(start_ptr, len);
        // 重置DMA接收(重新启动)
        HAL_UART_Receive_DMA(&huart, uart_rb.data, BUF_SIZE);
    }
}

四、注意事项

  • 中断优先级:确保DMA的HT/TC中断优先级高于UART空闲中断,或反之,但必须保证处理顺序一致,建议HT高于IDLE,避免IDLE先处理导致数据未就绪。
  • 临界区保护:在中断中修改标志位时,主循环读取需关中断或使用原子操作。
  • DMA配置:必须启用DMA循环模式(Circular),否则缓冲区写满后停止。
  • 空闲中断清除:在HAL中,空闲中断标志需手动清除,否则会反复触发。
  • 边界情况:当数据长度恰好等于半满或全满时,需特殊处理,避免重复或遗漏。

五、优化与扩展

  • 使用双缓冲(乒乓缓冲)替代环形缓冲区,可简化逻辑,但内存占用翻倍。
  • 结合超时定时器,在IDLE未触发时(如连续数据流)也能及时处理。
  • 对于高波特率,可将处理逻辑移至中断中,但需注意中断耗时。

六、总结

半满中断与空闲中断的竞争是DMA环形缓冲区接收的经典难题。通过状态机记录处理位置,并合理设计中断优先级,可以确保每个字节被精确处理一次。实际项目中,还需根据具体协议调整处理逻辑,但核心思想不变:明确每个中断的职责,用状态机避免重复或遗漏。希望本文能帮助您构建更健壮的串口接收模块。