简介:面向STM32入门开发者与嵌入式爱好者,基于STM32CubeMX配置工具和Keil5集成开发环境,以STM32F103C8T6最小系统板为载体,完整演示了串口输出功能的配置流程与printf格式化输出函数的底层重定向封装方法。压缩包共包含139个文件,整体大小3.45MB,主要文件类型有硬件抽象层库头文件与C源码、CubeMX工程配置文件、Keil工程文件以及编译生成的中间文件、目标文件和最终固件等,目录结构清晰,便于对照学习。工程内已初始化USART串口、系统时钟、定时器和DMA等相关外设,并在底层完成fputc函数的重定向处理,使得用户可直接在串口调试助手中接收单片机打印的调试信息。已有1925人学习下载,对刚接触STM32开发、想快速搭建串口调试输出环境的初学者来说,是一份可以直接打开编译并下载运行的实践参考工程。
1. 为什么 F103 调试要先解决串口输出
调试 STM32F103C8T6 时,串口输出是最常用的调试手段。这篇文章记录一条完整的落地路径:用 STM32CubeMX 生成 HAL 工程,在 KEIL5 里编译,把 printf 重定向到 USART1 串口,最后封装成自己的日志打印模块。反直觉的地方在于:printf 接上串口后没有输出,十有八九不是波特率配错,而是半主机模式直接把 MCU 卡死了。这篇笔记按 CubeMX 配置、裸串口跑通、printf 重定向、封装避坑、进阶技巧的顺序展开,新手能跟着做,老手可以直接看边界和坑。
2. CubeMX 配置:时钟与串口一次到位
2.1 先理清时钟:USART1 的时钟从哪来
F103C8T6 最高跑 72MHz,这个频率来自外部 8MHz 晶振经过 PLL 倍频。很多新手拿到手就在 CubeMX 里默认配置直接生成工程,串口也能工作,但偶尔乱码、偶尔上电无输出的问题,最后都追溯到时钟树配置这一层。
USART1 挂在 APB2 总线上,它的波特率发生器直接使用 PCLK2 作为时钟源。如果你在 CubeMX 的 Clock Configuration 页面里把 APB2 预分频设成 2 或者 4,HAL 库会自动用 HAL_RCC_GetPCLK2Freq() 拿到当前时钟去算波特率寄存器,所以波特率名义上还是 115200,但实际误差可能变大,在 115200 这种速率下一般能忍,到了 921600 或者更高就很容易乱码。真正让波特率彻底错乱的操作,是在 RCC 里把 HSE 晶振频率填错,比如板子上是 8MHz 晶振,CubeMX 里填了 25MHz,PLL 倍频后的系统时钟飙升到 225MHz,超过芯片极限,串口完全没法用。
我一般会先打开 Clock Configuration 页面看一眼:HSE 填的是不是 8,PLL Source 选没选 HSE,APB2 预分频是不是 1,APB1 预分频是不是 2。确认 PCLK2 一栏显示 72MHz,再往下配串口。这个检查只需要十秒,能省掉后面所有的乱码排查时间,属于血泪经验。
2.2 CubeMX 里把 USART1 和 KEIL5 工程一次配齐
生成工程的操作顺序我习惯固定下来,避免漏项:
- 打开 STM32CubeMX,芯片型号选 STM32F103C8Tx。
- RCC → HSE 选 Crystal/Ceramic Resonator。
- Clock Configuration 页面确认 PLL Source 为 HSE,系统时钟 72MHz,APB1 分频 2,APB2 分频 1。
- USART1 → Mode 选 Asynchronous(异步模式)。
- USART1 → Parameter Settings 里波特率 115200,数据位 8,无校验,停止位 1。
- NVIC 设置里使能 USART1 global interrupt。
- Project Manager → Project → Toolchain 选 MDK-ARM,生成工程。
参数按下面这张表核对,每一项都对应不同问题:
| 配置项 | 推荐值 | 作用 |
|---|---|---|
| HSE 晶振值 | 8 MHz | 填错直接导致全线乱码 |
| 系统时钟 | 72 MHz | F103C8T6 上限 |
| APB1 预分频 | 2 | APB1 外设时钟 36MHz |
| APB2 预分频 | 1 | APB2 外设时钟 72MHz |
| USART1 模式 | Asynchronous | 全双工异步串口 |
| 波特率 | 115200 | 调试默认,够快够稳 |
| 数据格式 | 8N1 | 8 数据位无校验 1 停止位 |
| NVIC 中断 | 使能 | 后面封装日志要用 |
引脚方面有个容易踩的地方:USART1 在 F103C8T6 上默认映射到 PA9(TX)和 PA10(RX),CubeMX 会自动分配。如果你在引脚视图里看到 Pin 是 PB6/PB7,那是 Remap 重映射,别选,除非你的板子故意把串口接在那两个脚上。C8T6 这类小封装绝大多数板子用的都是 PA9/PA10 默认映射。
2.3 生成后的工程结构:什么能改,什么不能改
生成出来的工程里,和串口直接相关的文件是 usart.c 和 stm32f1xx_hal_msp.c。前者可以理解成串口外设的初始化封装,后者负责引脚复用、时钟使能这些底层动作。main.c 里的调用顺序是固定的:先 HAL_Init(),再 SystemClock_Config() 配时钟,然后 MX_GPIO_Init(),最后 MX_USART1_UART_Init()。MX_USART1_UART_Init() 内部会填充 huart1 结构体并调用 HAL_UART_Init(),而 HAL_UART_Init() 里会根据 PCLK2 自动计算波特率寄存器 BRR 的值。
规则只有一条:所有以 MX_ 开头的函数文件都不要手工修改,CubeMX 重新生成时会覆盖。你自己想加的代码,比如后面要写的 printf 重定向,放在 usart.c 的 USER CODE 区域里就好,生成时会保留。第一次用 KEIL5 打开工程后,先把 Options → Target 里的 Optimization 调到 Level 0,方便调试时单步跟踪,等整个链路稳定了再开优化。
3. 在 KEIL5 里裸跑串口输出
3.1 用 HAL_UART_Transmit 阻塞发送最小可运行代码
先别碰 printf,用 HAL 库原生的发送函数跑通一次串口,这是验证链路最快的方法。下面的代码可以直接放在 main 函数的 while 循环里:
/* main.c */ #include "usart.h" uint8_t msg[] = "hello stm32\r\n"; while (1) { HAL_UART_Transmit(&huart1, msg, sizeof(msg) - 1, 100); HAL_Delay(500); }HAL_UART_Transmit 是阻塞发送:函数内部会不断检查发送数据寄存器空标志 TXE 和发送完成标志 TC,直到把所有字节发完或超时才返回。第四个参数是超时时间,单位毫秒,100 表示如果 100ms 内没发完就返回 HAL_TIMEOUT。115200 波特率下发送 14 个字符大约只需要 1.2ms,所以正常链路不会触发超时。如果返回超时,说明串口外设根本没工作,要么是时钟没配好,要么是引脚被占用。
更稳的写法是检查返回值,至少知道串口到底有没有在工作:
uint8_t msg[] = "hello stm32\r\n"; while (1) { HAL_StatusTypeDef status = HAL_UART_Transmit(&huart1, msg, sizeof(msg) - 1, 100); if (status != HAL_OK) { /* 在这里做错误处理,比如点一盏灯 */ } HAL_Delay(500); }这里的 sizeof(msg) - 1 是把字符串结尾的 \0 去掉,避免把空字符也发到串口上。这个细节很多人忽略,结果串口助手收出来的数据末尾多一个不可见字符,回调函数处理时容易踩坑。
3.2 为什么先不着急搞 printf
串口输出这条链路,拆开算有四个环节:硬件接线、CubeMX 初始化、HAL 库发送函数、C 库 printf 重定向。前面三个环节用裸发送就能验证,一旦把 printf 加进来,就会多出半主机、微库、浮点格式这些变量,任何一个出问题,现象都是"没输出"或"程序卡死",很难定位。
所以我的步骤是先让裸发送在串口助手上有稳定输出,确认这条链路是通的。如果这一步不过,优先查三件事:第一,PA9 有没有接 USB 转 TTL 的 RXD;第二,GND 有没有共地;第三,USB 转 TTL 小板有没有选对电压档位。硬件如果没问题,再回头检查 CubeMX 里串口参数是否和串口助手一致。裸发送一旦通了,后续 printf 出问题就只需要查 C 库相关配置,问题范围缩小一大半。
这一步还有个好处:它给出了"串口链路正常"的基准现象。后面你做日志封装,如果新代码打断点发现输出不对,可以回头跑这 5 行代码,一分钟内确认是硬件回归还是代码回归。我习惯把这个最小测试代码留在工程里,用宏注释掉,不删。
3.3 串口助手侧参数与硬件检查
裸发送验证的不只是 MCU 侧,串口助手也是出问题的高发区。参数必须和 CubeMX 完全一致:波特率 115200、8 数据位、无校验、1 停止位。很多串口助手默认勾选了"HEX 显示",你发的是 ASCII 字符,收到的却是一串 68 65 6C 6C 6F,这不是代码问题,是显示模式问题。
接线要分清角色:PA9 是 TX,接 USB 转 TTL 的 RX;PA10 是 RX,接 TX。新手最容易在这里把 TX 和 RX 接反,然后到处找代码问题。PA9/PA10 这两个引脚在 F103 系列一般标注为 FT,可容忍 5V 输入,但长时间工作在 5V 电平上不推荐,USB 转 TTL 小板能切 3.3V 就用 3.3V 档。
共地是另一个经典坑:只接了两根信号线没接 GND,串口助手里会收到乱码垃圾或者完全没反应。因为双方参考地电位不一致,TTL 电平判断全乱了。我每次焊接或接线后,习惯用万用表量一下 USB 转 TTL 小板 RX 引脚在空闲时是不是高电平,如果是,说明 MCU 侧 TX 输出正常。这一步能把接线问题从代码问题里彻底摘出去。
4. 把 printf 送到串口:重定向与半主机
4.1 printf 的输出路径
裸发送跑通之后,才能安全地引入 printf。printf 是 C 标准库函数,它的底层逻辑是:printf 把格式化的字符串逐字符交给标准库内部的底层输出函数,最终通过 fputc 把单个字符写到 stdout。问题就出在这个 stdout 上:在 PC 上,stdout 指向控制台;在一颗没有操作系统的 MCU 上,stdout 默认没有目标。
标准库在找不到 stdout 目标时,会启用半主机模式。半主机是一种通过调试器在宿主机和 MCU 之间传数据的机制,MCU 上执行半主机调用时,会触发一条 BKPT 0xAB 指令等待调试器响应。你在 KEIL5 里全速跑,没有调试器接管这个请求,MCU 就卡死在这条指令上。这就是很多人第一次在 F103 上调用 printf 后程序直接进入 HardFault 或停在断点处的根本原因。
所以重定向的核心就一句话:自己实现 fputc,把每个字符通过 HAL_UART_Transmit 丢进串口。这样 printf 的输出路径就变成:printf → 格式化 → fputc → USART 外设 → 串口助手。
4.2 重定向代码与 MicroLIB 的关系
直接实现 fputc 是最短的重定向方案:
/* usart.c 底部,放 USER CODE 区域内 */ #include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这段代码的逻辑是:把 printf 传进来的单个字符 ch 伪装成长度 1 的字节数组,交给 HAL_UART_Transmit 发送。超时给 0xFFFF 是让它几乎不超时,省得在高频打印时出现误报超时。但如果你的工程里用了实时操作系统或者大量中断,超时给太大反而会让发送卡死时长时间占着 CPU,一个更实用的做法是给一个明确的值比如 100,或者干脆用下面的 HAL 状态做错误上报。
与此同时,KEIL5 里必须打开 Options → Target,勾选 Use MicroLIB。MicroLIB 是面向 MCU 裁剪的精简 C 库,默认不依赖半主机,配合自己的 fputc 就能完整走通 printf。不勾 MicroLIB 而直接用标准库,即使你实现了 fputc,标准库的启动代码里还是可能去执行半主机相关初始化,程序依然有卡死风险。
MicroLIB 和标准库的取舍,直接用一张表对比:
| 对比项 | MicroLIB | 标准库 |
|---|---|---|
| 代码量 | 小,省 Flash | 大 |
| 半主机依赖 | 无 | 默认有,需额外关闭 |
| 浮点 %f 支持 | 不完整 | 完整 |
| 栈占用 | 较低 | 高,复杂格式可能超 1KB |
| 配置难度 | 简单 | 需要补半主机函数 |
用 MicroLIB 的代价主要是浮点格式化。打印整数和字符串完全没问题,但一旦 printf("temp = %.2f\r\n", temp) 这种浮点格式,MicroLIB 可能直接不输出或者输出错误内容。这在 F103 这种没有硬件 FPU 的芯片上尤其明显,因为软件浮点运算本身就很慢,加上微库对浮点格式化的支持本就残缺。
4.3 不勾 MicroLIB:用标准库关半主机
如果你的项目必须完整支持 %f,改用标准库并把半主机关掉,这也是成熟做法。方法是用编译指令告诉标准库不要使用半主机,同时补齐它需要的底层函数:
#include <stdio.h> #include "usart.h" /* 告诉 ARM 编译器:本工程不使用半主机 */ #pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { for (;;); } void _ttywrch(int ch) { (void)ch; } int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这里的关键是 #pragma import(__use_no_semihosting),它让链接器不再链接半主机相关实现。但标准库内部还是会引用 _sys_exit、_ttywrch 这些符号,所以你必须手工补上。编译器如果继续报缺 _sys_open、_sys_close 之类的符号,按同样的方式写一个空函数补上即可。这套代码写完后,%f 就能正常工作了。
代价是标准库的 printf 很重。一个带多个参数的格式化串,运行时栈占用可能达到 1KB 以上,而 KEIL5 默认的 Stack_Size 只有 0x400 也就是 1KB,很容易栈溢出。栈溢出的现象很隐蔽:printf 后程序不定时 HardFault,删掉 printf 就没问题。遇到这种情况,打开启动文件 startup_stm32f103xb.s,把 Stack_Size 从 0x400 改成 0x1000,让出 4KB 给堆栈。
4.4 重定向代码放在哪个文件
常见做法是放在 usart.c 的 USER CODE 区,和串口耦合,查找方便。也可以单独建一个 debug.c,把 fputc 和后面的日志封装一起管理。不管放哪里,整个工程里只能有一份 fputc,这是硬性约束。如果你接入了第三方日志库或者某些 RTOS 移植包,它们可能自带重定向,两套 fputc 只要有重复定义,链接器就直接报错;编译器没报错但行为异常的情况,多半是其中一套没生效。
我的建议是单独建一个 debug 模块。usart.c 是 CubeMX 生成的,虽然 USER CODE 区不会被覆盖,但放太多东西会让这个文件和 HAL 绑定太紧。独立模块的好处是你可以用编译宏控制它整体开关,比如定义 DEBUG_ENABLE 才参与编译,发布时直接关掉,打印代码一行都不用删。
/* debug.h */ #ifndef DEBUG_H #define DEBUG_H #include "usart.h" #ifdef DEBUG_ENABLE void DEBUG_Init(void); int fputc(int ch, FILE *f); #else #define DEBUG_Init() #endif #endif这样上层只需要调用 DEBUG_Init(),具体里面是初始化和重定向,对应用层透明。日志开关用一个编译宏控制,发布固件时把 DEBUG_ENABLE 注释掉,所有打印代码自动消失,不会留死代码在 Flash 里。
5. 避坑:串口输出经典翻车现场
串口输出的坑有个规律:现象越简单,根因越靠前。乱码优先查时钟链路,没输出优先查接线和初始化顺序,卡死优先查 C 库半主机设置。按这个优先级排查,比对着现象猜要快得多。下面五条是我在实际调试场面里反复遇到过的问题,每条都按现象、原因、解决三步写清楚。
| 现象 | 优先排查项 | 对应章节 |
|---|---|---|
| 输出乱码 | 时钟树、波特率、电源 | 5.1 |
| 程序跑飞进 HardFault | 半主机、微库 | 5.2 |
| 前几个字符丢失 | 初始化顺序、串口助手打开时机 | 5.3 |
| 中断里 printf 后假死 | 重入、阻塞、状态机破坏 | 5.4 |
| 接线全对但无输出 | 共地、引脚复用、自收发回环 | 5.5 |
5.1 输出乱码:时钟树与电源的锅
现象:串口助手能收到东西,但满是乱码;有时同一个字符串每次收到的内容还不一样。原因有两个方向:一是 CubeMX 里 HSE 晶振值填错,导致整条时钟链全错,串口波特率寄存器算出的实际速率和 115200 差了一大截;二是 USB 转 TTL 小板质量差或者供电不稳定,发送瞬间电平被拉低。解决:先回 CubeMX 确认外部晶振值,再重新生成工程;排查供电,不要用 USB 转 TTL 小板的 3.3V 直接驱动整块开发板,换成独立稳压供电;最后换一块 USB 转 TTL 小板交叉验证。乱码问题我从不盲目调波特率,先确认时钟,再确认电源。
5.2 程序跑飞进 HardFault:半主机断点是元凶
现象:代码调到 printf 那一行,程序全速跑直接进 HardFault_Handler 死循环;单步走能看到停在一条 BKPT 0xAB 指令上。原因:没有勾选 Use MicroLIB,标准库默认启用半主机,printf 在等调试器回应,没有调试器回应就永远卡死。解决:Options → Target 里勾选 Use MicroLIB,或者按 4.3 节用标准库加 __use_no_semihosting。看到 BKPT 0xAB 基本不用怀疑别的,就是半主机。这是 printf 重定向里最经典的坑,新人十有八九要踩一次。
5.3 前几个字符丢失:初始化顺序与上位机时机
现象:上电后串口助手收到的第一条日志开头少了几个字符,或者第一个字符变成乱码,后续正常。原因:printf 在外设初始化之前就被调用,比如某个全局变量的构造函数或者 MX_UART_Init 之前的代码里调用了 printf;另一个常见原因是串口助手在上电瞬间才打开,MCU 启动头几十毫秒发出的数据被上位机的 USB 枚举过程吃掉了。解决:所有打印放到串口初始化之后;上电前先打开串口助手;或者在 main 里串口初始化之后立即发一个 "\r\n" 做同步。如果还有问题,就在初始化后加一个 20ms 到 50ms 的 HAL_Delay,给 USB 转 TTL 留出稳定时间。
5.4 中断里调用 printf 导致系统假死
现象:定时器中断或者外部中断回调里调用了一次 printf,程序过一段时间随机进入 HardFault;屏蔽中断后一切正常。原因:printf 本身不可重入,HAL_UART_Transmit 又是阻塞实现,它内部会修改 huart1 的状态字段 gState。主循环正在发字符串时,中断里再次进入同一段发送逻辑,两个上下文互相踩状态,HAL 的状态机直接紊乱。解决:中断里永远不要直接调用 printf。改成在中断里置一个标志位,主循环轮询到标志位后再发送;或者使用下一章的环形缓冲区方案。这个约束要提前告诉你团队的每一个人,否则谁随手在中断里加一行打印,整块板子就翻车。
5.5 接线全对但没输出:共地与引脚复用
现象:看着波特率、数据位、停止位全对,信号线也接对了,但串口助手就是没数据;程序跑得好好的,LED 还在闪。原因:USB 转 TTL 和开发板之间没共地,信号线悬空状态下电平漂移;或者 PA9/PA10 在 CubeMX 里被复用成 GPIO,串口初始化后引脚功能被覆盖。解决:先量 PA9 空闲电平,正常应为高电平;再检查 CubeMX 引脚定义,PA9/PA10 不能同时配给别的外设;最直接的办法是回环测试,用杜邦线把 PA9 和 PA10 短接,执行一次 HAL_UART_Transmit 再执行 HAL_UART_Receive,看能不能收到自己发的数据。回环通过,说明 MCU 侧没问题,问题在外部接线或者 USB 转 TTL 小板;回环收不到,问题在代码和配置里。
如果你运气不好,同时踩了乱码和死机两个问题,记住一个原则:先解决死机再解决乱码。死机通常是半主机问题,乱码通常是时钟问题,先把 C 库配置稳定,再回来对时钟,别同时改两个地方,否则永远找不到根因。
6. 进阶:中断安全的打印与耗时验证
6.1 中断里安全打印的思路
中断安全的打印,核心思路是"中断里只存数据,主循环里再发"。下面是一个极简环形缓冲区,足够支撑调试场景:
#define LOG_BUF_SIZE 128 char log_buf[LOG_BUF_SIZE]; volatile uint16_t log_wr = 0; volatile uint16_t log_rd = 0; /* 中断里调用:只写缓冲区,不做阻塞发送 */ void LOG_Push(char c) { uint16_t next = (log_wr + 1) % LOG_BUF_SIZE; if (next != log_rd) { log_buf[log_wr] = c; log_wr = next; } } /* 主循环里调用:取出字符并发送 */ void LOG_Flush(void) { while (log_rd != log_wr) { HAL_UART_Transmit(&huart1, (uint8_t *)&log_buf[log_rd], 1, 100); log_rd = (log_rd + 1) % LOG_BUF_SIZE; } }这段代码里,LOG_Push 只做数组写入和指针移动,绝不调用 HAL_UART_Transmit,所以可以安全地放在中断回调里用。LOG_Flush 放在主循环中轮询。缓冲区满时会丢弃最新一个字符,在调试场景里这是可以接受的。这个方案的优点是零阻塞、无临界区代码,缺点是如果主循环太忙,日志会有延迟。我在实时性要求不高的项目里一直用这个套路,配合调试开关非常顺手。再用 GPIO 翻转法测一下就知道,发送几百字节对主循环的时间占用其实很小,真正占用大的是格式化阶段。
6.2 用 GPIO 翻转量化 printf 耗时
printf 到底慢不慢,不要猜,量一下最靠谱。在 fputc 里加两个 GPIO 翻转,然后用逻辑分析仪或者示波器看输出:
int fputc(int ch, FILE *f) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET); /* 打印开始,拉低 */ HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET); /* 打印结束,拉高 */ return ch; }选一个没被占用的 GPIO,比如 PA8,打印前拉低,打印后拉高。测量 PA8 低电平的宽度,就是单个字符从 printf 进入 fputc 到发送完成的实际耗时。115200 波特率下一个字符的理论传输时间是 87 微秒,如果量出来的宽度接近这个值,说明发送是连续高效的;如果宽度到了毫秒级,说明 HAL_UART_Transmit 内部在等待标志上消耗了大量 CPU,这时候你要么降低打印频率,要么换成 DMA 发送加空闲中断的方式。我早期习惯随手在中断里 printf,被坑了几次之后才养成两个习惯:一是中断里绝不打印,只置标志位;二是新板子到手先跑裸发送,再上 printf。这个顺序能帮你把串口问题从玄学变成可查的工程问题,希望帮到你。
本文还有配套的精品资源,点击获取