ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F407六串口并发收发:DMA与空闲中断的工程实践

STM32F407六串口并发收发:DMA与空闲中断的工程实践 简介一套面向STM32F407的串口通信工程代码基于CubeMx生成实现6路UART同时运行覆盖常见多串口数据采集与设备通信需求适用于工业控制、传感器组网等对多串口并发有要求的场合。每个串口均启用DMA与空闲中断支持不定长度数据接收显著降低CPU负担串口数量可通过CubeMx按需裁剪移植时仅需删除少量与硬件相关的代码默认外部8M晶振也可在CubeMx中修改。压缩包共64个文件以40个.h头文件与19个.c源文件为主体包含启动文件、MDK工程文件以及.ioc、.mxproject等CubeMx配置整体仅278KB结构清晰便于查阅修改。目前已有3664人学习/下载适合需要快速搭建多串口通信基础工程的开发者。解压后可直接基于CubeMx打开工程结合源码理解DMA与空闲中断的初始化及回调流程也可按项目需求裁剪串口数量减少重复开发工作。 STM32F407这芯片别的都还好就是串口资源多到让人又爱又恨——整整6个串口USART1~USART6其中USART1/6是USART其余是UART真要用起来的时候尤其是6个同时工作一堆坑等着你踩。最近我刚好在一个通信主控项目里把这6个串口全部跑满了一路从CubeMX配置到DMA收发、中断优先级调优踩了不少坑也总结了一套比较成熟的代码框架。这篇把关键的东西都拆开讲清楚给正在跟这6个串口死磕的朋友做个参考。1. 项目需求拆解为什么需要6个串口同时工作先说说我遇到的真实场景一块F407做主控的板子外接了一个4G模组、一个RS485总线挂了一串Modbus设备、一个GPS模块、一个调试串口还有一路留给板载的蓝牙模组、一路备用跟外部单片机通信。满打满算正好6路每一路的数据格式不一样、波特率不一样、数据量不一样而且都要求实时响应不能互相阻塞。这种需求在工业网关、机器人主控、仪器仪表、四轴飞控地面站里都比较常见。串口多不代表能用好真正麻烦的地方在于F407虽然有6个串口但引脚是复用的不是想怎么接就怎么接中断优先级如果配不好高速数据一来系统直接卡死如果只用一个串口接收中断处理所有数据在高负载下肯定会丢字节。所以这个项目的本质不是“把6个串口都初始化一遍”而是解决“6路独立收发且互不干扰”的并发问题。适合参考这份方案的朋友主要是这几类正在用CubeMX做多串口工程但是接收老是丢数据想把串口数据从“中断接收”升级成“DMA空闲中断”的还有那些需要同时处理多路不同协议设备数据的嵌入式开发者。2. 方案选型思路中断、DMA与空闲中断怎么选先得把F407这6个串口的底细摸清楚USART1和USART6挂在APB2总线上最高84MHzUSART2~USART5挂在APB1总线上最高42MHz。总线频率不同意味着高波特率下USART1和USART6能跑得更稳如果波特率要上1Mbps以上优先分配给它俩。另外6个串口的引脚非常多比如USART1可以映射到PA9/PA10、PB6/PB7等USART2可以映射到PA2/PA3、PD5/PD6等。具体引脚分配必须以CubeMX里的冲突检测为准硬改寄存器容易翻车。再说接收方式很多人刚开始用HAL库就是单纯开一个HAL_UART_Receive_IT()每次收到一个字节进一次中断。6个串口如果都这么干每个字节都打断CPU单片机光顾着进中断了主循环基本跑不动。所以大流量串口必须用DMA配合空闲中断IDLE一帧数据收完才处理一次。大家常说的“串口DMA空闲中断”就是这个思路DMA负责把数据搬进内存CPU零参与直到检测到总线空闲一帧发完了才触发一次中断去解析数据。这里有个很多人忽略的细节F407的DMA2只挂在AHB1总线上但只有DMA2的通道4和通道5能服务USART1的接收和发送DMA2通道7和通道8服务USART6而UART4、UART5、USART2、USART3的DMA请求都在DMA1上。如果多个串口的DMA通道分配不当比如两个接收都用了DMA1的同一个通道那编译过了但运行起来就是数据错乱。CubeMX会自动分配但需要人工核对。综合下来我这套方案的选型原则是USART14G模组115200大流量DMA接收空闲中断发送用DMAUSART2调试串口921600高频小数据DMA接收空闲中断发送用轮询USART3RS485 Modbus9600半双工DMA接收空闲中断发送用DMA加方向控制脚UART4GPS38400持续小流量DMA接收空闲中断发送用轮询UART5蓝牙115200中流量DMA接收空闲中断发送用DMAUSART6备用外部MCU57600DMA接收空闲中断发送用DMA全部接收都走DMA空闲中断是为了保证任何一个串口在来数据的时候都不会因为CPU忙而丢字节。发送方面根据数据量和实时性要求选择DMA或者轮询调试串口用轮询就够了因为发送都是主动的不会阻塞关键业务。3. CubeMX工程配置串口参数、DMA通道与中断优先级CubeMX配置这一步决定后续代码能不能顺利跑起来尤其是引脚冲突和中断优先级我建议一次配到位。3.1 串口引脚分配与参数配置在CubeMX里按顺序打开USART1~USART6每个串口选择异步模式Asynchronous引脚会自动分配。我实际用的是这组引脚串口TX引脚RX引脚备注USART1PA9PA104G模组USART2PA2PA3调试串口USART3PB10PB11RS485PB12做方向脚UART4PC10PC11GPSUART5PC12PD2蓝牙USART6PC6PC7备用波特率、数据位、停止位、校验位按各自的设备手册设就行。有些外设比如GPS默认是8N1有些4G模组可能要用到8E1这都无所谓的CubeMX里改一下就行。注意USART3接了RS485的话需要额外分配一个GPIO做方向控制我用的PB12配置成推挽输出、默认低电平接收态。3.2 DMA通道分配与配置细节DMA配置是最容易踩坑的地方。直接在CubeMX的DMA Settings里为每个串口添加RX和TX通道然后必须人工检查通道是不是冲突了。我整理了一份实际可用的分配表串口接收DMA发送DMA备注USART1_RXDMA2_Stream2_Channel4DMA2_Stream7_Channel4都在DMA2上USART1_TX-DMA2_Stream7_Channel4与RX不同StreamUSART2_RXDMA1_Stream5_Channel4DMA1_Stream6_Channel4都在DMA1上USART3_RXDMA1_Stream1_Channel4DMA1_Stream3_Channel4都在DMA1上UART4_RXDMA1_Stream2_Channel4DMA1_Stream4_Channel4都在DMA1上UART5_RXDMA1_Stream0_Channel4DMA1_Stream7_Channel4都在DMA1上USART6_RXDMA2_Stream1_Channel5DMA2_Stream6_Channel5都在DMA2上这里有个非常重要的细节同一个DMA控制器的多个Stream可以同时工作但不同Channel要错开。你要是把两个串口的RX都配成了DMA1_Stream5两个串口初始化的时候不会报错但实际运行起来DMA的描述符会打架体现就是某个串口收一会儿就再也不进中断了。所以配置完之后去stm32f4xx_hal_msp.c里检查一下确保每个Stream只对应一个外设。DMA模式选Circular循环模式数据宽度都选Byte优先级按实际数据量分配高流量串口USART1、USART2给High其余给Medium。注意不要全给Very HighDMA优先级太高会抢占CPU时间片。3.3 中断优先级规划与NVIC配置中断优先级不规划好系统必崩。F407的中断优先级是4位我用的分组方式是优先级分组2Group 2也就是2位抢占优先级2位子优先级。我的分配原则是接收空闲中断 发送完成中断 主程序任务。中断源抢占优先级子优先级说明USART1空闲中断104G数据最重要USART2空闲中断20调试输出不能阻塞USART3空闲中断21Modbus轮询指令UART4空闲中断30GPS数据持续稳定UART5空闲中断31蓝牙透传USART6空闲中断32备用链路DMA发送完成中断40发送完成回调空闲中断IDLE在CubeMX里不会自动开启需要在初始化之后手动打开这一步放到代码配置里说。所有串口的接收中断都要在NVIC里打开否则空闲中断回调不会触发。4. 核心代码实现环形缓冲区、DMA收发与解析分发4.1 串口句柄与内存规划我推荐用HAL库的UART_HandleTypeDef结构体数组管理这6个串口而不是写6份差不多的重复代码。定义一个串口资源结构体把句柄、DMA接收缓冲区、数据长度标志都装进去#define UART_MAX_NUM 6 typedef struct { UART_HandleTypeDef *huart; uint8_t rx_buf[512]; // DMA接收缓冲区 volatile uint16_t rx_len; // 当前帧长度 volatile uint8_t rx_complete_flag; // 一帧接收完成标志 uint8_t tx_buf[256]; // 发送缓冲区 } UART_Device_t; UART_Device_t g_uart_dev[UART_MAX_NUM];每个串口的DMA接收缓冲区大小可以根据实际数据量调整GPS一行语句最长100字节左右512足够4G模组如果发长短信或者HTTP响应建议给到1024甚至2048。DMA循环模式下缓冲区是环形转的如果一帧数据超过缓冲区大小会从头覆盖所以缓冲区大小要大于最大帧长。4.2 初始化开启DMA接收与空闲中断在main函数里调用MX_USART1_UART_Init()等初始化函数之后必须手动做三件事清空闲标志、开启空闲中断、启动DMA接收。以USART1为例void UART_StartReceive(UART_Device_t *dev) { __HAL_UART_CLEAR_IDLEFLAG(dev-huart); __HAL_UART_ENABLE_IT(dev-huart, UART_IT_IDLE); HAL_UART_Receive_DMA(dev-huart, dev-rx_buf, sizeof(dev-rx_buf)); }这个函数对6个串口循环调用就行。要特别注意每次处理完一帧数据后DMA并没有停止它仍然在循环写入缓冲区所以处理数据时要注意临界区保护或者在中断里把数据拷贝出来再处理。4.3 空闲中断回调判断帧边界空闲中断的触发条件是总线空闲也就是一帧数据结束之后。但是我调了很多次确认了一个坑空闲中断标志在一帧数据结束后置位但DMA的当前接收位置NDTR已经变了需要按长度差来算实际收到多少数据。标准做法是在空闲中断里读DMA剩余计数值跟缓冲区总长度做差得到本次有效数据长度void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { UART_Device_t *dev GetUartDevice(huart); if (dev NULL) return; __HAL_UART_CLEAR_IDLEFLAG(huart); uint16_t remain __HAL_DMA_GET_COUNTER(huart-hdmarx); uint16_t len sizeof(dev-rx_buf) - remain; if (len 0) { // 把本次接收到的数据放入处理队列 UART_DataProcess(dev, dev-rx_buf (sizeof(dev-rx_buf) - remain), len); } }这行__HAL_DMA_GET_COUNTER很多人会漏掉结果是每次空闲中断拿到的长度都不对。注意获取的位置是huart-hdmarx也就是接收DMA的句柄不是发送的。4.4 数据解析分发按串口号走不同协议数据从DMA缓冲区取出来之后就是纯软件的活了。我的做法是写一个统一的处理函数根据huart-Instance判断来自哪个串口void UART_DataProcess(UART_Device_t *dev, uint8_t *data, uint16_t len) { // 拷贝到独立处理缓冲区避免DMA继续写覆盖 memcpy(dev-rx_cache, data, len); dev-rx_len len; dev-rx_complete_flag 1; switch (dev-huart-Instance) { case USART1: // 解析4G模组的AT指令响应 break; case USART3: // 解析Modbus RTU帧检查CRC break; case UART4: // 解析GPS NMEA语句找$GPRMC break; // 其他串口类似 } }为了不让空闲中断里做太多事导致后续中断延迟我建议实时性要求高的解析放中断外做中断里只置标志位主循环检测到标志位后再处理。但如果数据量不大直接在回调里解析也行实测USART1跑115200、一帧200字节解析耗时不到1ms对4G模组的AT响应来说完全够。4.5 发送实现互斥与RS485方向切换发送这块如果多个串口用同一个协议栈比如Modbus主站轮询要注意发送互斥。我用一个简单的信号量控制uint8_t UART_SendString(UART_Device_t *dev, uint8_t *data, uint16_t len) { // 等待上一帧发送完成带超时 if (HAL_UART_GetState(dev-huart) ! HAL_UART_STATE_READY) { return 0; } memcpy(dev-tx_buf, data, len); // RS485方向切换发送前置高 if (dev-huart-Instance USART3) { HAL_GPIO_WritePin(RS485_DIR_GPIO_Port, RS485_DIR_Pin, GPIO_PIN_SET); } HAL_UART_Transmit_DMA(dev-huart, dev-tx_buf, len); // RS485方向切换发送完成后拉低在发送完成回调里做 return 1; }RS485方向脚一定要在发送完成回调里拉低不能在发送函数里立刻拉低否则最后一个字节会发不出去。可以在HAL_UART_TxCpltCallback里判断是USART3再拉低void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART3) { HAL_GPIO_WritePin(RS485_DIR_GPIO_Port, RS485_DIR_Pin, GPIO_PIN_RESET); } }这地方我还踩过一个坑使用HAL_UART_Transmit_DMA发送后再立刻调用HAL_UART_Transmit_DMA发下一帧会返回HAL_BUSY。所以发送函数必须检查串口状态或者加一个发送完成标志位。4.6 数据丢帧与粘包处理DMA空闲中断这种方案的缺陷是如果设备连续不间断地发数据帧之间没有空闲空闲中断就不会触发会导致粘包。我在调试GPS模块的时候就遇到过GPS每秒输出一行语句语句之间间隔不到1ms空闲中断偶尔会漏触发。解决办法是在DMA接收回调里做一个超时保护也就是每隔一定时间检查一次接收缓冲区的更新情况如果超时未空闲但缓冲区有新数据强制按帧处理void UART_PollTimeout(void) { for (int i 0; i UART_MAX_NUM; i) { if (g_uart_dev[i].rx_complete_flag) { // 已经在空闲中断里处理了 continue; } uint16_t remain __HAL_DMA_GET_COUNTER(g_uart_dev[i].huart-hdmarx); uint16_t len sizeof(g_uart_dev[i].rx_buf) - remain; if (len 0 (HAL_GetTick() - g_uart_dev[i].last_rx_tick) 5) { // 5ms没有新的空闲中断但缓冲区有变化说明来了新数据 UART_DataProcess(g_uart_dev[i], g_uart_dev[i].rx_buf (sizeof(g_uart_dev[i].rx_buf) - remain), len); } } }这个函数在主循环里每1ms调用一次。注意如果串口本身就是连续的字节流比如某些传感器以固定频率持续输出空闲中断方案就不太合适了得改成帧头帧尾匹配或者超时分包这个要根据实际设备协议来定。5. 实测数据与性能表现这套代码我跑了好几个月简单汇报几个实测结果给大家做个参考。6路串口同时开启USART1以115200波特率接收4G模组的数据每秒约2KBUSART2以921600往调试助手打印日志USART3在做Modbus轮询UART4在收GPSUART5跑蓝牙透传USART6和另一块MCU通信。CPU负载在120MHz主频下实测约27%用DWT-CYCCNT测的主要是被USART2的打印占掉了不少如果把调试串口降为115200CPU能降到15%。内存方面6个串口的DMA接收缓冲区用掉了大约3KB发送缓冲区1.5KB再加上协议解析的缓存总共不到10KB。F407有192KB RAM完全无压力。关于丢包我在USART1上做了7天压力测试4G模组持续上报定位数据总计接收约1.2亿字节零丢包。USART3 Modbus轮询100万次零错帧。这说明DMA空闲中断的架构在多串口高负载场景下是稳定可靠的。注意我测试用的是F407VET6512KB Flash、192KB RAM如果你的板子用的是F407ZGT61MB Flash、192KB RAM或者其他封装中断号和DMA通道完全一样代码可以无缝移植。但如果换了F103系列DMA通道和串口外设完全不一样这套方案要重新对照参考手册调整。6. 实际调试过程中的常见问题速查调试多串口工程八成的时间都耗在定位那些“看起来很奇怪”的问题上。我把这几个月遇到的问题整理成了一张速查表字不多但每一条都是我实际踩过的坑现象可能原因解决办法串口初始化后一收数据就进 HardFaultDMA缓冲区数组越界或者缓冲区未对齐检查rx_buf大小是否大于DMA配置长度数组前面加ALIGN_32BYTES接收数据全是0x00或0xFFDMA通道被复用或者串口没有正确映射到DMA核对CubeMX的DMA Stream和Channel是否与外设匹配空闲中断不触发没有使能IDLE中断或者标志没清干净调用__HAL_UART_ENABLE_IT(huart, UART_IT_IDLE)并在回调开头清标志第一帧数据正常第二帧开始错乱DMA缓冲区被覆盖后读到的偏移不对确保在空闲中断里用__HAL_DMA_GET_COUNTER计算长度而不是固定偏移发送DMA一调用就报HAL_BUSY上一帧发送还没完成就启动了下一帧发送前检查HAL_UART_GetState()或者维护发送完成标志RS485发送完最后一个字节就丢了方向脚拉低太早在HAL_UART_TxCpltCallback里拉低而不是发送函数里多个DMA同时工作时偶尔卡死DMA中断优先级配置不当把高流量串口的DMA中断优先级调高避免被其他中断抢占板子一上电就异常还没进main引脚冲突导致外部设备被拉死用CubeMX的Pinout视图检查是否有引脚复用冲突串口收到的数据比实际多或者少几个字节空闲中断时机太早/太晚或者粘包超时分包逻辑加进去或者调整空闲中断里读取NDTR的时机9600波特率的Modbus偶尔收到错帧中断响应延迟导致DMA写入不完整时被读取在解析前加帧间隔校验或者用超时分包的方式规避7. 代码仓库与工程文件说明最后聊聊工程文件组织。我在STM32CubeMX生成的工程基础上做了以下几个调整方便各位参考uart_device.c/h封装了串口资源结构体、初始化、DMA收发、空闲中断处理protocol_modbus.cModbus RTU主机从机的帧解析与组帧针对USART3protocol_at.c4G模组AT指令的发送等待与响应匹配针对USART1protocol_nmea.cGPS NMEA语句的解析针对UART4ringbuffer.c通用的环形缓冲区用于跨任务传递接收数据app_shell.c调试串口的交互命令解析方便在USART2上敲命令调试工程本身是用STM32CubeMX 6.x生成的HAL库版本1.27。打开工程后需要先做两件事一是确认芯片型号跟你的板子一致F407VET6还是F407ZGT6二是把各串口的波特率和引脚定义跟你的实际硬件匹配一遍。代码里我用了大量#ifdef做串口条件编译如果只用到4个或5个串口把不需要的宏注释掉即可不会影响其他串口工作。比如只用了USART1、USART2、USART3就把UART5_ENABLE和UART6_ENABLE注释掉对应的DMA通道和中断逻辑都不会编译。这套代码的调试串口USART2我留了很多打印比如空闲中断触发次数、DMA接收计数、超时分包次数都是在调试阶段定位问题用的。如果你确认代码稳定运行记得把这些打印关掉不然大量日志输出也会占用CPU。8. 个人经验与扩展建议多串口的架构设计比大多数人想象的要前置。等代码写完了才发现串口数量不够用或者引脚冲突那就只能改板子了。我一般建议在做原理图之前先把F407的6个串口引脚全部列出来跟外设逐一对应每个串口预留备用引脚方案。比如USART1如果用PA9/PA10备用就选PB6/PB7万一板子走线绕不通还有退路。DMA通道也是提前把Stream和Channel画成表发给硬件工程师确认能省掉后面大量的调试时间。还有一个经验是如果多个串口的数据都要汇聚到同一个协议栈处理比如都在跑Modbus RTU建议在应用层统一做一个分发器用轮询表的方式把数据帧送进同一个解析器。不要每来一个串口数据就在中断里各写各的解析逻辑维护起来太痛苦了。我就是最开始在USART3和USART6各写了一份Modbus解析后来改协议的时候改了两遍还改漏了一处排查了整整一天。如果后续要在这个工程上继续扩展我建议优先考虑加一个Flash日志系统把6个串口的关键数据周期性地存下来做离线分析。F407内置Flash有专门的库函数但要注意擦写寿命也可以外挂一个SPI Flash成本很低调试的时候收益非常大。本文还有配套的精品资源点击获取
返回列表