简介:基于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个串口的默认引脚、所属总线、时钟频率全列出来,再决定每个串口干什么活。
| 串口 | 默认引脚组合 | 所在总线 | 备注 |
|---|---|---|---|
| USART1 | PA9(TX)/PA10(RX),可复用PB6/PB7 | APB2 | 支持同步模式 |
| USART2 | PA2(TX)/PA3(RX),可复用PD5/PD6 | APB1 | 支持同步模式 |
| USART3 | PB10(TX)/PB11(RX),可复用PD8/PD9 | APB1 | 支持同步模式 |
| UART4 | PC10(TX)/PC11(RX) | APB1 | 仅异步 |
| UART5 | PC12(TX)/PD2(RX) | APB1 | 仅异步 |
| USART6 | PC6(TX)/PC7(RX),可复用PG14/PG9 | APB2 | 支持同步模式 |
这里有个重要区别: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,最大84MHz;USART2/3和UART4/5/6挂在APB1,最大42MHz。这意味着同一波特率下,不同总线的串口分频系数不同,但最终波特率误差都能控制在允许范围(一般在2%以内没问题)。时钟源务必选HSE外部晶振,用HSI内部RC做参考时钟时,高温下频偏会加大,通信就偶发乱码。
CubeMX里勾选6个串口注意这几点:
- UART Mode选Asynchronous即可,硬件流控(RTS/CTS)F407虽然有支持,但RS485这类场合根本不用,选Disable减少引脚占用。
- 波特率单独设置,同一项目里不同串口可以跑不同速率,比如调试串口115200,仪表串口9600,GPS串口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 DMA,DMA资源分配表填起来很紧张,尤其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; } }环形缓冲的最大好处是生产者和消费者解耦,中断里永远只做“写一个字节”,主循环想什么时候取就什么时候取,不会因为解析逻辑慢而丢数据。注意head==tail表示空、下一个位置等于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,子0 |
| USART2 | 调试串口 | 抢占7,子0 | 抢占7,子1 |
| USART3 | GPS/北斗接收 | 抢占5,子1 | 抢占6,子1 |
| UART4 | 传感器数据 | 抢占6,子0 | 抢占7,子2 |
| UART5 | 低速配置串口 | 抢占7,子2 | 普通轮询 |
| USART6 | 4G模组AT通信 | 抢占4,子0 | 抢占6,子2 |
USART6抢占4是因为4G模组可能一次回几百字节,不能被其他串口打断太久;调试串口放最低优先级,丢了打印无伤大雅。抢占优先级相同的串口之间,如果中断服务函数执行时间过长,还是会互相挤压,所以把IDLE+DMA方案里中断服务函数压到几个微秒以内非常重要,回调里严禁做耗时解析。
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_DMA,CPU直接不管了。
用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小时不间断串口数据对打)没有问题,但每个项目的波特率、帧结构、通信协议、硬件电气环境都不一样,代码作用是把通用收发机制跑通,具体协议层还得按需求改。移植时务必保留原来的帧处理回调挂接机制,不要为了所谓效率把回调逻辑内联到中断里,调试起来会很难受。
本文还有配套的精品资源,点击获取