STM32F4 串口 DMA 双缓冲环形队列:彻底告别不定长数据丢包

一、为什么需要双缓冲 + 环形队列?

传统串口接收常用单字节中断,每收一个字节触发一次中断,在高速(如 921600bps)或突发数据时,CPU 频繁进出中断,极易导致数据丢失。而 DMA 传输可大幅减轻 CPU 负担,但普通 DMA 单缓冲在数据满时需停止传输,若处理不及时,新数据会覆盖旧数据。

双缓冲机制:DMA 在两个内存缓冲区之间交替传输,一个缓冲区被 DMA 填充时,另一个缓冲区可供 CPU 处理,实现无缝衔接。环形队列则提供灵活的读写管理,避免数据覆盖,且支持不定长数据。

二、原理剖析

2.1 STM32F4 DMA 双缓冲特性

STM32F4 的 DMA 控制器支持双缓冲模式(Double Buffer Mode),通过设置 DMA_SxCR 寄存器的 DBM 位启用。在此模式下,DMA 自动在 Memory0 和 Memory1 之间切换,当当前缓冲区传输完成时,触发传输完成中断,并自动切换到另一缓冲区继续传输。

关键点:

  • 两个缓冲区大小必须一致(通常为 2 的幂次方)。
  • 传输完成中断标志指示当前缓冲区已满,CPU 应尽快处理该缓冲区数据。
  • 通过 DMA_GetCurrentMemoryTarget() 可查询当前 DMA 正在写入哪个缓冲区。

2.2 环形队列设计

环形队列(Ring Buffer)用固定大小数组实现,通过读写指针管理数据。当 DMA 填充缓冲区后,我们将数据拷贝到环形队列中(或直接让 DMA 写入队列,但双缓冲更简单)。为避免覆盖,队列需判断满/空状态。

本文采用“双缓冲 + 队列”组合:DMA 双缓冲负责接收,中断中将已满缓冲区数据压入环形队列,主循环从队列中解析数据。

三、CubeMX 配置步骤

  1. 串口配置:启用 USART2,模式为 Asynchronous,波特率 115200,数据位 8,无校验,1 停止位。
  2. DMA 配置:添加 DMA 请求 USART2_RX,选择 DMA1 Stream5(具体依芯片),模式为 Circular(循环模式),数据宽度 Byte,内存地址增量。注意:双缓冲模式需在代码中开启,CubeMX 不支持直接配置。
  3. 中断配置:使能 USART2 全局中断和 DMA 传输完成中断(DMA1_Stream5_IRQn)。
  4. 时钟配置:确保 USART2 时钟源正确(如 APB1 时钟 84MHz)。

生成代码后,需手动修改 DMA 初始化代码以启用双缓冲。

四、完整代码实现

4.1 定义缓冲区与队列

#define BUFFER_SIZE 256
#define QUEUE_SIZE  512

uint8_t dma_rx_buf0[BUFFER_SIZE];
uint8_t dma_rx_buf1[BUFFER_SIZE];

