用 DMA 环形缓冲区 + 空闲中断实现 STM32 不定长串口接收,避免丢帧的边界条件分析

一、为什么需要 DMA + 空闲中断?

传统串口接收(逐字节中断)在高速或长数据帧时,CPU 频繁进出中断,容易导致丢帧。而 DMA 可以在无需 CPU 干预的情况下,将串口接收到的数据搬运到内存缓冲区。但 DMA 本身不知道一帧数据何时结束,因此需要配合**空闲中断(IDLE)**来检测帧边界。

核心思路

  • DMA 持续将串口数据存入缓冲区(环形结构)。
  • 当串口总线空闲(即一帧数据接收完毕)时,触发空闲中断。
  • 在空闲中断中,根据 DMA 当前写入位置,计算本次接收的数据长度,并处理数据。

二、环形缓冲区设计

环形缓冲区(Ring Buffer)是一种先进先出的数据结构,通过读写指针循环使用固定内存。

#define BUFFER_SIZE 256  // 必须是 2 的幂,便于取模

uint8_t rx_buffer[BUFFER_SIZE];
volatile uint16_t read_index = 0;   // 读指针(应用层使用)
volatile uint16_t write_index = 0;  // 写指针(DMA 更新)

关键点

  • write_index 由 DMA 传输完成或空闲中断更新,表示 DMA 已写入的位置。
  • read_index 由应用层读取数据时更新。
  • read_index == write_index 时,缓冲区为空。

三、DMA 配置与中断协同

1. DMA 配置(以 STM32F1 为例)

void UART_DMA_Config(void)
{
    // 使能 DMA1 时钟
    RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);

    DMA_InitTypeDef DMA_InitStructure;
    DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
    DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_buffer;
    DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
    DMA_InitStructure.DMA_BufferSize = BUFFER_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_Channel5, &DMA_InitStructure);

    // 使能 DMA 半传输中断和传输完成中断
    DMA_ITConfig(DMA1_IT_TC5, ENABLE);
    DMA_ITConfig(DMA1_IT_HT5, ENABLE);

    // 使能 DMA 通道
    DMA_Cmd(DMA1_Channel5, ENABLE);
}

2. 空闲中断配置

void UART_IDLE_Config(void)
{
    USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);
    // 注意:空闲中断标志在 USART_SR 的 IDLE 位,需要读 SR 再读 DR 清除
}

3. 中断服务函数(关键)

void USART1_IRQHandler(void)
{
    if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET)
    {
        // 清除空闲中断标志(读 SR 再读 DR)
        USART_ReceiveData(USART1);

        // 获取当前 DMA 写入位置(即 DMA 当前传输计数)
        uint16_t dma_remaining = DMA_GetCurrDataCounter(DMA1_Channel5);
        uint16_t current_write = BUFFER_SIZE - dma_remaining;

        // 计算本次接收的数据长度(考虑环形回绕)
        uint16_t data_len;
        if (current_write >= write_index)
        {
            data_len = current_write - write_index;
        }
        else
        {
            data_len = BUFFER_SIZE - write_index + current_write;
        }

        // 注意:这里需要处理 data_len 为 0 的情况(可能只是空闲中断误触发)
        if (data_len > 0)
        {
            // 将数据交给应用层处理(例如存入队列或直接处理)
            ProcessReceivedData(rx_buffer, write_index, data_len);
            // 更新写指针
            write_index = current_write;
        }
    }
}

四、边界条件分析(重点)

1. 缓冲区回绕(Wrap-around)

环形缓冲区最大的陷阱是回绕。当 DMA 写入地址到达缓冲区末尾时,会回绕到开头。此时计算数据长度必须分段处理:

// 假设 write_index = 200,current_write = 50,BUFFER_SIZE = 256
// 数据实际分为两段:200~255 和 0~50
// 长度 = (256 - 200) + 50 = 106

2. 半传输中断与空闲中断的竞争

DMA 循环模式下,当传输过半时触发半传输中断(HT),传输完成时触发传输完成中断(TC)。这两个中断可以用来处理数据,但必须与空闲中断协调,否则可能重复处理或漏处理。

推荐策略

  • 半传输中断和传输完成中断中,仅更新 write_index(或直接处理数据),但要注意与空闲中断的互斥。
  • 更简单的方法:只使用空闲中断,因为空闲中断已经能确定一帧结束,且 DMA 循环模式会持续接收,不会丢失数据(只要应用层处理速度足够快)。

3. 数据溢出(Overrun)

如果应用层处理速度慢于 DMA 写入速度,缓冲区会被覆盖。此时需要检测溢出:

// 在空闲中断中,如果 data_len 大于 BUFFER_SIZE - 1,说明溢出
if (data_len >= BUFFER_SIZE)
{
    // 溢出处理:丢弃旧数据或重置缓冲区
    read_index = current_write;
    write_index = current_write;
}

4. 空闲中断误触发

在某些情况下(如线路噪声),空闲中断可能在没有完整帧时触发。此时 data_len 可能为 0 或极小,应过滤掉:

if (data_len == 0) return;  // 忽略空帧

五、完整代码示例(简化版)

// main.c
int main(void)
{
    // 初始化时钟、GPIO、USART1、DMA(略)
    UART_DMA_Config();
    UART_IDLE_Config();

    while (1)
    {
        // 应用层从缓冲区读取数据
        if (read_index != write_index)
        {
            uint8_t data = rx_buffer[read_index];
            read_index = (read_index + 1) % BUFFER_SIZE;
            // 处理数据...
        }
    }
}

// 空闲中断处理(已包含边界处理)
void ProcessReceivedData(uint8_t *buf, uint16_t start, uint16_t len)
{
    // 示例:将数据打印或存储
    for (uint16_t i = 0; i < len; i++)
    {
        uint16_t idx = (start + i) % BUFFER_SIZE;
        // 处理 buf[idx]
    }
}

六、注意事项与优化建议

  • 缓冲区大小:建议设为 2 的幂,这样 (index + 1) & (BUFFER_SIZE - 1) 比取模运算更快。
  • 临界区保护:在中断中更新 write_index,在主循环中读取,需考虑原子性。可使用 __disable_irq() 或使用 volatile 并确保读写顺序。
  • DMA 半传输中断的用途:如果应用层需要实时处理数据,可以在半传输中断中处理前半部分,在传输完成中断中处理后半部分,但需注意与空闲中断的同步。
  • 多帧连续接收:空闲中断可以处理连续多帧,但若两帧间隔极短,可能合并为一帧。此时需要应用层自定义协议(如帧头帧尾)来区分。
  • 性能考量:空闲中断中避免耗时操作,仅更新指针和拷贝数据,实际处理放到主循环。

七、总结

通过 DMA 环形缓冲区 + 空闲中断,可以实现高效的 STM32 不定长串口接收。关键在于理解环形缓冲区的回绕、DMA 计数器的更新时机,以及空闲中断与 DMA 中断的协同。本文分析的边界条件(回绕、溢出、误触发)是实际项目中常见的坑,掌握后能显著提升代码的健壮性。建议读者在开发板上实际测试,观察不同数据长度和频率下的行为,加深理解。