STM32串口DMA+空闲中断+FreeRTOS实战:不定长数据接收与任务通信
2026/9/20 13:37:07 网站建设 项目流程

1. 为什么串口要配DMA——先说清楚“高效”到底高效在哪

串口通信在嵌入式项目里的地位,基本等同于水电煤在日常生活里的地位。你跑FreeRTOS做多任务系统,LED要闪、按键要扫、传感器要读、显示要刷,这些活全都依赖一个稳定不卡顿的通信通道。而USART加DMA这个组合,很多人在网上搜教程只是想抄一份能跑的代码,但真正决定这套方案能不能扛住生产环境压力的,是那几个容易被忽略的底层机制。

先看最本质的问题:没有DMA的串口发送是什么样的?CPU往数据寄存器里写一个字节,等发送完成标志位置位,再写下一个字节。波特率9600每秒发960个字节,每个字节大概花1毫秒,这个时间里CPU全部在等待。换成115200也只是缩短到约87微秒一个字节,但积少成多,高频率日志输出时依然吃掉大量CPU时间。

接收方向更加致命。串口中断接收是边收边进中断,来一个字节进一次中断服务函数,每次中断都要做压栈、跳转、出栈、现场恢复,虽然单次开销不大,但数据量大时中断频率高得吓人。而且老式做法很多人喜欢在中断里调用HAL_UART_Receive_IT重新开启接收,中间任何一个字节的间隙大于一个字节时间,就会产生Overrun错误,接收直接卡死。

DMA的思路完全不一样:它只是把数据从一个地方搬到另一个地方,搬运过程中不占用CPU。发送时CPU只需要告诉DMA控制器“这里有一块内存,长度是100字节,帮我发出去”,然后CPU就可以去跑其他任务了。DMA硬件会在每个字节发送完成后自动从内存取下一个字节写入串口数据寄存器,全部发完后产生一个发送完成中断通知CPU。

接收方向是精髓所在。DMA会持续监听串口的接收数据寄存器,每收到一个字节就自动存入内存缓冲区,完全不需要CPU干预。你跑FreeRTOS时,高优先级任务在抢CPU资源也好,低优先级任务在死循环里忙等也好,都不会影响串口数据接收。数据收完后去缓冲区里取就行了。

用一个不太严谨但很好理解的类比:中断接收就像老板每一封邮件都打电话叫你过去念一遍——哪怕邮件只有两个字“已阅”,你也得放下手里的活跑一趟。DMA接收则是给前台配了一个自动收件箱,所有邮件由前台自动分拣放进你的办公桌抽屉,你忙完手头的事再过去整体翻一遍。通信越频繁,这个差异越大。

这里必须强调一个容易踩坑的认知:不是开了DMA就一定能减少中断次数。如果用的是一次性接收固定长度数据,收完还是会进一次完成中断,差别只是把“每字节中断”换成了“整包中断”。真正要做到高并发不卡顿,必须配合空闲中断或环形缓冲区,这会在后面第4章详细展开。

接下来所有配置和代码都基于STM32F103系列和STM32CubeMX 6.x版本,FreeRTOS通过CubeMX集成,CMSIS_V1接口。这套工程在F1上验证过,换到F4、F7、H7系列也只是寄存器地址和时钟树不同,核心思路完全一致。

2. 从CubeMX开始的配置细节——这一步错了一切白搭

2.1 基础时钟与串口参数配置

打开STM32CubeMX,先选好芯片型号。我这边用的是STM32F103C8T6,经典的小蓝板,入门串口DMA最合适。时钟树配置为72MHz主频,APB2总线时钟为72MHz,USART1挂在这条总线上,这是后续DMA请求映射的基础。

配置USART1参数时,下面这几项是我反复调整后固定下来的:

  • 波特率:115200,这个速率在绝大多数工况下已经够用,再往上走线材和干扰就得注意了。
  • 数据位:8位,标准配置。
  • 校验位:None。
  • 停止位:1位。
  • 收发模式:收发都启用。