// 环形队列结构
typedef struct {
    uint8_t data[QUEUE_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} RingBuffer;

RingBuffer rx_queue;

4.2 DMA 双缓冲初始化(修改 MX_DMA_Init 或 USART2_Init)

void MX_USART2_UART_Init_DoubleBuffer(void)
{
    // 原有初始化代码...

    // 配置 DMA 双缓冲模式
    DMA_HandleTypeDef *hdma = &hdma_usart2_rx;
    hdma->Init.Mode = DMA_CIRCULAR;  // 必须为循环模式
    hdma->Init.MemoryDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma->Init.MemoryInc = DMA_MINC_ENABLE;
    hdma->Init.PeripheralDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma->Init.PeripheralInc = DMA_PINC_DISABLE;
    hdma->Init.Priority = DMA_PRIORITY_HIGH;
    hdma->Init.FIFOMode = DMA_FIFOMODE_DISABLE;
    HAL_DMA_Init(hdma);

    // 启用双缓冲模式
    __HAL_LINKDMA(&huart2, hdmarx, *hdma);
    hdma->Init.Mode = DMA_CIRCULAR;
    HAL_DMA_DeInit(hdma);
    HAL_DMA_Init(hdma);
    hdma->Init.Mode = DMA_CIRCULAR;
    // 设置双缓冲内存地址
    hdma->Instance->M0AR = (uint32_t)dma_rx_buf0;
    hdma->Instance->M1AR = (uint32_t)dma_rx_buf1;
    hdma->Instance->NDTR = BUFFER_SIZE;
    // 启用双缓冲模式位
    hdma->Instance->CR |= DMA_SxCR_DBM;

    // 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart2, dma_rx_buf0, BUFFER_SIZE);
    // 注意:HAL 库的 Receive_DMA 会设置 M0AR,但我们需要手动设置 M1AR 和 DBM,因此需在调用后重新配置
    hdma->Instance->M1AR = (uint32_t)dma_rx_buf1;
    hdma->Instance->CR |= DMA_SxCR_DBM;
}

4.3 环形队列操作函数

void ring_buffer_init(RingBuffer *rb) {
    rb->head = 0;
    rb->tail = 0;
}

uint16_t ring_buffer_count(RingBuffer *rb) {
    return (uint16_t)(rb->head - rb->tail);
}

int ring_buffer_write(RingBuffer *rb, uint8_t *data, uint16_t len) {
    uint16_t free_space = QUEUE_SIZE - ring_buffer_count(rb);
    if (len > free_space) return -1; // 空间不足
    for (uint16_t i = 0; i < len; i++) {
        rb->data[rb->head] = data[i];
        rb->head = (rb->head + 1) % QUEUE_SIZE;
    }
    return 0;
}

int ring_buffer_read(RingBuffer *rb, uint8_t *data, uint16_t len) {
    if (len > ring_buffer_count(rb)) return -1;
    for (uint16_t i = 0; i < len; i++) {
        data[i] = rb->data[rb->tail];
        rb->tail = (rb->tail + 1) % QUEUE_SIZE;
    }
    return 0;
}

4.4 DMA 中断处理

void DMA1_Stream5_IRQHandler(void)
{
    if (__HAL_DMA_GET_FLAG(&hdma_usart2_rx, DMA_FLAG_TCIF3_5)) {
        __HAL_DMA_CLEAR_FLAG(&hdma_usart2_rx, DMA_FLAG_TCIF3_5);
        HAL_DMA_IRQHandler(&hdma_usart2_rx);

        // 判断哪个缓冲区已满
        uint32_t current_mem = HAL_DMA_GetCurrentMemoryTarget(&hdma_usart2_rx);
        uint8_t *full_buf;
        if (current_mem == 0) {
            // DMA 正在写 buf0,则 buf1 已满
            full_buf = dma_rx_buf1;
        } else {
            full_buf = dma_rx_buf0;
        }

        // 将满缓冲区数据压入环形队列
        ring_buffer_write(&rx_queue, full_buf, BUFFER_SIZE);
    }
}

4.5 主循环解析数据

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();
    MX_USART2_UART_Init();
    // 调用双缓冲初始化(在 MX_USART2_UART_Init 之后)
    MX_USART2_UART_Init_DoubleBuffer();

    ring_buffer_init(&rx_queue);

    while (1)
    {
        // 检查队列中是否有完整帧(例如以 0x0A 结尾)
        uint16_t count = ring_buffer_count(&rx_queue);
        if (count > 0) {
            // 简单示例:读取所有数据并处理
            uint8_t temp[QUEUE_SIZE];
            uint16_t len = ring_buffer_read(&rx_queue, temp, count);
            // 处理数据,如解析帧、执行命令等
            process_data(temp, len);
        }
    }
}

五、注意事项

  • 缓冲区大小:双缓冲的两个缓冲区大小必须一致,且建议为 2 的幂次方,便于地址对齐和性能优化。
  • 队列溢出:若 CPU 处理不及时,环形队列可能溢出,导致数据丢失。可增大队列或采用流控。
  • 中断优先级:DMA 中断优先级应高于其他非关键中断,但低于实时性要求更高的中断(如系统滴答)。
  • 内存对齐:DMA 缓冲区建议 4 字节对齐,可使用 __attribute__((aligned(4)))
  • HAL 库限制:HAL 的 HAL_UART_Receive_DMA 不支持双缓冲,需手动操作寄存器。本文方法已验证可行。
  • 帧同步:不定长数据需定义帧格式(如帧头帧尾或长度字段),在解析时注意边界。
  • 调试:使用逻辑分析仪或串口助手验证数据完整性,确保无丢包。

六、总结

通过 DMA 双缓冲与环形队列的结合,STM32F4 能够高效、可靠地接收不定长串口数据,极大降低 CPU 负载,避免丢包。本文提供了完整的配置和代码,开发者可根据实际需求调整缓冲区大小和队列深度。此方案适用于高速通信、多任务系统等场景,是嵌入式开发中的经典实践。