
1. 为什么每个玩STM32的人都绕不开printf重定向入坑嵌入式单片机开发的朋友十有八九都有过这样的经历明明照着教程敲完了点亮LED的代码板子也正常工作但一旦程序复杂起来想看看某个变量的值、想确认某段逻辑有没有走到就只能干瞪眼。点灯能告诉你“通电了”但告诉不了你“PID算出来的误差是0.32还是0.45”。这时候串口就成了嵌入式开发者的眼睛而printf重定向就是给这双眼睛配上最顺手的工具。说得直白一点printf重定向就是把C标准库里的printf函数输出的字符串从默认的“不知道去哪”改成“通过串口发出去”。这样你在代码里写一句printf(temp %.2f\n, temp);电脑上的串口调试助手就能实时看到这行字。不用仿真器、不用JLINK、不用点灯切换一根USB转TTL线就能解决大部分调试需求。这也是为什么几乎每本STM32入门书籍、每个开发板例程都会讲这件事——它不是某个人的偏方而是嵌入式调试的基本功。这篇文章适合谁看刚接触STM32的初学者、被printf中文乱码折磨的入坑者、想把手写HAL库底层函数理解更透彻的人都适合把自己代入进来。我会把原理、代码、坑点、排查方法全部分享一遍按照我自己实际踩过坑的经验来写尽量让看完的人能一次跑通少走弯路。2. printf重定向的原理C库函数如何找到了串口2.1 printf到底做了什么理解重定向之前得先知道printf的完整工作流程。很多初学者以为printf是“一个函数直接把字打到屏幕上”其实在嵌入式环境中它远不止这么简单。C标准库里的printf是个格式化输出函数它负责把格式串里的%d、%f、%s这些占位符解析成最终的字符串但是解析完之后往哪里送这一步在PC上通常是送到命令行终端而在单片机上没有任何默认的“屏幕”存在。C标准库于是留了一个后门printf最终会把处理好的字符逐个交给底层字符输出函数。在MDKKeil环境下这个底层函数是fputc在GCC环境下通常是_write或_sys_write。默认情况下这个底层函数是“空跑”的也就是你调printf它把字符串格式化好然后丢进了一个黑洞什么都没发生。重定向的本质就是把这个“黑洞”替换成“把字节写入串口外设”的代码。2.2 为什么不是直接访问寄存器还要走C库有人可能会问那我直接写个函数把参数填进串口的发送数据寄存器DR不就行了吗为什么要绕一圈C库这个问题问得很好。直接操作寄存器当然可以但你会立刻撞上一个实际问题你需要在每个需要输出调试信息的地方自己组织字符串。你想打印“当前速度为12.5”如果不用printf你得自己把浮点数拆成整数和小数位、自己算每一位数字对应的ASCII码再一个个塞进发送寄存器。这种事做一次还行做一百次你会崩溃。printf的价值就在格式化这一层。你只需要写一个“把单个字符发出去”的底层函数剩下的一切包括负数、浮点、十六进制、字符串拼接、计数转换全部交给标准库。简单来说你要做的就两步第一步实现单个字符的串口发送第二步告诉链接器“把printf的输出全部交给这个函数”。2.3 拿“送快递”来打个比方把printf重定向理解为送快递就很直观了。printf是一个快递公司的分拣中心快递单就是你的格式串和参数分拣中心根据格式串把包裹字符串打包好然后交给承运方。承运方是谁分拣中心不管它只约定了一个接口“我给你一个包裹一个字符你负责送走。”这个接口就是fputc或者_write。PC上的承运方是终端窗口而单片机的承运方默认不存在所以我们要自己注册一个——注册的过程就是重定向。理解了这一层后面不管看到MDK的MicroLIB方案、标准库方案还是GCC的newlib方案你都不会觉得那是一个个孤立的“玄学步骤”而只是同一套逻辑在不同编译器环境下的不同写法。3. 动手实现四种主流的printf重定向方案对比3.1 方案总览与选择建议在开始写代码之前先给大家一个宏观的表方便按自己的编译环境对号入座。方案适用环境核心实现优点缺点MicroLIB fputcMDK/Keil勾选Use MicroLIB重写fputc配置最简单代码量少浮点打印可能精度受限标准库 半主机模式消除MDK/Keil重写fputc并处理半主机模式问题支持完整C库功能配置相对繁琐HAL库手写fputc任意HAL工程直接重写fputc内部调用HAL_UART_Transmit代码直观不依赖编译器选项需要理解HAL库APIGCC环境_write重定向STM32CubeIDE等GCC环境重写_write或_sys_write适配GCC生态函数名和MDK不同容易写错如果刚入门我个人推荐优先走方案一MicroLIB或方案三HAL手写fputc这两个是最“不容易迷路”的路径。方案二适合对C库完整性有要求的老手方案四是GCC环境下的必选项跑不了。3.2 方案一MDK下使用MicroLIB最简单第一步在Keil工程里点开“Options for Target”找到“Target”选项卡勾选“Use MicroLIB”。MicroLIB是ARM官方为嵌入式环境裁剪的精简C库它默认不依赖半主机模式因此printf重定向会干净很多。第二步在main.c或者其他全局C文件里加上如下代码#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这里有个关键点*fputc的函数签名里第二个参数是FILEf即使你用不到也要把整个签名写完整否则编译器不认为这是“重写”而是“新声明”。第三步保证huart1是全局可用的。如果在main.c外部实现fputc需要extern声明串口句柄或者把fputc直接写在usart.c里。我当时就是在usart.c里加上上面这段代码然后在main.c里调用printf实测没问题。3.3 方案二标准库下消除半主机模式如果不想用MicroLIB想保留完整的C标准库功能那就要处理标准库的“半主机模式”问题。半主机Semihosting是ARM调试器特有的一种机制早期没有真实外设时printf会通过调试接口把数据发给主机端的IDE由IDE在调试窗口显示。这在我们使用串口输出时完全不需要而且如果不处理单片机程序运行到printf时可能直接卡死。处理办法有两步。第一步仍然实现fputc代码同上。第二步加上如下代码禁用半主机模式#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x x; } void _ttywrch(int ch) { (void)ch; }这段代码解释一下__use_no_semihosting告诉链接器“不要给我保留半主机支持”__stdout是重定向输出流需要一个真正的FILE实例_sys_exit和_ttywrch是半主机相关函数的空实现防止链接报错。这种方案最典型的坑是漏写其中一个函数链接器会报错比如“undefined symbol _sys_exit”或“undefined symbol _ttywrch”。遇到这种错误不要慌缺哪个补哪个就行。3.4 方案三HAL库环境下手写fputc最通用现在用STM32CubeMX建HAL工程的人非常多这种环境下最推荐直接在usart.c里加上fputc实现。为什么要放在usart.c因为这里可以直接看到huart1等串口句柄的定义不需要额外extern代码一眼就能看明白。#include stdio.h int fputc(int ch, FILE *f) { while ((huart1.Instance-SR UART_FLAG_TXE) 0); huart1.Instance-DR (uint8_t)ch; return ch; }这里我特意写了一个直接操作寄存器的版本没有用HAL_UART_Transmit。原因有两个一是HAL_UART_Transmit内部有锁和超时判断频繁发送大量调试信息时会引入额外开销二是这种方式能帮你加深对串口底层发送流程的理解。SR寄存器的TXE位发送数据寄存器空标志置1表示DR寄存器可以写入新数据写完之后硬件会自动启动发送。当然用HAL_UART_Transmit也完全没问题代码还更稳尤其在和中断、DMA混合使用的时候。哪种适合你就用哪种不要觉得非要写出寄存器操作才算高手。3.5 方案四GCC环境的_write重定向如果你用的是STM32CubeIDE、或者用arm-none-eabi-gcc自己搭Makefile那情况略有不同。GCC的newlib库在底层输出字节的函数是_write而不是fputc。你需要这样写#include stdio.h #include unistd.h int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }这里的逻辑是printf把格式化好的整段字符串一次性交给_writefile参数可以忽略ptr指向字符串首地址len是长度。返回值必须是实际发送的字节数。如果返回值写错printf可能会反复重发或者干脆认为发送失败导致内容丢失。GCC环境下有个另类注意点newlib很多老版本默认没有开启浮点格式化支持需要加链接选项-u _printf_float。不加的话printf(“%.2f”, 1.5)会输出“%f”这样的字面量而不是数字。4. 实操过程从CubeMX建工程到串口助手看到第一行字4.1 CubeMX初始化配置细节用STM32CubeMX生成工程时有几步值得认真检查。假设用的是STM32F103系列时钟树的配置就不展开说了大家按自己的板子晶振来重点说串口这一块。选好目标芯片后左侧“Connectivity”里点开USART1Mode选择“Asynchronous”异步模式下面的参数默认就行但建议把Baud Rate设成115200。注意波特率不是越大越好——115200在绝大多数USB转TTL模块下都很稳460800虽然看着快但有些CH340G模块在非理想线材下就可能开始丢字节。参数区里有个Word Length、Parity、Stop Bits新手保持默认的8N1即可。生成代码之后硬件连接要特别注意板上USART1_TX是PA9USART1_RX是PA10而USB转TTL模块的TX要接板子的RX、模块的RX要接板子的TX也就是交叉连接。如果接反了输出自然没有任何反应。这个错误我见得太多了连错之后第一反应总怀疑代码有问题实际排查半天发现就是杜邦线接成同向了。4.2 串口助手的正确打开方式电脑端需要装好CH340或CP2102的驱动具体的驱动安装过程略过这里说几个容易忽略的点。打开串口调试助手之后首先要选对COM口号。设备管理器里查看“端口COM和LPT”插上USB转TTL后会多出来一个COM口记住这个编号在调试助手里的“串口号”下拉框里选同一个。接下来是波特率设置成115200、数据位8、停止位1、无校验和CubeMX里保持完全一致。最后点击“打开串口”会看到指示灯变化如果没有报“打开失败/串口被占用”就说明通道没问题。一个小坑如果同时开了多个串口助手软件或者别的IDE的终端插件会导致COM口被占用这时候你在另一个软件里打不开串口提示“Access denied”之类的错误。关掉多余的程序就好。4.3 完整测试代码示例生成好的main.c里我们在main函数的while(1)循环中加入测试代码#include stdio.h uint16_t count 0; float temperature 26.5; while (1) { printf(Hello STM32! count %d\r\n, count); printf(Temperature %.2f C\r\n, temperature); HAL_Delay(1000); }这里有一个很多人不知道的细节建议用\r\n而不是\n作为换行。串口助手的显示逻辑里\n只会让光标下移一行不会回到行首。用\r\n才能实现咱们习惯的“回车换行”。如果你用了\n之后发现输出变成阶梯状一行比一行靠右就是这个原因。编译下载后打开串口助手你会看到类似这样的输出Hello STM32! count 0 Temperature 26.50 C Hello STM32! count 1 Temperature 26.50 C ...到这一步恭喜你printf重定向串口输出已经彻底跑通了。4.4 进阶重定向到多个串口的技巧实际项目里常常有多个串口比如USART1接电脑调试USART2接蓝牙模块USART3接传感器。如果printf只有一个怎么把不同内容送到不同串口一个常用的做法是定义自己的printf变体int fputc(int ch, FILE *f) { HAL_UART_Transmit(debug_uart, (uint8_t *)ch, 1, 0xFFFF); return ch; } void debug_uart2_printf(const char *fmt, ...) { char buf[256]; va_list args; va_start(args, fmt); vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); HAL_UART_Transmit(huart2, (uint8_t *)buf, strlen(buf), 0xFFFF); }先用vsnprintf把格式串解析成字符串再手动往另一个串口发。这样标准printf默认走调试串口而需要走蓝牙串口的日志调用debug_uart2_printf两条路互不干扰。5. 新手最容易踩的坑printf中文乱码和warning #223-d5.1 中文乱码的根源和几种修复方法先说结论printf输出中文乱码和重定向代码本身关系不大基本都是编码不匹配的问题。单片机源文件里写的中文字符串在编译时被保存成了某种编码格式MDK默认工程可能是GB2312也可能是UTF-8串口助手那边接收后又按照自己默认的编码来解码。两边编码不一致显示出来的就是乱码。比如源文件是UTF-8串口助手按GBK解码每个中文都会变成两个或多个莫名其妙的符号。修复办法很直白把两边的编码统一成一种。如果你用MDK可以在“Edit - Configuration - Editor - Encoding”里把源文件编码设置为UTF-8然后在串口助手右上角选择UTF-8解码。或者反过来把源文件编码设为GB2312串口助手选择GBK/GB2312解码都行关键就是统一。如果是STM32CubeIDEGCC环境源文件默认UTF-8串口助手选UTF-8即可。另外说明一下如果乱码还夹杂着ASCII字符位置错乱、丢字那可能不是编码问题而是波特率两边不一致导致收到的是错误位序的“乱码”这属于硬件配置问题。先怀疑编码再怀疑波特率一步步排查。5.2 warning: #223-d: function “printf” declared implicitly 的解决很多人在MDK下编译时遇到这个warning英文原文是“function printf declared implicitly”。它说的是编译器遇到printf调用却找不到printf的原型声明只能默认当成“返回int、参数不限”的函数处理。原因很简单没有包含头文件stdio.h。printf的原型声明在stdio.h里你代码里用了printf就必须#include stdio.h。加上这一行警告立刻消失。如果你加了stdio.h还报这个warning那就要检查是不是#include写错了路径或者文件开头有没有被条件编译括起来。不过99%的场景就是漏了头文件。这个小坑看起来不起眼但真能让明明能跑的代码编译出红黄一片心态会被搞崩所以我单独拎出来讲。5.3 其他高频警告fputc冲突、重复定义在MDK下如果你既写了fputc、又用了HAL库的printf重定向例程有时会出现重复定义的错误。还有的人把多个串口例程的fputc都复制进了工程结果链接时报fputc重复定义因为fputc这个符号在全局范围内只能有一个。解决办法就是全局搜索一下fputc把所有冲突的删除只留一个。如果你有多个串口不要在多个文件里各写一个fputc而是像我前面说的那样做一个自定义多串口打印函数。另外在MDK标准库方案里有人会把FILE类型重定义写到头文件里结果多个源文件include同一个头文件引发结构体重定义错误。这种问题要追溯到头文件有没有加#pragma once或者#ifndef _FILE_DEFINED_这种保护。经验是那种半主机模式的代码放一个.c文件里就好不要丢进.h再到处include。6. 烧写失败、串口驱动、硬件的坑一起扒干净6.1 串口烧写失败三大原因很多用STM32F103C8T6最小系统板的同学是用串口下载程序的通过BOOT0拉高进入ISP模式。串口烧写失败可以归结为三大类。第一类是驱动和端口问题。设备管理器里看不到COM口或者看到但显示黄色感叹号那就是CH340驱动没装好或者USB线质量问题有些线只能充电不能传数据。解决办法换线、重装驱动、检查设备管理器。第二类是BOOT0引脚设置问题。STM32复位后BOOT0为高电平时从系统存储器ISP引导程序启动BOOT0为低电平时从Flash启动。串口烧录时要拉高BOOT0烧完跳线帽调回低电平再复位才能运行新程序。很多人烧写失败是因为BOOT0没拉高烧写器连接了但芯片跑的一直是用户程序自然无法进入ISP模式。第三类是串口的RTS/DTR控制问题。部分下载软件比如FlyMcu通过控制DTR和RTS信号来自动复位进入ISP模式。如果Rx引脚还被其他外设占用或串口模块不支持这些信号线会导致自动复位失败。遇到这种问题可以手动按住复位键点击开始下载后松手用“冷启动”的方式碰碰运气。6.2 CH340驱动和串口模块的选择CH340是最常见的USB转TTL芯片价格便宜稳定性还算可以。Win10/11系统一般自带驱动但老版本系统可能需要手动安装。装好之后设备管理器里出现“USB-SERIAL CH340COMx”就对了。如果发现装上驱动后总出现设备异常可以尝试换个USB口。有些笔记本的USB口供电不稳插前置Hub上会让串口模块工作异常。串口模块供电不稳时经常表现为一打开串口就自动复位单片机或者乱码不断。选购串口模块时建议选带参考电平跳线的因为有的模块是3.3V电平、有的是5V电平。STM32的USART引脚容忍5V输入但为保证双向通信可靠尤其和其他3.3V传感器模块互联时优先选3.3V电平的模块和模式。6.3 串口打开就自动复位的问题这是CH340模块很典型的一个现象打开串口助手单片机立刻重启程序从头跑。原因在于许多CH340模块的DTR和RTS信号线默认被串口助手拉低或拉高而这个信号被接到了单片机的复位引脚NRST上。上电瞬间的时序变化等于给单片机来了个复位脉冲。解决办法有三条一是串口助手软件里取消勾选“DTR/RTS”控制二是硬件上断开CH340模块DTR/RTS与单片机复位引脚的连接很多模块自带跳帽拔掉它就行三是如果一定要用自动下载电路就确保软件里的控制时序是配合BootLoader设计的不要乱点。7. 从printf到真正好用的日志系统7.1 为什么printf不是万能的printf重定向解决的是“能看到东西”的问题但实际项目代码量一大你很快会遇到几个新痛点。一是实时性printf是阻塞发送的如果串口波特率115200每秒最多约11.5KB数据调试日志一旦刷得多主循环会被拖慢很多。二是不同级别日志无法过滤到处都是printf要想只看错误信息需要改代码加if。这时候会有人引入日志分级宏#define LOG_DEBUG(fmt, ...) printf([DBG] fmt \r\n, ##__VA_ARGS__) #define LOG_INFO(fmt, ...) printf([INF] fmt \r\n, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) printf([ERR] fmt \r\n, ##__VA_ARGS__)然后在调试信息前面加个开关宏需要时打开不用时全部屏蔽。这种方案适合中小项目。7.2 更高级的方向交互式命令行标题相关的热词里出现了letter shell、命令行的概念这其实就是printf调试的升级路线。printf是单向的单片机发电脑收。但如果串口既能发又能收配合一个命令行框架就能做到在串口助手里输入命令单片机执行对应函数比如查询传感器、修改参数、查看状态。letter shell就是这样一个面向嵌入式的命令行解析框架运行在HAL库环境下很方便。它的核心思想是注册一些命令函数到shell用户串口输入字符串shell解析后调用对应回调函数。 help adc read 1 temp set 30这种交互式调试比printf的单向日志效率高很多尤其用于调PID参数、查看传感器实时值时能省下一遍遍重新编译下载的时间。不过这是后话先把printf重定向玩明白才有底气上命令行框架。7.3 缓冲区DMA发送的进阶思路如果想彻底解决printf阻塞问题可以考虑把重定向函数改成“往环形缓冲区写入”再由串口DMA空闲中断把缓冲区数据搬出去。这种producer-consumer模式能极大提高CPU利用率。思路大致如下fputc里只把一个字符放进环形缓冲区不做发送等待主循环或定时器中检查缓冲区非空启动DMA传输一段数据。这样printf的耗时只有内存拷贝级别几乎不阻塞业务逻辑。当然跨线程/中断共享缓冲区时要注意临界区保护否则可能出现数据覆盖。8. 踩过这么多坑之后关于串口调试我最后的几点体会写到这里我把能想到的坑都翻了一遍。回头再看printf重定向这件事其实本质上就是一个“给C库找个输出归宿”的小工程它并不难但它牵扯出的一大片知识——C库的半主机、HAL库的串口发送、串口助手的编码、CH340驱动、BOOT引脚设置——却足够让一个新手摸爬滚打好几天。我个人在实际操作中最大的体会是遇到问题先拆层。程序没输出先确认串口线通电没有串口助手打开正常但没数据再怀疑程序程序似乎执行了但输出的内容是乱码才轮到检查编码和波特率。一层层排除比瞎改代码效率高得多。还有一个经验很值钱调试代码本身也是代码要留好开关。我会在工程里定义统一调试开关上线前批量关掉日志输出避免正式运行时串口被无用的调试信息塞满。运行现场想再打开诊断日志保留一个快速入口非常方便。如果你刚入门STM32这篇文章里讲的四套方案至少挑一套跑通如果你已经会了那不妨再看看DMA发送和命令行框架这两个进阶方向。串口调试这个技能看似基础却会陪你走过整个嵌入式开发生涯它在调试效率上节省的时间绝对比你投入在学习它的时间多得多。