
简介这是基于STM32CubeMX与KEIL5环境的STM32F103C8T6串口输出及printf函数封装工程面向刚接触STM32 HAL库开发的初学者解决串口调试时打印信息不便、printf重定向难配置等常见问题。压缩包内共139个文件约3.45MB包括19个c源码与46个h头文件、20个编译中间产物o/d/crf、1个uvprojx工程文件、1个hex固件以及1个ioc配置文件还附带map、lst、sct等链接与汇编列表文件便于核对编译细节和启动流程。已有1920人学习下载。工程基于CubeMX配置串口与时钟并在Keil5中实现printf函数的重定向封装读者可参考其串口初始化、HAL_UART_Transmit与fputc挂钩方式快速移植到自己的板卡。目录结构完整适合作为串口调试模块的模板工程尤其对想弄清HAL库下printf输出原理的开发者有直接参考价值。 调试嵌入式绕不开的一件事就是串口输出。不管你是初学 stm32f103c8t6还是已经玩了好几年单片机只要想快速验证某个传感器的数据、某个中断是否触发、或者某个算法中间变量是否正确最后大概率都是喊一句“printf 一下看看”。所以我这次把 STM32CubeMX 配合 Keil5 开发 stm32f103c8t6 的整套串口输出流程连同 printf 函数封装的几种常见写法一次性理清楚。这篇文章适合刚接触 F103C8T6 最小系统板的人也适合那些用 HAL 库但始终没搞明白“为什么 printf 重定向之后还是乱码/死机”的朋友。我会从 CubeMX 图形化配置开始逐步讲到 Keil5 工程里的代码修改、fputc 重定向原理、以及更灵活安全的变参封装方式最后把实际调试中遇到的典型问题列成速查表。文中的代码都基于 HAL 库这也是当前 CubeMX 默认生成的代码风格。1. 准备工作与整体思路1.1 硬件基础与板卡分析先说硬件。STM32F103C8T6 是 64 引脚 LQFP 封装内置 64KB Flash、20KB SRAM属于 F1 系列里很经典的一款。市面上常见的“最小系统板”基本都是 BluePill 布局板载一个 8MHz 晶振、USB 座、两个按键、两个 LEDPC13 和 PB1/PB2以及若干排针引出的 GPIO。串口输出最常用的是 USART1对应 PA9TX和 PA10RX。连接上位机时需要一块 TTL 转 USB 模块常见方案是 CH340 或 CP2102。接线很简单板子的 PA9 接模块的 RXDPA10 接模块的 TXD然后共地。注意这里一定是交叉连接我见过太多人把 TX 接 TX结果串口助手死活收不到数据。另外如果板子供电不稳建议直接用 USB-TTL 模块的 3.3V 输出给板子供电但别同时再插 ST-Link 的 3.3V两个电源打架容易把板子干烧。1.2 软件环境CubeMX Keil5 搭配逻辑STM32CubeMX 是图形化初始化代码生成工具它能帮你省掉大量手写寄存器初始化的时间。选用它配合 Keil5好处很明显引脚复用、时钟树、外设参数都在图形界面里确认减少了“对照数据手册翻寄存器位”的重复劳动。需要提醒的是CubeMX 生成的代码是 HAL 库风格。如果你之前用的是标准外设库SPL刚切换过来时会觉得 HAL 库函数名很长、封装层级很多但实际上 HAL 库的可读性和可移植性更好网上主流的 F103 教程也基本都转向 HAL 了。Keil5 安装时需要注意版本。MDK5 本身不带 STM32 芯片支持包需要到 Keil 官网下载对应 Device Family Pack。如果新建工程时找不到 STM32F103C8大概率就是 Pack 没装好。另外安装路径不要带中文否则后续编译容易出莫名其妙的问题。关于激活与注册机这里不做展开建议使用正版授权或官方评估版本避免后续编译器和调试器被限制。2. CubeMX 图形化配置串口工程2.1 新建工程与芯片选择打开 CubeMX在 Part Number 搜索栏输入 STM32F103C8双击对应芯片进入配置界面。这里会默认展示 LQFP48 封装的引脚图左侧是外设分类列表右侧是芯片引脚预览。初学者第一眼可能觉得复杂但只需要关注几个关键配置入口即可。2.2 时钟树配置要点进入 RCC 配置项如果板上有外部 8MHz 晶振把 HSE 设置为 Crystal/Ceramic Resonator。没有外部晶振的话就保持默认的 HSI内部 8MHz RC。对于串口这种对波特率精度有要求的外设尽量优先使用 HSE否则内部 RC 的温漂可能导致高波特率下误码率上升。进入 Clock Configuration 窗口把系统时钟 SYSCLK 配置到 72MHz。F103 最高主频就是 72MHz需要通过 PLL 倍频实现。如果 HSE8MHz典型配置是 PLL 倍频 9 倍得到 72MHz再经过 AHB 分频器APB1 总线最高 36MHzAPB2 总线 72MHz。USART1 挂载在 APB2 上USART2/3 挂载在 APB1 上。这里只要确保 APB1/APB2 外设时钟配置正确串口波特率准不准就取决于这一步了。2.3 USART1 参数配置左侧进入 Connectivity - USART1将 Mode 选为 Asynchronous。此时 CubeMX 会自动把 PA9/PA10 复用为 USART1 的 TX/RX并在芯片引脚图上以绿色标注。参数栏里重点设置Baud Rate: 115200。调试阶段 115200 是默认共识够快且稳定。Word Length: 8 Bits。Parity: None。Stop Bits: 1。数据方向默认是 Receive and Transmit保持即可。其他参数保持默认。生成代码后HAL_UART_Init 函数里会把这些参数填入 h uart1 结构体。2.4 SYS 与 Debug 配置左侧进入 SYS将 Debug 设置为 Serial Wire。这一步很多人会漏掉。如果不设置板载 ST-Link 的 SWD 接口虽然在下载时还能用但后续如果你想用 V9、V10 之类的调试器在线仿真或者跑 RTOS 时想用低功耗调试就可能出现“连接不上目标”的问题。最后在 Project Manager 里填好工程名和路径Toolchain/IDE 选择 MDK-ARM V5。如果电脑上装的是 Keil MDK 5.30 以上版本选 V5 没问题如果装的是更新的 AC6 编译器也可以选 V5 生成后再在 Keil 里切换。点 Generate Code 生成工程。3. printf 函数封装与重定向原理3.1 为什么要“封装”HAL 库要发送一串字符最直接的方式是调用 HAL_UART_Transmit但每次都要传入 huart 句柄、数据指针、长度和超时时间不仅麻烦而且代码可读性差。你写一个调试语句可能比你要调试的内容还长。于是大家很自然地想到了 printf。C 标准库里的 printf 是一个变参函数内部会逐个字符调用底层写字符函数。在桌面程序里这个底层函数负责往终端输出而在嵌入式环境里我们需要把这个底层输出目标改到串口。这个操作就叫“重定向”核心技术是重写 fputc 这个函数。3.2 方案一勾选 MicroLIB 重写 fputc在 Keil5 中勾选魔术棒 - Target - Use MicroLIB。然后在你自己的代码里添加#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这里的逻辑很直接printf 内部每次要输出一个字符就会调用 fputc 一次我们把字符通过 HAL_UART_Transmit 发送出去。0xFFFF 是超时时间单位是毫秒足够让函数在正常情况下不会卡死。MicroLIB 是 Keil 提供的一个精简版 C 运行库。它的优势是体积小支持 printf 浮点输出但占资源较少。缺点是部分标准库函数的行为和标准 C 库略有差异但对于串口调试来说完全够用。3.3 方案二不勾选 MicroLIB 的完整重定向如果不勾选 MicroLIB直接重写 fputc 在编译时是会报错的因为标准库认为半主机模式Semihosting的底层处理函数没实现。此时需要手动关闭半主机特性典型写法如下#include stdio.h #pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x x; } void _ttywrch(int ch) { ch ch; } int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这段代码的关键是#pragma import(__use_no_semihosting)它的作用是告诉链接器本工程不使用半主机模式。如果不加这一句printf 在首次输出时可能直接跳入 HardFault原因就是标准库尝试通过 JTAG/SWD 调试接口把字符送到宿主 PC 端但你的板子没有连接调试器的半主机通道。两个方案对比下来我个人的建议是如果你只是做调试输出、不想纠结底层勾选 MicroLIB 是最快的路。如果以后想把代码往 FreeRTOS、内存管理或者更复杂的应用上扩展就按方案二一次配好省得以后被库兼容性问题绊住。3.4 方案三基于 vsnprintf 的变参数封装上面两种方案虽然能用但有一个天然缺点它们输出的是单字符HAL_UART_Transmit 被调用次数等于字符串长度。对于几十个字符的日志多调用几十次函数在 72MHz 主频下看似不慢但在中断里被频繁调用时就显得浪费。更可控的封装是利用 vsnprintf 先把格式化结果缓冲到一块字符数组里再一次性用 HAL_UART_Transmit 发送。代码如下#include stdarg.h #include stdio.h void Debug_Printf(const char *fmt, ...) { char buf[256]; va_list args; int len; va_start(args, fmt); len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len 0) { if (len sizeof(buf)) { len sizeof(buf) - 1; } HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 0xFFFF); } }这段封装的好处有几点第一vsnprintf 自带缓冲区长度限制不会出现越界写内存的严重 bug第二串口发送只用一次 HAL 调用整体耗时更短第三即使不勾选 MicroLIB只要链接标准库时不涉及半主机模式这个函数一般也能正常工作。但要注意如果你改用了这个封装就不需要再去重定义 fputc两套机制同时存在反而可能导致输出重复。实际开发里我一般这样安排真正常用的调试语句统一用 Debug_Printf而少量需要和第三方库配合的 printf 调用再补上方案一或方案二。4. 实操过程与结果验证4.1 接线与编译下载完整接线清单如下STM32F103C8T6 板卡 PA9 接 CH340 模块 RXDPA10 接 CH340 模块 TXDGND 接 GNDCH340 模块 3.3V 接板卡 3.3V可选打开 Keil5 工程在 Project 窗口里找到 main.c 或者单独新增一个 debug.c 文件把上述重定向代码或 Debug_Printf 封装代码加进去。如果是新增文件记得在 Keil 中把文件添加到工程分组并保证头文件路径里有对应的包含目录。编译前检查魔术棒Options for Target里的几个关键配置Device 选的是 STM32F103C8Target 标签页里如果使用方案一勾选 Use MicroLIBC/C 标签页里语言标准默认即可不要乱开 C99 的某些严格模式Debug 标签页选择 ST-Link DEBUGGER并确认 Settings 里能识别到芯片编译无误后点击 Download。如果提示 Cannot Load Flash Programming Algorithm多半是 Pack 安装不完整或者 Flash 大小设置错误回到 Pack Installer 重新安装 STM32F1 系列支持包一般能解决。4.2 串口助手的观察方法打开串口助手选择对应 COM 口波特率 115200数据位 8停止位 1无校验。复位板子后发送区可以随便发点什么或者直接在代码的 while(1) 里加一句周期打印while (1) { Debug_Printf(hello stm32, counter %d\r\n, counter); HAL_Delay(500); }正常情况下串口助手应该每隔 500ms 收到一行文本。这里有个小细节日志字符串末尾尽量加\r\n也就是回车加换行。很多终端工具只认\n也能换行但部分软件比如早期版本的 SecureCRT、某些 Android USB 串口工具只处理\r\n不加\r会导致文本全部糊在同一行。观察到正常输出后还可以顺手验证一下各种格式化占位符Debug_Printf(float %.2f\r\n, 3.14159); Debug_Printf(hex 0x%04X\r\n, 0x1234); Debug_Printf(string %s\r\n, uart);浮点打印要特别留意。如果不勾选 MicroLIB并且编译器使用 ARMCC默认标准库可能为了减小代码体积而不支持浮点格式化此时打印 float 会输出空字符串或者错误。解决方法在 C/C 标签页勾选 Use MicroLIB或者确保链接的是完整版标准库再或者像方案三那样用 vsnprintf 配合完整库。4.3 实际调试中的现象记录我实际遇到过的典型情况是代码编译通过串口助手打开后发数据板子没反应然后我把 TXD 和 RXD 对调能收到一堆乱码。这个乱码十有八九不是波特率问题而是 CH340 模块上电瞬间的 AT 指令响应或者板子复位时的引脚电平抖动。解决办法是先断开串口线给板子断电重新上电等程序运行稳定后再接上串口线然后打开助手观察。顺序不对的话复位瞬间的毛刺足以让串口助手卡在第一帧乱码上。另外还要注意不要把 USART1 的 PA9/PA10 和板载 ST-Link 的虚拟串口混为一谈。早期的部分 BluePill 板没有板载 ST-LinkUSB 座直接连到 PA11/PA12那是 USB 外设用的和串口调试口不是一回事。有些人插上 Type-C 线发现设备管理器里多出一个串口就以为能给板子传串口数据实际上那可能是 USB 转串口芯片比如板上集成 CH340提供的 COM 口还是需要按 PA9/PA10 的接线方式连到模块的 RXD/TXD。5. 常见问题与排查技巧实录5.1 常见问题速查表现象可能原因排查与解决完全无输出接线错误TXD/RXD 方向接反交叉接线PA9 接 RXDPA10 接 TXD完全无输出串口调试器选择错误或驱动未装查看设备管理器确认 CH340 或 CP2102 驱动正常完全无输出波特率、数据位配置不一致统一为 115200-8-N-1乱码TXD/RXD 接反交换两根数据线再试乱码时钟源配置错误导致波特率偏差检查 CubeMX 时钟树是否达到 72MHz外部晶振是否实际存在乱码使用了内部 HSI 但芯片体质不佳换用外部晶振或者降低波特率到 9600 验证输出一段后卡死半主机模式未关闭未勾选 MicroLIB 且未执行__use_no_semihosting时按方案二补全重定向输出乱码夹杂 0xFF电平不匹配或传输线过长缩短杜邦线或使用屏蔽导线float 打印为空标准库精简版不支持浮点勾选 MicroLIB或改用完整标准库 vsnprintf5.2 一个容易忽略的坑中文注释与编码很多朋友喜欢在 printf 里直接输出中文字符串比如printf(温度: %.1f\r\n, temp);。Keil5 默认使用 GB2312 或 GBK 编码保存源文件时问题不大但如果代码文件保存为 UTF-8 编码生成的串口数据就是 UTF-8 字节流而串口助手可能默认按 GBK 解码于是中文变成乱码。这里没有绝对的对错只是要在工程早期统一编码。最简单的做法是调试日志尽量用英文或者确保串口助手支持 UTF-8 显示。如果实在要用中文把 Keil5 的 Editor - Encoding 设置为 UTF-8同时串口助手也要切成 UTF-8。否则你会浪费很多时间在“串口乱码”上实际只是编码不匹配。5.3 中断里调用 printf 的隐患如果你在串口接收中断、定时器中断或者外部中断回调里直接调用 printf很容易出现两种问题一种是中断嵌套导致输出时序错乱另一种是 vsnprintf 处理浮点数时占用较多栈空间而中断栈配置过小直接溢出。我踩过几次坑之后的经验是中断服务函数里尽量不要直接做格式化加发送更稳妥的做法是把日志字符串通过一个环形缓冲区暂存起来在主循环里统一输出。如果实在想在中断里发短消息可以自己封装一个仅支持整型转字符串的轻量函数避开 vsnprintf 的栈消耗。5.4 移植到其他 F1 芯片时要注意什么这套串口输出方案换到 STM32F103RCT6、STM32F103ZET6 上也很简单。CubeMX 里重新选择芯片型号USART1 依然是 PA9/PA10重定向代码里把 huart1 换成对应的句柄即可。但如果换到 F4 系列或 G0 系列需要注意 HAL_UART_Transmit 的参数类型没有变化但时钟树配置逻辑差异较大务必在 CubeMX 里重新配置时钟后再复用串口代码。6. 几个进阶扩展方向6.1 从查询发送到中断发送HAL_UART_Transmit 是阻塞发送发送期间 CPU 一直在等待。如果日志量大比如每秒打印几百条CPU 会很忙。更好的模式是开启串口发送中断调用 HAL_UART_Transmit_IT并且在发送完成回调 HAL_UART_TxCpltCallback 里清空占用标志。但这样会引入“上一帧还没发完下一帧请求又来了”的冲突问题需要用队列或状态机管理发送请求。6.2 小成本实现调试日志分级实际项目里日志往往不止“有输出”和“没输出”两档。你可以用宏封装一个简单的分级日志#define LOG_LEVEL 3 #if LOG_LEVEL 3 #define LOG_INFO(fmt, ...) Debug_Printf([INFO] fmt \r\n, ##__VA_ARGS__) #else #define LOG_INFO(fmt, ...) #endif这样一来发布版本只需要改 LOG_LEVEL就能把调试日志整体关掉不需要逐条删代码。这也是 printf 封装比裸调 HAL_UART_Transmit 更值得做的原因之一它把格式化逻辑集中到了一处后期维护成本明显降低。6.3 使用 SEGGER RTT 输出调试信息当你手头有 J-Link 调试器而不是单纯的串口模块时SEGGER RTT 是比串口输出更高效率的替代方案。它通过调试接口直接读写目标 RAM不占用 USART也不会因为打印日志而拖慢程序执行速度。但 RTT 需要 J-Link 的 RTT Viewer 配合如果你的主力调试器是 ST-Link这部分体验会打折扣。串口加 printf 封装依然是最普适的组合。根据我个人经验串口调试这块最容易出问题的点从来不是“代码会不会写”而是“环境配没配好、重定向有没有踩半主机的坑”。把这套流程理清一次后续换芯片、换编译器、加 RTOS基本就是复制粘贴加改引脚的事。如果你现在正准备开始拿一块 F103C8T6 最小系统板加一个 CH340 模块按上面的步骤走一遍半小时内看到串口里的 “hello stm32”后面就顺了。本文还有配套的精品资源点击获取