基于FreeRTOS的STM32串口DMA+空闲中断不定长数据收发方案
简介本资源是一套基于STM32F103RCT6平台的嵌入式实战代码面向嵌入式初学者与FreeRTOS进阶开发者聚焦DMA驱动串口实现不定长数据收发与多任务协同处理这一典型工业通信痛点。项目采用CubeMX生成的FreeRTOS框架适配正点原子Mini开发板含呼吸灯控制PA8、UART1双缓冲DMA接收、数据长度与内容自动入队、独立任务解析并回传等完整功能链路核心逻辑集中于stm32f1xx_it.c便于理解中断与DMA协同机制。压缩包共1063个文件涵盖574个C源码含外设驱动与任务实现、263个头文件定义接口与结构体、51个汇编启动文件及调试相关文件如axf、hex、map、dbgconf整体24.22MB工程结构规范支持IAR/Keil双环境。已有408人学习下载提供可直接烧录运行的完整工程含数学库依赖如arm_dct4_init_f32.c、iar_cortexM3l_math.a等助力快速掌握DMARTOS在实时串口通信中的落地实践。 写这篇东西之前先交代一句这个方案我在实际项目里已经跑了两年多从最初裸机轮询到后来的中断接收最后才彻底切到 DMA 空闲中断 FreeRTOS 这套组合。中途踩过的坑不少但凡是照着这套思路落地的同事基本都能在一个晚上把串口收发调通。今天就把完整的参考设计、关键代码和排查经验一次性整理出来。先说清楚这个方案到底是什么在 FreeRTOS 环境下用 STM32 的 DMA 完成串口数据的自动搬运配合串口空闲中断IDLE来判定一帧数据的结束从而实现对不定长数据的收发全程几乎不占用 CPU 时间。它解决的是传统串口接收中来一个字节中断一次、CPU 频繁进出中断导致实时性变差的问题同时解决了定长 DMA 接收无法应对可变长度数据包的尴尬。这套方案适合谁正在做物联网网关、运动控制、数传模块、Modbus 从站或者任何需要串口高效收发不定长协议的开发者。哪怕你之前只在裸机上跑过串口只要对 FreeRTOS 的任务、队列、信号量有基本概念都可以直接参照。1. 整体设计思路为什么必须是 DMA 空闲中断 FreeRTOS1.1 传统串口接收方式的痛点在揭开方案细节之前有必要先梳理一下串口接收的几种常见姿势以及它们各自的局限性。最原始的方式是阻塞式接收也就是轮询 RXNE 标志位。这种方式写起来最简单但 CPU 会被完全占死——在等待一个字节的时间里主循环什么都干不了。稍微好一点的是单字节中断每收到一个字节就触发一次中断在中断里把数据扔进缓冲区。这种方式能处理一般场景但高频数据比如 921600 波特率、持续不断的数据流会导致 CPU 频繁进出中断中断占用的时间比例非常可观系统实时性会明显下降。再往上一层是DMA 定长接收也就是提前配置好 DMA 接收一定数量的字节收满后触发传输完成中断。这个方案在数据帧长度固定的场景很好用像音频采集、传感器数据流这类。但问题在于实际业务中串口数据帧的长度往往是变化的。比如一个协议帧可能是A5 01 02 03 FC也可能是A5 01 02 03 04 05 06 FC长度不定定长 DMA 就无从下手。1.2 空闲中断为什么是解决不定长接收的关键STM32 的串口外设里有一个非常实用的标志位——IDLE空闲中断。它的触发条件是总线在接收到一个字节后持续一个字节周期没有新的数据到来就会置位 IDLE 标志。用大白话讲只要一帧数据发完了总线上自然会出现一段静默期这个静默期就是帧结束的天然信号。所以我们只需要做一个看似简单实则精妙的配合DMA 负责把串口收到的所有字节自动搬运到内存缓冲区空闲中断负责在一帧数据结束的时候通知 CPUCPU 在空闲中断里做的事仅仅是把当前 DMA 已经接收了多少字节算出来然后通知任务层去处理。这样 CPU 不必关心每个字节的到达只在一整帧数据来了的时候被叫醒一次效率自然就上去了。1.3 FreeRTOS 在这个方案里的角色有了 DMA 和空闲中断裸机其实也能跑但加上 FreeRTOS 之后整个架构就变得更清晰了。FreeRTOS 在这个方案里承担三个职责任务调度接收处理是一个独立任务发送是一个独立任务各自有明确的优先级互不阻塞。数据传递DMA 中断里不直接处理数据而是通过队列或信号量通知任务层由任务层在安全上下文里处理数据符合中断里只做最少的事的嵌入式铁律。同步机制串口资源是独占的发送任务要保证同一时刻只有一个地方在操作串口发送互斥信号量就是干这个的。我见过不少人把 DMA 接收到的数据直接在中断回调里做解析、做业务处理这在裸机上可能勉强能忍但一旦上了 RTOS就会引发优先级反转、临界区过长等问题。正确的姿势是中断只负责通知任务负责干活。2. 关键配置与初始化从底层寄存器到 CubeMX 的一步步设置2.1 需要用到的硬件资源在动手写代码之前先确认你手头 STM32 型号具有以下资源目标串口USART1/2/3 或 UART4/5对应的 DMA 通道/流不同系列名字不同F1 系列叫 DMA1 通道F4 系列叫 DMA2 数据流一个空闲中断IDLE Interrupt一个 DMA 传输完成中断这个可以不开因为空闲中断已经覆盖了收完一帧的事件这里有个细节F1 系列和 F4 系列的 DMA 架构不一样。F1 的 DMA 是通道 外设映射固定的比如 USART1_RX 只能映射到 DMA1_Channel5而 F4 的 DMA 是数据流 请求映射可配置灵活性更高。后面我给的是寄存器级代码针对 F1 系列如果你用 F4建议直接用 HAL 库逻辑完全一致。2.2 CubeMX 里的关键参数用 CubeMX 配置的时候核心参数就这几个USART 模式异步收发AsynchronousDMA 设置USART_RX 添加 DMA模式选 Circular循环模式USART_TX 添加 DMA模式选 NormalUSART 全局中断开启因为空闲中断是挂在串口全局中断里的DMA 中断接收 DMA 的中断可以不开发送 DMA 完成中断建议开启用于发送完通知关于接收 DMA 的模式这是我特别想强调的一点必须选 Circular循环模式。原因是在不定长接收中你无法预知一帧有多长DMA 可能收到一半就绕回缓冲区开头继续写。循环模式保证了数据永远不会覆盖越界而当前 DMA 写到哪了可以通过读取 DMA 的剩余计数寄存器CNDTR算出来。如果用 Normal 模式一次 DMA 传输满了就停遇到超过缓冲区大小的数据帧就会丢数据。缓冲区的设计也有讲究。一般建议DMA 接收缓冲区设成 2 的幂次方大小比如 256 字节或 512 字节这样通过位运算来计算索引更快也方便后续做环形缓冲区。我自己习惯设 512因为大多数业务协议帧不会超过这个长度超过的部分属于异常帧可以直接丢弃或报错。2.3 初始化代码寄存器版F1 系列为例下面是核心初始化代码我用的是标准外设库风格方便那些不想用 HAL 的人直接参考// 缓冲区定义 #define RX_BUF_SIZE 512 uint8_t rx_buffer[RX_BUF_SIZE]; // 串口引脚配置略直接看核心部分 void USART1_DMA_Init(void) { // 1. 使能 DMA1 时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 2. 配置 DMA 接收通道 DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rx_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 关键循环模式 DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); // 3. 使能 DMA 通道 DMA_Cmd(DMA1_Channel5, ENABLE); // 4. 配置串口 USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); // 5. 使能空闲中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 6. 使能串口全局中断 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 注意这里也需要使能RXNE但中断里不处理 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQSubPriority 0; NVIC_Init(NVIC_InitStructure); // 7. 启动 DMA 接收 USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); // 8. 使能串口 USART_Cmd(USART1, ENABLE); }第 6 步里有个容易忽略的细节为什么 RXNE 中断也要使能因为如果不使能 RXNE 中断空闲中断在某些型号上可能不会正确触发因为 IDLE 是在接收完一个字节后检测的而 RXNE 是收到一个字节的标志两者在中断控制器里有关联。实际测试中只开 IDLE 不开 RXNE会导致空闲中断不触发或触发不稳定。但注意RXNE 中断虽然使能了中断处理函数里不需要做任何事因为 DMA 已经把数据搬走了我们只需要在需要时通过读取 SR 寄存器来清除标志位。2.4 链接 DMA 与串口的细节最后一步USART_DMACmd是建立串口到 DMA 的桥梁使能之后串口每收到一个字节硬件会自动触发 DMA 请求把 DR 寄存器里的数据搬到内存这个过程不需要 CPU 参与。整个初始化完成后串口就处于待命状态DMA 在缓冲区里循环记录每个字节直到空闲中断来临。3. 核心实现中断服务函数中的数据处理逻辑3.1 串口中断服务函数这是整个方案最核心的部分我把完整代码贴出来再逐行拆解// 用于通知任务层的队列句柄 extern QueueHandle_t xRecvQueue; void USART1_IRQHandler(void) { uint32_t isr_flags USART1-SR; // 处理空闲中断 if (isr_flags USART_FLAG_IDLE) { // 1. 清除 IDLE 标志先读 SR再读 DR USART1-SR; USART1-DR; // 2. 计算当前接收到的数据长度 uint16_t rx_len RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 3. 应该有数据才处理 if (rx_len 0) { // 4. 将数据长度通过队列发送给任务层 BaseType_t xHigherPriorityTaskWoken pdFALSE; xQueueSendFromISR(xRecvQueue, rx_len, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } // 5. 重新开启 DMA 接收其实 Circular 模式不需要重启但代码里保留以确保极端情况 // DMA_Cmd(DMA1_Channel5, DISABLE); // DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE); // DMA_Cmd(DMA1_Channel5, ENABLE); } // 如果需要处理 RXNE 中断一般不需要在这里加逻辑 if (isr_flags USART_FLAG_RXNE) { // 什么都不用做DMA 已经搬走了数据 // 清标志位读 DR USART1-DR; } }这个中断服务函数有几个非常关键的细节很多人栽跟头就在这里。第一个是清除 IDLE 标志的方式。STM32 的 IDLE 标志有点特殊它不是简单地写 0 清除而是必须先读 SR 寄存器再读 DR 寄存器硬件才会自动清除。如果你在中断里用USART_ClearITPendingBit(USART1, USART_IT_IDLE)在某些库版本里其实也有效但寄存器操作的方式最可靠。我见过有人费了半天劲查为什么空闲中断只触发一次结果就是标志没清干净。第二个是如何计算接收长度。这里用到了 DMA 的 CNDTR 寄存器Current Data Transfer Counter它保存着 DMA 还有多少个数据没有传输。初始值设为 RX_BUF_SIZE每接收一个字节CNDTR 就减 1。所以当前已接收的数据长度 缓冲区总大小 - CNDTR 的当前值。这个计算方式简洁高效而且不需要额外的计数变量。第三个是中断里只做通知不做业务。我把rx_len通过队列发送给任务层任务层拿到长度后再从rx_buffer里取数据加工。如果直接在中断里解析数据、处理协议中断服务时间会被无限拉长严重时会导致其他中断丢失。这是一个新手非常容易犯的错误。3.2 Circular 模式下的读取位置问题这里我要专门展开讲一个非常容易踩坑的概念在循环模式下DMA 可能已经绕了一圈。假设缓冲区大小为 512第一次空闲中断来了此时 DMA 写入位置在 200说明收了 200 字节。任务层把这 200 字节取走处理。第二次空闲中断来的时候DMA 写入位置可能在 400说明从 200 到 400 又有 200 字节。第三次空闲中断来的时候DMA 写入位置可能在 100——这代表 DMA 已经绕回开头从 400 写到 512 填满后又从头写了 100 字节。所以任务层在取数据的时候不能默认数据是从缓冲区开头开始的必须通过上次读到的位置和当前 DMA 写入位置来计算。具体做法是// 任务层记录上一次读取的位置 static uint16_t last_pos 0; uint16_t cur_pos RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 判断是否有绕回 if (cur_pos last_pos) { // 正常情况数据在 [last_pos, cur_pos) 区间 process_data(rx_buffer[last_pos], cur_pos - last_pos); } else { // 发生了绕回数据分两段 // 第一段[last_pos, RX_BUF_SIZE) // 第二段[0, cur_pos) process_data(rx_buffer[last_pos], RX_BUF_SIZE - last_pos); process_data(rx_buffer[0], cur_pos); } last_pos cur_pos;这是我至今认为最容易出错的地方。很多开源代码只处理了不绕回的情况一旦缓冲区绕回就丢数据或者取到脏数据。建议你在任务层里加上对绕回的处理而不是用清空缓冲区这种偷懒方式。另一个方案是彻底放弃last_pos追踪改用每次空闲中断后重置 DMA 计数的方式。就是中断里把 DMA 停掉、把 CNDTR 重新设置为缓冲区大小、再重新启动。这种方式相当于每次收完一帧就重新开始数据总是从缓冲区开头排列。代码看起来简单但问题在于DMA 启停之间可能有字节丢失——如果串口在 DMA 停掉的那几个微秒里来数据新字节会直接丢失。我实测在高波特率下这个概率不小所以不推荐。3.3 接收任务的设计接收任务的核心代码void vUARTReceiveTask(void *pvParameters) { uint16_t rx_len; uint8_t process_buffer[RX_BUF_SIZE]; for (;;) { // 等待队列消息空闲中断通知 if (xQueueReceive(xRecvQueue, rx_len, portMAX_DELAY) pdTRUE) { // 拷贝数据到本地缓冲区避免直接操作共享缓冲区产生竞争 memcpy(process_buffer, rx_buffer, rx_len); // 调用协议解析函数 parse_protocol(process_buffer, rx_len); } } }为什么要拷贝一份因为rx_buffer 是 DMA 持续写入的共享区域。如果协议解析需要花时间期间 DMA 又往 rx_buffer 里写了新数据解析函数就会读到错乱的数据。拷贝到本地缓冲区之后解析过程与 DMA 写入就完全隔离了。注意一个权衡拷一份 512 字节的数据在任务里其实很快几十微秒相对于协议处理来说完全可以接受。如果要极致优化可以改成双缓冲区机制一个 DMA 写入一个任务处理交替使用。但那会让复杂度大幅上升普通项目没必要。4. 发送通道的优化DMA 发送 FreeRTOS 互斥保护4.1 为什么发送也需要 DMA接收用了 DMA发送自然也应该跟上。发送用 DMA 的好处在于当你要发一大包数据比如几百字节时CPU 只需要配置好 DMA 的源地址、目标地址、长度然后启动 DMA之后 CPU 就可以去干别的事了。DMA 搬运完数据后触发传输完成中断任务再继续后续操作。如果不用 DMA 发送而用阻塞式逐字节发送while (USART_GetFlagStatus(USART_FLAG_TXE) RESET);在 115200 波特率下发送 100 字节大约需要 8.7ms这段时间 CPU 完全卡死。如果系统里还有其他任务在跑这种卡顿是不可接受的。4.2 发送任务的互斥保护设计串口是独占资源如果有两个任务同时发起发送数据就会交错、乱码。所以发送必须用一个互斥信号量保护// 创建互斥信号量 SemaphoreHandle_t xUARTMutex xSemaphoreCreateMutex(); // 发送函数可被任意任务调用 void UART_SendData_DMA(uint8_t *data, uint16_t len) { // 获取互斥锁确保同一时刻只有一个任务在发送 if (xSemaphoreTake(xUARTMutex, pdMS_TO_TICKS(1000)) pdTRUE) { // 配置发送 DMA DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)data; DMA_Init(DMA1_Channel4, DMA_InitStructure); DMA_Cmd(DMA1_Channel4, ENABLE); // 等待发送完成用信号量通知而不是阻塞等待 xSemaphoreTake(xSendCompleteSem, pdMS_TO_TICKS(1000)); // 释放互斥锁 xSemaphoreGive(xUARTMutex); } }发送完成中断void DMA1_Channel4_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC4)) { DMA_ClearITPendingBit(DMA1_IT_TC4); // 通知发送完成唤醒等待的任务 BaseType_t xHigherPriorityTaskWoken pdFALSE; xSemaphoreGiveFromISR(xSendCompleteSem, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } }这里有一个比较隐蔽的问题DMA 发送时源数据的内存地址必须是稳定的。如果你传入的是一个局部变量数组函数退出后局部变量销毁但 DMA 可能还没搬完数据就会出现发送脏数据的情况。所以要么传入静态/全局缓冲区要么在发送完成前不允许调用者修改数据。我在项目中用了一个策略UART_SendData_DMA内部拷贝一份到发送缓冲区发送完成后再释放确保 DMA 源始终是本任务的私有区域。还有一个权衡点互斥信号量的等待时间。如果上一个任务发了一大包数据还没发完下一个任务等锁最多等 1000ms超过就放弃发送并返回错误。这个超时值要根据实际波特率来估算115200 波特率下1 字节需要约 86.8us100 字节约 8.7ms。如果你的协议有最大帧长超时设成最大帧长对应时间的 2 倍比较合理。5. 常见问题与排查技巧把这些坑踩熟了串口就稳了5.1 问题速查表我把自己和团队这两年里遇到过的问题整理成一张表方便你快速定位现象可能原因解决办法串口完全收不到数据DMA 未使能或串口 DMA 请求未开启检查USART_DMACmd是否调用收到一帧后不再接收IDLE 标志未清除中断卡死确认读 SR 再读 DR的清除顺序数据接收多次重复空闲中断被触发多次检查初始化时是否使能了USART_IT_RXNE可能产生了额外标志数据内容错乱缓冲区绕回未处理使用 3.2 小节的分段处理方式高波特率下偶发丢字节DMA 启停期间丢失数据尽量使用 Circular 模式不要频繁启停 DMA任务收到数据后解析出错直接解析共享缓冲区数据被 DMA 覆盖将数据拷贝到任务私有缓冲区后再解析发送数据偶尔乱码多个任务同时调用发送未互斥确认互斥信号量是否生效发送一直卡死发送完成信号量超时检查发送 DMA 中断是否使能或者等待时间设置是否太短空闲中断在低负载时触发慢接收一个字节后总线空闲时间不足属于正常现象IDLE 需要等待一个字节周期5.2 几个最容易忽略的细节第一中断优先级设置很关键。在 FreeRTOS 里中断优先级不能随意设置尤其使用xQueueSendFromISR、xSemaphoreGiveFromISR这样的 API 时中断优先级必须低于configMAX_SYSCALL_INTERRUPT_PRIORITY。否则 FreeRTOS 会拒绝在中断里切换任务甚至进入断言错误。我在 Cortex-M4 内核的芯片上习惯把串口中断优先级设为 5数字越大优先级越低核心实时性要求高的外设比如定时器设为 3 或 4让串口这个不那么紧急的中断不要抢占关键定时器。第二缓冲区大小与最大协议帧的关系。DMA 缓冲区必须大于等于可能出现的最大数据帧长度。如果协议帧最大 300 字节缓冲区至少 512留足余量。如果缓冲区小于帧长度数据会被截断或者覆盖排查起来非常痛苦。第三使用逻辑分析仪验证时序。当你怀疑数据丢失时不要直接在代码里加打印调试那只会让问题更复杂。用逻辑分析仪抓串口 TX/RX 引脚观察实际波形确认数据是否真的发出来了。实测中我发现过两次代码以为发了但引脚根本没输出的情况一次是 GPIO 复用配置错误一次是 DMA 通道映射错误都是靠分析仪才定位到。第四FreeRTOS 堆栈大小要给足。接收任务的堆栈如果设置得太小比如 128 字节调用memcpy和协议解析时可能溢出。我的经验是接收任务堆栈至少 256 字节协议解析复杂的建议 512 字节。如果怀疑堆栈溢出把configCHECK_FOR_STACK_OVERFLOW设为 1 或 2开启 FreeRTOS 的堆栈溢出检测出问题时系统会进入钩子函数。5.3 用串口调试助手实测的完整流程调通这套方案我建议按以下步骤走先不接 FreeRTOS在裸机上把 DMA 空闲中断跑通。用 USB 转 TTL 模块连接串口打开串口调试助手发送一帧数据比如01 03 00 00 00 0A C5 CD观察 DEBUG 输出是否打印出对应的长度和数据。确认长度计算正确。这一帧 8 字节空闲中断后 rx_len 应该等于 8。再逐步加入 FreeRTOS。先创建一个接收任务把数据从队列里拿出来打印。此时不要做协议解析只验证数据链路。最后加入协议解析。解析完毕后再考虑发送侧的 DMA 优化。每一步都验证通过再往下走不要一口气全写完再调否则出了 Bug 你会很难定位是 DMA 的问题、中断的问题还是 FreeRTOS 的问题。5.4 实测表现我手头一个项目用的 STM32F103RCT6主频 72MHz串口 1 工作在 460800 波特率DMA 接收缓冲区 512 字节。实测持续接收来自 4G 模块的 AT 指令响应每帧从 20 字节到 256 字节不等CPU 占用率在串口接收上的开销几乎可以忽略。加上 200Hz 的 PID 控制任务和 50Hz 的 UI 刷新任务系统整体运行稳定没有出现丢帧或者任务超时。还有一次在高负载测试时串口数据以 1000 帧/秒的速率持续注入每帧 100 字节持续跑了 72 小时没有发生一次数据错乱或者 DMA 计数异常。这套架构的稳定性我是有信心的。6. 进阶扩展状态机解析、多串口并发与低功耗注意事项6.1 协议解析在任务里跑状态机接收任务拿到完整数据帧后下一步就是对帧进行解析。建议的做法是写一个协议状态机而不是简单的switch-case。状态机的思路是STATE_WAIT_HEADER等待帧头STATE_RECEIVE_LENGTH接收长度字段STATE_RECEIVE_DATA接收数据体STATE_CHECK_CRC校验并处理每个状态只处理当前字节数据不足时保持状态等待下一帧。这样做的好处是即使一帧数据被拆成两段到达第一段在第一次空闲中断里第二段在第二次空闲中断里状态机也能正确处理不会因为半帧而解析失败。6.2 多串口并发如果有多个串口同时使用这套方案核心思路是一样的但有几个额外要注意的点每个串口独立配置自己的 DMA 通道/流注意 F1 系列 DMA 通道和外设的映射关系每个串口创建独立的接收队列任务层分别等待各自的队列发送互斥锁独立A 串口的发送不能阻塞 B 串口rx_buffer必须各自独立不能让两个串口共享一个缓冲区。我做过一个四串口同时跑的板子分别接 4G 模块、GPS、调试口和传感器四条链路同时工作互不干扰。6.3 低功耗模式的适配如果项目对功耗有要求这套方案还需要额外处理。进入 STOP 模式前要关闭串口和 DMA 的时钟吗不需要完全关闭但要注意停止 DMA 接收否则 STOP 模式下 DMA 请求无法响应可能产生总线错误将串口引脚配置为 EXTI 外部中断唤醒如果支持或者保持串口空闲中断唤醒部分低功耗系列支持唤醒后重新初始化 DMA 和缓冲区确保 CNDTR 恢复到初始值。这个主题展开又是一篇文章的量这里点到为止提示你关注即可。最后分享一点实际的体会代码写到这里方案的核心链路就完整了。回到最初的诉求——在 FreeRTOS 下高效实现 STM32 串口不定长数据收发DMA 空闲中断 任务通知这套组合是目前我看来性价比最高的方案。它既有 DMA 的零 CPU 开销又有空闲中断的帧结束感知能力还有 RTOS 的任务隔离和资源管理。无论你是在做工业控制、物联网网关还是简单的串口透传工具这套代码都能直接往上搬。如果后续想继续深入可以试着做两个方向一是把接收缓冲改成真正的环形缓冲并加一个半满中断来处理超长数据流传输效率和响应时间都会更好二是做一套抓包工具用这套逻辑把串口所有收发数据镜像到以太网或 USB 上调试的时候会非常方便。我在实际项目中就是这么干的省下了无数抠波形的时间。本文还有配套的精品资源点击获取