
简介基于CubeMx的STM32F407六串口例程面向需要同时管理和收发多路UART的嵌入式开发者可解决在DMA空闲中断模式下不定长数据接收的配置难题适合传感器采集、多设备通信、协议解析等项目场景。工程默认使用片外8M晶振6个串口均启用DMA及空闲中断串口数量可按需裁剪裁剪后只需删除极少量硬件相关代码即可移植到其他板卡CubeMx图形化配置也便于理解与二次修改。压缩包共64个文件以40个h头文件、19个c源文件为主包含初始化代码、中断回调与底层驱动另含启动文件、IOC工程配置及mxproject/MDK工程文件整体仅278KB结构紧凑、开箱即用。目前已有3664人学习下载。目录按HAL驱动库、CMSIS、应用代码等分层组织结合官方库可直接定位到UART初始化和DMA收发逻辑能大幅缩短前期开发时间让阅读者将注意力集中在业务功能实现上。 要搞STM32F407的6个串口同时工作这事我太有发言权了。F407这芯片最诱人的地方就是串口多——USART1/2/3加UART4/5/6一共6个双银行Flash、大RAM、主频168MHz拿来当多设备通信网关再合适不过。但真把6个串口全跑起来布线、时钟、DMA、中断优先级、缓冲区设计每一环都会出幺蛾子。这篇文章不做PPT式的理论梳理我就按实际项目里从零到全部跑通的顺序把每个坑和对应的解法讲清楚代码直接抄完能跑。1. 动手前先盘清楚串口资源与引脚规划先别急着写代码。F407虽然标称6个串口但不是所有引脚都能随意分配。GPIO复用功能是固定的选错引脚就得重画板子。我的习惯是先做一张资源表把6个串口的默认引脚、所属总线、时钟频率全列出来再决定每个串口干什么活。串口默认引脚组合所在总线备注USART1PA9(TX)/PA10(RX)可复用PB6/PB7APB2支持同步模式USART2PA2(TX)/PA3(RX)可复用PD5/PD6APB1支持同步模式USART3PB10(TX)/PB11(RX)可复用PD8/PD9APB1支持同步模式UART4PC10(TX)/PC11(RX)APB1仅异步UART5PC12(TX)/PD2(RX)APB1仅异步USART6PC6(TX)/PC7(RX)可复用PG14/PG9APB2支持同步模式这里有个重要区别USART和UART不是一回事。USART是同步异步通用收发器除了标准UART功能还带SPI-like同步模式UART4/5/6纯粹是异步串口没有CK时钟引脚。做普通串口通信这区别无所谓但万一哪天想用同步模式UART4/5/6直接没戏。引脚冲突是所有多串口项目的第一杀手。PC10/PC11/PC12这组引脚太热门了SDIO接口和以太网MAC也映射在这附近如果板上还要跑SD卡、网口就得把串口挪到复用引脚上或者换串口。我有个项目就是因为PC12被SDIO占了UART5被迫改用PF6/PF7的复用映射重画一版PCB才解决。项目启动前就把每个串口的引脚占用和复用冲突确认清楚是最便宜的避坑手段。还有个工程细节55是USART1的TX板子硬连线默认PA9。如果板子走了别的复用脚CubeMX里就得手动指定。2. 时钟树与CubeMX初始化配置的关键细节F407的串口时钟不是全都在一条总线上。USART1和USART6挂在APB2最大84MHzUSART2/3和UART4/5/6挂在APB1最大42MHz。这意味着同一波特率下不同总线的串口分频系数不同但最终波特率误差都能控制在允许范围一般在2%以内没问题。时钟源务必选HSE外部晶振用HSI内部RC做参考时钟时高温下频偏会加大通信就偶发乱码。CubeMX里勾选6个串口注意这几点UART Mode选Asynchronous即可硬件流控RTS/CTSF407虽然有支持但RS485这类场合根本不用选Disable减少引脚占用。波特率单独设置同一项目里不同串口可以跑不同速率比如调试串口115200仪表串口9600GPS串口38400。NVIC要全勾上CubeMX默认只在启用中断接收时才生成IRQHandler否则代码都能编译但就是收不到数据。初始化代码生成后会在MX_USARTx_UART_Init()里看到这样的结构体配置huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16;这里有个容易被忽略的字段OverSampling。16倍过采样是标配通信距离远、线路干扰大时降低到8倍能提高抗干扰能力但波特率误差容忍度会变差。默认不改只有实测误码时才去调。CubeMX本身会处理APB时钟分频和波特率寄存器数值但要知道一个原理如果外部晶振不是8MHz比如板子上焊了25MHz晶振CubeMX里没改对串口波特率就全偏。第一批PCBA回来后发现每个串口都乱码先查晶振频率和CubeMX配置是否一致这是多处走的弯路。3. 收发方案选型轮询、中断、DMA怎么搭配才合理6个串口同时工作收发策略不能一刀切全用中断。我的经验是按照数据量和实时性要求分成三类调试串口和使用频率低的命令串口轮询发送中断接收。发送时用HAL_UART_Transmit阻塞发出数据量少CPU不在乎这几毫秒。数据量中等、要求不丢字节的串口比如GNSS接收、雷达数据上传中断收发缓冲区设计小一点。高波特率、高数据量持续通信的串口比如和4G模组的AT指令流、高速传感器数据流DMA收发加空闲中断CPU几乎零负担。很多教程一上来就让你全上DMA这是误区。DMA虽好但F407只有一个DMA1和一个DMA2串口请求通道能映射到的Stream是有限的。6个串口同时开RX DMADMA资源分配表填起来很紧张尤其USART1的RX和TX都占用DMA2的Stream处理不好就和别的外设抢。建议根据每个串口的实际吞吐量决定是否配DMA而不是为了显得高级硬凑。中断接收的缓冲代码可以用最小化的环形缓冲区。给每个串口配一个256字节的ring buffer在中断里只负责把数据填进去主循环再去解析流水#define UART_RX_BUF_SIZE 256 typedef struct { uint8_t buffer[UART_RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } UART_RingBuffer; void UART_RxISR(UART_HandleTypeDef *huart, UART_RingBuffer *ring) { uint8_t data (uint8_t)(huart-Instance-DR 0xFF); uint16_t next (ring-head 1) % UART_RX_BUF_SIZE; if (next ! ring-tail) { ring-buffer[ring-head] data; ring-head next; } }环形缓冲的最大好处是生产者和消费者解耦中断里永远只做“写一个字节”主循环想什么时候取就什么时候取不会因为解析逻辑慢而丢数据。注意headtail表示空、下一个位置等于tail表示满的条件要在代码里体现否则溢出就会静默丢包。4. 空闲中断加DMA接收多串口数据帧处理的正确姿势处理不定长数据帧时轮询和普通中断都很痛苦你得知道帧长度要么定长解析要么逐字节超时判断。F407的UART有个非常好用的特性——空闲中断IDLE Line Interrupt。当一帧数据接收完毕、总线上出现一个字节时间的空闲时硬件会置位IDLE标志。配合DMA的循环接收模式就能实现“不需要预知帧长、不丢数据、CPU零参与”的完整接收方案。实现核心是两条链路DMA负责把数据从串口DR寄存器搬到内存RXNE中断和IDLE中断负责通知CPU。HAL库里没有直接封装的空闲中断API需要自己接管串口中断void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 记录已接收数据长度并处理帧 uart1.frame_len uart1.rx_len - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); HAL_UART_DMAStop(huart1); // 这里交给应用层做帧处理 HAL_UART_Receive_DMA(huart1, uart1.rx_buf, UART_RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }这段代码里有几个关键点值得展开DMA配置为循环模式Circular数据不断从串口搬到内存不会因用户层来不及处理而溢出。接收缓冲区大小就是DMA的传输长度UART_RX_BUF_SIZE比如配512字节。每次IDLE中断后用__HAL_DMA_GET_COUNTER读取DMA剩余计数算出本帧实际数据长度而不是依赖任何帧头帧尾协议。处理完后必须重启DMA接收否则后续数据不会继续搬运。6个串口都走这套逻辑时中断函数不能复制粘贴6份。我用宏把公共逻辑抽出来每个串口的IRQHandler只做参数替换减少出错概率#define UART_IDLE_DMA_HANDLER(uart, hdma, rx_buf, len_flag) \ if (__HAL_UART_GET_FLAG(uart, UART_FLAG_IDLE)) { \ __HAL_UART_CLEAR_IDLEFLAG(uart); \ len_flag uart.rx_len - __HAL_DMA_GET_COUNTER(hdma); \ HAL_UART_DMAStop(uart); \ ProcessFrame(uart, rx_buf, len_flag); \ HAL_UART_Receive_DMA(uart, rx_buf, uart.rx_len); \ }用宏之后6个串口的IRQHandler加起来不超过20行代码可读性和维护性都好很多。当然宏滥用也有风险——参数类型不检查如果某个函数签名写错编译期不报错但跑起来就是玄学问题。写完后多花10分钟做一次全参数复核。5. 中断优先级分配6个串口并发不丢数据的核心多串口项目里乱码、丢字节很多不是波特率问题而是中断优先级设计不合理。F407的NVIC可以配置抢占优先级和子优先级。我的分配原则是RX相关的中断永远高于TX相关的中断数据吞吐量大的串口高于慢速串口高实时性协议高于日志打印。以6个串口的典型分配为例串口用途RX优先级TX优先级USART1核心数据收发的RS485总线抢占5子0抢占6子0USART2调试串口抢占7子0抢占7子1USART3GPS/北斗接收抢占5子1抢占6子1UART4传感器数据抢占6子0抢占7子2UART5低速配置串口抢占7子2普通轮询USART64G模组AT通信抢占4子0抢占6子2USART6抢占4是因为4G模组可能一次回几百字节不能被其他串口打断太久调试串口放最低优先级丢了打印无伤大雅。抢占优先级相同的串口之间如果中断服务函数执行时间过长还是会互相挤压所以把IDLEDMA方案里中断服务函数压到几个微秒以内非常重要回调里严禁做耗时解析。NVIC分组我固定用HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4)全部用抢占优先级4位全给抢占子优先级不参与。这样分配直观好排查优先级数字越小越优先。用Gruoup_2也可以但判断中断嵌套关系时要多考虑一层日常项目没必要。优先级配置在初始化函数里可以用HAL库API逐个配置HAL_NVIC_SetPriority(USART1_IRQn, 5, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); HAL_NVIC_SetPriority(DMA2_Stream2_IRQn, 4, 0); HAL_NVIC_EnableIRQ(DMA2_Stream2_IRQn);这里埋伏一个容易忽略的问题DMA中断优先级必须高于对应的UART中断或者至少不低于。DMA传输完成产生的中断如果被UART中断卡住数据帧尾部会有一小段延迟连续大流量时可能出现DMA传输完成事件没来得及在下一轮DMA启动前处理导致环形缓冲区错位。我调USART1时发现帧头偶尔偏移2字节排查到最后就是这个优先级配置反了。6. 发送端同样要小心阻塞发送与DMA发送的奇奇怪怪问题接收端赶上很多项目死在发送。6个串口同时向外发数据如果全用HAL_UART_Transmit阻塞发送CPU会卡死在等待TXE标志上。慢速串口9600波特率下发100字节光等待时间就是104毫秒左右。6个串口轮流发一遍主循环本质上瘫痪了。实际项目中我这样处理发送低速、小数据量、对时间不敏感的串口用阻塞发送代码最简单。中速、频繁不定长上报的串口用中断发送——定义发送缓冲区启动HAL_UART_Transmit_IT在HAL_UART_TxCpltCallback里处理下一段。高速、大流量串口用DMA发送一次准备好整包数据调用HAL_UART_Transmit_DMACPU直接不管了。用DMA发送有个必须注意的坑DMA传输过程中不能修改正在被DMA读取的内存数据。因为DMA读取是硬件行为CPU和它以完全不同的速度访问内存哪怕改了一个字节发送内容就会错乱而且这种错乱极难稳定复现。正确做法是设置发送状态标志volatile uint8_t uart1_tx_busy 0; void UART1_SendData(uint8_t *data, uint16_t len) { while (uart1_tx_busy); // 等待上一次发送完成 memcpy(uart1_tx_buf, data, len); HAL_UART_Transmit_DMA(huart1, uart1_tx_buf, len); uart1_tx_busy 1; } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_tx_busy 0; } }这块发送缓冲必须是静态数组不能用局部变量。局部变量在函数返回后栈空间被回收DMA还在读那块内存内容是什么全靠运气。我第一次写DMA发送时就在这翻车数据发出去全是后半段垃圾。还有个细节是RS485总线方向切换。用RS485时、发送前拉高RE/DE引脚发送完成后必须等最后一位完全移出移位寄存器再拉低。DMA发送完成中断发生时最后一位可能还在移位寄存器里立刻切方向会截断最后一个字节。解决方法是发完数据后延时1个字节时间再切换或者用HAL_UART_DMAStop之后加微秒延时。这个坑在工控现场特别典型表现为下位机偶尔收不到尾字节CRC校验老失败。7. 实测联调中的典型异常与排查思路6个串口代码写完烧录后真正联调时几乎不可能一次通过。把我在两款不同板卡上遇到的实测现象和解决过程整理成一张排查表比长篇大论管用现象可能原因验证方法解决办法某个串口完全无输出引脚复用配置错示波器测TX引脚有无电平翻转查CubeMX引脚配置确认GPIO_AF映射波特率高时偶发乱码晶振频率配置错误或过采样率不当低速与高速对比测试核对RCC配置必要时切8倍过采样两块板子对接收发正常接PC乱码PC的USB转串口模块质量问题换一个CH340/FTDI模块测试更换工具避免电源和地干扰引入开DMA接收后一直触发帧超时环形缓冲区长度与DMA长度不一致打印rx_len和DMA_COUNTER保证两者数值一致循环模式启动参数正确两个串口同时收发时互串数据中断优先级配置过低导致RXNE丢失加大流量触发测试提高RX中断抢占优先级缩短ISR长时间运行后串口停止工作环形缓冲区溢出未处理检查head/tail差值的监控变量设计满标志位并在主循环处理溢出帧有一个我反复遇到的细节ST-Link或者调试器短路了PA9/PA10。USART1被调试器占用时TX/RX波形会异常很多项目组折腾半天代码最后发现是调试器把引脚拉住了。调试串口尽量不要用USART1留给真正的外部通信。我在设计规范里规定USART2作为调试串口USART1留作主通信通道这样调试器不会干扰业务数据。另外6个串口和一个USB转TTL模块对接测试时地线必须共地。TTL串口如果不共地信号参考电平不在绝对0V上波形会漂移表现出来就是某些板子能通、某些不能时好时坏。实验室测试台上6条USB串口线同时插着如果没共地光是地电位差就能让数据乱掉一半。8. 最终代码骨架与移植时的个人经验基于以上方案我的F407 6串口工程最终呈现出清晰的模块层次每个串口独立的初始化由CubeMX生成收发逻辑放在统一的uart_comm.c里用结构体数组管理6个串口的运行状态帧接收标志、环形缓冲区指针、DMA缓冲区、发送忙标志typedef struct { UART_HandleTypeDef *huart; DMA_HandleTypeDef *hdma_rx; UART_RingBuffer ring; uint8_t rx_dma_buf[512]; volatile uint8_t frame_ready; volatile uint16_t frame_len; uint8_t tx_buf[256]; volatile uint8_t tx_busy; void (*frame_handler)(uint8_t *data, uint16_t len); } UART_Port;这样6个串口共用一套操作函数新配一个串口只需新增一个结构体实例不用改任何业务逻辑。移植到其他F4系列芯片时只需要替换CubeMX重新生成的初始化部分uart_comm.c里的接口完全不变。如果换到L4或者G4系列寄存器名称和DMA映射有差异但这个结构是通用的。还要提醒一点的是STM32CubeMX生成的初始化函数按串口号自动命名6个串口的IRQHandler名字也是固定的不要手动改名。改名的后果是CubeMX再次生成代码时全被打回还容易产生重复符号。工程定义一个统一入口函数比如UART_Comm_Process()放到主循环里执行所有接收帧解析、发送队列调度保证主循环的整体逻辑不被多个中断打散。关于这代码能不能直接上量产项目我是这么看的经过真实验证、长时间压力测试48小时不间断串口数据对打没有问题但每个项目的波特率、帧结构、通信协议、硬件电气环境都不一样代码作用是把通用收发机制跑通具体协议层还得按需求改。移植时务必保留原来的帧处理回调挂接机制不要为了所谓效率把回调逻辑内联到中断里调试起来会很难受。本文还有配套的精品资源点击获取