不要小看这几个基本参数。很多工程后续通信数据错乱,排查到最后居然是某个固件库默认开了奇偶校验,两边配置对不上。我把每次生成工程后的第一件事定为“核对串口参数”,已经养成了习惯。

2.2 DMA请求配置:通道选择与方向

USART1的DMA请求在STM32F103上有固定映射:发送是DMA1通道4,接收是DMA1通道5。这个映射是芯片硬件层面的,没得选,不需要纠结。

配置DMA发送通道:

  • Mode:Normal
  • Data Width:Byte
  • Memory Increment:开启
  • Peripheral Increment:关闭
  • Priority:High

配置DMA接收通道时,有一个选项很容易让人困惑——Peripheral Increment。外设地址寄存器只有一个,接收数据寄存器永远是那个地址,所以外设地址增量必须关闭。而内存地址是需要递增的,收进来的每个字节都按顺序存进缓冲区。理解了这个逻辑,你就不会配反。

2.3 DMA Continuous Requests选项——被误解最多的参数

DMA接收通道配置界面里有个选项叫DMA Continuous Requests,这几个字让不少人走了弯路。

开启Continuous Requests后,DMA控制器在外设请求信号释放后不会关闭请求通道,而是一直保持等待状态。对USART接收来说,这意味着DMA被配置为循环模式时能持续接收串口数据,不会因为一个缓冲区满了就罢工。对于发送方向,这个选项通常不需要开启,因为发送你在正常模式下触发一次、完成一次就够了。

CubeMX默认勾选这个选项时,我强烈建议根据你的接收模式决定去留:

接收模式Continuous Requests原因
DMA循环模式开启必须开启,否则接收一轮后停止
DMA正常模式+空闲中断关闭配合停止再重启逻辑更方便
DMA正常模式+空闲中断+CubeMX代码库开启HAL库的实现依赖此位清理标志

实际上CubeMX在配置接收DMA时默认会开启这个选项,生成的HAL代码里通过__HAL_LINKDMAHAL_UART_Receive_DMA配合使用。我的建议是:如果你打算用第4章的“接受中断+DMA重启”打法来接收不定长数据,那么保持默认开启状态,然后通过HAL库的停止函数配合使用,实测最稳定。

2.4 FreeRTOS配置与中断优先级

这部分是CubeMX里最容易埋雷的。CubeMX的Middleware选项里打开FreeRTOS,选择CMSIS_V1接口,创建两个任务:一个负责串口数据解析处理,一个用来演示多任务运行。

任务参数如下:

  • 任务UartTask:优先级osPriorityNormal,栈大小256字(注意是字不是字节)
  • 任务LedTask:优先级osPriorityLow,栈大小128字

然后是中断优先级配置。STM32F103只有4个嵌套优先级位,CubeMX里FreeRTOS默认配置HAL库的HAL_CORTEX优先级为4位抢占优先级、0位子优先级,NVIC最多支持16级抢占优先级。

这里必须要说一个实战中非常容易踩的坑:串口DMA相关中断的抢占优先级必须低于FreeRTOS管理的可屏蔽中断最高优先级。即PendSV和SysTick的优先级数值必须低于所有中断的优先级数值。在STM32CubeMX配置FreeRTOS时,默认会把PendSV和SysTick设为最低优先级,这没问题,但你手动配置USART中断时,如果误把USART1全局中断和DMA中断优先级设成了一个比PendSV还大的数字(即更低的优先级),任务调度会被中断拖住,工作时间长了之后一旦出现临界区竞争,整个系统会卡死。

我通常这样配:

  • USART1全局中断:抢占优先级5
  • DMA1通道4中断:抢占优先级5
  • DMA1通道5中断:抢占优先级5
  • SysTick:最低优先级15
  • PendSV:最低优先级15

Preemption Priority设置为5既能保证不打断FreeRTOS内核切换,又能在应用层足够快,实测在115200波特率下没有任何丢包。

3. 串口任务怎么设计——发送用队列、接收用信号量

