简介:这套代码基于STM32F405RGT6单片机,面向需要同时管理串口1至串口5通信的嵌入式开发者,解决多路UART数据接收、独立缓冲与状态标志管理的工程问题。压缩包共145个文件,涵盖42个.h头文件、36个.c源文件,以及编译生成的.o、.crf、.axf、.hex等中间/烧录文件,另有Keil MDK工程配置与调试辅助文件,整体大小仅2.56MB,目录结构清晰,便于按类型查找。代码为每个串口定义了独立的接收缓冲区、接收计数与接收完成标志,演示了多串口并发收发的变量组织方式;工程内还包含标准外设库中的定时器、RTC、RCC、ADC、CAN、USART等模块源文件,方便开发者对照学习外设初始化及寄存器级操作,也可以直接在此基础上扩展其他外设驱动。已有1214人学习/下载,适合正在调试多串口通信、或希望基于STM32F4标准外设库快速搭建串口框架的嵌入式工程师与相关专业学生参考。 STM32F405RGT6这颗料我在项目里用了不少,LQFP64封装,64个引脚却给足了5路串口——USART1、USART2、USART3、UART4、UART5。很多设备做到后面就会发现,一路串口给调试打印,一路接RS485,一路接4G模组,一路接GPS,一路留给升级协议,接口数不够的时候要么换芯片要么加扩展,F405RGT6刚好卡在这个资源点上,512KB Flash、192KB RAM、带FPU的Cortex-M4F内核,做多串口网关或者工业采集板都非常合适。
这篇文章我就把自己实际在F405RGT6上跑通的五串口通信代码完整拆开讲,内容包括引脚分配、时钟配置、发送接收的几种写法和区别,以及调试串口时最容易踩的那些坑。如果你想在这颗芯片上同时使用多路串口、又不想把参考手册来回翻三遍才敢写代码,可以直接照着这套框架来做。
1. 先摸清F405RGT6上这5个串口的底细
1.1 5个串口的硬件资源与引脚分配
F405RGT6的5个串口并不是完全一样的。USART1、USART2、USART3是通用同步异步收发器,支持同步时钟输出、LIN、IrDA、智能卡模式;UART4和UART5是纯异步串口,没有同步时钟功能。对于绝大多数通信场景,比如接GPS、接RS485、接TTL模组,两者用起来没有区别。
引脚分配是第一个值得注意的地方,同一个串口往往有多组引脚映射,但引脚位置不同,实际布线难度完全不同:
| 串口 | 主用TX/RX | 备用TX/RX | 复用AF编号 |
|---|---|---|---|
| USART1 | PA9 / PA10 | PB6 / PB7 | AF7 |
| USART2 | PA2 / PA3 | PD5 / PD6 | AF7 |
| USART3 | PB10 / PB11 | PC10 / PC11 或 PD8 / PD9 | AF7 |
| UART4 | PA0 / PA1 | PC10 / PC11 | AF8 |
| UART5 | PC12 / PD2 | 部分封装无备用 | AF8 |
这张表看着简单,里面藏着两个大坑:UART4和USART3都复用PC10/PC11,这两个串口不能同时使用同一组引脚;UART5的RX脚PD2,在很多板上同时被SDIO的CMD线占用,如果你一边用SD卡一边用UART5,极容易出现“莫名初始化失败”的诡异问题。
所以拿到一块F405RGT6板子,第一件事就是打开数据手册的引脚定义页,把5个串口的引脚选择先定下来,别急着写代码。我自己的习惯是:USART1固定PA9/PA10跑调试日志,USART2用PA2/PA3接4G模组,USART3用PB10/PB11走RS485,UART4用PA0/PA1接GPS,UART5用PC12/PD2做设备间通信,前提是不和SD卡同时使用。如果要用SDIO,就把UART5挪到别的位置或者干脆换一个串口。
1.2 时钟源配置为什么影响所有串口
串口通信的底层是波特率发生器,波特率来源于串口外设时钟,而F405的串口时钟分挂两条总线:USART1挂在APB2上,默认84MHz;USART2、USART3、UART4、UART5都挂在APB1上,默认42MHz。系统时钟初始化一旦配错,5个串口会同时全乱。
波特率寄存器的计算方式不算复杂,只要记住分子是外设时钟频率就行。以APB1的42MHz为例,配置115200波特率、16倍过采样,分频系数就是42MHz除以(16×115200),约等于22.786。BRR寄存器写入这个值的小数定点形式,实际波特率误差可以控制在0.1%以内。但如果APB1实际频率是36MHz,按42MHz去计算并写入,波特率偏差就会直接超出串口容错范围,表现就是接收方全是乱码。
因此用CubeMX生成工程时,一定要先检查时钟树。外部晶振是8MHz还是25MHz,在RCC配置里必须填对,否则HSE_VALUE宏与实际晶振不匹配,PLL计算出的SYSCLK就是错的,最后会连锁导致APB1/APB2分频结果全部不对。遇到“代码看着完全正确但串口就是乱码”的情况,九成以上是这里出了问题。
2. 五串口通信的整体方案设计
2.1 每个串口干什么活,先想清楚再写代码
5个串口同时启用,最大的挑战不是初始化,而是后期调试时怎么区分数据到底从哪个口来、应该往哪个口去。如果所有串口都干同一件事,日志和数据混在一起,等设备跑起来你根本分不清哪条打印是哪个外设输出的。
我建议在写代码之前先做一张串口功能分配表。比如设备是一个数据采集网关:
- USART1:调试日志输出,连USB转TTL到PC,全程打印关键节点信息
- USART2:连接4G通信模组,处理TCP链路、云平台数据上报
- USART3:RS485总线,走Modbus RTU协议,挂载多台仪表
- UART4:GPS/北斗模块,解析NMEA语句,获取定位信息
- UART5:上位机本地升级串口,传输固件或配置文件
这样每个串口的职责边界清晰,数据流方向明确,遇到问题也能快速定位。调试口必须独立出来,千万不要把日志打印和业务通信混在同一个串口里。我在早期项目里图省事,让业务数据口顺带打印调试信息,结果通信一频繁,打印和业务数据互相穿插,定位问题花的时间远超预期。
2.2 收发方式选型:轮询、中断还是DMA
5个串口的收发方式如果不提前规划,代码写到最后一定是各种嵌套和阻塞。区别可以简单列一下:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 轮询 | 逻辑简单,代码直观 | 阻塞CPU,收发期间无法做别的事 | 调试打印、短数据、低速 |
| 中断 | 不阻塞主流程,响应及时 | 频繁中断会占用CPU时间 | 不定长数据、中等速率 |
| DMA+空闲中断 | 几乎不占CPU,高速可靠 | 配置复杂,内存占用大 | 大数据量、高速连续传输 |
对于F405RGT6这种多串口芯片,我的建议是全部串口接收都走中断。中断接收意味着每收到一个字节都会触发一次中断,在回调里把字节存入缓冲、重新开启下一次接收即可。发送则根据场景来,调试串口用阻塞发送,业务串口如果数据量大可以考虑DMA发送。
如果某个串口需要持续接收上百字节的数据帧,比如升级固件或高速传感器数据,单字节中断模式会频繁打断主循环,这时应该用DMA+空闲中断方案。F405系列较新的HAL库提供了HAL_UARTEx_ReceiveToIdle_IT这样的函数,实现了空闲中断+DMA接收,在接收完成或总线空闲时一次性给出整包数据。不过这个函数在不同HAL库版本里支持程度不同,用之前要确认当前库版本是否包含对应接口。
2.3 中断优先级分配思路
5个串口同时开启中断后,优先级分配直接影响系统的稳定性。STM32的NVIC中断优先级数字越小越优先,F405通常可配置4位抢占优先级。如果全部串口使用相同优先级,恰好有两个串口同时触发中断,CPU只能按中断号顺序执行,高频率的通信口就可能被低频率口拖累,出现延迟接收甚至溢出丢数据。
优先级分配的原则是:数据实时性要求越高的串口,优先级越要抬高;日志打印这种掉了也不影响业务的串口,优先级放最低。比如我常用的配置:USART3的RS485优先级抢占2,USART2的4G模组优先级抢占3,UART4的GPS优先级抢占4,UART5的升级口抢占5,USART1的调试日志抢占6。同时打开NVIC_PRIORITYGROUP_4分组,保证抢占优先级逻辑清晰。
除了优先级数值,中断回调函数的写法同样关键。回调函数里绝对不能做耗时操作,比如调用printf、HAL_Delay、解析协议等。收到的数据先在回调里放进缓冲区并置一个标志位,回到主循环后再慢慢处理。只要守住这条规则,5个串口同时跑也稳得住。
3. 从零配置和初始化五个串口
3.1 CubeMX配置五路串口的细节
用CubeMX生成基础工程是目前效率最高的方式。选好STM32F405RGT6后,在Pinout视图中直接用鼠标点选对应的TX/RX引脚,每个引脚会弹出可选功能列表,选择对应的USARTx_TX或USARTx_RX即可。软件会自动把引脚复用模式设置成AF7或AF8。
配置串口参数时,Mode选Asynchronous,波特率根据外设需求设置,大多数设备115200就够用。数据位8、停止位1、无校验、无硬件流控,这是最通用的配置。然后到NVIC Settings里勾选全局中断使能,这一步别忘了,否则HAL_UART_Receive_IT不会触发回调。
时钟树部分,先确认HSE外部晶振频率,再把PLL倍频系数设置为168MHz系统时钟,APB1预分频系数设置为4得到42MHz,APB2预分频系数设置为2得到84MHz。这样串口时钟就位了。生成代码后,main函数里会自动出现MX_USART1_UART_Init到MX_UART5_UART_Init的初始化调用,每个串口对应一个独立的初始化函数。
3.2 初始化代码结构,怎么做到串口多了不乱
5个串口如果每个都单独写一套接收缓冲和标志变量,代码会有大量重复,后期维护也容易出错。我习惯把每个串口的句柄、收发缓冲区和状态都封装成一个结构体:
typedef struct { UART_HandleTypeDef *huart; uint8_t rx_buf[128]; volatile uint8_t rx_len; volatile uint8_t rx_flag; } UART_Port; UART_Port g_uart1 = { .huart = &huart1 }; UART_Port g_uart2 = { .huart = &huart2 }; UART_Port g_uart3 = { .huart = &huart3 }; UART_Port g_uart4 = { .huart = &huart4 }; UART_Port g_uart5 = { .huart = &huart5 };初始化时统一遍历结构体,对每个串口调用HAL_UART_Receive_IT使能接收。中断回调里根据huart指针判断是哪个串口,把字节存入对应结构体的缓冲区,同时重新使能下一次接收。
这样做的好处非常明显:新增一个串口只需在结构体数组里增加一项,不用复制一大段重复代码。5个串口的数据接收逻辑只写一份,改一处就能同步更新所有串口的行为。
3.3 标准库或寄存器方式的关键计算
如果你不用CubeMX和HAL库,而是使用标准库或者寄存器操作,核心步骤是手动配置RCC时钟、GPIO复用、串口控制寄存器。BRR波特率寄存器的计算是绕不开的环节。
波特率计算公式为:USARTDIV = fck / (16 × BaudRate)。以APB1上的USART2为例,fck为42MHz,波特率115200时,USARTDIV = 42000000 / (16 × 115200) = 22.786。BRR寄存器的写入值是将USARTDIV的小数部分转换为4位或3位小数的定点格式。在实际工程中,如果计算出的分频系数不是整数,BRR最终也只能写入近似值,因此波特率必然存在微小误差,只要误差在约±2%以内,通信就不会出错。
寄存器方式初始化USART1的核心代码大致如下:
RCC->APB2ENR |= RCC_APB2ENR_USART1EN; RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; GPIOA->MODER |= (GPIO_MODE_AF_PP << GPIO_MODER_MODER9_Pos) | (GPIO_MODE_AF_PP << GPIO_MODER_MODER10_Pos); GPIOA->AFR[1] |= (7 << GPIO_AFRH_AFSEL9_Pos) | (7 << GPIO_AFRH_AFSEL10_Pos); USART1->BRR = 0x16C; // 84MHz时钟下约115200波特率 USART1->CR1 |= USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;这种方式配置起来比HAL库繁琐,但好处是代码体积小、执行快,而且你对每个寄存器的用途会记得更牢。对于刚接触F405的开发者,我建议先从CubeMX+HAL库入手,跑通之后再回头研究寄存器,两条路都能走通才算真正理解串口。
4. 发送接收代码怎么写才不容易翻车
4.1 阻塞发送与中断接收的组合
在一个多串口系统里,我推荐的组合是“阻塞发送 + 中断接收”。发送数据时,调用HAL_UART_Transmit阻塞等待发送完成,代码逻辑最简单直接;接收数据时,用HAL_UART_Receive_IT使能逐字节中断接收,每一字节到达后触发中断,拷贝到缓冲区后自动开启下一字节接收。
中断接收的HAL库回调函数是全局统一的HAL_UART_RxCpltCallback,所有串口共用这一个函数,所以需要通过huart指针区分来源:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { g_uart1.rx_buf[g_uart1.rx_len++] = g_uart1.tmp; g_uart1.rx_len %= 128; HAL_UART_Receive_IT(&huart1, &g_uart1.tmp, 1); } else if (huart == &huart2) { // 同样处理 } }这种逐字节中断接收方式每一字节都会触发一次中断,数据量大的时候中断频率很高。如果不用HAL库,直接在串口中断服务函数里读DR寄存器并存入缓冲区,效率会更高。但HAL库的方式胜在代码可读性好,回调机制清晰,5个串口的管理并不复杂。
4.2 五串口数据缓冲与协议解析框架
逐字节中断接收解决了“收到数据”的问题,但真正让多个串口稳定运行的关键,是数据缓冲和协议解析的解耦。中断回调只负责往缓冲区写数据,主循环负责从缓冲区读数据并解析,两者通过缓冲区衔接,互不阻塞。
最简单可靠的缓冲区是环形FIFO。写一个环形缓冲区大概需要read_index、write_index、buffer三个要素,写入时自增write_index,读取时自增read_index,两者相遇表示缓冲已空或已满。串口中断回调里调用写入函数,主循环的while里定时调用读取函数处理数据。
有了缓冲区后,协议解析就有了解耦的空间。以RS485口的Modbus RTU为例,主循环每轮检查环形缓冲区是否有完整帧,有就解析执行,没有就跳出继续下一轮。另外几个串口的数据处理类似,只是帧格式不同。这样5个串口共用一套“中断写入+主循环解析”的架构,比每个串口各写一套处理逻辑要清晰得多。
对于不定长数据,如果HAL库版本支持UARTEx,还可以用空闲中断判断一帧数据的结束。空闲中断指的是总线上超过一个字节时间没有新数据,则视为当前帧接收完成。这个机制配合DMA,可以在几乎不占用CPU的前提下完成整帧接收,是我在处理升级文件传输、传感器高速数据时的首选方案。
5. 调试中的常见问题和我的排查经验
5.1 串口乱码的三种常见原因
串口乱码是新手遇到最多的现象,但原因往往就那么几类。
第一是晶振频率配置错误。CubeMX生成的工程默认HSE_VALUE可能是25MHz,如果你的板子实际用的是8MHz晶振,不修改这个宏,系统时钟就会偏得离谱,串口全乱。检查SystemClock_Config函数中RCC_OscInitStruct的配置,务必确保PLL输入频率和实际硬件一致。
第二是波特率不匹配。代码里配置115200,串口调试助手里却选的9600,这属于低级错误,但出现频率极高,排查时先核对两侧参数。
第三是USB转串口工具没有正常工作。CH340、CP2102、FT232这类芯片都需要安装对应驱动,驱动异常时设备管理器根本看不到COM口,自然无法通信。另外USB转串口模块如果供电不足,也会出现偶发乱码和断连。
调试5个串口时不要一次性全开。先把USART1调通,能稳定收发字节后再依次打开其他串口,每打开一个就用调试助手验证一遍,这样出问题能快速缩小范围。
5.2 多串口同时收发丢数据
如果单独测试每个串口都正常,5个串口全部启用后偶发丢数据,绝大多数原因是中断处理耗时过长。HAL库的UART接收中断里如果直接调用printf、sprintf或者耗时很长的协议解析函数,中断会长时间占用CPU,期间的串口数据无法被及时处理,硬件接收寄存器溢出后数据自然就丢了。
解决办法有两个方向。一个是在中断回调里只做“拷贝字节到缓冲”这一件事,所有解析和处理全部放到主循环;另一个是对数据率较高的串口单独提升中断优先级,确保它不会被其他串口中断长时间打断。两个方向要配合使用,优先级只是降低被打断的概率,真正解决问题还是要让中断函数足够短。
如果是通过杜邦线连接外部设备,线长超过20cm且波特率高于115200时,信号质量也可能导致丢数据。此时把波特率降下来,或者换成屏蔽线、双绞线,问题往往立刻缓解。
5.3 引脚复用冲突
多串口系统最容易忽略的是引脚复用冲突,因为CubeMX有时并不会给出直观警告。前面提到的USART3和UART4同时使用PC10/PC11就是典型例子,两个串口都想占用同一个引脚,配置了不一定会报错,但实际运行只会有一个工作正常。
另一个我踩过的坑是UART5和SDIO共用PD2。一次设计中我同时启用了UART5和SD卡,SD卡初始化就出现偶发失败。排查很久才发现PD2被两个外设同时占用,中段信号互相干扰。最后把UART5的引脚从PD2挪开才彻底解决。
在F405上,PB3、PB4、PA15这些引脚默认是JTAG调试口,如果要当作串口引脚使用,必须先关闭对应JTAG功能,否则引脚电平被调试电路锁定,串口怎么配都不通。配置GPIO时还要确认复用功能编号,USART用AF7,UART4和UART5用AF8,写错AF号同样会导致串口无信号。
最后再分享一个我个人的习惯:不管需求里最终要不要用DMA,第一版调试代码一律全部配置成中断接收、阻塞发送,先把5路串口的数据通路全部跑通,再根据实际负载逐个优化成DMA。这个节奏看起来保守,但实际省下的调试时间远比我最初预想的多。多串口系统的复杂度不在单个串口,而在串口之间的资源协调,先把基础框架打稳,后面加功能才不至于推翻重来。
本文还有配套的精品资源,点击获取