【学习笔记】串口DMA接收+环形缓冲区
前言
最近在做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;
注意:
head和tail都加了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配合使用时,要注意:
-
三事件都要处理:不能只靠IDLE,HT和TC也要更新head,否则中间段的数据位置会错
-
错误恢复要干净:出错时head/tail归零,重启DMA,别半吊子清理
-
驱动层要抽象:BSP层不要直接调HAL,加一层适配,换平台时省大事
-
volatile不能忘:head和tail在ISR和任务里都会改,不加volatile编译器可能优化掉读取
-
APP层不需要知道head在哪:BSP层提供ReadByte接口就够了,封装好边界检查
这个方案目前跑了一段时间,挺稳定的。有同样问题的朋友可以参考。
附录:完整代码 地址
uart_driver.h 【适配层】
uart_driver_stm32.c 【适配层HAL库】
bsp_usart_dma.h 【驱动层】
bsp_usart_dma.c 【驱动层】
更多推荐


所有评论(0)