简介:为stm32f405rgt6开发者提供的串口1~5通信代码包,完整覆盖USART1至USART5的数据接收、缓存计数与标志管理,主要面向嵌入式硬件、单片机及ARM相关开发者,尤其适合需要快速搭建多串口通信框架的工程人员。压缩包共145个文件,以C源码和头文件为主体(42个h、36个c),同时包含Keil工程配置文件(uvprojx、uvopt)、编译生成文件(hex、axf、map)以及STM32F4标准外设库驱动(如stm32f4xx_usart.c、stm32f4xx_rcc.c等),资源整体仅2.56MB,便于下载与解压。目前已有1214人学习,代码中为每个串口独立定义了接收缓冲区、接收计数及接收完成标志,变量和宏定义清晰,便于读者理解串口收发流程并快速移植到自身项目中,也可在此基础上进一步扩展DMA或中断接收逻辑,具有较好的参考与复用价值。
1. 上手前的硬件盘点:5路串口到底怎么分配
用STM32F405RGT6做多串口通信,这个需求在实际项目里太常见了。一颗芯片同时接GPS模块、传感器、上位机、蓝牙模块,再加上一路调试串口,正好5路。F405这颗料在串口资源上确实厚道,片上集成了USART1、USART2、USART3、UART4、UART5,一共5路独立的串口外设,基本能覆盖绝大多数多设备通信场景。这里写的就是基于标准外设库的直接可用的代码梳理,包含初始化、发送、接收、中断处理这些核心内容。
在动手写代码之前,先得把芯片的串口资源分布搞清楚,否则代码写完了跑起来才发现引脚映射不对,排查起来非常痛苦。
1.1 先分清USART和UART的区别
很多朋友从STM32F103转过来,看到F405上既有USART又有UART,型号名字里带“UART4”“UART5”的,容易误以为跟USART不一样。实际上差异很小:USART支持同步模式,多一个SCLK时钟引脚输出,可以外接需要时钟同步的设备;UART只支持异步模式,就是最常用的TX/RX两根线收发。我们的日常串口通信基本都是异步模式,所以USART1/2/3和UART4/5在用法上没有本质区别,初始化时配置的参数也完全一样。
F405上这5路串口,总线挂载情况不一样,这个非常重要。USART1挂在APB2上,时钟频率84MHz;USART2、USART3、UART4、UART5挂在APB1上,时钟频率42MHz。很多从F103迁移过来的工程,沿用72MHz/36MHz的时钟参数,在F405上就会出问题——波特率算出来的误差很大,115200这种常用波特率实测会乱码。这一点在后面初始化代码里会具体体现。
1.2 引脚映射与复用功能表
F4系列和F103有一个非常大的区别:F103的串口引脚是固定的,F4系列则是通过复用功能(Alternate Function)映射的。也就是说,串口外设的信号要想从引脚输出,必须先把这个引脚配置成对应的AF模式,否则引脚就是个普通GPIO,数据根本出不去。
常用的默认引脚映射如下:
| 串口 | TX引脚 | RX引脚 | 复用编号 | 总线时钟 |
|---|---|---|---|---|
| USART1 | PA9 | PA10 | AF7 | APB2 84MHz |
| USART2 | PA2 | PA3 | AF7 | APB1 42MHz |
| USART3 | PB10 | PB11 | AF7 | APB1 42MHz |
| UART4 | PA0 | PA1 | AF8 | APB1 42MHz |
| UART5 | PC12 | PD2 | AF8 | APB1 42MHz |
这5组引脚之间互相不冲突,做5路串口同时通信非常舒服,不用抢引脚。当然F405的串口引脚不止这一套映射,比如USART2可以重映射到PD5/PD6,USART3可以到PC10/PC11,但默认这组是最好用的,原理图设计也最顺,我建议没有特殊原因别去重映射,省得看数据手册的AF表看得头晕。
2. 核心代码:基于标准库的串口初始化
这里以标准外设库(STM32F4xx_DSP_StdPeriph_Lib)为例,因为现在网上能找到的例程和参考代码大部分还是标准库风格,而且标准库的寄存器操作思路更直观,理解起来比HAL库的层层封装容易得多。HAL库版本的差异我在后面单列一小节说明。
2.1 时钟使能这一步别搞错
F405的时钟树和F103不一样,初始化时先分清APB1和APB2。USART1的时钟使能位在RCC_APB2PeriphClockCmd里,USART2/3/UART4/UART5的时钟使能位在RCC_APB1PeriphClockCmd里。同时还要使能对应GPIO端口的AHB1时钟。
// 使能串口时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // USART1挂APB2 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // USART2挂APB1 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); // USART3挂APB1 RCC_APB1PeriphClockCmd(RCC_APB1Periph_UART4, ENABLE); // UART4挂APB1 RCC_APB1PeriphClockCmd(RCC_APB1Periph_UART5, ENABLE); // UART5挂APB1 // 使能GPIO时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOC | RCC_AHB1Periph_GPIOD, ENABLE);这里有个经验:串口时钟和GPIO时钟分开使能,如果只使能了GPIO时钟忘了串口时钟,初始化USART时写寄存器没反应;如果只使能串口时钟忘了GPIO时钟,GPIO配置写不进去。两个都开是基本前提。
2.2 GPIO复用模式配置
F4系列的GPIO配置比F103多两个参数:GPIO_OType和GPIO_PuRd。串口的TX/RX引脚要配置成复用功能GPIO_Mode_AF,输出类型推挽,上拉。
GPIO_InitTypeDef GPIO_InitStructure; // USART1 引脚 PA9(TX) PA10(RX) GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOA, &GPIO_InitStructure);注意:GPIO_PinAFConfig这个函数是F4系列特有的初始化步骤,漏掉它是最常见的“串口不工作”原因。F103的代码里没有这一步,直接从F103移植过来的朋友最容易在这个地方翻车。
2.3 USART外设初始化
串口参数配置比较常规,8位数据位、1位停止位、无校验、无硬件流控、收发都开。关键是波特率。USART1挂APB2(84MHz),USART2/3/UART4/5挂APB1(42MHz),初始化结构体里只要把波特率数值填进去,库函数会根据外设挂载的总线频率自动计算分频系数,所以不用手工算BRR寄存器,前提是RCC时钟配置正确。
USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE);USART2/3/UART4/5的初始化代码结构完全一样,只是把USART1替换成对应的外设名,引脚按1.2节的表格换成对应引脚。这里不重复贴5遍代码了,后面第4节会给一个统一管理多串口的模板,代码复用性更好。
2.4 HAL库版本怎么写
如果你用的是STM32CubeMX生成的HAL库工程,初始化代码会被CubeMX自动生成,核心差异在于:
UART_HandleTypeDef huart1; void MX_USART1_UART_Init(void) { 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; HAL_UART_Init(&huart1); }注意HAL库的引脚复用配置在HAL_UART_MspInit函数里,由CubeMX自动生成,这一点比标准库省心。但HAL库的中断接收不是直接在中断服务函数里读数据,而是要用HAL_UART_Receive_IT启动一轮接收,数据到了之后进回调函数HAL_UART_RxCpltCallback,处理完后需要再次调用HAL_UART_Receive_IT才能继续接收下一字节。这个机制刚到HAL的人经常忘了重新启动接收,导致只能收到一帧数据。
3. 数据收发:从轮询到中断再到空闲+DMA
有了串口基础配置,接下来是收发逻辑。不同场景用不同的收发方式,下面按从简到繁的顺序说。
3.1 最简单的轮询发送和接收
发送用TXE标志判断发送数据寄存器是否为空:
void USART_SendByte(USART_TypeDef* USARTx, uint8_t data) { while (USART_GetFlagStatus(USARTx, USART_FLAG_TXE) == RESET); USART_SendData(USARTx, data); } void USART_SendString(USART_TypeDef* USARTx, uint8_t* str) { while (*str) { USART_SendByte(USARTx, *str++); } }接收用RXNE标志判断是否收到数据:
uint8_t USART_ReceiveByte(USART_TypeDef* USARTx) { while (USART_GetFlagStatus(USARTx, USART_FLAG_RXNE) == RESET); return USART_ReceiveData(USARTx); }轮询方式适合主循环里周期性查询接收,比如每10ms查一次有没有数据。缺点很明显:如果数据来得又快又急,主循环忙别的事情时可能丢数据。对实时性要求高的场景,得用中断。
3.2 中断接收的标准写法
中断接收的核心是在初始化时打开接收中断,在中断服务函数里取数据。以USART1为例:
// 初始化时加这两行 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 中断服务函数 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); // 这里把data存入缓冲区或者做帧处理 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }注意USART_ReceiveData函数内部会清除RXNE标志位,所以中断里不主动清问题也不大,但保险起见加上USART_ClearITPendingBit更规范,防止某些边界情况下标志位状态异常。
五个串口的IRQHandler分别是:USART1_IRQHandler、USART2_IRQHandler、USART3_IRQHandler、UART4_IRQHandler、UART5_IRQHandler。每个串口一个中断服务函数,函数名是启动文件里定死的,不能自己改名。
3.3 空闲中断+DMA接收不定长数据
如果协议帧是不定长的,用固定字节中断接收往往要配合超时判断或者帧头帧尾解析,稍微有点繁琐。F405的串口支持空闲(IDLE)中断,一帧数据发完,总线空闲了,就触发一次中断。配合DMA,可以实现“收完一整帧再处理”的效果,CPU几乎不干预数据搬运。
思路是:串口RX用DMA接收,每收到一个字节,DMA自动搬运到缓冲区。同时开启串口空闲中断,当一帧数据发送完毕、总线进入空闲状态,触发IDLE中断,此时在中断里读取DMA当前剩余计数,就能算出这一帧收到了多少字节。
#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; uint16_t rx_len = 0; void UART_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); // USART1_RX 对应 DMA2_Stream2 Channel4 DMA_InitStructure.DMA_Channel = DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)(&USART1->DR); DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize = RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_1QuarterFull; DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream2, &DMA_InitStructure); DMA_Cmd(DMA2_Stream2, ENABLE); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); } void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 读DR清IDLE标志 DMA_Cmd(DMA2_Stream2, DISABLE); rx_len = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA2_Stream2); DMA_SetCurrDataCounter(DMA2_Stream2, RX_BUF_SIZE); DMA_Cmd(DMA2_Stream2, ENABLE); // 此时rx_buf里有rx_len个字节的数据,可以交给协议栈处理 } }这个模式实测在115200波特率下,每帧不超过256字节,跑得非常稳。需要注意F405的USART1_RX对应DMA2_Stream2_Channel4,USART2_RX对应DMA1_Stream5_Channel4,每个串口的DMA通道和Stream号都不同,用的时候要对着参考手册查,这个表在STM32F405的数据手册DMA章节里有,网上也能查到,我就不贴全表格了,你们实际配的时候一定要核对。
4. 五路串口工程模板:统一管理不迷糊
实际工程里5路串口的协议和处理逻辑各不相同,如果每个串口的初始化、发送、接收都单独写一坨代码,工程文件会非常乱。我习惯用一个结构体把每个串口的信息封装起来,统一索引、统一操作。
4.1 用结构体统一管理多串口
typedef struct { USART_TypeDef* USARTx; // 外设基地址 uint32_t RCC_APBxPeriph_USARTx; // 外设时钟 uint32_t RCC_AHB1Periph_GPIOx; // GPIO时钟 uint16_t TX_Pin; uint16_t RX_Pin; uint8_t TX_Source; uint8_t RX_Source; uint8_t AF_Value; GPIO_TypeDef* GPIOx; uint8_t IRQn; } USART_Config_t; USART_Config_t usart_config[5] = { {USART1, RCC_APB2Periph_USART1, RCC_AHB1Periph_GPIOA, GPIO_Pin_9, GPIO_Pin_10, GPIO_PinSource9, GPIO_PinSource10, GPIO_AF_USART1, GPIOA, USART1_IRQn}, {USART2, RCC_APB1Periph_USART2, RCC_AHB1Periph_GPIOA, GPIO_Pin_2, GPIO_Pin_3, GPIO_PinSource2, GPIO_PinSource3, GPIO_AF_USART2, GPIOA, USART2_IRQn}, // 以此类推,USART3/UART4/UART5按表格填充 };有了这张配置表,初始化函数就是循环调一遍,收发函数只传外设基地址就行,代码量能省一大半。更重要的是,后面如果换了引脚,只需要改配置表里的Pin和Source,不用动任何逻辑代码。
4.2 一个实际的调用示例
举例一个典型场景:串口1接上位机,串口2接GPS,串口3接传感器,串口4接蓝牙,串口5做调试日志输出。那么主循环里就可以:
while (1) { // 串口1 收到上位机指令,回显应答 if (rx_flag[0]) { USART_SendString(USART1, (uint8_t*)"cmd ok\r\n"); rx_flag[0] = 0; } // 串口5 打印调试日志 USART_SendString(USART5, (uint8_t*)"system running\r\n"); delay_ms(100); }每个串口的中断服务函数只做一件事:把收到的数据放到对应的环形缓冲区或者置一个标志位,具体协议解析放到主循环里做。这样中断里的处理时间很短,不容易丢数据,主循环里又可以根据标志位判断数据到达,逻辑非常清晰。
5. 调试工具与常见问题排查
5.1 串口调试助手和USB转串口芯片
调试5路串口,工具很重要。上位机推荐用支持多串口同时打开、带波形显示、支持定时发送的调试助手,比如正点原子的XCOM、野火的串口调试助手,或者串口猎人、SSCOM。这些工具在Gitee和CSDN上都能找到下载,基本都免费。
USB转串口芯片方面,市面上最常见的是CH340和FTDI的FT232。CH340便宜,几块钱一根线,Windows 10/11插上一般能自动识别驱动,个别精简版系统需要去官网手动装一下驱动。FT232稳定但贵,适合工业现场。如果有硬件条件,还可以用逻辑分析仪直接抓TX/RX引脚的波形,排查波特率不匹配、接线错误这类问题非常直观。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全没有输出 | GPIO没配成AF模式 | 检查是否调用了GPIO_PinAFConfig |
| 完全没有输出 | 串口时钟没使能 | 检查RCC_APB1/APB2PeriphClockCmd |
| 乱码 | 波特率不匹配 | 检查双方波特率,检查总线频率配置 |
| 乱码 | 时钟树配置不对 | F405主频168MHz,APB1=42MHz,APB2=84MHz |
| 只能收到第一个字节 | HAL库中断接收未重新启动 | 回调函数里重新调用HAL_UART_Receive_IT |
| 接收一段时间后死机 | 缓冲区溢出 | 加大缓冲区,或用环形缓冲区 |
| 数据都是0xFF | RX引脚悬空/虚焊 | 检查硬件连接,示波器量波形 |
还有一个坑值得单独提:F405复位后,串口TX引脚默认是高电平,如果板子上TX/RX接反了,通信完全不通但示波器上能看到波形。这个时候先别急着改代码,拿万用表量一下两边引脚有没有接反。我排查过好几个“串口死活不通”的板子,最后都发现是杜邦线插反了。
6. 一点实操体会
串口是嵌入式开发最常用的调试和通信手段,F405这颗芯片5路串口全部启用后,做一个多设备网关绰绰有余。如果手上同时有F103和F405的工程,要注意它们最大的区别就是GPIO复用配置和时钟树参数,这两个地方搞定了,代码迁移非常快。
另外建议从一开始就把串口驱动封装成独立的模块,发送函数、接收回调、缓冲区管理分开写。5路串口的工程如果不封装,代码会越来越难维护,封装好后加一个第6路串口(虽然F405没有)或者换一个外设,主逻辑都不用动。
最后分享一个排查串口问题时的小技巧:先用串口1做最简单的回环测试,就是初始化好之后,把TX和RX用杜邦线短接,自己给自己发数据。如果回环通了,说明串口外设、引脚、中断逻辑都没问题。这时候再接外部设备,问题范围就缩小到外部设备的协议、波特率、接线上了。一步一步排查,比拿到板子就闷头写代码要高效得多。
本文还有配套的精品资源,点击获取