☰
STM32 UART代码跨F1/F4与标准库/HAL库兼容实战
2026/10/14 19:06:28 网站建设 项目流程

简介:面向STM32开发者的UART通信代码包,覆盖F1与F4系列,同时提供标准库与HAL库两种实现,适用于快速搭建串口收发、外接GPS/蓝牙模块或与PC通信等场景。压缩包共4个文件,含2个头文件和2个源文件,体积仅33KB;头文件用于接口声明,源文件实现初始化、数据发送、接收及中断处理等核心逻辑。标准库版本直接操作寄存器,资源占用少、性能更优,适合对底层控制有要求或资源受限的项目;HAL库版本屏蔽硬件细节,代码易读易维护,能显著缩短开发周期,两者可对照学习,也便于按项目需求灵活切换。已有268人学习,代码内保留完整注释和典型配置,开发者可直接集成到自己的工程中,并在此基础上定制帧协议、超时处理等功能,是嵌入式串口开发路上一份实用的参考。

1. 一套UART通信代码,F1、F4通吃又兼容标准库、HAL库,难点到底在哪

同一个串口Demo,从F1的电路板换到F4上,重新编译后往往不是直接能跑:串口不输出,或上位机收到全是乱码,甚至程序卡死在等待标志位的地方。问题不在你会不会写UART初始化,而在两代芯片的时钟树、GPIO复用方式、中断向量差别都实实在在存在;再从标准库迁到HAL库,API全变,接收中断还多了一套“一次性触发”机制。做嵌入式串口的人都体会过这种换个平台就翻车的滋味。这篇笔记针对“UART通信代码,包含F1、F4,支持标准库、HAL库”这个需求,先把两代芯片和两套库的差异讲透,再给可以直接改的工程代码、参数设置和排查顺序,适合既要维护旧工程又要上新平台的开发者,也适合从标准库全面迁到HAL库的团队。

2. 折腾UART前先看三件事:F1/F4的时钟域、F4的GPIO复用和波特率误差

标准库和HAL库,本质上描述的是同一块硬件,差别在封装层次。标准库的特点是“薄”:每个外设对应一个Init结构体、一组函数,直接操作寄存器位,没有对象管理,也没有回调分发。HAL库的特点是“厚”:把外设封装成Handle结构体(例如UART_HandleTypeDef),初始化时会检查状态、设置硬件锁,中断处理内部按模块分发回调。对UART来说,标准库的USART_Init(USART1, &USART_InitStructure)就是一次性把BRR、CR1等寄存器写好;HAL库的HAL_UART_Init(&huart1)不仅写寄存器,还会把配置保存到huart1.Init结构体供后续查询。

选库不是凭喜好,要结合项目现状。如果代码是基于标准库的老工程、要长期维护,没必要为“新”而迁HAL,标准库直接改改就能用;如果是新项目且要快速生成、用CubeMX配置外设,或者项目里还用到USB、以太网等中间件,HAL库和CubeMX生态能省很多事。至于“一套代码同时支持标准库、HAL库”,我一般不会在同一个工程里混编,原因是两套库的头文件里大量外设地址宏同名冲突,硬混会带来一堆编译期和运行期问题。正确的做法是:把协议解析、命令处理放到上层,把串口硬件操作抽成统一接口,标准库和HAL库分别提供各自的实现。这样“支持两套库”的含义就是同一份协议代码可以分别配合标准库工程或HAL库工程编译,换库不动业务逻辑。

2.1 标准库和HAL库在UART代码结构上的本质差异

先给一个对比表,方便照着选型:

对比维度标准库写法HAL库写法
外设对象直接传USART1等指针UART_HandleTypeDef结构体
初始化USART_Init + USART_CmdHAL_UART_Init,内部调用底层初始化回调
发送USART_SendData + 查标志位HAL_UART_Transmit / 中断 / DMA
接收USART_ReceiveData + 查标志位HAL_UART_Receive / HAL_UART_Receive_IT
中断管理自己写ISR,手动清标志系统ISR调HAL_UART_IRQHandler,统一分发回调

HAL库的接收中断是基于buffer size的:你告诉它“收满N个字节再通知我”,收满后自动停止。标准库的RXNE中断是持续有效的,只要有数据就进ISR,读完DR自动清。很多人从标准库迁到HAL库时感觉“串口收了一次就不收了”,根因就在这个一次性机制。另外HAL库的错误处理更完善,发送和接收都有HAL_StatusTypeDef返回值,标准库则需要自己封装超时和错误判断。

