前言

最近在做STM32的串口通信,用DMA接收数据。本来以为HAL库的HAL_UART_Receive_DMA直接搞定,结果实际跑起来一堆问题...

数据丢失、覆盖、处理不及时,各种玄学bug。后来查资料发现,需要在DMA和应用层之间加一个环形缓冲区,记录一下这个过程。


遇到的问题

最初的代码

一开始我是这么写的:

uint8_t rx_buffer[256];

void init(void)
{
    HAL_UART_Receive_DMA(&huart1, rx_buffer, 256);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 处理数据...
}

问题1:数据覆盖

DMA是循环模式,写到末尾会从头开始。如果应用层还没处理完,新数据就把旧数据覆盖了。

问题2:不知道收了多少

HAL_UART_RxCpltCallback只在收满时触发,如果只收到了10个字节,根本不知道。

问题3:处理太慢丢数据

应用层处理数据需要时间,但DMA不管,一直往里写。结果处理一半,后面的数据就被冲掉了。


解决方案:环形缓冲区

查了一圈资料,发现通用做法是:

串口DMA  →  环形缓冲区  →  应用层处理
(生产者)    (中间层)      (消费者)

环形缓冲区的好处:

  • 先进先出:保证数据顺序

  • 解耦:生产者和消费者速度可以不一样

  • 零拷贝:DMA直接写到底层数组,不用搬数据


环形缓冲区实现

数据结构

/**
 * @brief 环形缓冲区控制块
 *
 * 采用 head/tail 单索引方式管理缓冲区:
 * - head 指向下一个写入位置
 * - tail 指向下一个读取位置
 * - head == tail 表示缓冲区空
 * - (head + 1) % size == tail 表示缓冲区满
 */
typedef struct
{
    uint8_t  *buffer_ptr;   /**< 环形缓冲区数据指针(由调用方静态分配) */
    uint16_t        head;   /**< 写指针,指向下一个可写入位置           */
    uint16_t        tail;   /**< 读指针,指向下一个可读取位置           */
    uint16_t        size;   /**< 缓冲区总大小(字节数)                 */
} Ring_Buffer_t;

核心思想:

  • head:指向下一个可写入的位置

  • tail:指向下一个可读取的位置

  • head == tail

  • (head + 1) % size == tail

初始化

/**
 * @brief 初始化环形缓冲区
 *
 * 使用静态环形缓冲区前,需要调用该函数进行初始化。该函数将把用户指定的
 * 缓冲区空间的指针传递给环形缓冲区控制块,并初始化环形缓冲区控制块的参数。
 *
 * @param[in,out] rb   环形缓冲区句柄
 * @param[in]     buf  缓冲区指针(由调用方分配的静态数组)
 * @param[in]     size 缓冲区大小(字节数)
 *
 * @retval Ring_Buffer_OK             初始化成功
 * @retval Ring_Buffer_ERRORPARAMETER 参数错误(rb/buf 为 NULL 或 size 为 0)
 */
Ring_Buffer_status_t Ring_Buffer_Init(Ring_Buffer_t *rb, uint8_t *buf, uint16_t size)
{
    if (rb == NULL || buf == NULL || size == 0)
        return Ring_Buffer_ERRORPARAMETER;

    rb->buffer_ptr = buf;
    rb->head = 0;
    rb->tail = 0;
    rb->size = size;
    return Ring_Buffer_OK;
}

写入

/**
 * @brief 向环形缓冲区写入数据
 *
 * 将指定长度的数据写入环形缓冲区。若缓冲区空间不足,仅写入能容纳的部分,
 * 并通过 bytes_written 返回实际写入的字节数。
 *
 * @param[in,out] rb            环形缓冲区句柄
 * @param[in]     data          待写入的数据指针
 * @param[in]     len           待写入的数据长度(字节数)
 * @param[out]    bytes_written 实际写入的字节数
 *
 * @retval Ring_Buffer_OK             写入成功
 * @retval Ring_Buffer_ERRORPARAMETER 参数错误(rb/data/bytes_written 为 NULL 或 len 为 0)
 * @retval Ring_Buffer_ERRORFULL      缓冲区满,仅部分写入(*bytes_written < len)
 */
