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

一、背景与挑战

在 STM32 等 MCU 上,UART 接收不定长帧时,常见方案是 DMA + 环形缓冲区。DMA 负责将数据从外设搬运到内存,CPU 通过中断感知数据到达。但 DMA 的半满中断(Half Transfer)和 UART 的空闲中断(IDLE)可能同时发生,若处理不当,会导致数据重复读取、丢失或帧错位。

二、原理剖析

1. DMA 半满中断与传输完成中断

  • 当 DMA 配置为循环模式(Circular Mode)时,缓冲区被分为两半:上半区和下半区。
  • 半满中断(HT)在 DMA 传输到缓冲区中点时触发,传输完成中断(TC)在传输到缓冲区末尾时触发。
  • 这两个中断用于通知 CPU 可以安全处理已接收的数据,而不会与 DMA 写入冲突。

2. UART 空闲中断

  • 空闲中断在 UART 接收线上检测到空闲(即无数据)时触发,通常用于判断一帧数据结束。
  • 空闲中断与 DMA 配合时,CPU 需要从 DMA 缓冲区中读取当前已接收的数据,但此时 DMA 可能仍在写入。

3. 竞争场景

假设缓冲区大小为 256 字节,DMA 循环模式。

  • 场景 A:接收数据长度超过 128 字节,DMA 触发半满中断,CPU 处理上半区数据。此时若数据流短暂停顿,UART 触发空闲中断,CPU 又去处理整个缓冲区,导致上半区数据被重复处理。
  • 场景 B:接收数据长度小于 128 字节,DMA 未触发半满中断,空闲中断触发,CPU 需要读取当前 DMA 位置之前的数据,但 DMA 可能正在写入,导致读取不一致。

三、处理策略

核心思想:用状态机管理缓冲区读写位置,并利用 DMA 的当前数据指针(NDTR)来精确计算有效数据长度。

1. 缓冲区设计

  • 定义环形缓冲区,使用两个指针:读指针(CPU 使用)和写指针(DMA 更新)。
  • 在 DMA 中断(HT/TC)中更新写指针,在空闲中断中根据当前 DMA 位置更新写指针并触发帧处理。

2. 中断优先级与嵌套

  • 将 DMA 中断优先级设为高于 UART 空闲中断,确保 DMA 中断能及时处理,避免数据覆盖。
  • 在空闲中断中,先关闭 DMA 中断(或暂停 DMA),再读取数据,处理完后再恢复。

3. 状态机实现

定义以下状态:

  • IDLE:等待数据。
  • RECEIVING:正在接收数据。
  • FRAME_READY:一帧数据完整,待处理。

在中断中更新状态,在主循环中处理帧数据。

四、代码实现(基于 STM32 HAL 库)

1. 配置 DMA 和 UART

// 缓冲区定义
#define BUFFER_SIZE 256
uint8_t dma_buffer[BUFFER_SIZE];

// 环形缓冲区结构
typedef struct {
    uint16_t head;  // 读指针
    uint16_t tail;  // 写指针(由中断更新)
    uint8_t buffer[BUFFER_SIZE];
} ring_buffer_t;

ring_buffer_t rx_ring;

// 初始化 DMA 接收
void uart_dma_init(void) {
    // 使能 UART 空闲中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    // 启动 DMA 循环接收
    HAL_UART_Receive_DMA(&huart1, dma_buffer, BUFFER_SIZE);
    // 初始化环形缓冲区
    rx_ring.head = 0;
    rx_ring.tail = 0;
}

2. DMA 中断处理(半满和完成)

void DMA1_Channel5_IRQHandler(void) {
    if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_HT)) {
        __HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_HT);
        // 更新写指针到缓冲区中点
        rx_ring.tail = BUFFER_SIZE / 2;
        // 处理上半区数据
        process_data(0, BUFFER_SIZE / 2);
    }
    if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_TC)) {
        __HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_TC);
        // 更新写指针到缓冲区末尾
        rx_ring.tail = BUFFER_SIZE;
        // 处理下半区数据
        process_data(BUFFER_SIZE / 2, BUFFER_SIZE / 2);
    }
}

3. UART 空闲中断处理

void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_IDLE);
        // 暂停 DMA,防止写入冲突
        HAL_DMA_Abort(&hdma_usart1_rx);
        // 获取当前 DMA 剩余计数
        uint16_t remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
        uint16_t current_pos = BUFFER_SIZE - remaining;
        // 更新写指针到当前位置
        rx_ring.tail = current_pos;
        // 处理从上次读指针到当前写指针的数据
        process_data(rx_ring.head, (rx_ring.tail - rx_ring.head + BUFFER_SIZE) % BUFFER_SIZE);
        // 重新启动 DMA 接收
        HAL_UART_Receive_DMA(&huart1, dma_buffer, BUFFER_SIZE);
        // 重置读指针
        rx_ring.head = rx_ring.tail;
    }
}

4. 数据解析函数

void process_data(uint16_t start, uint16_t len) {
    // 从环形缓冲区中读取数据,进行帧解析
    for (uint16_t i = 0; i < len; i++) {
        uint8_t byte = dma_buffer[(start + i) % BUFFER_SIZE];
        // 处理字节,例如存入帧缓冲区
    }
    // 更新读指针
    rx_ring.head = (rx_ring.head + len) % BUFFER_SIZE;
}

五、注意事项

  • 缓冲区大小:应大于最大帧长,且为 2 的幂次方,便于取模运算。
  • 临界区保护:在中断中更新 rx_ring.tail 时,主循环可能正在读取,需使用临界区或原子操作。
  • DMA 暂停与恢复:在空闲中断中暂停 DMA 后,必须确保在重新启动前,DMA 已经完全停止,否则可能丢失数据。
  • 半满中断与空闲中断同时触发:若两者同时发生,先处理半满中断(更新 tail),再处理空闲中断(根据当前 DMA 位置更新 tail),避免重复处理。
  • 测试验证:使用不同长度的数据帧(小于半满、等于半满、大于半满)进行压力测试,确保逻辑正确。

六、总结

通过合理设计环形缓冲区、利用 DMA 的 HT/TC 中断和 UART 空闲中断,并采用状态机管理,可以有效解决半满中断与空闲中断的竞争问题。关键在于精确控制读写指针,并确保在中断处理中数据的一致性。此方案适用于大部分嵌入式平台,具有较高的可靠性和实时性。