2.2 F1、F4两种芯片在UART外设上的四个差异

两代芯片在串口外设上的差异,比很多人想象的更具体。下面这个表涵盖了四个最关键的点:

差异点F1系列F4系列
常见串口资源USART1/2/3,部分型号有UART4/5USART1/2/3/6,部分型号有UART4/5/7/8
GPIO复用方式无AFR寄存器,靠CRL/CRH的CNF位配复用推挽每个引脚有AFR寄存器,必须指定AF编号
主要时钟域USART1在APB2,最高72MHz;USART2/3在APB1,最高36MHzUSART1/6在APB2,最高84MHz;USART2/3/4/5/7/8在APB1,最高42MHz
初始化代码差异TX配复用推挽、RX浮空输入,直接写GPIO_InitStructMode选AF_PP,Alternate填GPIO_AF7_USARTx或GPIO_AF8_UARTx

其中时钟域差异直接决定波特率。波特率由PCLK / (16 * USARTDIV)算出来,USARTDIV存放在BRR寄存器里。F103上USART1的PCLK按72MHz算,USART2/3按36MHz算;F407上USART1/6按84MHz算,USART2/3按42MHz算。同一个115200目标值,F1和F4写进BRR的数值完全不同。代码迁移时如果沿用旧工程的初始化参数,即使编译通过,实际波特率也是错的。这就是“包含F1、F4”的UART代码必须先定串口、再算时钟、后配参数的原因。

2.3 动手前必做的三个核对动作

第一个核对:串口挂在哪条总线上。在芯片参考手册里查目标UART对应的APB1或APB2最大时钟频率,记下这个值,后面配置波特率要用。

第二个核对:引脚复用编号。F1看参考手册的复用功能表,直接沿用GPIO_Mode_AF_PP + GPIO_Mode_IN_FLOATING;F4必须查AF表,USART1/2/3/6通常对应AF7,UART4/5/7/8通常对应AF8。这个参数填错,代码能编译,但引脚不会复用成串口功能,电平永远不对。

第三个核对:目标波特率是否落在该时钟域能精确输出的范围。一般用PCLK / (16 * 目标波特率)算出USARTDIV,取整后回代算实际波特率,误差在±2%以内才稳。9600、115200这类常用值在主流时钟配置下问题不大,但接近PCLK/16上限的非整数波特率一定要实际算一遍,别只看配置器上显示的数字。

3. 标准库在F1上跑通UART:最小初始化、轮询收发和中断收发的完整写法

标准库在F103工程里用起来直接、依赖少,非常适合旧项目维护和快速验证。下面代码以USART1、PA9=TX、PA10=RX为例,APB2时钟72MHz,串口参数115200-8-N-1。这套代码直接复制到标准库工程里就能跑,注意工程里要包含stm32f10x_usart.c和stm32f10x_gpio.c源文件。

3.1 标准库初始化:时钟、GPIO与USART参数

/* 标准库 F1 UART 初始化示例:USART1, PA9(TX), PA10(RX) */ void uart1_init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; /* 开启GPIOA和USART1时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); /* TX: PA9 配置为复用推挽输出 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* RX: PA10 配置为浮空输入 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 串口参数:115200-8-N-1 */ 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); }

这段代码里最需要注意的是时钟使能函数。F1的USART1挂在APB2上,所以要用RCC_APB2PeriphClockCmd;USART2/3挂在APB1上,要换RCC_APB1PeriphClockCmd。如果串口号和时钟使能不匹配,寄存器根本写不进去,后面的初始化全部白做,这是F1上最常见的低级错误。

GPIO配置里,TX用复用推挽是因为串口发送由USART外设控制引脚电平,推挽能同时输出高和低;RX用浮空输入是因为空闲时引脚由对端驱动,浮空态不会引入额外的上下拉电阻影响信号。硬件流控设成None,表示只用TX、RX、GND三根线;如果实际接的是RS485或者带流控的模块,USART_HardwareFlowControl和USART_Mode都要按硬件改。最后一步USART_Cmd是标准库的使能开关,漏掉它前面全部不生效。

3.2 轮询收发:两块可以直接抄的收发函数