Ring_Buffer_status_t Ring_Buffer_Write(Ring_Buffer_t *rb, const uint8_t *data, 
                                        uint16_t len, uint16_t *bytes_written)
{
    *bytes_written = 0;
    for (uint16_t i = 0; i < len; i++)
    {
        uint16_t next_head = (rb->head + 1) % rb->size;
        if (next_head == rb->tail)
            return Ring_Buffer_ERRORFULL;  // 满了就停
        
        rb->buffer_ptr[rb->head] = data[i];
        rb->head = next_head;
        (*bytes_written)++;
    }
    return Ring_Buffer_OK;
}

关键就一行:(rb->head + 1) % rb->size,取模实现环形。

读取

/**
 * @brief 从环形缓冲区读取数据
 *
 * 从环形缓冲区中读取指定长度的数据。若缓冲区中数据不足,仅读取可用的部分,
 * 并通过 bytes_read 返回实际读取的字节数。
 *
 * @param[in,out] rb         环形缓冲区句柄
 * @param[out]    data       读取数据的存放缓冲区
 * @param[in]     len        期望读取的长度(字节数)
 * @param[out]    bytes_read 实际读取的字节数
 *
 * @retval Ring_Buffer_OK             读取成功
 * @retval Ring_Buffer_ERRORPARAMETER 参数错误(rb/data/bytes_read 为 NULL 或 len 为 0)
 * @retval Ring_Buffer_ERROREMPTY     缓冲区空,无可读数据(*bytes_read 为 0)
 */
Ring_Buffer_status_t Ring_Buffer_Read(Ring_Buffer_t *rb, uint8_t *data, 
                                       uint16_t len, uint16_t *bytes_read)
{
    *bytes_read = 0;
    if (rb->tail == rb->head)
        return Ring_Buffer_ERROREMPTY;

    for (uint16_t i = 0; i < len; i++)
    {
        if (rb->tail == rb->head)
            break;
        
        data[i] = rb->buffer_ptr[rb->tail];
        rb->tail = (rb->tail + 1) % rb->size;
        (*bytes_read)++;
    }
    return Ring_Buffer_OK;
}

和串口DMA配合

这是最关键的部分。DMA直接写入环形缓冲区的底层数组,但问题是:怎么知道DMA写到哪了?

整体架构

代码分了三层,职责清晰:

┌──────────────────────────────────────────────────────────────────┐
│  APP 层(app_usart_dma.c)                                       │
│  TaskA: 等通知 → ReadByte → 状态机拼帧 → CRC → 队列发给 TaskB     │
│  TaskB: 等队列 → 打印帧内容                                       │
├──────────────────────────────────────────────────────────────────┤
│  BSP 层(bsp_usart_dma.c)                                       │
│  环形缓冲区管理 + 虚拟头指针更新 + 任务通知                         │
├──────────────────────────────────────────────────────────────────┤
│  驱动适配层(uart_driver_stm32.c)                                 │
│  HAL 回调 → 抽象事件转换 → 上报 BSP 层                            │
└──────────────────────────────────────────────────────────────────┘

为什么要加中间的驱动适配层?因为BSP层不应该直接依赖HAL库。以后换MCU(比如换GD32、ESP32),只需要实现一份 uart_driver_xxx.c,BSP层一行不用改。

驱动适配层:事件抽象

uart_driver.h 定义了统一的事件类型,跟具体MCU无关:

typedef enum
{
    UART_EVENT_HALF_RX = 0,    // DMA 半传输完成(HT)
    UART_EVENT_FULL_RX,        // DMA 全传输完成(TC)
    UART_EVENT_IDLE,           // 总线空闲(IDLE)
    UART_EVENT_ERROR,          // UART 接收错误
} Uart_RxEventType_t;

STM32的实现 uart_driver_stm32.c 负责把HAL事件翻译成这些抽象事件:

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (USART1 != huart->Instance || g_RxEventCb == NULL)
        return;
​
    Uart_RxEventType_t event;
    switch (huart->RxEventType)
    {
    case HAL_UART_RXEVENT_HT:   event = UART_EVENT_HALF_RX; break;
    case HAL_UART_RXEVENT_TC:   event = UART_EVENT_FULL_RX; break;
    case HAL_UART_RXEVENT_IDLE: event = UART_EVENT_IDLE;    break;
    default: return;
    }
    g_RxEventCb(event, Size);
}

