简介:这是一份面向STM32F429开发者的串口通信工程示例,基于标准外设库实现UART/USART收发,覆盖GPIO复用配置、USART结构体初始化、阻塞/中断收发、错误回调以及DMA配合等常用环节,适合正在学习STM32F4库函数开发或需要快速搭建串口调试框架的初学者与项目开发者。压缩包共295个文件,约17.11MB,包含完整Keil工程源码(.c/.h)、编译产物(.o/.axf/.map/.lst)、依赖与浏览信息文件(.d/.crf)以及工程配置(.uvprojx/.uvoptx/.sct),可直接打开运行或参考移植。目前已有1029人浏览学习。其中除了核心串口代码外,还附带标准外设库各模块源文件、启动文件与清理脚本,便于梳理串口通信的完整调用链,同时便于理解库函数封装层次。作者在描述中表示可答疑并提供外包支持,对初学者尤为实用。
1. 串口通信在F429上的第一道坎:标准库配好的外设,上电却不是那么回事
调试F429开发板串口的人,十有八九会撞上这种情况:程序烧进去了,调试助手界面也打开了,按一下发送,回显要么是乱码,要么是断断续续的半个帧。这不是硬件玄学,大部分原因是时钟树、引脚复用和波特率配置在初始化阶段就没对齐。这份工程把USART1的标准库配置整理成一套可以直接编译烧录的代码,PA9/PA10做收发,轮询发送、查询接收、中断接收三套写法都排好了,烧进去就能在调试助手上看到回显。适合刚接触F4平台还沿用标准库的开发者,也适合想快速定位串口乱码的熟手当底层模板。
2. 时钟树与引脚复用:先让外设时钟和AF选项对上
串口的初始化顺序,理论上没有严格规定,但实际调试里,先配时钟再配引脚这条线不能乱。F429的时钟树是两级总线结构:GPIO挂在AHB1上,USART1挂在APB2上,USART2/3/4/5挂在APB1上。代码里第一步要打开GPIOA的AHB1时钟,第二步打开USART1的APB2时钟,顺序反了,GPIO寄存器读出来可能全是0xFF,甚至直接触发HardFault。
2.1 两级总线的差异:APB1与APB2不是一回事
F429系统主频到180MHz时,高速外设总线APB2能到90MHz,低速的APB1只有45MHz。USART1和USART6挂在APB2上,USART2到USART5挂在APB1上,这个分布不是随手写的,它决定了你能用的最高波特率档位。
| 外设 | 所在总线 | 最高频率 | 对串口的影响 |
|---|---|---|---|
| USART1 / USART6 | APB2 | 90MHz | 高波特率优先选这两路 |
| USART2 / USART3 / USART4 / USART5 | APB1 | 45MHz | 波特率高了BRR量化误差明显 |
| GPIOA 至 GPIOK | AHB1 | 180MHz | 复用前必须开对应组时钟 |
有个容易踩的盲区:USART2虽然是串口,但它在APB1上,有人照抄USART1的初始化,用RCC_APB2PeriphClockCmd去使能USART2时钟。标准库不会报错,因为RCC_APB2Periph_USART2这个位根本不存在,你的USART2寄存器完全没被使能,读出来的状态全是复位值。要么外设时钟使能失败,要么等到天荒地老中断也不来。F429这块芯片的串口资源多,但不代表每个串口的待遇一样,挂APB1的那几个,跑个19200倒是无所谓,想上921600就得掂量掂量了。
2.2 引脚复用AF7:GPIO_Mode_AF之后还有一步
很多人写串口初始化时,把GPIO配置成GPIO_Mode_OUT就想当然觉得能用,结果发现引脚有电平翻转,但对端收到的全是乱码。原因在F4的多路复用架构:每个引脚可以映射多个外设功能,你必须明确告诉引脚控制器,我现在需要USART1而不是别的外设挂在这个引脚上。这一步叫AF选择,USART1对应的是AF7,代码里写作GPIO_AF_USART1。
void UART_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 先开两个时钟:GPIOA挂在AHB1,USART1挂在APB2 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); /* PA9 = USART1_TX, PA10 = USART1_RX */ 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); /* 关键的第二行:把PA9、PA10切换到USART1的AF7功能 */ GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); }两组GPIO_PinAFConfig才是真正让引脚挂到USART1上的动作。GPIO_AF_USART1是头文件里定义好的枚举值,字面是7,对应F429参考手册里的AF7复用表。逻辑上分两步理解:GPIO_Init把引脚模式改成复用模式,告诉引脚控制器“这个引脚不归GPIO模块管了”;GPIO_PinAFConfig则把引脚的复用编号写进AFRL/AFRH寄存器,告诉引脚控制器“具体转接给哪个外设”。
参数上我一般都用GPIO_Speed_50MHz而不是100MHz,因为UART是低速串行协议,上升沿太陡反而增加振铃,50MHz是信号完整性和功耗的平衡点。GPIO_PuPd_UP的理由是UART空闲电平是高电平,引脚悬空时上拉能避免RX端误触发起始位,让RXNE出现无意义的置位。GPIO_OType保持推挽输出就行,别为了省一个上拉电阻去开漏,后面你会为电平匹配问题多接一根飞线。
3. USART外设初始化:波特率、字长与使能顺序
时钟和引脚打开之后,轮到USART外设本身的配置。这里要用到USART_InitTypeDef结构体,描述串口通信协议的一组属性。工程里的默认参数是115200、8位数据、无校验、1位停止位,这也是电脑端串口调试助手最常见的默认组合。
3.1 协议参数选型:8N1不是唯一选择
void UART_Config(uint32_t baud) { USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = baud; 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); }8N1的意思是8个数据位、无校验(None)、1个停止位,这是异步串口最常见的形式。但不是所有场景都该用8N1,比如连接Modbus总线设备,经常要求8E1,即偶校验加1位停止位。这时把USART_Parity改成USART_Parity_Even即可,F4标准库里校验位会占用数据帧的第9位,实际有效数据仍然8位。有一点要注意:如果你校验位开了偶数校验,结构体里的USART_WordLength仍然写USART_WordLength_8b,库函数会自动把字长调整成带校验位的9位,这个逻辑藏在源码里,不理解的人容易在配置好后发现接收到的字节高一位总是异常。
USART_Mode_Rx | USART_Mode_Tx同时开了收发两个方向。不少顺手抄的代码只开TX,结果回显测试时发现主机永远收不到数据。USART_HardwareFlowControl_None是禁掉硬件流控,四线制串口才需要RTS/CTS,没有接那两根线就打开流控,数据会一直卡在对方发不出来的状态。
3.2 波特率误差:为什么减半进位在这里是分水岭
USART_Init内部会用PCLK2频率和你的目标波特率计算BRR寄存器值。BRR = PCLK2 / baud,标准库做了余数四舍五入的处理,即加上baud/2再整除。理论上所有波特率都能配,但误差不是线性的。
| 目标波特率 | PCLK2=90MHz时BRR | 实际波特率 | 误差 |
|---|---|---|---|
| 9600 | 9375 | 9600.0 | 0.00% |
| 115200 | 781 | 115239.4 | 0.03% |
| 1843200 | 48 | 1836734.7 | 0.35% |
| 3000000 | 30 | 3000000 | 0.00% |
从表里能看到,大部分常用波特率误差都在1%以内,UART的容错范围通常在±2%左右,问题不大。真正的坑发生在高波特率配合非整数分频系数的时候。以250000为目标时,BRR = 90M / 250000 = 360,没误差没问题;但有些人会用2000000,BRR=45,实际波特率正好2M,也没问题。问题是那些除不尽的目标,例如1843200这种带有设备厂商烙印的速率,误差已经到了0.35%,在长线上来回反射后误码率会明显上升。
我一般会避免使用非标准波特率,直接选时钟能整除的值。如果你对端设备固定死了某个特殊波特率,优先考虑用USART_OverSampling_8把16倍过采样降到8倍,BRR变成之前的整数误差会摊薄一半。这个参数在USART_InitStructure结构体里没有,需要在调用USART_Init之后单独调用USART_OverSamplingCmd(USART1, USART_OverSampling_8),小心别漏。
3.3 使能顺序:USART_Cmd放最后不是形式主义
工程里的初始化顺序是固定的:先GPIO后USART,USART_Init写完结构体后最后才USART_Cmd使能外设。顺序反过来会出什么现象?外设一使能,移位寄存器就开始工作,而此刻BRR可能还是复位值0,波特率被当成无穷大,引脚上的电平会闪一下异常波形。如果有对端连着,这一瞬间就可能被识别成帧起始。
另一个容易被忽略的状态:上电复位后DR寄存器里可能残留上次调试暂停时的数据。修复的方法是给串口模块做一个复位动作——读一次SR寄存器,再读一次DR寄存器。标准库的USART_ClearITPendingBit在F4上并不总能把所有残留清干净,最可靠的方式是手动读这两次。这个习惯我后面每次换新板子都保留着。
4. 数据收发:轮询、查询与中断三种写法
一个真正能跑的串口工程,除了初始化,还得解决“怎么把数据发出去”和“怎么把数据收进来”这两个问题。这份工程里三套代码对应三个使用阶段:刚开始验证用轮询,简单交互用查询,做正式产品就用中断加缓冲。
4.1 轮询发送的TXE与TC细节
void UART_SendByte(uint8_t byte) { /* TXE置1表示发送数据寄存器已空,可以写入下一个字节 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, byte); } void UART_SendString(char *str) { while (*str) UART_SendByte((uint8_t)*str++); }TXE是发送数据寄存器空标志,代表CPU可以把数据写进外设的发送缓冲区,但此时数据不一定已经出现在引脚上。还有另一个标志位TC,表示整个移位寄存器把数据帧完全送出、引脚回到空闲状态。轮询发送一串数据时用TXE就够了,TC反而不是合适的判断条件——如果每发一个字节都等TC,串口速率会被拉低,因为TC要等整个帧发送完成才置位。但发第一个字节前,我建议先等一次TC,避免外设刚使能时的链路抖动吞掉首字节。
4.2 查询接收的卡死问题
uint8_t UART_ReceiveByte(void) { /* 等待RXNE,表示接收数据寄存器非空 */ while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET); return (uint8_t)USART_ReceiveData(USART1); }这段代码逻辑没问题,但工程里绝不建议在裸机主循环里直接调用它。原因是没做超时保护,如果对端不回应,程序会无限卡死在while循环里。调试时你会发现按了暂停键,PC指针正好停在USART_GetFlagStatus那一行,看起来像死机,实际是死等。
我通常在工程里给查询接收套一个超时窗口:用一个全局变量做标志,SysTick每毫秒减一次,进入UART_ReceiveByte时先把这个标志设置成200,循环等待时每个周期查一次标志,减到0就直接返回0xFF表示超时。这样既保留了查询模式的简单性,又给主循环留了退路。正式的工程里收数据会用到下一节的中断方案,查询模式只适合初始化自测和发送应答类交互。
4.3 中断接收:NVIC优先级与环形缓冲
做产品时,串口数据什么时候到是不确定的,主循环不可能一直死等。工程里用USART1的接收中断加环形缓冲区解决。
#define UART_RX_BUF_SIZE 64 static volatile uint8_t rxBuf[UART_RX_BUF_SIZE]; static volatile uint16_t rxRd = 0, rxWr = 0; void USART1_IRQHandler(void) { /* 判断RXNE中断标志,读取DR会自动清除标志 */ if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { rxBuf[rxWr] = (uint8_t)USART_ReceiveData(USART1); rxWr = (rxWr + 1) % UART_RX_BUF_SIZE; } }中断初始化代码:
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); 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);USART_ReceiveData一次读取会同时把RXNE硬件标志清零,千万别画蛇添足在读取后手动清标志,标准库的某些清零函数反而会把下一次中断触发条件弄乱。环形缓冲的读写指针是分离的:rxWr只由中断服务函数写,rxRd只由主循环读,两者相等时说明缓冲为空。这个结构足够应对连续几帧数据的接收压力,前提是主循环读取频率要高于最坏情况下的填充速度,否则缓冲区溢出后新数据会覆盖旧数据。
4.4 主循环取数据:超时判帧
中断负责把字节塞进缓冲,主循环负责把缓冲里的字节按帧取出来。最常见的取帧策略是超时判定:同一个串口的相邻字节间隔如果超过3到5个字节时间,就认为一帧结束。工程里用SysTick实现一个简单的帧超时计数:
if (rxRd != rxWr) { while (rxRd != rxWr) { uint8_t ch = rxBuf[rxRd]; rxRd = (rxRd + 1) % UART_RX_BUF_SIZE; frameBuf[frameLen++] = ch; } if (frameLen > 0) { /* 在这里处理一帧完整数据 */ UART_SendString((char *)frameBuf); frameLen = 0; } }这个设计适合变长协议,每条指令长度不同,用固定长度判帧反而麻烦。注意帧缓冲的数组也要做边界保护,如果传输内容超过缓冲长度,要么截断要么报错,裸数组越界写是很隐蔽的内存踩踏。
5. 串口配置避坑合辑:现象、原因与处置顺序
标准库串口的坑,多半不在C语法层面,而在芯片配置和外部环境的匹配。整理了五条最常遇到的问题,每条都按现象到原因再到解决展开。
5.1 换板子后全部乱码
现象:工程在原来那块F429板上能正常收发,换成另一块F429板子后,所有波特率下的串口助手都显示乱码,示波器看TX引脚有波形跳动,但完全没有可辨识的帧结构。
原因:F429的外部晶振变了。原来板子用25MHz晶振,新板子用8MHz,但代码里的HSE_VALUE还写着25MHz,SystemInit计算PLL参数时把8MHz当25MHz乘,整个系统主频跑偏,PCLK2不再是90MHz,BRR寄存器算出来的波特率自然不对。
解决:先看新板子晶振丝印,确认频率后修改stm32f4xx.h里的HSE_VALUE宏,再核对SystemInit里PLL_M、PLL_N、PLL_P这三个参数。修改后建议用示波器量一下MCO引脚输出的时钟信号验证。F429的PA8可以复用成MCO1,输出内部时钟,实测频率和预期一致再烧程序验证串口。
5.2 TX引脚有电平变化但字节内容错乱
现象:发送时用示波器量PA9,能看到高低电平翻转,但接上USB转串口工具后,助手显示的内容和发送内容完全对不上,甚至每个字节都重复出现相同错误。
原因:GPIO错配成GPIO_Mode_OUT而不是GPIO_Mode_AF。普通输出模式下引脚被GPIO模块接管,USART外设的数据根本没路由到引脚上,此时看到的电平可能是GPIO初始化时的静态电平,或者是代码里附加的翻转操作,不是协议波形。
解决:把GPIO_InitStructure.GPIO_Mode改成GPIO_Mode_AF,然后补上GPIO_PinAFConfig那两行。配置完检查一下复用功能切换寄存器AFRL/AFRH,如果AF编号写成别的值,比如把AF7误写成AF8,引脚会被接到另一个外设上去,现象和这类似。
5.3 上电后第一个字节丢失
现象:程序启动后第一次调用UART_SendString,对端收不到第一个字符,从第二个字符开始一切正常。这个现象在低速9600波特率时尤其明显。
原因:USART_Cmd使能外设后,发送路径还没稳定。有些情况是GPIO从复位状态切换到复用状态时,引脚电平有一个短暂的过渡期;更多情况是代码没有等待发送状态机就绪就直接写DR寄存器,第一个字节被状态机的初始化过程吞掉。
解决:发送第一个字节前强制等待TC标志置位,让移位寄存器完全空闲后再连续发送:
void UART_SendStringSafe(char *str) { /* 首字节前等待TC,确保发送状态机完全空闲 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); while (*str) UART_SendByte((uint8_t)*str++); }从那以后我把发送函数的入口统一改成先等TC再发,虽然损失了TXE轮询的一点实时性,但换来了每次上电行为一致。
5.4 中断接收丢帧:两个字节只收到一个
现象:主机连续发0x01 0x02两个字节,中断里只在缓冲里收到0x01。把波特率调低后又能收到两个,看起来跟速率有关。
原因:两个字节间隔过短时,RXNE标志在第二个字节到达时自动被新数据覆盖,而中断服务函数如果执行时间太长,比如在里面做了打印、延时,第二个字节的RXNE标志可能来不及处理就被硬件再次置位覆盖。另一个常见元凶是NVIC优先级配置不合理,如果其他外设中断频繁抢占串口中断,RXNE在等待期间被连续两次触发,最终只保存了最后一次状态。
解决:中断服务函数里只做一件事,读数据放进环形缓冲,任何协议解析或打印都移到主循环。NVIC优先级上,把串口中断抢占优先级设得比其它外设高,例如抢占优先级1,其它外设从2开始排;同抢占优先级内不再用子优先级区分,避免嵌套深度失控。
5.5 高波特率死活对不上
现象:把波特率设成1843200或3M时,收发双方配置一致,但传输过程频繁误码,几个KB数据里总有几个字节是错的。降到115200一切正常。
原因:BRR寄存器是整数,PCLK2除以波特率的结果如果不能整除,余数会让实际波特率偏离目标,高波特率下位周期缩短,同一个百分比的绝对误差对应的时间偏差以纳秒计,采样点刚好落在信号边沿附近。
解决:优先选择PCLK2能整除的波特率,例如90MHz下选115200、3000000、4500000这类有明显整除关系的值。如果目标波特率无法整除,调用USART_OverSamplingCmd切换到8倍过采样,量化步长从1/16位缩短到1/8位,误差直接减半。硬件上TX线串一个33Ω电阻抑制振铃,USB转串口工具选带独立晶振的,少用内部RC振荡器的廉价方案。
排查顺序建议:先查HSE_VALUE,再量MCO,然后软件里打印PCLK2频率确认,最后才检查USART_Init参数,这条链路能处理九成以上串口异常。6. 收尾技巧:printf重定向与验证路径
工程里最后一层是调试便利性。很多开发者发现裸机工程里调用printf没有输出,或者输出一段后进入奇怪的卡死状态,这和标准库的半主机模式有关。串口打印调试信息是刚需,解决办法是重定向fputc到USART1。
int fputc(int ch, FILE *f) { /* 和轮询发送一样,等TC保证字节真正发完 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }加了这段代码,还需要在IDE里启用微库选项。MDK环境下把Use MicroLIB打钩,IAR里对应配置是允许C库使用底层I/O重定向。否则标准库的fputc默认打进半主机机制,程序会在底层暂停等待调试器接管,于是你看到的现象就是第一行输出正常,第二行开始卡住。重定向完成后,printf就能和普通调试助手顺畅交互,格式化输出浮点数之类的工作也都能正常跑。
验证路径上我有一套固定的动作:先接好USB转串口工具,确认接线无误,给板子上电,串口助手设好波特率115200和8N1,发送一个单字节0x55,看回显是否一致;然后发送一串文本验证连续发送;最后把波特率提到460800,观察是否出现断帧。如果全程稳定,再继续往上压速率。
这个工程虽然小,但我每次换新板子重写串口底层的习惯是固定的:先查外部晶振,再量MCO确认PCLK频率,然后才是USART参数,最后用0x55单字节过一遍链路。从那以后我在串口上翻车的次数少了很多,多数问题都能在十分钟内定位。希望帮到你。
本文还有配套的精品资源,点击获取