/* 发送单字节:等TXE空闲,写入DR,再等TC发送完成 */ void uart1_send_byte(uint8_t data) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, data); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); } /* 接收单字节:等RXNE置位,读DR即自动清标志 */ uint8_t uart1_recv_byte(void) { while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET); return (uint8_t)USART_ReceiveData(USART1); } /* 发送一包数据:循环调用单字节发送 */ void uart1_send_buf(const uint8_t *buf, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uart1_send_byte(buf[i]); } }

发送函数里我故意等了两个标志:TXE和TC。TXE表示数据寄存器空,可以写入下一个字节;TC表示移位寄存器也发送完了。如果只等TXE,连续发送时帧与帧之间可能没有足够间隔,对端严格按帧接收时会出现粘包。TC等待会有一些耗时,但对大多数帧协议来说值得,能保证一帧发完再发下一帧。

接收函数的USART_FLAG_RXNE会在数据寄存器收到新字节时置位,调用USART_ReceiveData后硬件自动清标志,不需要手动写清除代码。轮询接收的缺点是CPU全程等待,主循环里只要干别的事就可能漏数据,所以实际项目中轮询接收只用于调试或低速握手,正式通信建议走中断。

3.3 中断接收:标准库ISR里怎么判断和清标志

/* 开启接收中断 */ USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); /* 配置NVIC:F1的USART1中断号 */ NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);
/* USART1中断服务函数 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = (uint8_t)USART_ReceiveData(USART1); /* 把收到的字节交给上层协议解析 */ protocol_push_byte(data); } /* ORE溢出标志也要检查,避免长时间运行后丢帧 */ if (USART_GetITStatus(USART1, USART_IT_ORE) != RESET) { (void)USART_ReceiveData(USART1); /* 读DR清ORE */ } }

标准库的中断是持续有效的:只要RXNE置位就进中断,读完DR后硬件自动清RXNE,不需要像HAL库那样重新挂载接收。ORE标志容易被忽略,它表示数据寄存器被新数据覆盖,旧数据没来得及读。如果中断里只处理RXNE,ORE一直在,后续新数据来了也进不了正常接收路径,表现为“跑着跑着串口就不收了”。代码里读一次DR就是为了清掉ORE。协议解析函数要写得短,不要把耗时的帧处理放在中断里,最好只做字节入队,解析放主循环。

4. HAL库在F4上跑通UART:CubeMX配置、核心API与回调的一次性机制

F4新工程建议用CubeMX生成HAL代码,初始化基本不用手写寄存器。但自动生成不等于能直接用,串口参数、NVIC优先级、回调逻辑都要自己核对。HAL库还有一个隐藏特性:配置项和实际寄存器写入之间隔了一层,时钟树一变,同样的参数生成的BRR完全不一样,这一点往往是“换板子就乱码”的根源。

4.1 CubeMX里串口参数和时钟树怎么配

配置项建议值说明
ModeAsynchronous异步模式,无时钟输出
BaudRate115200与对端严格一致
Word Length8 Bits8-N-1最常见
ParityNone选择None/Even/Odd
Stop Bits1标准配置
Over Sampling16高波特率可实验8,但误差容忍度下降
UART Interrupt开启中断接收必须开
DMA Tx/Rx按需大帧或高频率数据才需要

CubeMX里配置串口前,先去时钟树页面确认USART所在总线的时钟频率。以F407为例,USART1/6通常挂在APB2上,典型值84MHz;如果把APB2分频改成42MHz,CubeMX会重新计算BRR,同一个波特率配置值对应的实际分频就变了。所以每次改时钟树后都要重新生成代码,不要手动改初始化结构体里看起来很顺眼的数字。

4.2 F4上HAL库的发送和中断接收代码

/* 轮询发送:带超时,避免死等 */ uint8_t tx_buf[6] = {0xAA, 0x55, 0x01, 0x02, 0x03, 0x04}; HAL_StatusTypeDef status = HAL_UART_Transmit(&huart1, tx_buf, 6, 100); if (status != HAL_OK) { /* 超时或发送错误,可查huart1.ErrorCode */ } /* 中断接收:只接收1个字节,收到后进回调 */ uint8_t rx_byte = 0; HAL_UART_Receive_IT(&huart1, &rx_byte, 1);

HAL_UART_Transmit的第四参数是超时毫秒数,阻塞模式下超过100ms还没发完就返回HAL_TIMEOUT。这个参数不能填0,否则在极端情况下会永久阻塞。实际项目里我会把超时设成与帧长度相关:一帧115200下约1ms,给10倍余量已经足够,设个100通常不会误伤正常通信。

