简介一套基于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 RAMLQFP48 封装某宝上的“最小系统板”大多数就是这颗料。串口输出就是用 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 里显示为 STM32F103C8Target 界面的晶振设置要看清标准最小系统板外部晶振多数是 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默认引脚是 PA9TX和 PA10RX波特率我习惯设 1152008 位数据、无校验、1 位停止位CH340 串口助手最常见的参数。之后去 Clock Configuration 里把系统时钟配置到 72MHzCubeMX 会自动计算分频系数。最后在 Project Manager 里设置工程名和路径路径不能有中文Toolchain 选 MDK-ARM V5Code 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 DebuggerJ-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 的第三个参数是超时时间单位是 ms0xFFFF 表示最长等 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 MicroLIBMDK HAL写完直接用 printf最常见换了 GDB/IDE 要重新适配Keil5 日常调试vsnprintf 封装C 库 HAL可指定串口截断安全跨编译器每次多一次缓冲拷贝多串口项目、量产工程直接 HAL_UART_TransmitHAL最底层最可控没有格式化能力中断里发固定帧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 只有 20KBDMA 缓冲要规划好一般一帧 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 口按时间戳追加到文件的每一行。比如数据格式定义为temp26.5 hum50.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 上完整跑通不止跑通还要故意制造几个故障再亲手解决它们这些经验比任何教程都记得牢。本文还有配套的精品资源点击获取