STM32F4 DMA双缓冲环形队列接收不定长串口数据

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

在STM32F4中,串口接收数据时,若使用单DMA缓冲,当数据量超过缓冲长度或处理不及时,新数据会覆盖旧数据,导致丢帧。双缓冲机制允许DMA在两个缓冲区之间交替传输,而CPU可同时处理另一缓冲区数据,避免冲突。环形队列则提供灵活的存储管理,适配不定长数据,防止缓存溢出。

二、双缓冲与环形队列原理

1. DMA双缓冲模式

STM32F4的DMA控制器支持双缓冲模式(Double Buffer Mode),通过设置DMA_SxCR的DBM位启用。此时,DMA在内存地址0和内存地址1之间切换,当当前缓冲区传输完成,自动切换至另一缓冲区,并触发传输完成中断(TC)。CPU可在中断中处理已完成缓冲区,而DMA继续填充另一缓冲区,实现流水线操作。

2. 环形队列设计

环形队列(Ring Buffer)使用固定大小数组,通过读写指针管理数据。写指针由DMA中断更新,读指针由应用层消费。当写指针追上读指针时,队列满;读指针追上写指针时,队列空。为避免覆盖,需在写入前检查剩余空间,若不足则丢弃或报警。

三、硬件与软件配置步骤

1. 硬件连接

  • STM32F407开发板(或其他F4系列)
  • USB转TTL模块连接USART2(PA2=TX,PA3=RX)
  • 确保共地

2. 软件配置(使用STM32CubeMX + HAL库)

步骤1:配置USART2

  • 模式:异步模式,波特率115200,8位数据,无校验,1停止位
  • 开启DMA接收,选择DMA2 Stream5(USART2_RX),方向外设到内存,模式循环(Circular)或双缓冲(Double Buffer)
  • 注意:HAL库的DMA双缓冲需手动设置,CubeMX仅支持循环模式,但可后续修改

步骤2:配置DMA

在CubeMX中,DMA设置选择USART2_RX,优先级高,数据宽度字节。生成代码后,修改DMA初始化结构体,启用双缓冲:

// 在main.c中修改DMA句柄
DMA_HandleTypeDef hdma_usart2_rx;

// 初始化后,设置双缓冲
hdma_usart2_rx.Init.Mode = DMA_DOUBLE_BUFFER_MODE; // 双缓冲模式
hdma_usart2_rx.Init.MemoryDataAlignment = DMA_MDATAALIGN_BYTE;
HAL_DMA_Init(&hdma_usart2_rx);

// 设置两个缓冲区地址
HAL_DMAEx_MultiBufferStart(&hdma_usart2_rx, (uint32_t)&huart2.Instance->DR, (uint32_t)rxBuf1, (uint32_t)rxBuf2, RX_BUF_SIZE);

步骤3:定义缓冲区与环形队列

#define RX_BUF_SIZE 256
uint8_t rxBuf1[RX_BUF_SIZE];
uint8_t rxBuf2[RX_BUF_SIZE];

// 环形队列结构
typedef struct {
    uint8_t *buf;      // 指向实际数据区(这里用两个DMA缓冲区组合)
    volatile uint16_t head;  // 写指针(由DMA中断更新)
    volatile uint16_t tail;  // 读指针(应用层更新)
    uint16_t size;     // 队列总大小(2*RX_BUF_SIZE)
} RingQueue;

RingQueue rxQueue;

四、完整代码实现

1. 初始化与启动

// 初始化环形队列
void RingQueue_Init(RingQueue *q, uint8_t *buf, uint16_t size) {
    q->buf = buf;
    q->head = 0;
    q->tail = 0;
    q->size = size;
}

// 启动DMA接收
void UART_RX_Start(void) {
    // 将两个DMA缓冲区视为连续内存,但实际不连续,需特殊处理
    // 这里采用简单方法:使用一个大的静态数组,然后让DMA交替写入不同区域
    // 为简化,我们使用两个独立缓冲区,但环形队列逻辑需调整
    // 推荐:使用一个2*RX_BUF_SIZE的数组,DMA双缓冲时,地址0指向数组前半,地址1指向后半
    // 但DMA要求内存地址连续,双缓冲模式允许两个独立地址,故环形队列需分别管理
    // 此处采用更简单方案:使用循环DMA模式(单缓冲),配合空闲中断,但本文重点双缓冲,故继续
    
    // 启动双缓冲DMA
    HAL_UART_Receive_DMA(&huart2, rxBuf1, RX_BUF_SIZE); // 第一次启动,实际会使用双缓冲配置
    // 注意:HAL_UART_Receive_DMA内部会调用HAL_DMA_Start_IT,但双缓冲需用HAL_DMAEx_MultiBufferStart
    // 因此需手动调用
    HAL_DMAEx_MultiBufferStart(&hdma_usart2_rx, (uint32_t)&huart2.Instance->DR, (uint32_t)rxBuf1, (uint32_t)rxBuf2, RX_BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); // 使能空闲中断,用于判断帧结束
}

2. DMA中断回调

