简介:基于STM32F103C8T6微控制器、HAL库和STM32CubeMX的UART串口通信完整资料包,面向嵌入式初学者以及需要快速完成串口收发的开发者,解决从CubeMX可视化配置到HAL库代码调用的常见疑问。压缩包共146个文件,大小约4.31MB,以HAL库驱动源文件(c/h)和CubeMX工程配置(ioc/mxproject/uvprojx)为核心,同时包含o、axf、map、hex等编译中间与最终产物,以及scvd、lst等调试记录文件,可直接打开工程查看,也可编译烧录验证。目前已有6055人学习下载,热度较高。借助该工程,读者能完整了解STM32CubeMX中系统时钟、GPIO和UART外设的图形化配置流程,理清HAL_UART_Init、HAL_UART_Transmit、HAL_UART_Receive等函数的使用情景与参数含义;描述中提到的NV_USART模块也在工程中有对应代码,适合需要掉电保存接收数据的场景。整套资源自带构建链和调试信息,可作为串口通信项目底稿,方便二次开发和功能扩展。
1. 串口看起来简单,但你的 HAL 工程为什么总差最后一步
第一次调 STM32 串口的人,十有八九会卡在同一个位置:代码编译通过、下载成功,串口助手却要么一片乱码,要么一个字节都不回。尤其在从标准库转到 HAL 库和 STM32CubeMX 之后,寄存器操作被藏到图形界面后面,很多人连串口挂在哪条总线上、时钟是多少都不清楚,调起来跟碰运气一样。这个基于 STM32F103C8T6 的 HAL 库工程包,好就好在不是只给一个能闪烁的 main.c,而是把 UART 串口通信数据收发的完整链路都串好了:CubeMX 配置、HAL 初始化、中断收发、printf 重定向、回环和上位机联调都覆盖到了。对刚入门的嵌入式开发者来说,可以顺着工程一步步复现;对已经写过不少单片机代码的工程师,也能直接借用里面的收发框架和避坑经验。
2. 用 CubeMX 把串口配置到可信:时钟树、引脚映射、参数面板三个关键点
想要串口不翻车,第一步不是写代码,而是确认 CubeMX 里的配置。很多时候问题根本不出在代码逻辑,而是配置阶段埋下的雷。下面三个地方,是我每次拿到新板子都会先过的。
2.1 时钟树和 PCLK1/PCLK2:波特率误差是从这里来的
STM32F103C8T6 这颗芯片有多个 USART,但它们的时钟源不一样。USART1 挂在 APB2 总线上,默认时钟 72MHz;USART2 和 USART3 挂在 APB1 总线上,默认时钟 36MHz。这个差异直接影响波特率发生器 UARTDIV 的计算,也决定了你设多少波特率最稳。
在 CubeMX 里,我习惯先把时钟树调到一个确定的状态:选 HSE 外部晶振,然后让 HCLK 到 72MHz,APB1 预分频器保持默认的 /2,这样 PCLK1 就是 36MHz。以下是一份典型的 USART2 初始化代码,包里的工程也是这样生成的:
static void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 115200; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; /* 收发都开,别只开 TX */ huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart2.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart2) != HAL_OK) { Error_Handler(); } }注意,HAL 库这里的 BaudRate、WordLength、StopBits 只是把用户参数装进结构体,真正生效是在 HAL_UART_Init 里读取实例对应的 PCLK 来算分频系数。所以我一直强调:PCLK1 是多少,必须心里有数。如果 APB1 预分频器被改成 /1,PCLK1 变成 72MHz,同一份初始化代码生成的实际波特率就翻倍了,上位机自然收到全乱码。
串口广泛使用的 115200 波特率,在 36MHz 下误差很小;但如果想用 460800 甚至更高,就需要确认芯片能否在目标误差范围内产生这个频率。常见做法是列一张表,把期望波特率、实际分频值和误差算出来,误差超过 2% 就要另想办法。资源包里没有单独把这张表做成文档,但附录会给一个计算函数,你可以直接替换 BaudRate 来试。
2.2 引脚映射:USART2 和 USART1 的 TX/RX 到底在哪个脚
F103C8T6 引脚不算多,USART 复用关系却是初学者的重灾区。PA9/PA10 是 USART1,PA2/PA3 是 USART2,PB10/PB11 是 USART3。很多旧例程默认用 USART1,如果你新建工程时随手选的是 USART2,又在 PA9/PA10 上找信号,那当然什么都收不到。
包里的工程默认用的是 USART2,对应 PA2 作为 TX2,PA3 作为 RX2。在 CubeMX 的 Pinout 视图里,直接点选 PA2 和 PA3,系统会弹出功能选择列表,选 USART2_TX 和 USART2_RX 即可。引脚功能分配好后,CubeMX 会自动生成 GPIO 初始化代码,把引脚模式配成复用推挽输出和浮空输入,不需要手写 AFIO。
这里有个容易忽略的坑:如果你的板子把 PA2/PA3 同时接到了其他外设,比如 ADC 通道或者按键,那么即使 CubeMX 显示配置成功,实际信号也会互相干扰。我一般会在打开工程后先看左下角的资源冲突提示,确认没有黄色或红色警告。还有一个检查技巧:查看生成的 MX_GPIO_Init 里,PA2/PA3 的 GPIO_Mode 是否被正确设置成 GPIO_MODE_AF_PP。如果是 GPIO_MODE_OUTPUT_PP,说明引脚根本没切到串口复用模式。
2.3 参数面板:8N1、Mode、NVIC、DMA 这四个地方先填对
串口参数面板看起来很直观,实际经常因为漏勾一个选项导致功能残缺。常见的一组稳定配置是:Baud Rate 115200,Word Length 8 bits,Parity None,Stop Bits 1,Mode 选 TX_RX 而不是只选 TX,Hardware Flow Control None,Over Sampling 16。这套组合就是我们常说的 8N1,直接对应串口助手的“115200 8N1”。
但只填完这些还不够。很多人第一次用 HAL 库,中断接收一直不触发,问题十有八九出在 NVIC Settings 页面没有勾选 USART2 global interrupt。CubeMX 生成的中断处理名是 USART2_IRQHandler,这个使能位是在 NVIC 里独立配置的,不是说你调用了 HAL_UART_Receive_IT 就一定能进中断。我在包里的.ioc文件里特意把这一点保留成了默认勾选状态,方便直接编译验证。
如果你打算以后扩展 DMA 接收,可以在 DMA Settings 页面把 USART2_RX 和 USART2_TX 各添加一个 DMA Channel,配置成 Circular 或 Normal。这里建议 RX 用 Circular,TX 用 Normal,后面第 6 章会更详细讲。
3. HAL 库收发函数的拆解和中段接收的推荐写法
配置完成只是把 MCU 的串口外设从“0”变成“1”,真正干活的是 HAL 库里的收发函数。这个包对轮询、中断、DMA 三种模式都留了接口,但一开始不建议全用,先把最常用的两种吃透。
3.1 轮询、中断、DMA:根据数据量和实时性下菜碟
HAL 库把串口收发封装成了三组接口,看起来像同一类函数,实际行为差异很大。
轮询模式最直观,调用 HAL_UART_Transmit 和 HAL_UART_Receive 时会一直阻塞等待,直到数据发完或收到指定长度。它的优点是逻辑简单,缺点也明显:在等待过程中 CPU 什么都干不了,如果上位机一直不发数据,程序就卡死在接收函数里。适合只在初始化阶段发调试信息,或者你有把握对方一定会回应的情况。
中断模式用 HAL_UART_Transmit_IT 和 HAL_UART_Receive_IT,函数本身只是把请求登记到句柄里,真正的搬运在中断服务程序里进行。CPU 不用忙等,等中断到来自动搬运,适合小数据量和事件驱动型业务。
DMA 模式是通过 DMA 通道搬运数据,适合连续大流量传输。CPU 占用率最低,但初次配置要花时间理解 DMA 通道映射和中断标志。三种模式的选择逻辑,我的经验是:寄存器单个字节用轮询,普通命令行交互用中断,连续上传传感器数据流或接收变长帧用 DMA。
3.2 中断接收的正确姿势:回调函数里重新挂接收
HAL 库的中断接收是一个“一次性”请求。这句话是很多人踩坑的源头。你以为调了一次 HAL_UART_Receive_IT,串口就会一直自动接收,实际上它在收到指定字节数后就停了,之后不再产生接收中断。如果你想持续接收,就必须在每次接收完成回调里再次调用 HAL_UART_Receive_IT。
包里的工程采用的就是这种持续接收方式,代码逻辑非常干净:
uint8_t rx_byte = 0; uint8_t rx_buf[64]; volatile uint16_t rx_len = 0; volatile uint8_t frame_ready = 0; void UART2_StartReceive(void) { /* 启动第一次接收,等待一个字节 */ HAL_UART_Receive_IT(&huart2, &rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart != &huart2) { return; } /* 这里用换行符作为一帧结束标志 */ if (rx_byte == '\n') { rx_buf[rx_len] = '\0'; frame_ready = 1; } else if (rx_len < sizeof(rx_buf) - 1) { rx_buf[rx_len++] = rx_byte; } /* 关键:重新挂接收,否则接收只触发一次 */ HAL_UART_Receive_IT(&huart2, &rx_byte, 1); }这段代码里,rx_byte是每次中断填进去的临时变量;rx_buf是用户缓冲区;rx_len是累计长度;frame_ready是供主循环判断的帧完成标志。回调函数执行完后,必须再次调用 HAL_UART_Receive_IT,这样下次数据到达时才能继续触发中断。
之所以这样设计,是因为 HAL 库为了减轻中断处理负担,认为“收完一次就结束”更安全,避免开发者忘记处理接收缓冲而反复触发。把重新挂接收的调用放在回调末尾,是嵌入式工程师普遍采用的做法,虽然不是唯一方案,但最容易理解。
3.3 printf 重定向到串口:让调试输出更顺手
串口调通以后,下一步往往是想用 printf 打日志。因为 printf 内部最终调用 fputc,而你要做的就是把 fputc 重新定向到串口。包里的工程提供了一份兼容 Keil 和 GCC 的写法,核心代码只有几行:
#include <stdio.h> int fputc(int ch, FILE *f) { /* 用串口2发送一个字节,阻塞等发送完成 */ HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }在 Keil MDK 中,需要同时勾选 Target 选项里的 MicroLIB,这样才能避免半主机模式导致的硬件异常。如果你用的是 GCC 工具链,有些版本会把底层输出函数定义为__io_putchar,那时候就改成:
int __io_putchar(int ch) { HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }完成重定向后,main 函数里的printf("Hello\r\n")就会自动从串口输出。这里有个提醒:printf 内部有格式化过程,耗时相对较长,不要在高频中断里调用,否则会拖慢整个中断响应,甚至导致中断标志丢失。
4. 接线、回环和 PC 联调:把收发流程完整跑一遍
代码写好后,必须动手把它跑起来。这一章先讲硬件连接,再做两种验证:一种是板卡自己的回环测试,另一种是和 PC 上位机联调。顺序不能乱,先排除硬件问题,再验证代码逻辑。
4.1 硬件接线:TTL 转 USB 模块,TXD/RXD 必须交叉
绝大多数人调试串口时会用一个 USB 转 TTL 模块,常见方案是 CH340 或 FT232。这个模块的作用是把 PC 的 USB 转成串口 TTL 电平,然后和 STM32 的 UART 引脚直连。
连接方式看着简单,却最容易搞错。模块的 TXD 要接 STM32 的 RX 引脚,模块的 RXD 要接 STM32 的 TX 引脚,也就是交叉连接。很多初学者把两根线一一对应直连,结果完全不通,还以为是代码问题。
我给的接线表是这个包的实际接线依据:
| USB转TTL模块 | STM32F103C8T6 | 说明 |
|---|---|---|
| TXD | PA3 (USART2_RX) | 模块发,单片机收 |
| RXD | PA2 (USART2_TX) | 单片机发,模块收 |
| GND | GND | 必须共地,否则参考电位不一致 |
| 3.3V | 一般不需要 | 模块不向板子供电时不用接 |
特别提醒,地线一定要接。很多“串口乱码”“数据对不上”的诡异问题,根源就是模块和板子各自用不同的电源,没有电位参考点,信号完全错乱。如果你用 5V 输出的 TTL 模块,先确认 STM32 板子上的引脚是兼容 5V 输入;F103 的大部分引脚是 5V 容忍的,但在没有手册佐证时,我建议还是给串口做电平转换,或者直接把模块调到 3.3V 档,稳一点。
4.2 板级回环测试:先短路 TX/RX,再谈其他
回环测试是用来验证 MCU 串口外设本身是否工作的最快方法。做法是把 USART2 的 TX 和 RX 引脚短接,也就是 PA2 和 PA3 之间连一根杜邦线,然后通过程序自发自收。如果发送一个字节,中断回调能收到一个字节,说明芯片内部串口通路完全正常,问题与它无关。
资源包里有个 LoopbackTest 函数,代码很简单:
void UART2_LoopbackTest(void) { uint8_t test_data[] = {0x55, 0xAA, 0x01, 0x02, 0x0E}; for (uint8_t i = 0; i < sizeof(test_data); i++) { HAL_UART_Transmit(&huart2, &test_data[i], 1, HAL_MAX_DELAY); HAL_Delay(10); } }发送完成后,你在串口助手里应该能连续收到 55 AA 01 02 0E 五个字节。如果收不到,说明硬件连接或初始化存在问题,但也别急着换芯片。先用万用表量 PA2/PA3 是否有电压变化,再看 CubeMX 的初始化是否正确。如果把这五个字节当成一帧数据,还可以测试中断接收的稳定性,看是否丢字节或者多字节。
4.3 PC 联调:串口助手配合 Python 脚本,验证数据时序
回环测试过了以后,下一步是撤掉 PA2/PA3 之间的短接线,把模块的 TXD 接 PA3,RXD 接 PA2,然后打开串口助手,设置 115200、8N1、无流控。发送任意字符串,比如“Hello”,如果工程里的中断回显代码在工作,你会在接收区立刻看到同样的内容。
除了用图形化串口助手,我也会用脚本做更精确的时序验证。以 Python 的 pyserial 为例,包里的验证脚本思路如下:
import serial ser = serial.Serial('COM3', 115200, timeout=1) test_cmd = b'Hello STM32\r\n' ser.write(test_cmd) recv = ser.read(64) print('recv:', recv) ser.close()如果有条件,把发送周期调成 10ms 一次连续运行,观察几百帧数据里有没有出现丢字节。配合逻辑分析仪抓取 UART 波形,可以同时看到帧间隔和每个字节的实际宽度,比单纯靠肉眼判断可靠得多。这也是我自己每换一套硬件环境都会做的事。
5. STM32 串口避坑指南:5 个问题,每个都能让工程师迷糊半天
很多串口问题不是代码逻辑不行,而是硬件状态、工具配置和时序细节混在一起。下面这五个坑,都是我在拆这个工程的过程中反复遇到过的,按“现象、原因、解决”的方式写出来,方便你对照排查。
5.1 乱码和满屏??:先查波特率,再查地线,最后查时钟
现象:程序下载成功,也能看到数据输出,但显示的是乱码,不是可读的 ASCII 字符。有时候是每隔一段时间固定错几个字节。
原因:最常见的是上位机波特率和 MCU 实际波特率不一致。比如你以为 PCLK1 是 36MHz,但 CubeMX 里 APB1 预分频器被改成了 /1,实际 PCLK1 变成了 72MHz,波特率翻倍。另一个高频原因是没有共地,模块和板子的参考电位不同。还有一种情况是发送缓冲区内容未初始化,直接把 0xCC 这种地址填充字符发了出来。
解决:先用一条杜邦线把 TX 和 RX 短接,做回环测试,判断是 MCU 内部的问题还是外部模块问题。然后把串口助手波特率从 9600 到 460800 都试一遍,看是否有某一个档位能够稳定识别。最后打开 CubeMX 的 Clock Configuration,确认 HCLK、APB1、APB2 这三级时钟,别只看外设配置页里的波特率数字。
5.2 只能发不能收:Mode 没开全、NVIC 没勾选、RX 接错
现象:发送给上位机字符正常,但从串口助手发给 MCU 的数据没有任何反应,回调函数永远不执行。
原因:第一种是初始化时 Mode 只选了 UART_MODE_TX,没有选 TX_RX,导致 RX 通路根本没使能。第二种是 NVIC 设置里没有勾选串口全局中断,中断接收无法触发。第三种是接线时把模块 TXD 接到了 STM32 TX 上,等于两个发送端对着怼。
解决:先看生成的huart2.Init.Mode,确认是不是UART_MODE_TX_RX。然后打开 CubeMX 的 NVIC Settings 页面,确认USART2 global interrupt是 Enabled。最后再核对硬件连线,用万用表量模块 TXD 的电压,正常情况下空闲应该是高电平,这样能排除模块损坏或 RX 引脚虚焊的问题。
5.3 中断只进一次:回调里忘了重新启动接收
现象:程序刚上电时能正常收到第一帧数据,后面就再也没反应,复位一下又能收一次。
原因:这就是前面第 3 章说的 HAL 库中断接收是一次性的。调用一次 HAL_UART_Receive_IT,只会为接收指定长度字节准备一次中断。如果你的回调函数里没有再次调用 HAL_UART_Receive_IT,那么接收通道就被“用完”了。
解决:在 HAL_UART_RxCpltCallback 末尾重新调用HAL_UART_Receive_IT。如果你使用的是 DMA 循环模式,则不需要每次重新挂载,但也要在 Idle 中断处理函数里确认 DMA 是否还在继续传输。这个问题的典型特征是“复位一次,能用一次”,只要见到这个规律,直接检查回调函数末尾。
5.4 内部时钟 HSI 引发的波特率漂移:上电正常,跑热就丢字节
现象:刚上电时通信正常,运行半个小时后开始偶然丢字节,再久一点甚至出现连续乱码。
原因:如果 CubeMX 的时钟源选的不是 HSE 而是 HSI,系统主频就靠内部 RC 振荡器。HSI 的初始精度大约在 ±1% 左右,再加上温度漂移,会让 UART 波特率慢慢偏离目标值。F1 系列的 UART 对波特率误差比较敏感,超过 2% 后采样窗口会出错,数据就会随机丢失。
解决:把时钟源改成外部晶振 HSE,并让 CORE 主频跑到 72MHz。如果硬件设计里没有外部晶振,那就降低波特率到 9600 或者 19200,用更大的位时间窗口来抵消频率漂移。还有一种兼顾方法是开启自动波特率检测,但 F1 的自动检测能力有限,只适合特定场景,不能完全依赖。
5.5 上电瞬间收到垃圾字节:浮空引脚和复位时序
现象:每次给 MCU 上电,串口助手的接收区会立即收到一个 0xFF 或乱码字节,之后一切正常。
原因:在 MCU 复位期间,GPIO 还没有被初始化成复用功能,引脚处于浮空状态,外部干扰或模块上电瞬间的脉冲可能被当成起始位。另外,如果 TX 引脚被电平拉低,串口就会误认为发送开始,从而产生一个假字节。
解决:在 CubeMX 里把串口 RX 引脚的上拉设置为 Pull-up,让空闲状态保持在高电平。同时在 main 函数里,等系统时钟和外设初始化完成后再调用 HAL_UART_Receive_IT,不要在复位后立刻开启接收。还有一个效率更高的做法是让上位机主控发送一帧握手命令,只有收到握手命令后才开始业务数据交互,主动过滤掉上电噪声。
6. 进阶玩法:IDLE 空闲中断配合 DMA,收任意长度帧
前面讲的中断接收适用于数据量不大、帧格式简单的场景。如果数据是一包一包来的,每包长度又不固定,再靠逐字节回调拼帧,CPU 容易被频繁中断拖累。更稳妥的方案是用硬件空闲中断加 DMA:DMA 负责把数据连续搬进内存,串口空闲中断负责告诉 CPU“一包数据已经结束了”。这个思路在很多量产项目里都能见到。
需要做的事情分两步。第一步在 CubeMX 的 DMA Settings 里为 USART2_RX 添加一个 DMA 通道,模式选 Circular,数据宽度都选 Byte。第二步在代码里定义一个足够大的接收缓冲区和空闲中断处理逻辑:
#define RX_DMA_BUF_SIZE 256 uint8_t uart2_rx_dma_buf[RX_DMA_BUF_SIZE]; volatile uint16_t uart2_rx_len = 0; void UART2_StartDMAReceive(void) { /* DMA 循环接收,一直往缓冲区写 */ HAL_UART_Receive_DMA(&huart2, uart2_rx_dma_buf, RX_DMA_BUF_SIZE); /* 开启空闲中断 */ __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); }第二步需要把空闲中断的判读代码放进 stm32f1xx_it.c 的 USART2_IRQHandler 里。CubeMX 生成的中断服务函数已经调用了 HAL_UART_IRQHandler,所以做法是先在它之前判断空闲标志:
void USART2_IRQHandler(void) { uint16_t len = 0; if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); /* DMA 还剩下多少计数,就知道这一帧实际收了多少字节 */ len = RX_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); if (len > 0) { uart2_rx_len = len; /* 在这里把 uart2_rx_dma_buf 里的数据交给协议解析层 */ } /* 重新开启 DMA 接收,因为 Circular 模式下会自动循环 */ HAL_UART_Receive_DMA(&huart2, uart2_rx_dma_buf, RX_DMA_BUF_SIZE); } HAL_UART_IRQHandler(&huart2); }这个方案的要点是:DMA 一直在循环写缓冲区,空闲中断意味着总线上出现了一小段安静时间,正好是一帧结束的信号。取 DMA 剩余计数器的差值,就能知道新到的数据长度。相比逐字节中断,这样每帧才进一次中断,CPU 占有率低得多,而且不会漏字节。
要注意,空闲中断标志必须在 HAL_UART_IRQHandler 之前判断和处理,因为 HAL 库本身不会替你清这个标志。如果你先调用了 HAL_UART_IRQHandler,它可能会修改一些状态位,导致空闲标志被意外覆盖。
从那以后,我每次拿到一套 UART 相关的新代码,都会强制自己先做一遍“电源共地检查、回环测试、断帧观察”这三件事。串口通信工作量不大,但坑全藏在细节里,按这个顺序能省下一大半排错时间。希望帮到你。
本文还有配套的精品资源,点击获取