HAL_UART_Receive_IT的设计和标准库完全不同:它是一次性任务,收满指定长度后自动停止,等待期间由中断一个字节一个字节地填进你给的buffer,收满后调用回调函数。上面代码收1字节,回调就会在每收到一个字节时被调用。如果想持续接收,必须在回调里再次调用这个函数,这就是HAL库接收一个经典坑的来源,下一节讲。

4.3 回调里重新挂载,以及F4的GPIO复用配置

/* UART接收缓冲区 */ #define RX_BUF_MAX 128 uint8_t rx_frame[RX_BUF_MAX]; volatile uint16_t rx_len = 0; /* HAL的回调:每次收满后自动调用 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_len < RX_BUF_MAX) { rx_frame[rx_len++] = rx_byte; } /* 关键:重新挂载接收,否则只进一次 */ HAL_UART_Receive_IT(huart, &rx_byte, 1); } }

回调里务必判断huart->Instance,因为同一个工程里可能有多个串口,回调是所有UART共用的,不判断就会互相污染。HAL_UART_Receive_IT(huart, &rx_byte, 1)必须在回调里重新调用,这是HAL库持续接收的标准做法。有人把这段放到主循环里做,但中断接收的触发时机是硬件,主循环轮询反而会加大延迟,不建议。

F4的GPIO复用是另一个重点。F1的GPIO寄存器只有CRL/CRH,配好模式和速度就完事;F4每个引脚多了AFR寄存器,串口引脚必须写AF编号。

/* F4的PB6/PB7做USART1时,Alternate必须配成AF7 */ GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF7_USART1; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

F1代码迁移到F4时,这一段是最容易漏的。Alternate漏写或者写错编号,引脚就只是普通推挽输出,串口波形永远不会出现。CubeMX生成的代码里已经带了这段,但如果你是手工移植标准库工程,必须自己补。另外F4的GPIO时钟使能宏长这样:__HAL_RCC_GPIOB_CLK_ENABLE(),和F1标准库的RCC_APB2PeriphClockCmd不是一个体系,混用会同时出编译错误和运行错误。

5. UART通信代码的常见问题与排查顺序:五条血泪经验

串口通信出问题时,很多人第一反应是改波特率、换接线,其实大部分问题都能从时钟配置、GPIO复用、SR/ISR标志位里找到答案。这类问题看似玄学,实际每一步都有寄存器层面的证据。下面五条是我在多个项目里踩过、也帮别人排查过的真实教训,按“现象→原因→解决”列清楚。

5.1 中断接收只触发一次,之后再无反应

现象:程序跑起来,上位机第一帧能收到,之后无论发多少帧都没反应,调试器看程序还在正常运行。

原因:HAL_UART_Receive_IT是一次性的,收满指定字节后不再接收。你只调用了一次,回调里没有重新挂载,RXNE中断虽然还在,但HAL内部已经取消了接收状态,数据白收。

解决:在HAL_UART_RxCpltCallback回调里重新调用HAL_UART_Receive_IT(huart, &rx_byte, 1)。同时确认回调里用的是传入的huart指针而不是写死的某个句柄,多个串口共用一个回调时,写死句柄会导致串口2收的数据跑到串口1的缓冲区。

5.2 F4换芯片后TX引脚恒为高电平

现象:程序烧进F4板子,示波器量串口TX引脚,始终是空闲高电平,发送函数一直在跑但波形不存在。

原因:GPIO没有启用复用功能。F4的引脚要选择AF编号,USART1/2/3/6常见是AF7,UART4/5/7/8常见是AF8。Alternate字段漏写或写错,引脚就是普通IO,串口外设的信号根本没有引到引脚上。

解决:检查GPIO_InitStruct.Alternate是否和目标串口匹配,并使能对应GPIO时钟。用逻辑分析仪量引脚之前,先确认CubeMX生成的引脚映射表,别把PA9和PB6当成同一个功能。

5.3 波特率看着一样,长时间运行后偶发乱码

现象:刚开机通信正常,跑几分钟或温度变化后出现偶发错帧,上位机收的帧校验失败率高。

原因:波特率误差超了±2%。常见场景是把高波特率或非整数分频速率挂在了低频率时钟域上。F1的USART2/3挂在36MHz的APB1上,当目标波特率接近或超过PCLK/16理论上限时,BRR的量化误差会被明显放大。

