简介:一套基于STM32CubeMX生成的STM32F103C8T6标准工程,配套KEIL5开发环境,核心演示串口输出功能与printf函数重定向封装,适合刚接触STM32的嵌入式初学者,也方便开发者快速搭建带打印调试能力的项目骨架。包内共139个文件,以C源码与H头文件、编译中间文件O/AXF/HEX、CubeMX配置的IOC文件及KEIL工程文件为主,整体约3.45MB,文件结构完整,既能直接编译烧录,也能对照学习外设初始化与底层封装思路。目前已有1920人学习下载,工程中针对串口发送做了printf重定向,可直接用标准格式化输出到调试助手,省去逐个字节发送的麻烦。通过这套源码,读者可以理解STM32的HAL库串口配置流程与printf重定向原理,并将封装方法复用到其他项目;同时保留的HEX文件也便于快速验证功能。 做 STM32 开发,绕不开一套组合拳:CubeMX 生成工程、Keil5 编译烧录、串口往外打调试信息。最近我把 stm32f103c8t6 的串口输出和 printf 函数封装完整走了一遍,从 CubeMX 配置到 Keil5 工程设置,再到自己写一个可靠好用的打印函数,过程中踩了不少坑,也整理出几套可以直接抄作业的写法。这篇文章就把完整思路、关键代码和排查经验一次性说清楚。
这套内容适合两类人:一是刚拿到 C8T6 最小系统板、还没搞明白串口该怎么玩的初学者;二是已经会点灯、但 printf 一打印就乱码或者不输出、被重定向折腾到怀疑人生的朋友。串口调试永远值得花时间打磨,它不光是往电脑上发几个字符的事,后面接 GPS 模块、接 4G 模组、接各类传感器,全靠同一套底层逻辑。
1. 项目核心思路与方案选型
1.1 标题里那些关键词到底在说什么
先说 STM32CUEB,其实大家口口相传的准确名字是 STM32CubeMX,一个图形化配置工具,用来初始化芯片引脚、时钟、外设,然后直接生成工程代码。很多教程里把它简写或写错,搜索时两个名字都能搜到,但下载软件时认准 CubeMX 就行。
KEIL5 指的是 Keil MDK-ARM 5.x,目前 STM32 开发最主流的 IDE 之一。stm32f103c8t6 是意法半导体经典的 M3 内核芯片,64KB Flash、20KB RAM,LQFP48 封装,某宝上的“最小系统板”大多数就是这颗料。串口输出就是用 USART 外设把数据发到电脑端串口助手。printf 函数封装,则是把 C 库的 printf 重定向到底层串口,让调试代码像在电脑上写控制台程序一样方便。
这一连串词串起来的完整工作流程是:CubeMX 配置 USART1 和时钟,生成 MDK-ARM 工程,再用 Keil5 编译、下载,最后通过重定义 fputc 或者自封装打印函数,让程序里的 printf 能直接出现在电脑屏幕上。
1.2 为什么我用 CubeMX + HAL 而不是标准外设库
现在网上还能搜到很多基于标准库的 stm32f103c8t6 例程,代码确实简洁,寄存器也看得清清楚楚。但我建议新项目直接用 HAL 库,原因很简单:CubeMX 生成的工程已经把时钟树、引脚复用、中断优先级这些最容易出错的地方全部处理好了,你只需要在图形界面里勾选配置。对于 C8T6 这种入门板子,HAL 库多出来的那点资源开销完全不用担心。
更重要的是生态问题。现在 ST 官方主推 HAL,后续如果你想换 F4、F7、H7,或者用 CubeMX 配合 FreeRTOS、LVGL 等中间件,全是同一套框架。标准库学完还得再学一遍 HAL,不如一步到位。Keil5 在这里的作用就是编译器和调试器,CubeMX 负责生成代码,两个工具配合好,后面所有工程都是这套流程。
2. 环境准备与工程搭建全流程
2.1 Keil5 安装和芯片支持包这个坑
Keil5 和 Keil4 最大的区别是“支持包”机制:MDK 核心只是一个 IDE 骨架,具体支持哪颗芯片,要单独安装 Device Family Pack。很多人装上 Keil5 后打开工程提示 “Device not found”,就是因为没装 STM32F1 的支持包。
正确做法是:先安装 MDK-ARM(版本选 5.30 左右都行,太新或太旧偶尔会有兼容问题),安装路径不要带中文;然后在 Pack Installer 里找到 STMicroelectronics 目录下的 STM32F1xx Series,把对应 Device Pack 装上。也可以直接在官网下载 Keil.STM32F1xx_DFP.x.x.x.pack 离线包,双击导入,速度更快。装好后新建工程时 Device 列表里能看到 STM32F103C8,就说明支持包生效了。
C8T6 这个型号在 Keil 里显示为 STM32F103C8,Target 界面的晶振设置要看清,标准最小系统板外部晶振多数是 8MHz,但你用的时钟源必须和 CubeMX 里的 RCC 配置一致,否则后面串口乱码的第一个嫌疑点就埋下了。
2.2 CubeMX 配置串口的最小步骤
打开 CubeMX,新建工程,选择芯片 STM32F103C8Tx,这一点没什么可说的。进入 Pinout & Configuration 界面后,有几处必须配置:
第一,SYS -> Debug 选 Serial Wire。这颗芯片默认 SWD 引脚是可以复用的,不把它设置成调试口,程序第一次烧进去后 ST-Link 可能就连不上了,只能按复位键碰运气。养成好习惯,新建工程先把 SWD 打开。
第二,RCC -> HSE 选 Crystal/Ceramic Resonator。如果板子上有 8MHz 晶振,这里就对应外部高速晶振;如果你的板子实际没焊晶振,那就只能靠内部 HSI,时钟树就完全不同了,不求 72MHz 满速也能跑。
第三,USART1 模式选 Asynchronous,默认引脚是 PA9(TX)和 PA10(RX),波特率我习惯设 115200,8 位数据、无校验、1 位停止位,CH340 串口助手最常见的参数。之后去 Clock Configuration 里把系统时钟配置到 72MHz,CubeMX 会自动计算分频系数。
最后在 Project Manager 里设置工程名和路径,路径不能有中文;Toolchain 选 MDK-ARM V5,Code Generator 里建议勾选 “Generate peripheral initialization as a pair of .c/.h files per peripheral”,这样每个外设独立成文件,工程结构清爽得多。
2.3 Keil5 打开工程后的三个关键设置
CubeMX 生成的是 MDK-ARM 文件夹里的 .uvprojx 工程,双击就能打开。但先别急着编译,三个设置确认好再动手:
第一个,Options for Target -> C/C++ 页面,Verify 一下 Define 里有没有 USE_HAL_DRIVER 和 STM32F103C8Tx。通常 CubeMX 会自动生成,但如果你的工程是从别的模板改的,这两个宏缺失会编译出一大堆底层错误。Include Paths 也要检查,确保包含了 Core、Drivers 下的头文件目录。
第二个,Options for Target -> Debug 页面,选择你实际的下载器。ST-Link 就选 ST-Link Debugger,J-Link 就选 J-Link,设置里还要确认 Flash Download 的编程算法是 STM32F10x Med-density Flash 64K。C8T6 是 Medium-density,选错算法下载时会报地址越界。
第三个,Target 页面里勾上 Use MicroLIB。这个选项对串口重定向至关重要,稍后细说。不勾也能编过,但很多标准库函数在嵌入式环境里会牵扯到半主机模式(semihosting),导致程序卡死在奇怪的地方。用 MicroLIB 能省掉至少一半烦恼。
3. printf 函数封装的三种硬核方案
3.1 为什么说“重定向”是串口调试的基石
单片机里原本没有 printf 这个概念,printf 是 C 标准库提供的格式化输出函数,它最终是通过 fputc 这个底层字符输出函数把一个个字符送出去的。在电脑上,fputc 写到屏幕;在单片机上,它的默认实现可能是写到调试器、或者什么都不干。所以要让 printf 走串口发出去,本质就是重新实现 fputc,让它调用 HAL_UART_Transmit。
但如果不想依赖标准库,也可以自己写一个格式化函数。两种思路各有优势,我实际项目里往往会同时备着:调试阶段用标准库 printf,代码里又有自己的 uart_printf 供中断或者特殊场景调用。
3.2 方案一:标准库重定向 fputc + MicroLIB
在 Keil5 环境下,最经典的做法是新建一个 retarget.c 或者在 main.c 顶部加一段代码:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这段代码里 huart1 是 CubeMX 生成的全局串口句柄。HAL_UART_Transmit 的第三个参数是超时时间,单位是 ms,0xFFFF 表示最长等 65 秒,正常发送一个字节用不了那么久,但这样写能防止极端情况阻塞死。编译时需要勾选 Target -> Use MicroLIB,原因前面说了:标准库的重定向在 MDK 里默认带半主机依赖,MicroLIB 是专门为嵌入式裁剪的轻量库,重定向后不容易出幺蛾子。
用的时候直接printf("hello\r\n");即可。注意我习惯在字符串末尾加\r\n而不是只加\n,因为很多串口助手只认\r\n才换行,不加\r的话文字会叠在同一行。
3.3 方案二:不依赖标准库的 vsnprintf 封装
如果你不想动标准库,或者在一些不能用 MicroLIB 的工程里,可以用 vsnprintf 自己封装一个。vsnprintf 是标准 C 库里的格式化函数,把格式化后的结果写进一个字符数组,然后再由你决定用什么方式发出去。封装代码如下:
#include <stdarg.h> #include <stdio.h> #include "usart.h" void UART1_Printf(const char *fmt, ...) { static char buf[256]; va_list args; uint16_t len; va_start(args, fmt); len = vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len > sizeof(buf) - 1) { len = sizeof(buf) - 1; } HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 0xFFFF); }这里用 static 数组而不是栈上数组,是为了避免在某些栈空间紧张的配置下崩掉。如果格式化字符串本身内容不可控,比如来自网络或用户的输入,vsnprintf 会自动截断,比 sprintf 安全得多。这个函数的用法和 printf 一模一样,只是函数名换成 UART1_Printf,括号里的格式化参数照写:UART1_Printf("temp: %.2f C\r\n", temp);。
3.4 三种方案到底怎么选
标准库里其实还有多个重定向入口,除了 fputc,也可以从半主机模式层面改造,比如实现_sys_write、关闭 semihosting 等。但在 MDK5 + HAL 的现代写法里,fputc 重定向是最稳定、最不容易出问题的路径。自封装方案的优势是不依赖具体 IDE,换到 GCC 或 IAR 也通用,并且能随意指定串口句柄。如果你手上有两个串口,一个打印一个通信,自封装方案就能自由切换,而标准库重定向只能管一个默认输出流。
结合经验,下面这张表是我常用的选择依据:
| 方案 | 依赖 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| fputc + MicroLIB | MDK + HAL | 写完直接用 printf,最常见 | 换了 GDB/IDE 要重新适配 | Keil5 日常调试 |
| vsnprintf 封装 | C 库 + HAL | 可指定串口,截断安全,跨编译器 | 每次多一次缓冲拷贝 | 多串口项目、量产工程 |
| 直接 HAL_UART_Transmit | HAL | 最底层,最可控 | 没有格式化能力 | 中断里发固定帧 |
4. 完整代码实现与串口验证记录
4.1 CubeMX 生成文件里的核心代码位置
CubeMX 生成工程后,串口初始化主要在 usart.c 的 MX_USART1_UART_Init 里,时钟初始化在 main.c 的 SystemClock_Config 里,用户代码写在 main 函数预留的 USER CODE 区。我不建议去改初始化函数,CubeMX 重新生成代码时会覆盖它们;要加的代码都放在标记之间。
串口发送的核心函数是 HAL_UART_Transmit,它的原型是:
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);注意 Size 是 uint16_t,最多 65535 字节,数据指针指向 uint8_t 数组。printf 产生的字符数组是 char 类型,转换成 uint8_t 指针即可。这也是很多新手最容易忽略的细节:类型不匹配编译告警,然后稀里糊涂把数据截断了。
4.2 一个可以直接跑通的完整 main 示例
下面是一个最简示例,把重定向和测试输出都放在 main.c 的 USER CODE 区:
#include "main.h" #include "usart.h" #include <stdio.h> UART_HandleTypeDef huart1; void SystemClock_Config(void); static void MX_GPIO_Init(void); static void MX_USART1_UART_Init(void); int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); int count = 0; while (1) { printf("count = %d\r\n", count++); HAL_Delay(500); } }这段代码烧进去后,打开串口助手,波特率 115200,就能看到每秒两次的计数输出。如果没看到数据,先把 TX/RX 两根杜邦线反接试一下,再检查 COM 口号是不是被其他软件占用了。
这里要多说一句:HAL_Delay 默认用的是 SysTick,而 printf 里的 HAL_UART_Transmit 又是阻塞发送。在高波特率下阻塞时间非常短,但如果你在主程序和中断里同时调用 HAL_UART_Transmit,可能出现数据交错。后面项目变复杂了,建议给串口发送加一个互斥标志,或者在中断里只使用不等待发送完毕的 DMA 方式,把“打印”和“实时响应”解耦开。
4.3 验证过程中的关键现象记录
我第一次验证时遇到的现象很有代表性:程序能跑,LED 能闪,就是串口没输出。排查了很久发现是 CubeMX 工程生成时路径带有中文,Keil 的编译器头文件引用出现异常,但报错又不够明确。重新把工程放到纯英文路径后,一切正常。所以工程路径、用户名叫中文这种环境问题,遇到怪毛病时可以第一时间怀疑。
第二个现象是输出全乱码。当时波特率设为 115200,但 CubeMX 时钟树里 HSE 填的是 25MHz,而板上实际是 8MHz 晶振。HSE 差了三倍多,导致 USART 波特率发生偏移,当然乱码。改成 8MHz 后问题消失。这个坑属于 F103C8T6 最小系统板的经典坑,尤其是从淘宝买的板子,标注的晶振值必须要和实物一致,最好直接看原理图确认。
5. 常见问题与排查技巧实录
5.1 printf 不输出但工程编译正常
这是新手问得最多的问题。编译没问题,烧录成功,串口助手不开或没反应。依次检查三件事:第一,串口助手是否真的打开了正确的 COM 号,拔插 USB 转 TTL 后在设备管理器里看新增的 COM 口;第二,接线是否交叉连接,板子的 TX 要接 USB 转 TTL 的 RX,如果接成 TX 对 TX,数据是发不出去的;第三,代码里是否真的调用了 printf,放在循环外面的语句只执行一次,看过就没下文了。
如果以上都没问题,就要检查重定向代码有没有被编译器优化掉,或者根本没编进工程。CubeMX 生成工程后可能没有把 retarget.c 加入编译,需要在 Keil5 左侧项目管理器里确认文件前面的方框是勾选的。另外,用标准库重定向时不要忘记勾选 Use MicroLIB,不勾选时也会出现 printf 调用了但串口没消息的现象,原因是底层出口不是你的 fputc。
5.2 串口中断只收到一次就再也不进中断
这个坑出现在你用 HAL 库做中断接收时。HAL_UART_Receive_IT 做的是“一次一收”的逻辑:你每次调用它,就只能接收一个字节进中断,处理完回调后如果不重新调用,中断通道就关闭了。正确写法是在回调函数里再调一次:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 处理收到的数据 rx_data HAL_UART_Receive_IT(&huart1, &rx_data, 1); // 继续接收下一字节 } }如果数据量大、间隔很短,这种逐字节中断方式还容易丢失数据,因为中断频繁打断主程序。更省心的方案是用空闲中断(IDLE)加 DMA,收到一帧后再一次性处理,这对串口日志记录、GPS 数据处理都更友好。C8T6 的 RAM 只有 20KB,DMA 缓冲要规划好,一般一帧 256 字节以内问题不大。
5.3 烧录失败和调试器连不上的几种原因
“No target connected”是另一个高频报错。遇到时先检查 ST-Link/J-Link 的接线:VDD、GND、SWDIO、SWCLK 四根线,SWDIO 和 SWCLK 是交叉很容易接反。确认板上供电正常,有些最小系统板用 ST-Link 供电时要把跳线帽设置正确。
其次是 Flash Download 算法选择错误,如果编程算法里没有 STM32F10x Med-density 128K 或 64K 的选项,就在 Flash Download 页面点 Add 添加。再有就是目标板进入低功耗或引脚被复用了,如果之前烧过把 SWD 引脚改成普通 GPIO 的程序,此时必须按住复位键,在点击下载的瞬间松开,让芯片复位后立刻被调试器连接。
5.4 printf 打印浮点数出不来
调试传感器时经常要打印 float,比如温度、电压。有些旧版 MDK 配合 MicroLIB 时,printf 的 %f 格式化可能输出空白或者显示 “f” 之类的字符,原因是嵌入式裁剪版库对浮点格式化的支持不完整。解决办法有两个方向:一是确认 Target 页面里的 MicroLIB 勾选状态,并尝试更新 MDK 到较新版本;二是干脆不用 %f,自己手工把浮点拆成整数和小数部分:
float temp = 26.53f; int int_part = (int)temp; int frac_part = (int)((temp - int_part) * 100); printf("temp = %d.%02d\r\n", int_part, frac_part);这个方法简单可靠,嵌入式里大量使用。要注意负数取整可能对小数部分有影响,处理前先把绝对值算清楚。
6. 把这套串口能力复用到更多场景
6.1 给 printf 加时间戳和分级日志
串口打点和裸 printf 最大的区别,是日志信息能不能帮你定位问题。我后期给串口封装加上了时间和级别两个维度:每次打印自动带上毫秒级计数,并区分 DEBUG、INFO、ERROR。这样程序跑飞或者流程异常时,能直接从时间戳上看出卡在哪个阶段。
实现思路并不复杂,关键是封装一个统一的日志入口。底层输出仍然用前面写的 UART1_Printf,只是在格式化前把时间戳前缀拼进去。实际工程里还可以用条件编译控制 DEBUG 版本把日志全量输出,量产版本只保留 ERROR,通过一个宏开关切换。
6.2 把串口数据记录成文本文件
很多新玩法关注“串口数据记录仪,把数据输出为文本形式”。最简单的方式是串口助手自带的“保存日志”功能,把收到的所有内容直接存成 txt。若想更自动化,可以用 Python 写一段脚本读 COM 口,按时间戳追加到文件的每一行。比如数据格式定义为temp=26.5 hum=50.3\r\n,脚本按行解析后还能进一步绘制曲线或者入库。
如果单片机自己有 SD 卡,也可以让 C8T6 把格式化好的字符串通过 FATFS 写到 txt 里,这就是一个离线记录仪的核心。关键仍然是格式化数据的统一出口,所以一开始把 UART1_Printf 这种封装做好,后面接什么通道都方便。
6.3 同一个串口框架对接各种通信模组
串口外壳写好了,后面就顺畅了。用 C8T6 对接 ESP01S WiFi 模组、HX711 称重芯片、MT6701 磁编码器,甚至 4G 模组做 MQTT 上云,底层都是这些知识点:CubeMX 配置对应的串口,发送时打包成帧,接收时用中断/DMA 解析协议。你在 printf 重定向上省下来的时间,正好可以投入到协议解析和滤波算法这些真正有含金量的部分。
以 4G 模组为例,AT 指令交互本质上就是“单片机发字符串给模块,模块回字符串给单片机”。你要做的只是把 UART1_Printf 换成发往 4G 串口的 SendString,再把模块返回数据接进接收解析状态机,一整套通信链路就能搭起来。
最后说点个人体会。串口和 printf 这种看起来最基础的东西,反而是整个嵌入式开发生命周期里使用率最高的调试手段。现在我接任何新板子,第一件事就是点亮 LED,第二件事就是搞定串口打印。这两个地基打牢,后面移植代码、调传感器、排查问题都会顺手很多。特别是 printf 封装,早一点养成自己的统一风格,后续几十个项目都会受益。建议你花一个晚上把本文的方案在 C8T6 上完整跑通,不止跑通,还要故意制造几个故障再亲手解决它们,这些经验比任何教程都记得牢。
本文还有配套的精品资源,点击获取