
🎬 渡水无言 :个人主页渡水无言
❄专栏传送门 : 《linux专栏》《嵌入式linux驱动开发》《linux系统移植专栏》
❄专栏传送门 : 《freertos专栏》 《STM32 HAL库专栏》《linux裸机开发专栏》
❄专栏传送门 :《产品测评专栏》
⭐️流水不争先,争的是滔滔不绝
📚博主简介:第二十届中国研究生电子设计竞赛全国二等奖 |国家奖学金 | 省级三好学生
| 省级优秀毕业生获得者 | csdn新星杯TOP18 | 半导纵横专栏博主 | 211在读研究生
在这里主要分享自己学习的linux嵌入式领域知识;有分享错误或者不足的地方欢迎大佬指导,也欢迎各位大佬互相三连
目录
前言
在嵌入式串口通信中,高效、低 CPU 占用的不定长数据接收 是核心痛点。传统逐字节中断方案 CPU 负载高、易丢包,轮询方案实时性差。本文基于UART+DMA 环形缓冲 + 空闲中断+ 流缓冲区的方案来详细解决这些问题,从原理、代码到实战全流程覆盖,适合 FreeRTOS 通信任务、机器人上位机通信等场景复用。
一、核心技术原理总览
在串口通信中,数据以字节为最小单位连续传输,MCU 无法预知数据的发送起止时刻。从物理层来看,一帧数据本质上就是一段连续的字节流,如果没有额外的机制,系统不仅无法高效接收数据,更无法可靠地判断哪些数据已经完整接收或可以处理。
UART+DMA 环形缓冲 + 空闲中断+ 流缓冲区完美解决这些问题:
DMA 会将串口收到的数据循环写入大小为 256 字节的固定缓冲区,串口持续接收数据时,DMA 会自动完成数据搬运,CPU 无需逐字节干预。当上位机连续发送数据时,DMA 会自动缓存数据;
当发送暂停超过设定时长。
STM32 会触发串口空闲中断,通知 CPU 处理缓冲区中的完整数据帧。
**流缓冲区:**在中断和任务之间搭建一个安全通道,中断只负责快速存入数据,通信任务阻塞等待读取,实现中断与任务解耦,保证线程安全、不丢数据。

二、DMA环形区详解
DMA(Direct Memory Access,直接存储器访问)是 STM32 的硬件外设,是一种让外设直接把数据写入内容的机制。
让CPU 从 "搬运工" 变 "管理者":仅在数据接收完成后处理数据,无需逐字节干预。
有无 DMA 的核心差异
假设上位机发送 10 字节数据:11 22 33 44 55 66 77 88 99 AA

DMA 支持两种工作模式:
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 普通模式 | 接收满指定长度后停止,需手动重启 DMA | 固定长度数据(如 ADC 采样、SPI) |
| 环形模式 | 写满缓冲区后自动回到开头,无限循环接收,永不停止 | UART 不定长数据接收(本方案) |
环形 DMA 工作原理直观示意 # 缓冲区结构(以8字节为例)
buf[0] buf[1] buf[2] buf[3] buf[4] buf[5] buf[6] buf[7]
写入流程:
写入开始 → → → → → → → → 写满后回到 buf[0] 循环写入。

