简介:STM32F429基于Cortex-M4内核,串口通信是嵌入式开发中最基础也最常用的功能;该压缩包正是一套完整的ST标准库串口通信工程例程,面向STM32F4初学者,提供可直接编译运行的参考项目。包内共295个文件、约17.11MB,包含45个c源文件与46个h头文件,覆盖USART、GPIO、DMA等外设驱动,配套uvprojx工程可直接在Keil中打开,另有o、axf、map、lst等编译调试文件。已有1029人学习下载;例程完整展示从GPIO复用配置、USART初始化到数据收发与中断处理的流程,代码结构清晰、注释完整,便于修改波特率与通信协议,适配实际项目或毕设,也可帮助理解阻塞、中断与DMA传输差异。
1. 串口通信调试翻车点太多:直接跑这份F429标准库工程,USART收发一次打通
“串口通信”在嵌入式开发里看着最简单,翻车率却一直不低。最近调一块电机驱动板,上位机每 100ms 发一帧速度指令,板子要么没回应,要么在串口助手里显示乱码。用逻辑分析仪抓波形才发现 TX 引脚有信号但波特率对不上——问题根本不在协议,而在时钟配置。这种排查最耗时间,因为乱码的锅可能在时钟、引脚复用、中断优先级、标志位等待任何一个环节。
这份基于 STM32F429 标准库的串口通信工程,把 USART1 从 GPIO 复用、参数配置到中断接收、printf 重定向的完整链路全部打通,烧录后串口助手发什么板子回什么。适合刚上手 F429 开发板、想在裸机工程里用标准库做串口收发的开发者,也适合在 HAL 库和标准库之间来回切换、想对比底层寄存器写法的熟手。后面按工程结构、核心代码、避坑、跑通和二次改造的顺序讲,每章都能直接照着操作。
2. 工程目录与时钟配置:标准库项目骨架先搭明白
2.1 标准库工程目录:每个文件夹管什么
拿到一份标准库工程,第一步不是急着编译,而是看清目录。标准库的特点是“外设驱动自己写”,代码分层比 HAL 工程更直观,但代价是没人帮你管引脚复用和时钟总线。这份工程的目录结构如下:
F429_USART/ ├── USER/ # 用户代码 │ ├── main.c # 主循环入口 │ ├── usart.c # USART 驱动(GPIO、初始化、中断) │ ├── usart.h # 头文件与外部接口声明 │ └── stm32f4xx_it.c # 中断服务函数入口 ├── SYSTEM/ # 系统组件:延时与时钟辅助 │ ├── delay.c │ └── sys.c ├── CORE/ # 启动文件与内核头文件(core_cm4.h 等) └── STM32F4xx_FWLIB/ # 标准库源码 ├── inc/ # stm32f4xx_usart.h、stm32f4xx_gpio.h 等 └── src/ # 对应 .c 实现main.c 负责调用外设初始化并进入主循环;usart.c 把 USART1 的 GPIO 配置、USART 参数配置、中断处理封装成独立函数。我习惯把中断函数也挪到 usart.c 里,改串口逻辑时不用在两个文件之间来回跳。CORE 和 STM32F4xx_FWLIB 目录基本不用动,真正要改的只有 USER 里的代码。
这种分层有一个实际好处:换芯片型号时保留 SYSTEM 和 USER 的代码,只换 CORE 和 FWLIB 的库文件,适配成本会低很多。如果你拿到这份工程想换个串口,比如从 USART1 换到 USART3,只需要在 usart.c 里改三个位置:GPIO 引脚号、外设时钟使能函数、AF 编号。其他逻辑不用动。
2.2 时钟树配置:USART1 为什么要挂在 APB2 上
STM32F429 的时钟系统分 AHB1、APB1、APB2 三级。USART1 和 USART6 挂在 APB2 上,USART2 到 USART5 挂在 APB1 上。工程默认配置是系统时钟 180MHz,APB1 分频后 45MHz,APB2 分频后 90MHz。这组频率直接影响 USART_Init 内部计算波特率分频值 BRR。
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); // GPIOA 挂在 AHB1 上 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // USART1 挂在 APB2 上使能外设时钟这行代码有个容易踩的坑:串口号变了,总线也要变。用 USART2 时要把 RCC_APB2PeriphClockCmd 改成 RCC_APB1PeriphClockCmd,否则外设时钟根本没打开,寄存器的写入全部无效。标准库不会提醒你这件事,表现就是初始化代码执行完,示波器上 TX 引脚毫无反应。
另一个常见问题是 SystemCoreClock 的值与实际不符。如果工程里有其他地方手动改过 PLL 配置,但 SystemCoreClock 全局变量没更新,USART_Init 就会按错误的时钟频率计算 BRR。调试时如果发现实际波特率与预期不一致,先检查 SystemCoreClock 是否为 180000000。我一般在 main 函数入口调用 SystemCoreClockUpdate() 强制刷新一次,避免这个隐患。
2.3 为什么用标准库:和 HAL 库的排查逻辑差异
不少开发者习惯 HAL 库的初始化结构体,但标准库在排查底层问题时有独特优势。HAL 库的 HAL_UART_Init、HAL_UART_Receive 内部做了大量状态管理和超时判断,调用链长,出问题时先要拆封装;标准库的 USART_Init 直接把 BRR、CR1、CR2 寄存器写死,数据手册对应关系一目了然,适合裸机开发和调试。
代价是标准库不管引脚复用。HAL 库里一个 GPIO_Init 结构体就能同时完成模式、速度、上下拉配置,标准库必须额外调用 GPIO_PinAFConfig 指定复用功能编号。我在工程里把两步拆成两个函数:先配 GPIO,再配 USART,逻辑顺序和数据手册章节一致,也方便在初始化前后打断点看寄存器变化。
3. 核心代码走读:GPIO 配置、USART 初始化与中断接收链路
3.1 GPIO 复用初始化:引脚模式与 AF 编号
USART1 的默认引脚是 PA9(TX)和 PA10(RX)。在 F429 上 GPIO 挂在 AHB1 总线上,所以时钟使能用 RCC_AHB1PeriphClockCmd。初始化代码:
void USART1_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); 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(GPIOA, GPIO_PinSource9, GPIO_AF_USART1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource10, GPIO_AF_USART1); }GPIO_Mode_AF 是关键:它把引脚从普通 IO 切换到外设复用模式,此时引脚方向由 USART 外设控制,CPU 不能直接读它的电平。GPIO_Speed_50MHz 在 115200 波特率下绰绰有余,如果短接导线过长或对 EMI 敏感,可以适当降到 25MHz 减少边沿噪声。GPIO_OType_PP 推挽输出保证引脚能主动拉高拉低,空闲时依靠 GPIO_PuPd_UP 保持高电平,正好符合 UART 协议的空闲态。
GPIO_PinAFConfig 是最容易被漏掉的一步。PA9 和 PA10 的 USART1 复用编号是 AF7,但换到其他串口时编号可能不同,比如 USART2 的 TX 是 PA2,AF 编号同样是 AF7,但引脚不同。找不到正确值时翻开芯片数据手册的 Alternate Function Mapping 表查一下,别凭记忆填。
3.2 USART 参数配置:波特率、字长与停止位怎么选
GPIO 初始化完成后进入 USART 外设的寄存器配置:
void USART1_Config(void) { USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = 115200; // 波特率 USART_InitStructure.USART_WordLength = USART_WordLength_8b; // 8 位数据 USART_InitStructure.USART_StopBits = USART_StopBits_1; // 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); }参数组合上,8 位数据、1 位停止位、无校验是串口助手的默认配置,也是大多数传感器的通信格式。如果对接的设备需要偶校验,把 USART_Parity 改成 USART_Parity_Even,同时注意数据位要改成 9 位(WordLength_9b 数据结构要相应调整)。USART_Mode 这里同时使能了接收和发送,调试前期不建议省,回环测试需要双向通路。
USART_Init 内部会根据 SystemCoreClock 计算 BRR 寄存器。以 90MHz 的 APB2 时钟算,115200 波特率对应的分频数是 781 左右,这个计算由库函数完成。修改波特率时只需要改 USART_BaudRate 的值,它会自动计算分频值,不用手动改写寄存器。但要注意,APB1 上的 USART2 最高只能拿到 45MHz 时钟,同样设置 460800 波特率时误差会比 APB2 上的 USART1 大,高速通信时优先用 USART1。
3.3 中断接收:从 RXNE 标志到缓冲区的完整链路
接收侧用中断而不是轮询,避免主循环被串口数据阻塞。中断入口代码:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); // 读 DR 寄存器 rx_buffer[rx_write_index++] = data; // 存入接收缓冲 USART_ClearITPendingBit(USART1, USART_IT_RXNE); // 防御性清除 } }USART_GetITStatus 检查 RXNE 标志,RXNE 置位表示接收数据寄存器非空。USART_ReceiveData 返回 DR 寄存器的低 8 位数据,这个读操作本身会清除 RXNE,ClearITPendingBit 是为了防止某些库版本里标志位残留而加的双保险。中断里只做两件事:读数据、存数组,不做协议解析,这是保证不丢字节的核心原则。
使能接收中断还需要配 NVIC:
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);USART_ITConfig 打开外设的中断源,NVIC_Init 把 USART1 中断映射到 NVIC 并使能。两个优先级这里都设为 0,单外设工程够用;如果系统里还有定时器或 DMA 中断,建议把串口的抢占优先级调低,比如设为 2,防止高频串口数据打断实时控制。还有一个隐藏坑:接收过程中如果发生 ORE(Overrun Error)标志,会被 USART_GetITStatus(USART_IT_RXNE) 遗漏,实际工程里要同时检查 USART_IT_ORE,否则连续收数据时容易丢帧。
3.4 printf 重定向:把串口变成标准输出设备
printf 重定向到串口是嵌入式调试效率提升非常大的一步。标准库的 printf 最终会调用 fputc,因此只需要重定义这个函数:
int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }TXE(Transmit Data Register Empty)标志表示发送数据寄存器为空,循环等待它清零后再写入。USART_SendData 把字符写入 DR 寄存器,硬件会负责移位输出。返回值必须是 ch,否则 printf 的返回值逻辑会异常。这个写法在 115200 波特率下输出几十个字符不会有明显的阻塞感,但如果频繁调用 printf 输出浮点数,CPU 占用会显著上升,裸机调试时输出频率要克制。
printf 重定向有一个绕不开的前提:必须处理半主机模式。标准库默认认为目标板连了调试器,通过半主机通道把字符送到宿主机,没有这个通道时就执行 BKPT 指令卡死。解决方法是加一段禁用声明:
#pragma import(__use_no_semi_hosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x = x; } void _ttywrch(int ch) { }这段代码和 fputc 一起放到 usart.c 里,keil 编译时才会走自定义的 fputc 路径。如果你看到 printf 一调用程序就失去响应,先检查是不是漏了这个声明。
4. 避坑实录:串口乱码、丢字节与 printf 卡死,一次说清楚
4.1 现象:串口助手收到乱码,逻辑分析仪显示波特率偏了
串口助手里看到乱码,第一反应是波特率配错了,但很多时候配置界面明明写的是 115200。用逻辑分析仪抓 RX 引脚的波形,测量单个 bit 的脉冲宽度,如果是 8.7 微秒左右才是真正的 115200,差得远就说明 BRR 分频值被算错了。原因通常在时钟:SystemCoreClock 全局变量和实际 PLL 配置不一致,USART_Init 按错误频率计算分频数。
解决的思路:不动 SystemInit 的 PLL 参数。在 main 入口调用 SystemCoreClockUpdate(),确认 SystemCoreClock 等于 180000000。如果改过时钟树,必须同步检查 APB1/APB2 的分频配置,保证 USART_Init 执行时挂载总线的 PCLK 频率是正确的。
4.2 现象:一帧数据收不完整,中间丢字符
中断里处理了太多事是丢字节的头号原因。USART 接收中断触发后 RXNE 置位,如果软件没有及时读走 DR 寄存器,下一个字节到达时硬件会直接覆盖旧数据。很多人在中断里写字符串拼接、协议解析、甚至调 printf,中断处理时间超过一个字节的传输时间,帧就断了。115200 波特率下一个字节传输大约 87 微秒,中断里超过这个量级就会丢。
解决的原则是让中断“快进快出”。中断服务函数里只做 USART_ReceiveData 和数组存储,几十个周期内必须退出。协议解析放到主循环的 while 里,用读写索引判断有没有新数据。如果接收频率确实很高,优先考虑 DMA 加空闲中断的方案,但标准库下我一般先保证中断及时性,再谈性能优化。
4.3 现象:printf 没有输出,程序疑似卡死在不明位置
main 里执行到 printf 后程序不再运行,调试器暂停后看到 PC 停在 BKPT 0x00 指令。这个现象的本质是半主机模式。标准库的 printf 默认通过半主机通道把字符送到调试器,目标板上没有对应的宿主环境,CPU 执行 BKPT 等待调试器响应,然后永远卡住。我在工程里加的那段 _sys_exit 和 _ttywrch 声明就是专门解决这个问题的。
如果不想加代码,还有一条捷径:在 Keil 工程配置里勾选 Use MicroLIB。MicroLIB 会把半主机逻辑整体裁剪掉,fputc 重定向直接生效。但我更推荐显式禁用半主机的方式,因为 MicroLIB 的 printf 对浮点数支持有限,如果你在串口调试里输出浮点数据,会看到格式化结果异常。禁用半主机后还能保留完整版 printf 的浮点格式化能力。
4.4 现象:发送的最后一个字节丢了,波形显示到了一半
用 USART_SendData 发一整串数据,逻辑分析仪显示最后一个字节没有完整发出去。问题出在等待标志位选错了。TXE 标志表示数据寄存器空,但它不保证移位寄存器已经完成输出。最后一字节写入 DR 后,TXE 立即置位,代码继续往下跑,而移位寄存器还在工作中。如果这时关闭串口、进入低功耗或者复位,最后一个字节就丢了。
正确做法是发送完成时等待 TC(Transmission Complete)标志:
void USART_SendString(uint8_t *str, uint16_t len) { for (uint16_t i = 0; i < len; i++) { USART_SendData(USART1, str[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); }从 TXE 到 TC 的时机差异,在多数场景下只是几微秒的差别,但它在“发送最后再断电”或“发送完立刻切换 GPIO 模式”时会直接导致数据丢失。从那以后我发完数据总会习惯性等一下 TC。
4.5 现象:TX 和 RX 交叉接反,数据完全不通
接线问题导致的通信失败,在串口调试里占相当比例。USB 转 TTL 模块的 TXD 要接板子的 RXD(PA10),RXD 接 PA9。有些模块丝印方向和开发板标注不一致,交叉接错后数据完全不回应。用万用表量 TXD 引脚电压,如果电平有跳变但通信不通,先怀疑交叉连接,再怀疑模块供电问题。
电平匹配也要注意。F429 是 3.3V 电平,USB 转 TTL 模块如果工作在 5V 跳线模式下,TXD 引脚输出高电平会是 5V,长时间运行可能损坏引脚。我用的模块带 3.3V/5V 切换跳线帽,接线完成后先用串口助手发一个字节,确认有回应后再继续调试协议内容。
5. 硬件连接、编译烧录与串口助手验证:把工程完整跑起来
5.1 硬件连接:USB 转 TTL 模块怎么接
先备齐材料:STM32F429 开发板、USB 转 TTL 模块、三根杜邦线、串口助手软件。开发板上的 PA9 和 PA10 引脚多数有丝印标注,如果不确定,用万用表蜂鸣档量引脚和板载排针之间的导通关系。接线采用交叉方式:
| 开发板引脚 | 连接方向 | USB 转 TTL 模块 |
|---|---|---|
| PA9 (USART1_TX) | → | RXD |
| PA10 (USART1_RX) | ← | TXD |
| GND | — | GND |
接线的顺序建议:先接 GND,再接 TXD/RXD。GND 必须共地,否则两边参考地不一致,接收到的电平判断会出错。USB 转 TTL 模块上的供电引脚(3.3V 或 5V)建议不要给开发板供电,只用它做电平转换,开发板单独用 USB 线供电。这个习惯能避免模块供电能力不足引入的电压跌落和重启问题。
5.2 编译与烧录:Keil 工程导入与下载配置
用 Keil MDK 打开工程,先看 Options for Target 里的 Device 是否选的是 STM32F429 系列对应的具体型号,比如 STM32F429IGT6。芯片型号选错会导致寄存器地址映射异常,最常见的问题就是外设寄存器写不进去。然后在 Utilities 或 Debug 设置里配置调试器,ST-Link 选择 SWD 模式,下载速率默认即可。
F429 使用内置 Flash,Flash Download 里要选择对应的 STM32F4xx Flash 算法。选错 Flash 算法会直接导致下载失败。工程文件路径建议放在纯英文目录下,Keil 对中文路径的兼容性不好,经常出现编译通过但下载时找不到源文件的玄学问题。编译快捷键 F7,下载快捷键 F8,下载完成后手动复位一次开发板,让程序从入口开始执行。
5.3 串口助手验证:发什么回什么
打开串口助手,选择设备管理器里对应的 COM 口,波特率设为 115200,数据位 8,停止位 1,无校验。先发送一个字母 A,观察回显是否是大写 A。如果回显正确,说明从 GPIO 到 USART 再到中断接收的整个链路都是通的。如果回显是乱码,优先检查波特率和时钟配置,再把串口助手的自动添加换行符功能关掉,Windows 下 COM 口的默认选项有时会干扰发送。
工程里 main 函数的主循环逻辑是一个回环测试:
int main(void) { USART1_GPIO_Config(); USART1_Config(); USART1_Init(); // 使能接收中断 while (1) { if (rx_index > 0) { uint8_t data = rx_buffer[--rx_index]; USART_SendData(USART1, data); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } } }中断把收到的字节存进缓冲区,主循环取出并原样发送,串口助手上就能看到回显。这个回环逻辑是验证通路最直接的方法。跑通之后再去掉回环,改成实际项目的协议解析逻辑。验证时还可以把逻辑分析仪探头夹在 PA9 引脚上,发送一串自定义数据,观察波形起始位、数据位、停止位是否符合预期,这样能直观判断波特率精确度。
6. 二次开发:从回环 Demo 到协议解析的实用改造
6.1 引入环形缓冲区处理不定长数据
递增数组做缓冲区的问题是写满了会越界,改成环形缓冲区能循环利用内存。核心是读写索引分别管理:
#define RX_BUF_SIZE 256 uint8_t rx_ring[RX_BUF_SIZE]; volatile uint16_t rx_write = 0; volatile uint16_t rx_read = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { rx_ring[rx_write++] = USART_ReceiveData(USART1); if (rx_write >= RX_BUF_SIZE) rx_write = 0; } }主循环通过 rx_read 是否等于 rx_write 判断有没有新数据,读索引随处理进度移动。这个设计把接收和解析解耦,中断只负责写入,主循环按自己的节奏消费数据。缓冲区大小按实际帧长调整,256 字节在多数传感器场景下够用。
6.2 自定义帧协议:帧头、帧尾与校验
不定长数据光有缓冲区还不够,需要定义帧格式避免把两帧数据混在一起。我常用的帧结构:
AA 55 [LEN] [DATA...] [SUM] 0D 0A主循环里用状态机逐字节解析,状态包括等待帧头、等待长度、等待数据、校验、等待帧尾。状态机的优势是不用阻塞等整帧数据,来一个字节推进一步,帧头没匹配就丢弃当前字节继续找。校验字段用前面所有字节的累加和,不匹配就回到等待帧头状态。
6.3 用串口命令行做调试交互
最后分享一个裸机工程里很实用的技巧:把串口做成简单命令行,按行接收命令,字符串匹配触发功能。主循环里解析出命令名和参数后,调用对应的测试函数,这样不用反复烧录代码就能查看外设状态或调整参数。配合 printf 回显,整个调试体验会顺畅很多。注意 Windows 串口助手下 printf 要成对用 \r\n,单独用 \n 会造成显示错行。
从那以后我每次动串口相关代码,都强制走一遍:先确认时钟和引脚 AF 表,再调中断优先级,最后才是协议解析逻辑。协议解析永远放主循环,不占中断时间。希望帮到你。
本文还有配套的精品资源,点击获取