// 在stm32f4xx_it.c中调用HAL_DMA_IRQHandler,然后重写回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART2) {
        // 判断当前DMA使用哪个缓冲区(通过DMA的NDTR或当前内存地址)
        uint32_t curMemAddr = (uint32_t)hdma_usart2_rx.Instance->M0AR; // 当前内存地址
        uint8_t *completedBuf;
        if (curMemAddr == (uint32_t)rxBuf1) {
            completedBuf = rxBuf2; // 因为DMA刚切换到buf2,所以buf1已完成
        } else {
            completedBuf = rxBuf1;
        }
        // 将数据写入环形队列(这里需注意,环形队列需支持两个独立缓冲区,简化处理:直接复制到队列)
        // 实际应用中,可让环形队列的buf指向一个连续区域,DMA双缓冲的每个缓冲区独立,但可复制到队列
        // 为高效,可设计双缓冲环形队列,但复杂。此处演示复制法:
        for (int i = 0; i < RX_BUF_SIZE; i++) {
            if (RingQueue_IsFull(&rxQueue)) {
                // 队列满,丢弃数据或置溢出标志
                break;
            }
            rxQueue.buf[rxQueue.head] = completedBuf[i];
            rxQueue.head = (rxQueue.head + 1) % rxQueue.size;
        }
        // 重新启动DMA(双缓冲模式会自动切换,无需重启,但需确保NDTR重新加载)
        // 在双缓冲模式下,DMA自动切换,无需额外操作,但需注意:当缓冲区传输完成,DMA会停在另一缓冲区,等待下一次触发?
        // 实际上,双缓冲模式在传输完成后,DMA会自动切换并继续传输,但需要重新设置NDTR?
        // 正确做法:在回调中重新调用HAL_DMAEx_MultiBufferStart,但这样会重置,导致丢数据。
        // 更好的方法是使用循环双缓冲,但STM32F4不支持,因此需在回调中重新配置。
        // 这里简化:使用循环DMA模式(单缓冲)更简单,但本文主题双缓冲,故采用手动重启。
        // 注意:重启前需确保DMA已停止,否则冲突。
        HAL_DMA_Abort(&hdma_usart2_rx);
        HAL_DMAEx_MultiBufferStart(&hdma_usart2_rx, (uint32_t)&huart2.Instance->DR, (uint32_t)rxBuf1, (uint32_t)rxBuf2, RX_BUF_SIZE);
    }
}

3. 空闲中断处理(用于不定长帧)

// 在USART2_IRQHandler中调用HAL_UART_IRQHandler,然后重写
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART2) {
        // 清除空闲标志
        __HAL_UART_CLEAR_IDLEFLAG(&huart2);
        // 此时DMA可能正在接收,但空闲表示一帧结束,可处理当前缓冲区数据
        // 但双缓冲下,可能两个缓冲区都有数据,需全部处理。
        // 简单做法:停止DMA,处理所有数据,然后重启。
        HAL_DMA_Abort(&hdma_usart2_rx);
        // 处理两个缓冲区中已接收的数据(通过NDTR判断实际接收长度)
        uint16_t ndtr1 = __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); // 当前缓冲区剩余
        // 但双缓冲下,NDTR只反映当前缓冲区,需结合状态。
        // 为简化,这里不深入,实际应用建议使用循环DMA+空闲中断。
        // 重启DMA
        HAL_DMAEx_MultiBufferStart(&hdma_usart2_rx, (uint32_t)&huart2.Instance->DR, (uint32_t)rxBuf1, (uint32_t)rxBuf2, RX_BUF_SIZE);
    }
}

4. 应用层读取数据

uint16_t RingQueue_Read(RingQueue *q, uint8_t *outBuf, uint16_t len) {
    uint16_t cnt = 0;
    while (cnt < len && q->tail != q->head) {
        outBuf[cnt++] = q->buf[q->tail];
        q->tail = (q->tail + 1) % q->size;
    }
    return cnt;
}

五、注意事项与优化建议

  • 双缓冲模式限制:STM32F4的DMA双缓冲模式在每次传输完成时需重新配置,否则不会自动连续传输,这会导致中断频繁且可能丢数据。实际工程中,更推荐使用循环DMA模式(单缓冲)配合空闲中断,或使用FIFO(如STM32F7/F4的FIFO)实现双缓冲效果。
  • 缓存覆盖问题:在双缓冲回调中,若处理时间过长,DMA可能已覆盖下一缓冲区,因此需保证处理速度。可考虑将数据复制到更大的队列,或使用双缓冲+乒乓处理。
  • 丢帧问题:空闲中断用于帧结束判断,但若数据连续无空闲,可能无法触发。可结合超时定时器(如定时器中断)检测帧间隔。
  • 内存对齐:DMA缓冲区需按1字节对齐(或4字节,取决于外设),但建议使用__attribute__((aligned(4)))
  • 中断优先级:DMA中断和串口中断优先级需合理设置,避免嵌套冲突。
  • HAL库版本:不同HAL库版本对双缓冲支持不同,建议查阅参考手册。

六、总结

本文介绍了STM32F4使用DMA双缓冲环形队列接收不定长串口数据的方法,但需注意双缓冲模式在F4上的局限性。实际开发中,更稳健的方案是采用循环DMA+空闲中断,或使用DMA传输完成中断+环形队列。核心思想是:利用DMA减轻CPU负担,通过环形队列管理数据,结合空闲中断识别帧边界,从而避免缓存覆盖与丢帧。希望本文能为你提供有价值的参考。