搞嵌入式的朋友应该都有过这种经历:代码逻辑明明没啥问题,但程序跑起来就是不对,要么卡死、要么数值诡异,你又不知道它到底执行到哪一步。这时候最高效的办法就是往串口打日志,把关键变量、运行状态、甚至函数入口出口都打印出来。而真正用起来最顺手的,就是直接把C库的printf重定向到串口——代码里写printf("temp = %d\r\n", temp),就能在电脑上的串口调试助手看到内容。
这篇文章就专门讲清楚一件事:如何在Keil MDK环境下,把printf的输出重定向到串口,含标准库方案和MicroLIB方案两种做法,并解释底层原理、踩坑点、乱码处理、DMA/中断扩展等。内容适合刚学STM32或增强型51的朋友,也适合那些printf一直都是“死代码”从来没真正跑通的工程师。看完你就能在自己的工程里直接抄作业。
1. 为什么非要把printf“重定向”到串口
1.1 printf最终调用了谁:半主机模式的坑
先弄清楚一个很多人没搞明白的问题:在Keil MDK里,直接写printf("hello\r\n"),程序编译能过,但下载到单片机里,要么完全没有输出,要么一执行到printf程序就跑飞了。为什么?因为C标准库的printf,默认输出目标是“标准输出设备”,在桌面程序里是显示器,而在裸机单片机环境里,这个目标并没有被定义好。
ARM的C库(也就是Keil MDK自带的ARM Compiler内置C库)提供了一个所谓的“半主机模式”(semihosting)。这个模式里,printf输出会通过调试器(比如ST-Link、J-Link)转发到PC端,由调试工具显示出来。听起来很方便,但问题是:如果目标板没有连接调试器,或者调试器在release模式下不开放半主机通道,那么程序在执行到printf时会直接进入一个异常分支,在Cortex-M系列上就表现为HardFault,程序卡死。
所以想要让printf真正“落地”,必须自己接管底层输出。C库的printf最终会调用一个函数:fputc,而fputc的默认实现是往调试通道发送字符。我们要做的事情很简单——重写fputc,把字符串的发送目标从调试通道改成串口外设。这就是“重定向”的本质。
1.2 为什么不用现成的printf替代方案
很多人为了省事,会自己写一个类似printf的函数,比如u1_printf("temp = %d", temp),然后用可变参数加vsnprintf自己拼格式化。这样做当然也可以,尤其是一些只支持标准C89的老编译环境。但问题在于:每换一个串口、每换一个工程,都要重新维护一套私有打印函数,代码移植性差,团队成员看代码也得适应你的约定。
而用标准printf加重定向,好处是显而易见的:格式化语法是通用标准,%d、%s、%.2f、%03x大家都认识,不需要额外学习;代码里写日志的语法和PC端程序一致;后续如果输出目标从UART改成别的通道(比如LCD、日志文件、蓝牙模块),只需要改一个fputc函数即可,业务代码完全不用动。
所以从长远维护的角度,学会重定向printf是嵌入式调试功底的必修课。不过有一点要提前说清楚:标准库的printf带完整浮点支持,代码体积大、内存开销高;MicroLIB的printf精简了部分功能,代码体积小,但对浮点格式化的支持是阉割的。这两者的取舍,我会在第3节重点展开。
2. 开工前的准备工作:硬件、驱动和串口助手
2.1 硬件连接与USB转TTL选型
要看到printf输出,硬件链路是:单片机UART_TX → USB转TTL模块的RX → USB转TTL模块的TX → 单片机UART_RX(如果你需要双向通信),同时GND必须共地。
这里最容易踩坑的地方就是TX和RX的交叉连接。我见过不止一个朋友把单片机的TX接到USB转TTL模块的TX上,结果串口助手完全没反应,还以为是代码写错了。记住口诀:发送接接收,接收接发送,地线必须通。
USB转TTL模块的常用芯片有CH340、CP2102、FT232等。其中CH340最常见,便宜、兼容性好,但Windows 10/11偶尔会在驱动上出问题,表现为插上后设备管理器里看不到COM口。解决办法是先装官方驱动,然后在设备管理器里检查“端口(COM和LPT)”下面是否有带感叹号的设备,如果有,右键更新驱动程序、手动指定到驱动目录即可。FT232相对最稳,但价格也最贵。新手上路推荐CH340模块就够用,价格十块钱左右。
调试时还有一个细节:模块如果自带3.3V/5V电平选择跳线,一定要根据单片机IO电平选对。STM32大部分IO是3.3V,接到5V电平的串口模块上可能损坏单片机引脚;51系列和部分STM32板子本身是5V供电的,需要选5V档。不确定的时候,优先用3.3V,并仔细看MCU数据手册的VDD范围。
2.2 串口助手的正确设置
硬件连接好之后,打开串口调试助手(推荐XCOM、SSCOM或者SCommAssistant,这几个都轻量好用),一定要做以下设置:
- 串口号选择正确:在设备管理器里看到是COM3还是COM9,选对应端口;
- 波特率、数据位、停止位、校验位要匹配。
- 打开串口后,把发送区域的“发送新行”勾上,这样发送时自动追加换行符。
最关键的问题是波特率。波特率就是每秒传输的符号数,比如115200表示每秒传115200个比特。你程序里配置的波特率必须和串口助手里的波特率完全一致,否则收到的全是乱码。有些人明明代码没问题,字符串也发了,但串口显示的是“锟斤拷”或者一堆不认识的符号,八成就是两边的波特率没对上。
另外,串口助手底部还有一个“DTR”和“RTS”选项。这两个信号在普通串口调试里一般不需要勾选,尤其DTR如果勾上了,有些板子的复位电路会被拉低,导致每次打开串口时单片机自动复位,程序从头开始跑,你会看到串口里莫名其妙输出好几遍启动日志。遇到这种情况,把DTR和RTS的勾去掉就好。
2.3 一份能用的UART发送驱动
重定向printf有一个大前提:你至少得有一个能往串口写一个字节的函数。不管是用寄存器操作还是HAL库,这个函数必须存在,哪怕很简单,比如:
// 寄存器版:STM32F103 USART1发送一个字节 void uart_send_byte(uint8_t ch) { while (!(USART1->SR & USART_SR_TXE)); // 等待发送数据寄存器为空 USART1->DR = ch; }如果你用的是HAL库,那对应的是HAL_UART_Transmit,但要注意这个函数有超时机制,实参里通常要传一个超时时间,比如HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF)。中断版本则是HAL_UART_Transmit_IT。
我个人建议新手至少理解一下寄存器版,因为它最直观地反映了发送的底层流程:先把数据塞进DR寄存器,然后等发送完成或等待发送寄存器空。不理解这一点,后面排查波特率问题、DMA问题时会很吃力。
方向明确了,接下来就是正戏:如何优雅地重定向printf。
3. 两种重定向方案:标准库与MicroLIB
3.1 标准库方案:完整版printf全功能
标准库方案适合对浮点格式化有需求、不介意代码体积稍大的工程。具体操作分两步:
第一步,在工程设置里,确认没有勾选“Use MicroLIB”。在Keil MDK里点击魔法棒图标,打开Options for Target选项卡,选中Target标签,MicroLIB复选框保持不勾选状态。
第二步,写fputc函数。在任意一个C文件中加入:
#include <stdio.h> int fputc(int ch, FILE *f) { // 等待发送数据寄存器为空 while ((USART1->SR & USART_SR_TXE) == 0); // 将字符写入发送数据寄存器 USART1->DR = (uint8_t)ch; return ch; }关键点在于返回值:fputc要求返回“实际发送的字符”,所以一定要写成return ch,不能写成return 0或return 1,否则某些编译器会认为发送失败,行为不可预期。
这里再解释一下为什么用标准库就能完整支持浮点:标准库的printf实现里包含了完整的浮点格式化逻辑,%f、%g、%.2f都会转成对应的字符串。代价就是链接体积变大,通常会增加几KB到十几KB的Flash占用,具体取决于编译优化等级和工程使用情况。
使用标准库时还需要注意一点:如果工程里开启了微库,那么标准库方案就不成立了,所以这个选项只能二选一。不少人一开始没搞清楚,勾了MicroLIB又想用%f,结果发现浮点打印一直是0.00,这就是微库的功能阉割问题。
3.2 MicroLIB方案:体积小但精度受限
MicroLIB是Keil专门为嵌入式裁剪的轻量C库。它体积小、速度快、内存占用少,因此被大量用于Cortex-M系列和51等资源受限平台。
在Keil中勾选“Use MicroLIB”后,同样写fputc重定向函数。但和标准库不同的是,MicroLIB的维c库实现里,有些格式化逻辑是精简掉的。最典型的就是对浮点格式化的支持不完整,甚至完全不支持。你可能会发现:
- 打印整数%d、%x、%s都很正常;
- 打印浮点%f、%.2f,IDE编译都不报错,运行结果却是0.00或乱码;
- 如果用微库和标准库混着链接,还会出现奇怪的malloc崩溃问题。
所以,不少量产的项目,尤其是打印日志密集型工程,直接用微库加整数打印,既省Flash又可满足需求。如果必须打印浮点,可以自己先把浮点转成字符串,比如用sprintf先格式再传出去,或者把浮点拆成整数部分和小数部分分别打印:
float val = 3.1415f; int int_part = (int)val; int frac_part = (int)((val - int_part) * 10000); printf("%d.%04d\r\n", int_part, frac_part);这个方法虽然笨,但在微库下非常好使,输出结果和%f差不多。
3.3 Keil里的配置步骤与代码整合
再梳理一遍最完整的配置流程,跟着走一遍基本不会出错:
- 先写好串口的初始化代码,确保UART发送功能已经打开;
- 编写fputc函数,内容就是往UART发送一个字符;
- 打开Options for Target,Target选项卡,勾选或不勾选Use MicroLIB;
- 编译烧录,用串口助手验证printf是否输出。
这里有一个绝大多数新手会遇到的坑:勾选了Use MicroLIB之后,工程可能会报错,提示__use_no_semihosting之类的符号冲突,或者链接时找不到__stdout。这个问题的原因在于,半主机模式的标准库入口被裁剪后,缺少stdout重定向目标。解决办法是在工程里添加一段代码声明不使用半主机模式:
#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; void _sys_exit(int x) { x = x; } void _ttywrch(int ch) { ch = ch; }这段声明告诉链接器:我这个工程不使用半主机模式,别给我链接半主机的底层。有了这个声明,fputc重定向才能干净地工作。
还有一种情况,如果你的单片机是STM32且用的是HAL库,用标准库时可能会遇到报错:Error: L6915E: Library reports error: __use_no_semihosting was requested, but _ttywrch was referenced。这个报错出现的原因也是半主机相关符号被引用,处理方法还是加上面这段代码。
我把标准库方案和MicroLIB方案整理成一张对照表,方便你按自己项目情况选择:
| 对比项 | 标准库 | MicroLIB |
|---|---|---|
| 代码体积 | 较大 | 较小 |
| 浮点格式化支持 | 完整 | 有限(部分版本不支持) |
| 半主机模式默认状态 | 默认存在,需要声明禁用 | 裁剪后需要手写支持 |
| 适用场景 | 功能齐全的调试和开发 | 资源紧张的量产项目 |
| 推荐指数(新手) | 高 | 中 |
一句话总结:没有特殊限制的工程,建议先用标准库,把printf跑通;等到最后优化体积、确认不需要浮点打印了,再切换到MicroLIB省那几KB空间。
4. 长格式、中文与转义:printf活着但输出不对怎么办
4.1 中文乱码:一个字符一个字节的约定
串口打印英文没毛病,但打印中文可能出现乱码,比如“温度”两个字在串口助手显示成“娓╁害”。原因主要有两个。
第一个原因是编码不匹配。你在Keil编辑器里写代码,源文件保存的是GB2312编码还是UTF-8编码,直接决定了字符串在Flash里存的是哪一串字节。而串口助手默认按GB2312或者UTF-8对收到的字节流进行解码。如果两边编码不一致,中文肯定乱。
解决办法:一种是把源文件统一改成UTF-8编码,同时在串口助手里把编码方式选成UTF-8;另一种是源文件用GB2312/ANSI编码,串口助手选默认GBK。关键是两边保持一致。
第二个原因是一个中文字符在UTF-8编码下占3个字节,而很多串口助手是按单字节解码的。即便编码方式选对了,如果你的发送时序有误、或者某个字符被断包,也会出现乱码。这个没有特别好的办法,只能保证发送函数不要丢字节,波特率不要设太高(115200以下比较稳),然后尽量把中文日志的字符串统一放到一个const数组里,一次性printf发出去,避免多个小printf拼接到一起。
另一个常见现象是串口助手显示“???”,这是因为串口助手收到的字节不符合当前文本编码。可以先在串口助手里把编码切换到UTF-8试试;如果还是问号,就检查fputc是否真的发送了完整字节,尤其是中文在UTF-8下是多字节,fputc会逐字节发送,理论上没问题,但如果你的串口助手开了“HEX显示”,就会把这些字节显示成十六进制数,看起来像是乱码。
4.2 不同库对printf结果的差异
除了中文和编码,还有一类问题更隐蔽:同样的printf代码,标准库和MicroLIB输出的内容还不一样。比如:
printf("%.2f\r\n", 3.14159);标准库会输出3.14,MicroLIB在某些版本下可能输出3.00,甚至直接输出0.00。原因前面说过了:浮点格式化在微库中是精简实现,甚至被裁剪成“不解析浮点参数”。
还有整数格式化的大坑:%d在标准库中是int,但如果你传入一个uint8_t变量,会发生隐式类型提升,结果通常是正确的;如果你传入uint8_t的指针,没有做类型转换,就会打印出错误的值。所以建议在printf里使用显式类型转换:printf("%u", (unsigned int)len)。
另外,浮点打印如果遇到%f输出总是0.00,除了微库问题之外,还有一种可能是参数没有进浮点寄存器。ARM Cortex-M的AAPCS调用约定要求在参数列表中有浮点参数时,如果系统支持FPv4或更高级别,编译器可能会把前几个浮点参数放进FPU寄存器,而某些早期版本的MicroLIB重定向机制对这个处理不完整,导致printf从寄存器里取不到浮点值。这种情况下要么关闭FPU使用软浮点,要么把浮点数先强制转换成一个更大的整数表示。
如果你的工程是M0/M0+内核,好消息是不存在FPU寄存器问题,但坏消息是这类芯片Flash通常小,更依赖微库裁剪,所以浮点打印问题反而更常见。
4.3 转义字符的正确使用与输出格式化技巧
最后把转义字符的几个经典坑说明白。很多初学者写printf("hello\n"),发现串口助手里文本没有换行,只显示一个空格或者直接重叠到下一行开头。原因:\n是换行(LF),ASCII码是0x0A,而很多串口终端需要\r\n(回车+换行)才能正确换行。所以嵌入式串口打印,字符串结尾建议用\r\n,比如:
printf("temp = %d\r\n", temp);有些串口助手提供了“发送新行”功能,那个是影响串口助手发给单片机的数据,和单片机printf输出的换行格式没有任何关系。也就是说,你在单片机侧printf输出的内容是\r\n,串口助手收到什么就是什么,除非你勾选“接收换行转换”。
还有格式化宽度的问题。打印十六进制时想补零,用%08X可以输出8位十六进制,前面补0,这在打印寄存器值时特别好用。比如想打印USART1->SR,可以写成:
printf("USART1->SR = 0x%08X\r\n", (unsigned int)USART1->SR);打印有符号负数的%d你也要注意,如果你的变量是uint32_t但值超过了0x7FFFFFFF,那么用%d打印会显示负数,因为%d按有符号整数解释,建议改成%u。
格式化的细节虽多,但真正让你在调试时效率翻倍的,往往是那些不起眼的技巧:十六进制带前导零、%d和%u的区分、字符串结尾用\r\n。这些习惯养成后,写日志代码基本不用想。
5. 进阶:中断发送、DMA发送和RTOS注意
5.1 中断发送与临界区
上面讲的fputc实现是阻塞式的,也就是“while等待发送寄存器空,空了一个字节一个字节地发”。这种方式简单可靠,但在波特率低(比如9600)时,发一串日志会让CPU空转很久。比如一个100字节的字符串,在9600波特率下传输需要100 * 10bit / 9600 ≈ 104ms,这期间CPU全在while循环里等,主流程被完全卡住。在实时性要求高的系统里,这是不可接受的。
改进方案之一是中断发送。把fputc改成只把字符塞进一个环形缓冲区(ring buffer),然后在串口发送完成中断里,从缓冲区取下一个字符发送。这样printf调用本身几乎是异步的,不阻塞主流程。但代价是逻辑复杂度上来了:环形缓冲区的读写要处理并发问题,缓冲区满了要处理覆盖策略,单字节发送完成后要判断是否还有数据。
由于这里是在讲fputc重定向,需要指出一个很多人踩过的坑:如果你在fputc里调用HAL_UART_Transmit_IT,也就是用中断方式发送单字节,那么每次printf调用只触发一次发送,发送完成后中断又停了,后面的字符就再也发不出去了。正确做法是维护一个队列,在中断中持续从队列取数据发送,而不是在fputc里一次性丢一个字节。
更直观地理解:阻塞式发送是“把字符交给硬件,站在旁边等它发完”;中断式发送是“把字符写进篮子,让硬件发完一个自己来取下一个”。你要保证篮子里一直有货,发送才会连续。
5.2 DMA发送与内存对齐
比中断更高效的是DMA。DMA可以把一串内存数据直接搬运到UART数据寄存器,完全不占用CPU。配置好DMA发送之后,你只需要调用HAL_UART_Transmit_DMA(&huart1, buf, len),硬件就会自动把buf里的数据按顺序发送出去,发完触发一个完成中断。
但DMA对缓存区有要求:使用DMA时,你传入的buf必须能持续保持有效直到发送完成。因此在DMA未完成之前,不要修改buf内容。接着,如果你在高频调用printf,请记住DMA发送是异步的:你调用完函数后,buf可能还在等待发送,这时候如果主流程修改了buf,发出去的数据就会损坏。所以常见做法是先拷贝一份到专用DMA缓冲区,或者用一个双缓冲机制。
另一个坑是cache一致性,在带D-Cache的Cortex-M7等芯片上,DMA和CPU对内存的视图可能不一致。CPU写buf后,如果不做cache clean,DMA可能读不到最新数据。出现这种问题,可以先关闭D-Cache,或者在调用DMA发送前执行SCB_CleanDCache(),发送完成后再执行SCB_InvalidateDCache()。
DMA方式对printf重定向来讲有点重,因为fputc是逐字符调用、逐个返回的,单个字符用DMA发反而是杀鸡用牛刀。更常见的做法是把printf先往一个sprintf缓冲区里格式化,再利用DMA发送整个字符串。这就打破了“直接用printf重定向”的简单思路,但如果你追求极致性能,这是一个正确的扩展方向。
5.3 RTOS环境下的互斥与重入
在多线程/RTOS环境下,printf存在重入问题。两个任务同时调用printf,格式化缓冲区是共用的,这会导致字符串穿插、乱码,甚至内存踩踏。标准做法是给printf加互斥锁,比如FreeRTOS里用互斥信号量保护printf调用:
void safe_printf(const char *fmt, ...) { va_list args; xSemaphoreTake(printf_mutex, portMAX_DELAY); va_start(args, fmt); vsnprintf(buffer, sizeof(buffer), fmt, args); va_end(args); uart_dma_send(buffer, strlen(buffer)); xSemaphoreGive(printf_mutex); }这里要注意:DMA发送本身是异步的,如果发送还没完成就释放互斥锁,下一个任务可能立刻改写buffer,导致发送中途数据变化。所以要么在DMA发送完成中断里释放信号量,要么用阻塞式发送,要么使用双缓冲轮流切换。
另外,如果在中断服务函数(ISR)里调用printf,是非常危险的行为。很多printf实现本身不是中端安全的。如果你非要在ISR里记录日志,我建议用一个简单的方案:把日志字符串先塞进一个环形缓冲区,然后由低优先级任务统一取出来发送。
RTOS环境的printf重定向,本质上已经不是“怎么把fputc指向串口”的问题了,而是“如何让多个任务安全地共享串口资源”的问题。重定向只是替你铺好了第一公里的路,后面的并发控制要靠自己来设计。
6. 常见问题速查表与个人调试心法
6.1 速查表
这里把重定向printf最常见的几个问题和排查思路整理成一张速查表,遇到问题直接对照检查:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 串口助手完全没输出 | TX/RX接反、GND没共地、串口号选错 | 检查接线,确认设备管理器里的COM口 |
| 输出全是乱码 | 波特率不匹配、编码设置不一致 | 确认程序波特率与助手一致;检查中文编码 |
| printf执行后程序死机 | 半主机模式没有被禁用 | 添加__use_no_semihosting及相关符号声明 |
| 浮点打印为0.00 | 使用的MicroLIB浮点格式化被裁剪 | 改用标准库,或自行拆分整数和小数打印 |
| 字符串输出不换行 | 缺少\r\n | 把\n改成\r\n |
| 用HAL库时链接报错L6915E | 半主机相关符号被引用 | 加入__use_no_semihosting声明,检查微库选项 |
| DMA发送的内容错乱 | 缓冲区被提前修改、未做cache clean | 使用专用DMA缓冲区,发送完成前不修改 |
| RTOS下打印穿插 | 多任务重入 | 用互斥锁保护printf,或改用队列串行化打印 |
| 中文字符变成多个乱码字节 | UTF-8多字节拆分 | 统一编码,注意串口助手解码方式 |
6.2 调试里最实用的小习惯
要说这七八年反复折腾printf串口调试,我最大的感受是:调试日志也是一种“接口设计”,不是随手写几个printf就完了。
第一,日志要有明确的分级和tag。比如错误用[ERR]、警告用[WARN]、信息用[INFO],写个宏把它们包起来,方便打印的时候一眼看清日志等级,也方便后期裁剪上线版本里的调试输出。
第二,善用一个总开关宏。在工程里定义一个宏,比如DBG_PRINTF_ENABLE,控制所有调试打印是否生效。量产编译时直接把宏设为0,所有printf调用自动变成空操作,或者被编译器优化掉,不用到处注释代码。
#if DBG_PRINTF_ENABLE #define DBG_PRINTF(...) printf(__VA_ARGS__) #else #define DBG_PRINTF(...) #endif第三,慎用浮点打印,尤其是在中断和性能敏感路径里。能用整数解决的,尽量用整数。调试PID参数是一个典型的反面教材:你希望PID的P、I、D系数能实时显示,于是直接printf("%.2f", pid_p)、printf("%.2f", pid_i),一套下去波特率115200都发不过来,还好readable,但CPU占用率高得吓人。后来我把浮点参数放大了100倍,变成整数打印,P=123就代表1.23,日志立刻轻快很多。
第四,养成HEX显示和文本显示切换的习惯。有时候打印十六进制寄存器值、数组、通信帧,用HEX显示比文本显示直观得多;排查串口乱码时,也先用HEX显示看字节是否和自己预期一致,再考虑文本编码问题。
第五,给串口助手设置合适的接收缓冲区。如果一个高速输出的单片机连续打印几秒钟,串口助手接收缓冲区如果太小,或者没有选择“暂停显示”,你会发现最后几行日志缺失或卡顿。这不是串口丢数据,而是PC端接收程序丢显示。
6.3 失败过一次之后的经验
回头想想,我自己第一次搞通printf重定向也花了不少时间,当时卡住的问题不是fputc怎么写,而是不知道要勾选MicroLIB、也不知道半主机模式这回事。每次一执行printf,程序就HardFault,在调试界面里看寄存器,经常莫名跳到一个不知道的地址。
后来查了不少资料才明白,所谓的重定向,本质上就是切断了printf和半主机调试通道之间的连接,把它引导到数据库的串口上。这个过程分成两层:第一层是把工具链的标准输出目标重新指向fputc,第二层是把fputc的实现绑定到UART硬件。
现在再回头看,其实核心知识并不复杂,但它的价值非常大。往小了说,printf重定向能让你省掉无数个“猜程序跑到哪一步”的时间;往大了说,它让你理解了一个关键概念——编译器的标准库和底层硬件之间,其实有一层可以自由改写的软件接口。搞懂这一层,以后你看到任何库函数和实际设备之间的适配问题(比如输出到LCD、输出到蓝牙、输出到日志文件),都能很快找到切入点。
所以,如果你现在还处在“printf在单片机里跑不通”的阶段,不用急,按这篇文章的思路一步步来,把硬件接线、串口驱动、fputc、MicroLIB选项这几个点都排一遍,一定能把日志打出来。等你在串口助手里看到第一行hello、world的时候,那种“终于通了”的感觉,确实很爽。