ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32串口DMA+空闲中断+FreeRTOS实战:不定长数据接收与任务通信

STM32串口DMA+空闲中断+FreeRTOS实战:不定长数据接收与任务通信 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总线时钟为72MHzUSART1挂在这条总线上这是后续DMA请求映射的基础。配置USART1参数时下面这几项是我反复调整后固定下来的波特率115200这个速率在绝大多数工况下已经够用再往上走线材和干扰就得注意了。数据位8位标准配置。校验位None。停止位1位。收发模式收发都启用。不要小看这几个基本参数。很多工程后续通信数据错乱排查到最后居然是某个固件库默认开了奇偶校验两边配置对不上。我把每次生成工程后的第一件事定为“核对串口参数”已经养成了习惯。2.2 DMA请求配置通道选择与方向USART1的DMA请求在STM32F103上有固定映射发送是DMA1通道4接收是DMA1通道5。这个映射是芯片硬件层面的没得选不需要纠结。配置DMA发送通道ModeNormalData WidthByteMemory Increment开启Peripheral Increment关闭PriorityHigh配置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_LINKDMA和HAL_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全局中断抢占优先级5DMA1通道4中断抢占优先级5DMA1通道5中断抢占优先级5SysTick最低优先级15PendSV最低优先级15Preemption Priority设置为5既能保证不打断FreeRTOS内核切换又能在应用层足够快实测在115200波特率下没有任何丢包。3. 串口任务怎么设计——发送用队列、接收用信号量3.1 任务通信的骨架队列和信号量的分工FreeRTOS里做串口通信最容易犯的错误是把串口收发逻辑直接塞进某个大任务里用延时等待收发完成。这种做法在逻辑简单时没问题但一旦通信协议复杂起来你的任务会被串口收发拖成一条直线日志打慢一点整个系统节奏全乱。正确的思路是把串口抽象成两个独立的生产者消费者通道发送方向任何一个任务要发数据把数据打包压进消息队列UartTask从队列里取出来调用DMA发送接口。这样做的好处是发送数据的任务不会被串口阻塞发完就把队列交给DMACPU立刻回去干自己的活。接收方向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中断的联动发送路径最核心的代码是下面这一段。任何任务想发数据只需要调用uartSendStringBaseType_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接收缓冲区逻辑是这样DMA空闲运行持续把接收到的字节搬入缓冲区。对方发完一帧数据后串口总线空闲触发IDLE中断。IDLE中断里算出当前缓冲区已接收的数据长度。拷贝或标记数据后重启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_FRAMEWAIT_FRAME收到IDLE中断表示一帧结束取长度置标志释放信号量重启DMAHANDLED任务取走数据清标志回到WAIT_FRAME注意每帧之间DMA会连续运行但rxLen和frameDone保证了任务侧不会重复处理同一帧数据。只要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.cuart_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); #endifuart_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_SIZE是3072字节这个配置在我上面的工程里会明显不够用。实测至少需要8192字节才能稳定运行任务创建、信号量创建、接收缓冲区分配和发送队列调度。如果你还要跑LVGL这类图形库这个值更是要提到20480以上。另一个容易被忽略的坑是任务栈大小。UartTask里调用snprintf和HAL_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_DMA和HAL_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有两个CacheDMA搬运的数据不会自动同步到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这意味着缓冲已满但空闲中断一直不触发后续数据无处存放出现溢出。这种情况在高频大流量数据下确实会发生。解决思路有三种加大缓冲区代价是内存占用升高。用DMA循环模式加环形缓冲区适合大流量场景。在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接收启动代码添加进去别忘了。
返回列表