简介:这份资源面向使用STM32F429进行嵌入式开发的初学者与工程师,聚焦标准库下的串口通信实现,帮助读者解决GPIO复用配置、USART初始化、数据收发与错误处理等常见问题。压缩包共295个文件,约17.11MB,包含45个c源文件与46个h头文件,覆盖stm32f4xx_tim、rcc、rtc、adc、flash、fmc、can等外设驱动,另有62个o目标文件、61个crf交叉引用文件及axf、map、lst等编译产物,并附带uvprojx工程、sct分散加载与bat脚本,便于直接编译验证。资源围绕USART1的PA9、PA10引脚配置展开,涉及波特率、数据位、停止位、校验位设置,以及阻塞与中断两种收发方式、DMA传输和HAL_UART_ErrorCallback错误回调等要点,可帮助读者掌握帧错误、校验错误与溢出错误的排查思路。目前已有1029人学习下载,适合作为串口通信调试与项目移植的参考工程。
1. 从寄存器到中断:为什么标准库串口通信依然是绕不开的基本功
很多人第一次在 STM32F429 上点亮 LED 之后,紧接着要啃的就是串口通信。原因很直接:没有串口,你连程序跑没跑起来都不知道,调试基本靠猜。标准库虽然这几年被 HAL 库抢了不少风头,但在很多存量项目、教学场景和对执行效率有要求的场合,它依然是首选。这套资源围绕 STM32F429 标准库串口通信展开,覆盖从 GPIO 复用配置、USART 初始化、中断收发到环形缓冲区管理的完整链路。适合刚接触 F429 的嵌入式新手,也适合从 F103 迁移过来、想搞清楚 F429 时钟树和串口差异的老手。串口通信看着简单,但真要做到稳定收发不丢包,里面有不少细节值得掰开揉碎讲。
2. 标准库串口通信的底层逻辑与工程搭建
2.1 F429 的 USART 外设和 F103 有什么本质区别
STM32F429 的 USART 外设相比 F103 系列,最明显的变化是时钟源和引脚复用的复杂度上了一个台阶。F103 的 USART1 挂在 APB2 上,配置起来相对直接;F429 的 USART1 和 USART6 挂在 APB2,USART2/3 挂在 APB1,但 APB1 和 APB2 的时钟频率在 F429 上可以配到很高,比如 APB2 跑 90MHz、APB1 跑 45MHz,这就意味着波特率计算时的分频系数跟 F103 完全不是一回事。
另一个容易翻车的地方是 GPIO 复用功能。F103 上配置 USART 引脚只需要把 GPIO 模式设为复用推挽,再使能外设时钟就行。F429 多了 GPIO 复用功能选择寄存器(GPIO_AFRL 和 GPIO_AFRH),你必须明确把对应引脚复用成 USART 功能,否则引脚就是普通 IO,串口死活不出波形。我见过不止一个从 F103 转过来的开发者卡在这里,查了半天代码觉得没问题,其实就是少配了一步 GPIO_PinAFConfig。
F429 的 USART 还支持更高的波特率,理论上可以到 fPCLK/8,实际项目中跑到 921600 甚至更高都没问题。但高波特率下对时钟精度的要求也更高,如果外部晶振选的是 8MHz 而 PLL 配置没算准,高波特率下误码率会明显上升。
2.2 用标准库搭一个可复用的串口工程骨架
先看工程目录结构。我一般会这样组织:
Project/ ├── CMSIS/ # 内核相关文件 ├── FWLIB/ │ ├── inc/ # 标准库头文件 │ └── src/ # 标准库源文件 ├── User/ │ ├── main.c │ ├── stm32f4xx_conf.h │ ├── usart.c # 串口配置 │ └── usart.h └── Output/ # 编译输出标准库的移植步骤这里不展开,重点看串口配置部分。下面是一个 USART1 的初始化函数,波特率 115200,8 位数据位,1 位停止位,无校验:
#include "stm32f4xx.h" #include "usart.h" void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 使能 GPIOA 和 USART1 时钟 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); /* 将 PA9 和 PA10 复用为 USART1 */ GPIO_PinAFConfig(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); /* 配置 PA9 为复用推挽输出(TX) */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 配置 PA10 为浮空输入(RX) */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, &GPIO_InitStructure); /* USART1 参数配置 */ USART_InitStructure.USART_BaudRate = baudrate; 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_ITConfig(USART1, USART_IT_RXNE, ENABLE); /* 配置 NVIC */ NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); /* 使能 USART1 */ USART_Cmd(USART1, ENABLE); }这段代码里有几个关键点需要说明。GPIO_PinAFConfig是 F4 系列特有的复用配置函数,第一个参数是 GPIO 端口,第二个是引脚编号,第三个是复用功能编号。PA9 和 PA10 对应 USART1 的 AF7,标准库里已经用GPIO_AF_USART1这个宏定义好了。
GPIO 模式这里 TX 配成复用推挽、RX 配成复用浮空,这是常见做法。有人会把 RX 配成上拉输入,短距离通信没问题,但长距离或者有干扰的环境下,上拉可能影响电平判决,建议还是按手册推荐来。
NVIC 的优先级分组需要在 main 函数开头设置一次,比如NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2),这样抢占优先级和响应优先级各占 2 位。如果串口中断优先级设得太低,在高频中断场景下可能丢数据。
2.3 中断接收和环形缓冲区的配合方式
直接在中断里处理数据是大忌。中断服务函数应该尽量短,只做数据搬运,把解析逻辑放到主循环里。下面是一个典型的环形缓冲区实现:
#define RX_BUFFER_SIZE 256 typedef struct { uint8_t buffer[RX_BUFFER_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuffer; static RingBuffer rxBuf = { .head = 0, .tail = 0 }; /* 中断服务函数 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); uint16_t next = (rxBuf.head + 1) % RX_BUFFER_SIZE; if (next != rxBuf.tail) { /* 缓冲区未满 */ rxBuf.buffer[rxBuf.head] = data; rxBuf.head = next; } /* 缓冲区满则丢弃,也可以在这里置溢出标志 */ USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } /* 主循环中读取 */ uint16_t RingBuffer_Available(void) { return (rxBuf.head - rxBuf.tail + RX_BUFFER_SIZE) % RX_BUFFER_SIZE; } uint8_t RingBuffer_Read(void) { uint8_t data = rxBuf.buffer[rxBuf.tail]; rxBuf.tail = (rxBuf.tail + 1) % RX_BUFFER_SIZE; return data; }head 和 tail 都加了 volatile 修饰,因为它们在中断和主循环中都会被访问,不加的话编译器优化可能出玄学问题。缓冲区大小取 2 的幂次可以优化取模运算,但 256 本身也不大,直接用 % 运算可读性更好。
判断缓冲区满的条件是(head + 1) % SIZE == tail,这样会浪费一个字节的空间,但换来了无锁设计的简洁性。如果对空间极度敏感,可以用额外的标志位来区分满和空,但代码复杂度会上升。
3. 串口通信的避坑与常见问题排查
3.1 现象:串口助手收到乱码
原因通常有三个:波特率不匹配、时钟配置错误、或者 GPIO 复用没配对。先确认串口助手和代码里的波特率一致,然后检查 SystemInit 里 PLL 的配置,特别是 HSE 的值。如果用的是 8MHz 晶振但代码里按 25MHz 算,系统时钟就是错的,波特率自然也对不上。最后确认 GPIO_PinAFConfig 有没有调用,这个漏了的话引脚根本不在 USART 模式。
3.2 现象:能发不能收,或者能收不能发
先看硬件接线,TX 和 RX 有没有交叉接。然后检查 GPIO 模式,TX 要配复用推挽,RX 要配复用输入。如果用的是中断接收,确认 NVIC 配置和 USART_IT_RXNE 使能有没有漏。还有一种情况是 USART_Cmd 在中断配置之前就调用了,导致第一个字节丢失,建议先配置完再使能 USART。
3.3 现象:高波特率下丢数据
F429 在 115200 下一般很稳,但跑到 921600 以上时,如果中断优先级太低或者中断里处理时间太长,就会丢字节。解决办法是提高串口中断的抢占优先级,同时确保中断服务函数里只做最少的操作。另外检查 APB 时钟频率,如果 APB2 只有 45MHz 而你要跑 921600,分频系数会很小,误差可能超过 3%,这时候要么提高 APB2 时钟,要么换用支持更高时钟的 USART。
3.4 现象:程序跑一段时间后串口无响应
大概率是缓冲区溢出或者中断标志没清。检查环形缓冲区的满判断逻辑,如果 head 和 tail 的类型是 uint8_t 而缓冲区大小是 256,取模运算会出问题。另外确认 USART_ClearITPendingBit 有没有调用,虽然读取 USART_ReceiveData 会自动清 RXNE 标志,但显式清除更保险。还有一种可能是中断里发生了硬件错误,比如访问了非法地址,导致程序跑飞,这种情况需要结合 HardFault 调试。
3.5 现象:用 printf 重定向后程序卡死
printf 重定向到串口时,如果用的是阻塞发送,而串口初始化还没完成就调用了 printf,程序会卡在等待 TXE 标志的死循环里。解决办法是在 printf 之前确保串口已经初始化,或者在重定向函数里加超时退出机制。另外注意 microlib 和标准库的 printf 实现差异,microlib 不支持浮点数打印,如果需要打印浮点,得用标准库或者自己实现格式化。
4. 进阶技巧:用 DMA 把 CPU 从串口收发里解放出来
中断收发在低波特率下够用,但如果你要连续接收大量数据,比如接一个 GPS 模块或者和另一个 MCU 高速通信,中断频繁触发会吃掉不少 CPU 时间。这时候 DMA 就是更优解。F429 的 USART1 支持 DMA 收发,配置思路是:接收用 DMA 循环模式,发送用 DMA 正常模式。
先看接收端的 DMA 配置:
void USART1_DMA_Rx_Init(void) { DMA_InitTypeDef DMA_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_InitStructure.DMA_Channel = DMA_Channel_4; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)rxDmaBuffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize = RX_DMA_BUFFER_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_Init(DMA2_Stream2, &DMA_InitStructure); DMA_Cmd(DMA2_Stream2, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); }这里用的是 DMA2 Stream2 Channel4,对应 USART1 的接收请求。循环模式下,DMA 会自动把接收到的数据依次填入 rxDmaBuffer,填满后从头覆盖。主循环里通过读取 DMA_GetCurrDataCounter 来获取当前接收进度,或者用空闲中断来判断一帧数据结束。
空闲中断的用法是:在串口初始化时使能 IDLE 中断,当总线空闲时触发中断,此时读取 DMA 剩余计数就能算出这一帧收了多少字节。这个方案在 Modbus、GPS 解析等场景下非常实用。
发送端用 DMA 就简单多了,把数据填到发送缓冲区,配置 DMA 正常模式,启动传输,等传输完成中断里置个标志就行。注意发送缓冲区在传输完成前不能被修改,否则会发出错误数据。
一个容易忽略的点是 DMA 和中断的优先级配合。如果 DMA 传输完成中断和串口接收中断同时触发,优先级配置不当可能导致数据竞争。我一般会把 DMA 中断优先级设得比串口中断低一级,确保串口接收不会因为 DMA 中断而延迟。
从那以后我每次配置串口 DMA 都会先确认三件事:DMA 流和通道有没有选对、外设地址和内存地址有没有搞反、循环模式和正常模式有没有用错。这三条踩过任何一条,调试起来都是血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取