BSP层通过 Uart_Driver_RegisterCallback 注册自己的回调,驱动层在ISR中调用它。这样BSP层看到的永远是抽象事件,不用关心底层是STM32还是别的MCU。

环形缓冲区结构

跟前面通用版本不同,这里没有单独的 ring_buffer 模块。环形缓冲区直接嵌在BSP层里,结构更简洁:

typedef struct
{
    uint8_t *buf;                     // 数据缓冲区指针(DMA 写入目标)
    uint16_t size;                    // 缓冲区总大小(字节)
    volatile uint16_t head;           // 写位置(DMA ISR 更新)
    volatile uint16_t tail;           // 读位置(APP 层 ReadByte 更新)
    volatile uint32_t overflow_count; // 错误/溢出计数
} Bsp_RxRingBuffer_t;

注意headtail 都加了 volatile,因为ISR和任务都会访问它们,不加的话编译器可能优化掉读取。

虚拟头指针

核心思路:DMA直接往 buf 数组里写,head 用来追踪DMA写到了哪里。我叫它"虚拟头指针"——因为真正的head是由DMA硬件决定的,我们只是通过NDTR寄存器去"猜"它在哪。

BSP层的 BSP_USART_DMA_OnRxEvent 回调处理三种事件:

static void BSP_USART_DMA_OnRxEvent(Uart_RxEventType_t event, uint16_t size)
{
    uint16_t ndtr   = 0;
    uint16_t offset = 0;
​
    switch (event)
    {
    case UART_EVENT_HALF_RX:
    case UART_EVENT_FULL_RX:
        // HT/TC 事件:通过 DMA 剩余计数器计算已接收量
        ndtr   = Uart_Driver_GetDmaCounter();
        offset = Uart_Driver_GetRxBufSize() - ndtr;
        g_Ring_Buffer_Handle.head = offset % g_Ring_Buffer_Handle.size;
        break;
​
    case UART_EVENT_IDLE:
        // IDLE 事件:驱动层直接给了本次接收字节数
        g_Ring_Buffer_Handle.head = size % g_Ring_Buffer_Handle.size;
        break;
​
    case UART_EVENT_ERROR:
        // 错误事件:重置缓冲区,重启 DMA 接收
        g_ErrorCode = size;
        g_Ring_Buffer_Handle.head = 0;
        g_Ring_Buffer_Handle.tail = 0;
        g_Ring_Buffer_Handle.overflow_count++;
        Uart_Driver_RestartRxDma(g_Ring_Buffer_Handle.buf,
                                 g_Ring_Buffer_Handle.size);
        break;  // 错误事件不通知 APP
    }
​
    // 通知 APP 任务(非错误事件才会走到这里)
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    if (g_AppTaskHandle != NULL)
    {
        xTaskNotifyFromISR(g_AppTaskHandle, 0,
                           eSetValueWithOverwrite,
                           &xHigherPriorityTaskWoken);
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}

关键点:

  • HT/TC事件:通过 Uart_Driver_GetDmaCounter() 读DMA的NDTR寄存器,用 缓冲区大小 - NDTR 算出已接收字节数,再取模得到head位置

  • IDLE事件:驱动层直接告诉BSP层收了多少字节(size参数),直接用

  • ERROR事件:清空缓冲区(head和tail都置零),重启DMA,不通知APP

APP层怎么读数据

APP层不需要知道head在哪。BSP层提供了 BSP_USART_DMA_ReadByte 接口,逐字节从缓冲区读:

BSP_USART_DMA_status_t BSP_USART_DMA_ReadByte(uint8_t *byte)
{
    if (byte == NULL)
        return BSP_USART_DMA_ERRORPARAMETER;
​
    if (g_Ring_Buffer_Handle.tail == g_Ring_Buffer_Handle.head)
        return BSP_USART_DMA_ERRORRESOURCE; // 没数据
​
    *byte = g_Ring_Buffer_Handle.buf[g_Ring_Buffer_Handle.tail];
    g_Ring_Buffer_Handle.tail =
        (g_Ring_Buffer_Handle.tail + 1) % g_Ring_Buffer_Handle.size;
​
    return BSP_USART_DMA_OK;
}

APP层的TaskA收到ISR通知后,循环调用 ReadByte 把数据喂给帧解析状态机:

void APP_USART_DMA_TaskA(void *pvParameters)
{
    // 向 BSP 层注册自己
    BSP_USART_DMA_RegisterAppTask(xTaskGetCurrentTaskHandle());
​
    // 状态机变量
    Frame_State_t state = FRAME_STATE_WAIT_HEAD1;
    uint8_t frame_data[FRAME_MAX_DATA_LEN];
    uint16_t frame_length = 0, data_idx = 0;
    uint8_t crc_h, crc_l;
​
    while (1)
    {
        // 阻塞等待 ISR 通知
        xTaskNotifyWait(0x00, 0xFFFFFFFF, NULL, portMAX_DELAY);
​
        // 逐字节消费,喂给状态机
        uint8_t byte;
        while (BSP_USART_DMA_ReadByte(&byte) == BSP_USART_DMA_OK)
        {
            switch (state)
            {
            case FRAME_STATE_WAIT_HEAD1:
                if (byte == FRAME_HEAD1)
                    state = FRAME_STATE_WAIT_HEAD2;
                break;
            case FRAME_STATE_WAIT_HEAD2:
                // ... 帧头同步逻辑
                break;
            // ... 长度、数据、CRC、帧尾
            case FRAME_STATE_WAIT_TAIL:
                if (byte == FRAME_TAIL)
                {
                    if (CRC16_Modbus_OK == CRC16_Modbus_Verify(...))
                    {
                        // 校验通过 → 队列发给 TaskB
                        xQueueSend(xQueueHandle_A_To_B, &out_frame, 0);
                    }
                }
                state = FRAME_STATE_WAIT_HEAD1;
                break;
            }
        }
    }
}

TaskB就简单了,阻塞等队列,收到帧就打印:

void APP_USART_DMA_TaskB(void *pvParameters)
{
    USART_Frame_t rx_frame;
    while (1)
    {
        if (xQueueReceive(xQueueHandle_A_To_B, &rx_frame, portMAX_DELAY) == pdPASS)
        {
            log_i("=== Frame Received, len=%u ===", rx_frame.length);
            for (uint16_t i = 0; i < rx_frame.length; i++)
                log_i("[%02u] 0x%02X", i, rx_frame.data[i]);
        }
    }
}

错误处理

UART接收过程中可能出现帧错误、噪声错误、溢出错误。驱动层通过 HAL_UART_ErrorCallback 捕获,转换成 UART_EVENT_ERROR 上报BSP层。

BSP层的处理策略很暴力但有效:清空缓冲区,重启DMA,记录计数

case UART_EVENT_ERROR:
    g_ErrorCode = size;
    g_Ring_Buffer_Handle.head = 0;
    g_Ring_Buffer_Handle.tail = 0;
    g_Ring_Buffer_Handle.overflow_count++;
    Uart_Driver_RestartRxDma(g_Ring_Buffer_Handle.buf,
                             g_Ring_Buffer_Handle.size);
    break;

为什么不清空整个缓冲区?因为DMA马上会用新数据覆盖,清不清无所谓。关键是把head和tail归零,让状态机重新开始同步。

overflow_count 可以通过 BSP_USART_DMA_GetOverflowCount() 给APP层查询,方便调试。

实际效果

改造后:

  • HT/TC/IDLE三事件都能触发回调,head指针实时更新

  • APP层用ReadByte逐字节消费,不会漏掉任何数据

  • 出错自动重启,不会卡死

  • 换MCU只需要改驱动适配层,BSP和APP层不用动


总结

环形缓冲区本身不复杂,核心就是取模运算。但和DMA配合使用时,要注意:

  1. 三事件都要处理:不能只靠IDLE,HT和TC也要更新head,否则中间段的数据位置会错

  2. 错误恢复要干净:出错时head/tail归零,重启DMA,别半吊子清理

  3. 驱动层要抽象:BSP层不要直接调HAL,加一层适配,换平台时省大事

  4. volatile不能忘:head和tail在ISR和任务里都会改,不加volatile编译器可能优化掉读取

  5. APP层不需要知道head在哪:BSP层提供ReadByte接口就够了,封装好边界检查

这个方案目前跑了一段时间,挺稳定的。有同样问题的朋友可以参考。


附录:完整代码 地址

超级鹿鹿子/stm32 - Gitee.com

uart_driver.h                【适配层】

uart_driver_stm32.c        【适配层HAL库】

bsp_usart_dma.h        【驱动层】

bsp_usart_dma.c        【驱动层】


Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