解决:先算BRR:USARTDIV = PCLK / (16 * 目标波特率),取整后回代算实际波特率,误差超过±2%就换串口、调时钟分频或降波特率。F4上建议高波特率用USART1或USART6,它们挂在84MHz的APB2上,量化误差更小。

5.4 长时间运行后串口卡死,程序还在跑,就是收不到数据

现象:系统连续运行几小时后串口不再收发,喂狗正常,主循环正常,只有通信死掉。

原因:ORE溢出标志卡住了接收路径。RXNE置位后如果没及时读DR,新字节会把旧数据覆盖,硬件置ORE。之后如果只查RXNE标志,中断不再触发,接收路径一直堵着。

解决:中断里检查ORE,读SR再读DR清标志。标准库直接USART_ReceiveData(USARTx)就能清,HAL库可用__HAL_UART_CLEAR_OREFLAG(&huart1)。更重要的是接收中断里不要做耗时解析,只入环形缓冲,解析放主循环,从源头减少ORE出现概率。

5.5 标准库和HAL库放同一个工程,编译报重复定义

现象:把标准库写的外设驱动直接拷进HAL库工程,编译报USART1重复定义,或者一堆寄存器的宏冲突。

原因:两套库的头文件都定义了相同的外设基地址、寄存器位和中断号,宏名完全一样且值也一样,链接器无法分辨。如果强编码解决冲突,又会在启动文件的时钟初始化顺序上出问题,RCC函数行为两套库差异很大。

解决:不要在同一个工程里混编。所谓“这套代码支持标准库、HAL库”,正确含义是提供两种实现,工程编译时选其中一套。上层协议通过接口调用,底层库选择在编译期定死。做法见下一章。

6. 一套UART协议代码同时兼容F1/F4与两套库:接口分层加上一句验证口诀

让同一套串口协议代码在F1/F4、标准库/HAL库之间切换,核心不是把两个库揉在一起,而是把硬件操作隔离出去。协议层负责组帧、解析、校验,硬件层只暴露统一接口。这样F1的旧板子编标准库实现,F4的新板子编HAL实现,协议代码一个字节都不用改。

/* uart_if.h:协议层只依赖这个接口 */ typedef struct { void (*init)(uint32_t baud); int32_t (*send)(const uint8_t *data, uint16_t len); int32_t (*recv)(uint8_t *data, uint16_t len, uint32_t timeout_ms); void (*start_recv_int)(uint8_t *buf, uint16_t len); } uart_if_t; extern const uart_if_t uart_if_std; extern const uart_if_t uart_if_hal;

标准库实现:

static int32_t uart_std_send(const uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { while (USART_GetFlagStatus(UART_DEV, USART_FLAG_TXE) == RESET); USART_SendData(UART_DEV, data[i]); } while (USART_GetFlagStatus(UART_DEV, USART_FLAG_TC) == RESET); return 0; } const uart_if_t uart_if_std = { uart_std_init, uart_std_send, uart_std_recv, uart_std_start_recv_int, };

HAL库实现:

static int32_t uart_hal_send(const uint8_t *data, uint16_t len) { HAL_StatusTypeDef status = HAL_UART_Transmit(&huart_dev, (uint8_t *)data, len, 100); return status == HAL_OK ? 0 : -1; } const uart_if_t uart_if_hal = { uart_hal_init, uart_hal_send, uart_hal_recv, uart_hal_start_recv_int, };

上层写帧协议时只调uart_if_hal.send(...)或uart_if_std.send(...),换芯片、换库都只换接口绑定的实现。工程里加uart_if_std.c还是uart_if_hal.c由编译条件决定,协议层永远不include任何库头文件,这样从F1迁F4时工作量就落在两个C文件里,而不会蔓延到所有业务代码。

对应的验证口诀就三句话:先量TX波形,再算BRR,最后盯SR/ISR标志位。波形没有先查GPIO和时钟;波形有了但对方收不对,按PCLK反推实际波特率算误差;都不行就挂调试器看标志位,TXE卡住说明发送没完成,RXNE不置位说明根本没收到,ORE置位说明溢出。我接手串口通信项目时,第一件事永远是把“串口挂在哪个总线、引脚AF几号、目标波特率误差多少”写进代码注释里,三个月后看这段注释比什么文档都顶用。希望这套思路和代码能帮你也少走几次弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询