这时候又会出现一个问题了:
如何区分新 / 旧数据?
这时候可以设置两个变量curr_pos和last_pos。
curr_pos:DMA 当前写入位置。
last_pos:上一次处理的位置。
两者之间的区域就是"新数据"。
当curr_pos > last_pos,新数据就是一段连续区间。
当curr_pos <last_pos,说明 DMA 绕回开头,新数据分为两段:
[last_pos, 缓冲区末尾]+[buf[0], curr_pos)。
三、空闲中断
空闲中断(IDLE Interrupt)是 UART 的硬件特性:当串口线路上超过 1 个字符的传输时间无新数据时,自动触发中断。
注意:空闲中断并不等同于 "协议帧结束"。空闲中断并不了解任何协议格式,也不关心数据内容,它只基于串口物理层的行为作出判断。换句话说,空闲中断表达的真实语义是:"串口在刚刚接收了一串字节之后,暂时没有新的字节到来"。
时间轴 →
[11] → [22] → [33] → [44] → [55] (连续字节)
↓
空闲时间 > 1 个字符传输时间
↓
触发 IDLE 空闲中断
收完55后无新数据,UART 触发空闲中断,通知 CPU 处理已接收的11 22 33 44 55。
四、流缓冲区
在 RTOS 系统中,中断和任务存在一个天然矛盾:中断需要快速执行,不能做复杂处理;而协议解析任务逻辑复杂、耗时不可控。如果在中断中直接解析数据,会导致系统实时性下降,甚至出现数据覆盖问题。
FreeRTOS 流缓冲区(StreamBuffer)(面向字节流的通信机制)正是为了解决这个矛盾而设计的,它是一个由内核管理的 FIFO 缓冲区,专门用于在中断和任务之间传递字节流数据,核心特点如下:
与队列有所不同,其并不关心"消息边界",把数据视为一条连续的字节流,和串口、DMA 的数据特性完全匹配。
形象理解:流缓冲区是一根 "水管"。
中断端:不断向水管里 "灌水"(写入字节),不用关心任务什么时候读取。
任务端:在需要的时候从水管里 "接水"(读取字节),不用关心数据什么时候写入。
只要水管里有数据,双方就能顺利工作,互不影响。
在我们的项目中,流缓冲区的使用流程如下:
(通过xStreamBufferSendFromISR()函数写入流缓冲区)
- 空闲中断触发后,将 DMA 新接收的数据写入流缓冲区。
- 通信任务(如
CommTask)阻塞在流缓冲区通过xStreamBufferReceive()读取接口上,不占用 CPU。 - 流缓冲区有数据时,自动唤醒通信任务,开始协议解析。
注意:流缓冲区不保证一次读取能拿到完整的一帧数据,它只负责传递字节流,如何拼成完整的协议帧,依然需要由协议解析状态机来完成。
五、示例代码
cpp
/**
****************************************************************************************************
* @brief STM32 UART1 DMA环形缓冲 + 空闲中断 + FreeRTOS流缓冲区 示例代码
* @platform STM32 + FreeRTOS + HAL库
* @function 解决串口粘包/丢包,实现低负载、高可靠不定长数据接收
****************************************************************************************************
*/
#include "usart.h"
#include "FreeRTOS.h"
#include "stream_buffer.h"
#include "task.h"
/* 配置参数 */
#define UART1_RX_DMA_SIZE 256 /* DMA环形缓冲大小 */
#define UART1_RX_SB_SIZE 512 /* 流缓冲区大小 */
#define UART1_RX_TRIG_LEVEL 1 /* 触发唤醒阈值 */
/* 全局句柄与缓冲 */
static uint8_t uart1_rx_dma_buf[UART1_RX_DMA_SIZE]; /* DMA环形接收缓冲区 */
static StreamBufferHandle_t uart1_rx_stream = NULL; /* 流缓冲区句柄 */
extern UART_HandleTypeDef huart1; /* UART句柄 */
/**
* @brief 串口DMA+空闲中断初始化
*/
void UART_DMA_IDLE_Init(void)
{
/* 创建流缓冲区 */
uart1_rx_stream = xStreamBufferCreate(UART1_RX_SB_SIZE, UART1_RX_TRIG_LEVEL);
/* 启动UART1 DMA循环接收 */
HAL_UART_Receive_DMA(&huart1, uart1_rx_dma_buf, UART1_RX_DMA_SIZE);
/* 使能空闲中断 */
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}
/**
* @brief UART1 空闲中断回调函数(核心:DMA → 流缓冲区)
* @note 运行在中断上下文
*/
void UART1_Idle_Callback(void)
{
static uint16_t last_pos = 0;
uint16_t curr_pos = UART1_RX_DMA_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
if (curr_pos == last_pos) return;
BaseType_t hpw = pdFALSE;
/* 情况1:DMA未回绕,直接读取一段 */
if (curr_pos > last_pos)
{
uint16_t len = curr_pos - last_pos;
xStreamBufferSendFromISR(uart1_rx_stream, &uart1_rx_dma_buf[last_pos], len, &hpw);
}
/* 情况2:DMA回绕,分两段读取 */
else
{
uint16_t len1 = UART1_RX_DMA_SIZE - last_pos;
xStreamBufferSendFromISR(uart1_rx_stream, &uart1_rx_dma_buf[last_pos], len1, &hpw);
if (curr_pos > 0)
{
xStreamBufferSendFromISR(uart1_rx_stream, uart1_rx_dma_buf, curr_pos, &hpw);
}
}
last_pos = curr_pos;
portYIELD_FROM_ISR(hpw);
}
/**
* @brief 通信任务:从流缓冲区读取数据
*/
void Comm_Task(void *arg)
{
uint8_t buf[64];
while (1)
{
/* 阻塞等待数据 */
size_t len = xStreamBufferReceive(uart1_rx_stream, buf, sizeof(buf), portMAX_DELAY);
if (len > 0)
{
// 在这里处理接收到的数据:协议解析/业务逻辑
}
}
}
UART_DMA_IDLE_Init函数
完成三件事:创建流缓冲区 → 启动 DMA 环形接收 → 打开空闲中断,是整个方案的初始化入口。
UART1_Idle_Callback(最核心)空闲中断触发后,自动计算 DMA 指针,判断是否回绕,并将新数据安全送入流缓冲区,自动唤醒任务。
✅ 解决粘包:靠空闲中断分割数据段。
✅ 解决丢包:靠环形缓冲 + 流缓冲区缓存。
Comm_Task以阻塞方式从流缓冲区读取数据,不占用 CPU,收到数据后再进行解析,实现中断与任务完全解耦。
总结
我们可以把整个串口接收流程拆成四层来理解,各层职责清晰:
DMA 层:负责将串口收到的字节持续写入物理内存,全程不需要 CPU 干预;
空闲中断层:判断 "一批数据已经接收完成,可以转交处理",是上层任务的触发信号;
流缓冲区层:作为 "安全通道",在中断上下文与任务上下文之间传递数据,避免中断阻塞和数据丢失;
CommTask 任务层:在任务上下文对流缓冲区中的数据进行协议解析和业务处理。