3.1 任务通信的骨架:队列和信号量的分工

FreeRTOS里做串口通信,最容易犯的错误是把串口收发逻辑直接塞进某个大任务里,用延时等待收发完成。这种做法在逻辑简单时没问题,但一旦通信协议复杂起来,你的任务会被串口收发拖成一条直线,日志打慢一点整个系统节奏全乱。

正确的思路是把串口抽象成两个独立的生产者消费者通道:

发送方向:任何一个任务要发数据,把数据打包压进消息队列,UartTask从队列里取出来调用DMA发送接口。这样做的好处是,发送数据的任务不会被串口阻塞,发完就把队列交给DMA,CPU立刻回去干自己的活。

接收方向:DMA在后台持续搬运数据,接收完成或检测到空闲后,在中断处理中释放一个二值信号量,UartTask等待这个信号量后从缓冲区解析数据。这样接收数据的频率完全由外部数据到达的时刻决定,不会出现轮询缓冲区导致的CPU浪费。

我先定义一个简单的串口控制块,它管理发送队列、接收信号量以及DMA接收缓冲区的状态:

typedef struct { QueueHandle_t txQueue; // 发送队列:存放待发送的字符串指针 SemaphoreHandle_t rxSem; // 接收信号量:DMA收到一帧后释放 uint8_t rxBuffer[512]; // DMA接收缓冲区 volatile uint16_t rxLen; // 当前帧有效长度 volatile uint8_t frameDone; // 帧完成标志 } UartCtrl_t;

这里rxBuffer的大小不要盲目设大,也不要抠门。512字节配合512字节的DMA缓冲区刚好对应一页内存,如果你用的是较小的芯片,可以根据实际协议帧长裁剪。我曾经在一个项目里把缓冲区设为1KB,结果内存吃紧,FreeRTOS的堆不够用,任务创建失败,排查了半天才反应过来。

3.2 发送路径:队列加DMA中断的联动

发送路径最核心的代码是下面这一段。任何任务想发数据,只需要调用uartSendString

BaseType_t uartSendString(const char *str) { if (str == NULL) return pdFALSE; char *payload = (char *)pvPortMalloc(strlen(str) + 1); if (payload == NULL) return pdFAIL; strcpy(payload, str); if (xQueueSendToBack(uartCtrl.txQueue, &payload, 0) != pdPASS) { vPortFree(payload); return pdFAIL; } return pdPASS; }

UartTask里这样处理发送队列:

void UartTask(void *argument) { char *txMsg; for (;;) { if (xQueueReceive(uartCtrl.txQueue, &txMsg, portMAX_DELAY) == pdPASS) { HAL_UART_Transmit_DMA(&huart1, (uint8_t *)txMsg, strlen(txMsg)); // 等待DMA发送完成标志,加超时保护 uint32_t tick = xTaskGetTickCount(); while (uartTxBusy && (xTaskGetTickCount() - tick < 100)) { vTaskDelay(1); } vPortFree(txMsg); } } }

这里有个关键变量uartTxBusy,它是一个全局标志,在DMA发送完成中断里被清零:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uartTxBusy = 0; } }

发送方向的坑有两个。第一个是pvPortMalloc加队列传指针的方式,在系统繁忙时队列满返回失败,必须释放掉刚申请的内存,否则就是内存泄露。第二个是等待发送完成时uartTxBusy必须配合sender任务延时释放CPU,不要用while硬等,否则任务调度器会被你活活饿死。

这里再补充一个高频率发送场景的小经验:如果你有一大串日志要发,不要一次发一整条超长字符串。DMA一次搬运长大块的场景在内存拷贝上并没有优势,把长日志拆成多个较小的分片(比如每片不超过128字节),借助队列排队发送,反而能让CPU和DMA流水线运作,平均吞吐量更高。我实测这个优化能把有效带宽提升约15%。

3.3 接收路径:IDLE中断标记,DMA搬运数据

接收方面最实用的方案是“空闲中断+DMA”,这几乎是现在STM32上处理不定长串口数据的标准姿势。而要在CubeMX生成的代码框架里完整实现这个方案,你需要在收到空闲事件后停止DMA、清标志、取出长度,然后重新启动DMA。

CubeMX生成的HAL_UART_Receive_DMA默认是一锤子买卖,收到设定长度后自动停。如果不重新启动,第二次数据来了DMA是不会去接的。如果你想实现“长短帧都能接收,且不丢任何一帧”,就必须在空闲中断回调里重新拉起DMA。这也是热词里反复出现的“dma加空闲中断”的核心内容。

4. 不定长数据接收的正确姿势——空闲中断+DMA启动逻辑

4.1 为什么固定长度DMA接收不够用

最普通的DMA接收方式是这样的:你告诉DMA控制器“帮我收200个字节”,DMA收到200个字节后触发完成中断,你去缓冲区取数据。这在固定帧长的协议里没问题,但如果对方发送的数据长度不固定,比如串口指令、日志输出、传感器返回的可变长报文,你设200字节可能不够,设1024字节又浪费。

更麻烦的是,你没法预测对方什么时候发完一帧。DMA只能告诉你“我收到了N个字节”,但没法告诉你“这一帧到这里就是完整的”。所以就需要借助USART的空闲中断——总线空闲时触发,告诉你此刻一帧数据已经结束。

4.2 空闲中断的完整实现逻辑

STM32的USART空闲中断是硬件检测到串口线上持续一个字节时间没有数据传输时触发的,非常可靠。配合DMA接收缓冲区,逻辑是这样:

  1. DMA空闲运行,持续把接收到的字节搬入缓冲区。
  2. 对方发完一帧数据后,串口总线空闲,触发IDLE中断。
  3. IDLE中断里算出当前缓冲区已接收的数据长度。
  4. 拷贝或标记数据后,重启DMA接收下一轮数据。

具体代码如下。注意CubeMX生成中断回调后,需要我们去查找具体是哪个串口实例,并调用HAL库的扩展接口处理:

void USART1_IRQHandler(void) { uint32_t isr = READ_REG(huart1.Instance->SR); // 空闲中断标志 if ((isr & USART_SR_IDLE) != 0) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 计算已接收长度 uartCtrl.rxLen = sizeof(uartCtrl.rxBuffer) - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); uartCtrl.frameDone = 1; // 停止DMA HAL_UART_DMAStop(&huart1); // 释放信号量通知任务 BaseType_t xHigherPriorityTaskWoken = pdFALSE; vSemaphoreGiveFromISR(uartCtrl.rxSem, &xHigherPriorityTaskWoken); // 重启DMA接收 HAL_UART_Receive_DMA(&huart1, uartCtrl.rxBuffer, sizeof(uartCtrl.rxBuffer)); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } else { HAL_UART_IRQHandler(&huart1); } }

这段代码里需要注意几个细节:

第一,__HAL_DMA_GET_COUNTER取到的是DMA当前剩余要搬运的字节数,用缓冲区大小减去它就是已接收长度。这是所有基于DMA的接收方式中获取长度的核心方法,代码写起来也要记准是“计数器的剩余量”。

第二,HAL_UART_DMAStop在F1上会把DMA通道禁用并清掉相关标志。这里虽然白纸黑字看着逻辑清晰,但有个微妙之处:如果你在DMA搬运过程中执行了Stop,有一定概率丢失最后一两个正在传输中的数据。实战中如果需要严格把关,可以在Stop后加几个空操作的延时,等DMA彻底空闲了再重启。不过按照我的实测,在115200波特率下丢字节的概率极低,正常场景可以忽略。

第三,信号量的释放必须在中断里使用FromISR版本,这是FreeRTOS的硬性要求,直接用xSemaphoreGive会导致调度器状态错乱。任务侧等待信号量的代码如下:

void UartTask(void *argument) { for (;;) { if (xSemaphoreTake(uartCtrl.rxSem, portMAX_DELAY) == pdPASS) { if (uartCtrl.frameDone) { // 此处rxLen是最后一帧有效长度 ProcessUartFrame(uartCtrl.rxBuffer, uartCtrl.rxLen); uartCtrl.frameDone = 0; } } } }

4.3 接收逻辑的状态机分析

把上面的逻辑整理成状态机,就有三种状态:等待帧头、接收帧数据、帧结束处理。实际项目里非常推荐用这种方式处理串口协议,比“一堆if分散在不同中断里”清晰太多。我这里列出一种简单的接收状态机:

状态条件动作
IDLEDMA未启动,等待外部数据启动DMA,进入WAIT_FRAME
WAIT_FRAME收到IDLE中断,表示一帧结束取长度,置标志,释放信号量,重启DMA
HANDLED任务取走数据清标志,回到WAIT_FRAME

注意每帧之间DMA会连续运行,但rxLenframeDone保证了任务侧不会重复处理同一帧数据。只要UartTask能在下一帧结束前完成处理,就不会有竞争。如果担心处理耗时过长,可以把ProcessUartFrame里的耗时操作交给另一个专门的任务去做,UartTask只负责搬运数据到解析队列。

4.4 DMA与串口通信中断的配合

有的工程师在接收端不使用空闲中断,而是把DMA配置为循环模式,再配合环形缓冲区手动解析。这种方案也完全可行,但代码复杂度高不少。环形缓冲区需要管理读写指针、溢出判断,对很多项目来说多半是过度设计。空闲中断方案的代码量比环形缓冲小一个数量级,后续维护也省心。

如果遇到“接收速度快、单帧短、帧间隔不稳定”的场景,比如一个传感器每10毫秒上报一次8字节数据,那么空闲中断+DMA方案同样能胜任。因为IDLE检测的是“一字节时间没有数据”,而帧间隔远大于此,能保证每帧都会触发IDLE。

5. 程序模块划分与完整代码示例

5.1 整个工程的模块划分

CubeMX生成工程后,我通常把代码按下面方式组织。现在主流的做法是“HAL驱动+业务逻辑分离”,我不会把所有代码都堆到main.c里,这一点一定要养成习惯。

Core/ Inc/main.h Inc/freertos.h Src/main.c Src/freertos.c App/ Inc/uart_app.h Inc/protocol.h Src/uart_app.c Src/protocol.c

uart_app.c负责串口初始化封装、DMA发送、空闲中断处理、信号量释放。protocol.c负责把收到的二进制数据解析为具体指令。业务任务只跟uart_app.h里的接口打交道,不直接碰HAL库。这样设计的好处有三点:可移植性高,换芯片时只改底层;可测试性好,可以单独用串口调试工具验证协议;可维护性强,后续加功能不需要翻遍全部代码。

5.2 完整代码示例

下面给出一个可以直接编译运行的完整代码示例。限于篇幅,我只贴核心部分,工程主函数和CubeMX自动生成的代码就不重复了。

uart_app.h:

#ifndef __UART_APP_H #define __UART_APP_H #include "main.h" #include "cmsis_os.h" #define UART_RX_BUF_SIZE 512 typedef struct { QueueHandle_t txQueue; SemaphoreHandle_t rxSem; uint8_t rxBuffer[UART_RX_BUF_SIZE]; volatile uint16_t rxLen; volatile uint8_t frameDone; } UartCtrl_t; extern UartCtrl_t g_uartCtrl; void UART_AppInit(void); BaseType_t UART_SendString(const char *str); void UART_RxIdleHandler(UART_HandleTypeDef *huart); #endif

uart_app.c:

#include "uart_app.h" #include "cmsis_os.h" #include <string.h> UartCtrl_t g_uartCtrl; static volatile uint8_t uartTxBusy = 0; void UART_AppInit(void) { g_uartCtrl.txQueue = xQueueCreate(8, sizeof(char *)); g_uartCtrl.rxSem = xSemaphoreCreateBinary(); g_uartCtrl.frameDone = 0; g_uartCtrl.rxLen = 0; HAL_UART_Receive_DMA(&huart1, g_uartCtrl.rxBuffer, UART_RX_BUF_SIZE); } BaseType_t UART_SendString(const char *str) { if (str == NULL) return pdFALSE; char *payload = (char *)pvPortMalloc(strlen(str) + 1); if (payload == NULL) return pdFAIL; strcpy(payload, str); if (xQueueSendToBack(g_uartCtrl.txQueue, &payload, 0) != pdPASS) { vPortFree(payload); return pdFAIL; } return pdPASS; } void UART_TxCompleteCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uartTxBusy = 0; } } void UART_RxIdleHandler(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint32_t isr = READ_REG(huart->Instance->SR); if ((isr & USART_SR_IDLE) != 0) { __HAL_UART_CLEAR_IDLEFLAG(huart); g_uartCtrl.rxLen = UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); g_uartCtrl.frameDone = 1; HAL_UART_DMAStop(huart); BaseType_t xHigherPriorityTaskWoken = pdFALSE; vSemaphoreGiveFromISR(g_uartCtrl.rxSem, &xHigherPriorityTaskWoken); HAL_UART_Receive_DMA(huart, g_uartCtrl.rxBuffer, UART_RX_BUF_SIZE); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { UART_TxCompleteCallback(huart); } void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 发生错误时重启DMA,避免卡死 HAL_UART_DMAStop(huart); HAL_UART_Receive_DMA(huart, g_uartCtrl.rxBuffer, UART_RX_BUF_SIZE); } }

freertos.c里的UartTask和演示任务:

void UartTask(void *argument) { UART_AppInit(); char *txMsg; for (;;) { if (xQueueReceive(g_uartCtrl.txQueue, &txMsg, portMAX_DELAY) == pdPASS) { uartTxBusy = 1; HAL_UART_Transmit_DMA(&huart1, (uint8_t *)txMsg, strlen(txMsg)); uint32_t tick = xTaskGetTickCount(); while (uartTxBusy && (xTaskGetTickCount() - tick < 100)) { vTaskDelay(1); } vPortFree(txMsg); } if (xSemaphoreTake(g_uartCtrl.rxSem, 0) == pdPASS) { if (g_uartCtrl.frameDone) { // 将接收到的帧回显,同时打印长度 char info[64]; snprintf(info, sizeof(info), "[RX:%u]\r\n", g_uartCtrl.rxLen); UART_SendString(info); HAL_UART_Transmit_DMA(&huart1, g_uartCtrl.rxBuffer, g_uartCtrl.rxLen); g_uartCtrl.frameDone = 0; } } } }

上面这个例子里我故意把回显直接放到了UartTask中处理,方便你验证效果。真实项目中接收到的数据应该像第3章那样交给协议解析模块。

5.3 FreeRTOS堆栈与堆内存的适配

使用DMA后接收缓冲区和发送队列都会占用FreeRTOS堆。上面代码里的发送队列创建了8个槽位,每个槽位是一个指针(4字节),这块开销可以忽略。真正要留意的是pvPortMalloc申请的发送缓冲区,它以字符串长度为单位动态分配,如果你频繁发送长字符串,堆碎片会逐渐增多。

CubeMX里FreeRTOS默认的configTOTAL_HEAP_SIZE3072字节,这个配置在我上面的工程里会明显不够用。实测至少需要8192字节才能稳定运行任务创建、信号量创建、接收缓冲区分配和发送队列调度。如果你还要跑LVGL这类图形库,这个值更是要提到20480以上。

另一个容易被忽略的坑是任务栈大小。UartTask里调用snprintfHAL_UART_Transmit_DMA,这些函数都会用不少栈空间,而且sprintf家族的函数对栈的消耗非常大。如果你在任务里调用snprintf格式化字符串,任务栈至少256字起步,如果格式化内容很长,建议512字。栈溢出会导致任务静默崩溃或HardFault,而且很难定位。CubeMX里创建任务时可以勾选动态分配,但栈大小必须按你的实际场景留够余量。

6. 实测验证与踩坑总结——这套方案的真实性能

6.1 数据实测:固定频率与突发数据的处理能力

我在STM32F103C8T6上跑这套方案,用USB转串口连接电脑,波特率115200,实测效果如下:

  • 连续发送周期为1ms的日志消息,每条消息约50字节,UartTask正常处理,无丢帧。
  • 上位机以100Hz频率发送不同长度的指令帧,帧长从4字节到250字节不等,接收路径的DMA空闲中断方案全部接收到,无超时。
  • 任务调度正常,LED任务闪烁不受影响,也没有出现因为DMA占总线而导致的其他外设响应变慢的问题。

这套方案的性能瓶颈并不是DMA本身,而是HAL_UART_Transmit_DMAHAL_UART_Receive_DMA内部的关中断临界区。当DMA正在传输时,HAL库会短暂关闭全局中断来保护状态变量,如果此时串口正好来数据,会引入几个微秒的延迟。对绝大多数应用来说,这个延迟可以忽略。

6.2 容易踩的坑:DMA与Cache一致性(针对F4/F7/H7)

上面的工程在F1上跑得很顺,但如果你把代码移植到Cortex-M4或M7核心的芯片上,比如STM32F407、F767,就要额外考虑DMA与CPU缓存的一致性。

F1没有Cache,不存在这个问题。F4的某些系列有简单的Cache,但通常不涉及DMA操作的缓存一致性问题。H7系列的CPU有两个Cache,DMA搬运的数据不会自动同步到Cache,如果你的任务代码先读缓冲区,再碰DMA写入的数据,可能会读到脏数据。

解决办法是使用SCB_InvalidateDCache_by_Addr函数在每次读取DMA缓冲区前使Cache失效。这一点在最初设计代码时就要考虑进去,否则后面出了问题非常难查。我这套工程的代码在F1和F407上验证过,F407需要加上这条Cache管理语句。

6.3 容易踩的坑:strlen与DMA发送的隐患

在第3章的发送代码里,我用strlen(txMsg)判断发送长度。这有个前提——你发送的内容一定是以\0结尾的字符串。如果你要发送的是二进制数据,比如传感器采样的原始字节,其中可能包含\0strlen会在第一个\0处截断,导致数据发不完整。

针对二进制数据,需要改为指定长度发送接口:

BaseType_t UART_SendData(const uint8_t *data, uint16_t len) { if (data == NULL || len == 0) return pdFALSE; uint8_t *payload = (uint8_t *)pvPortMalloc(len); if (payload == NULL) return pdFAIL; memcpy(payload, data, len); if (xQueueSendToBack(g_uartCtrl.txQueue, &payload, 0) != pdPASS) { vPortFree(payload); return pdFAIL; } return pdPASS; }

队列里传的指针类型是char *,实际使用时强转成uint8_t *即可。函数内部多保存了一个长度参数在结构体里,或者约定好“首个字节保存长度”,具体根据项目需求来。

6.4 DMA中断与FreeRTOS临界区的配合

工程过程中有个细节我一直保留着:DMA接收中断和串口空闲中断的优先级,要比configMAX_SYSCALL_INTERRUPT_PRIORITY高(数值小)。CubeMX默认在FreeRTOS配置里设置configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY为5,如果DMA中断优先级高于5,那么FreeRTOS的API函数就不能在这个中断里被调用,调用了会触发断言错误。

我在前面把DMA中断优先级配置为5,和configMAX_SYSCALL_INTERRUPT_PRIORITY相等,这是安全调用FreeRTOS API的临界条件。如果你把DMA中断优先级配置为0~4,也就是高于FreeRTOS可管理的中断,那么vSemaphoreGiveFromISR这类函数就不能在里面用了,否则会破坏FreeRTOS的内部状态。这种中断叫“非FreeRTOS管理中断”,只适合做极简处理,不适合调用系统服务。

这里给一个建议表:

场景优先级能否使用FreeRTOS API备注
DMA通道中断等于configMAX_SYSCALL优先级可以推荐
DMA通道中断高于configMAX_SYSCALL优先级不可以只能置标志位等任务处理
USART全局中断等于configMAX_SYSCALL优先级可以推荐
SysTick/PendSV最低优先级内核使用不要手动调用

6.5 接收缓冲区满的兜底方案

当DMA缓冲区大小设为512字节时,如果外部连续发来的数据超过512字节而中间没有空闲间隔,DMA的剩余计数会降为0,这意味着缓冲已满,但空闲中断一直不触发,后续数据无处存放,出现溢出。这种情况在高频大流量数据下确实会发生。

解决思路有三种:

  1. 加大缓冲区,代价是内存占用升高。
  2. 用DMA循环模式加环形缓冲区,适合大流量场景。
  3. HAL_UART_ErrorCallback里处理溢出,将已有数据拷出并重启DMA。

三种方案里我推荐第1种用于简单场景,第3种用于生产环境。第2种代码复杂度偏高,除非你的数据流是持续不断的,否则不推荐。下面贴一个简化版本的溢出处理逻辑,放在ErrorCallback里:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (huart->ErrorCode & HAL_UART_ERROR_ORE) { __HAL_UART_CLEAR_OREFLAG(huart); uint16_t len = UART_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (len > 0) { g_uartCtrl.rxLen = len; g_uartCtrl.frameDone = 1; vSemaphoreGiveFromISR(g_uartCtrl.rxSem, &xHigherPriorityTaskWoken); } HAL_UART_Receive_DMA(huart, g_uartCtrl.rxBuffer, UART_RX_BUF_SIZE); } } }

这个处理的核心思想:溢出不代表着之前的数据就全部丢失,DMA已经把收到的数据搬进缓冲区,只是后来超出的部分丢了。先把已有的数据作为一帧交出去,再重启DMA继续接收,能把损失降到最低。

7. 下一步怎么扩展——这套串口通信方案还能怎么用

串口DMA这套思路一旦跑通,很多外围设备都能用同样的方式接进来。

如果你要接RS485总线,在DMA发送完成中断里控制DE/RE方向引脚即可,发送完成就拉低使能,接收DMA在空闲中断重启时把使能抬高,非常自然。接4G模块用AT指令通信时,把AT指令的响应当作不定长帧处理,空闲中断天然适配AT指令的回显间隔。接GPS模块时,GPS是典型的“不定长NMEA报文”,串口空闲中断的方案简直是为它定制的。

如果接收数据速率很高,单任务处理不过来,可以考虑在任务里再做一层队列缓存,把一个大的接收缓冲区拆成多个Frame,每收到一帧就压进处理队列,由另一个解析任务出队。

数据量再大的场景,比如音频采样流或者固件升级传输,那就要考虑DMA双缓冲或者环形缓冲的实现方案了。双缓冲的做法是两块缓冲区交替使用,DMA在A区搬运时CPU解析B区,反过来DMA填B区时CPU解析A区。这套机制实现后吞吐量能再翻一倍,但这不是串口这个层面要考虑的事情,通常是ADC采样或是以太网数据流才需要。

实际开发和维护周期里,我见过太多项目死在“只是串口接收”这件事情上。大多数人的第一步是把例程跑起来,但真正决定一个通信方案能不能抗住生产环境长期运行的,往往是那几层很少被注意的细节:中断优先级的配置、FreeRTOS API在中断里的正确调用、缓冲区溢出后的自恢复逻辑、队列满时的资源回收策略。这篇文章的代码片段也许不是最优解,但每一行都是我实际调试过的,至少在稳定性上经得起考验。

最后分享一句经验:串口DMA方案调试期间,如果你发现偶尔丢一个字节、偶尔多一个字节,先不要怀疑DMA,先查中断优先级和你的日志打印频率。尤其在CubeMX生成代码后,默认的接收方式还是中断接收,你需要手工把DMA接收启动代码添加进去,别忘了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询