做嵌入式调试这几年用串口打印日志是最频繁的动作之一。我记得第一次在Keil里写STM32程序明明照着教程配置好了UART也调用了printf可串口助手那边就是一片空白。那时还不知道Keil环境下的printf默认并不是往串口发数据的而是走了一个叫“半主机模式”的输出通道专门给调试器显示用。不把它重定向到串口打印函数写得再多也白搭。这篇就把这件事彻底聊透在Keil里把printf输出到串口的三种重定向方法包括标准库方案、MicroLIB方案以及适合异步DMA场景的格式化转发方案最后再给你一份MicroLIB的详细对比清单。只要你用的是MDKARM核或者C51核基本都能在下面找到对应的解法。1. 为什么非要折腾printf重定向串口调试的基础认知1.1 串口printf到底解决什么问题嵌入式开发和PC端开发最大的区别就是没有Console你点开一个命令行窗口是空的程序要么跑起来啥也看不到要么崩了不知道错在哪。调试手段无非那几样LED闪烁、在线调试打断点、逻辑分析仪抓波形还有最被低估的串口日志。LED太粗糙只能表示“大概走到这里了”表达不了“这一帧数据为什么解不出来”在线打断点会改变程序时序很多死等、超时、中断相关的bug一旦停在断点上就复现不了而printf配合串口调试助手能让你在不干扰程序运行节奏的前提下按时间维度看到完整的运行轨迹。尤其在定位“程序今天比昨天多跑了3毫秒”“为什么这个状态机没切过去”这类问题时日志几乎是唯一高效的路径。Keil作为最常用的嵌入式IDE串口printf的玩法自然也是开发者的刚需。只是它的默认行为和很多人的直觉不太一样这就是接下来要说的半主机模式。1.2 半主机模式printf默认输出的那个“黑洞”半主机模式Semihosting是ARM为嵌入式开发设计的一套机制允许目标板上的代码把输入输出请求“转发”给调试主机也就是你电脑上正在跑调试器的那个Keil。简单理解就是目标板说“我要打印”调试器在电脑上接住并显示。听着很方便但实际用起来全是坑。坑的根源有两个。第一半主机模式的输出目标是调试器不是串口外设所以你必须先在Keil里打开仿真或者连接调试器printf的内容才会显示在Debug (printf) Viewer窗口而串口助手那边永远空空如也。第二半主机模式依赖目标板上的一段专用代码和BKPT指令如果板子脱离调试器独立运行printf触发到半主机调用时行为不确定轻则卡死重则直接进HardFault。这就是为什么“直接把printf扔进Keil工程”根本不管用。串口调试的核心思路是让printf最终走到你板子的UART外设上再由串口线送到电脑里的串口调试助手。这一步把数据流从“目标板-调试器”改成“目标板-串口线-串口助手”就叫重定向。1.3 重定向的本质改掉printf的输出底层理解重定向只需要想清楚一个事实printf本身不关心数据最终去哪它只负责按格式化字符串把数据拼好然后把每一个字符交给底层的一个输出函数去发送。在Keil的ARMCCAC5/armclangAC6环境下这个输出函数是fputc而在Keil C51环境下这个函数是putchar。所以重定向的本质就一句话自己实现fputc或putchar在里面把字符塞给串口外设的发送寄存器。printf每格式化出一个字符就会调一次你的函数你的函数再用串口发出去一个字节一个字节地传串口助手里就能看到完整的字符串了。这里要特别提醒一下很多刚入门的同学照着网上的文章看到fputc就直接抄结果用的是Keil C51编译51单片机怎么编译都报符号不匹配。原因很简单C51是另一套C实现它不认fputc(FILE *)这种标准库钩子而是认putchar(char)。所以后面看代码时先确认自己到底是MDKARM工程还是C5151单片机工程别抄错对象。2. 方法一标准库 半主机模式规避不勾选MicroLIB2.1 第一步实现fputc钩子函数这套方法不使用MicroLIB依赖的是ARM标准C库。第一步是重写fputc下面以STM32F103HAL库为例#include stdio.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }逻辑很好理解printf每格式化出一个字符就把它作为ch传进来HAL_UART_Transmit再把这一个字节发到串口1。return ch是调用标准库的约定让printf认为这个字符已经成功输出。如果你用的是寄存器操作可以省掉HAL库的调用开销直接操作外设寄存器效果一样int fputc(int ch, FILE *f) { while (!(USART1-SR USART_SR_TXE)); // 等待发送数据寄存器为空 USART1-DR (uint8_t)ch; // 写入一个字节 return ch; }这一步完成后printf的字符已经有地方去了但这套标准库方案还差一步关键操作也是最容易翻车的地方处理半主机模式。2.2 第二步堵住半主机模式的“漏洞”只写fputc还不够。因为Keil的ARM标准库在编译printf相关功能时默认会把半主机模式的支持代码也链接进来。你需要告诉链接器“我这个工程不用半主机”否则编译时可能报错或者程序在运行时意外触发半主机调用。处理的标准做法是在工程里加上这么一段通常在fputc下面#pragma import(__use_no_semihosting) struct __FILE { int handle; }; FILE __stdout; FILE __stderr; void _sys_exit(int x) { x x; } void _ttywrch(int ch) { ch ch; } char *_sys_command_string(char *cmd, int len) { (void)cmd; (void)len; return NULL; }这里简单解释一下每段代码的作用。#pragma import(__use_no_semihosting)是告诉ARM链接器本工程不需要半主机支持函数。但链接器会检查符号引用是否齐全所以你必须补上_sys_exit、_ttywrch、_sys_command_string这些原本由半主机库提供的函数不然链接器会报找不到符号的错误。struct __FILE和__stdout、__stderr则是标准库内部用来管理输出流的结构本质上是给printf一个“合法的文件句柄”让它可以走你重定向的fputc。注意不要在这段代码里偷懒删函数也不要用空函数直接返回。很多人在网上论坛看到“随便定义就行”的说法但链接器对符号的检查是严格的漏一个函数工程就编译不过。2.3 方案一的优缺点功能最全代价是代码量大方法一最直观的优点是用的是标准C库printf的行为最接近PC端标准C99的格式化功能相对完整。在你需要复杂格式化、浮点数打印、或者将来要移植到其他平台时这套方案的心理负担最小。缺点也很明显。一是代码里要维护那一堆半主机相关函数每次新建工程都得复制一遍容易漏。二是标准库完整实现的体积不小在Flash比较紧张的芯片上会感受到压力。我用STM32F103C864KB Flash实测过一个只包含printf串口初始化的最小工程标准库方案的Code段大约在7KB左右对于64KB的片子还好但如果换到8KB Flash的小芯片这个开销就比较肉疼了。另外还有一个容易栽跟头的细节标准库方案里fputc的FILE *f参数类型在不同编译器版本上可能不一样。有些从网上复制的代码会写int fputc(int ch, FILE *f)在AC5下没问题但换个工程模板或者库版本时可能因为头文件定义差异导致编译警告甚至错误遇到时记得先检查这一行。3. 方法二MicroLIB fputc重定向推荐方案3.1 勾选MicroLIB一个复选框的生效逻辑MicroLIB是ARM提供的标准库精简版官方定位是“为嵌入式深度定制优先考虑最小化内存占用”。在Keil MDK里启用它只需要在Options for Target界面的Target标签页勾选Use MicroLIB复选框然后重新编译。就这么一步没有任何额外的代码。勾选之后链接器会自动把标准C库替换成MicroLIBprintf的实现也随之变化——最核心的变化是MicroLIB移除了半主机模式的支持逻辑printf的输出流默认就面向用户自定义的fputc不需要再去处理#pragma import、_sys_exit那些东西。用MicroLIB之后整个输出相关的代码可以精简成下面这样#include stdio.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }没错只需要这么多。不用__stdout不用_sys_exit不用任何半主机处理。相比方法一少了十几行代码每次新建工程时手都要少酸一点。3.2 为什么MicroLIB能“免去”半主机处理的麻烦这个点值得展开讲一下理解了它用MicroLIB时会更有信心。半主机模式本身是ARM C标准库的可选特性标准库默认把它包含进去因为你可能在开发阶段确实想通过调试器输出。但MicroLIB的设计初衷就是最小化实现它把半主机相关代码整个拿掉了printf的底层字符输出直接指向一个弱符号的fputc。当你自己实现了一个强符号的fputc时链接器就优先使用你的版本。这个过程不需要你说“我不用半主机”因为MicroLIB压根就不链接半主机那套东西。所以两个方案的本质区别不是“要不要写fputc”而是谁帮你把半主机的麻烦处理掉了方法一是你手动挡掉方法二是MicroLIB本身就把它去掉了。3.3 代码体积与浮点printf的代价MicroLIB的省是实打实的。还是那个STM32F103最小printf工程勾选MicroLIB之后Code段从7KB左右降到了3KB出头这还不是极限优化常规写项目时MicroLIB通常能比标准库省下30%到50%的FlashRAM也能省下几百字节。在Flash和RAM都很金贵的MCU上这个差距足以决定工程能不能塞进去。但省有省的代价最大的坑是浮点printf。默认情况下MicroLIB的printf不支持%f格式化你用printf(volt %.2f\r\n, 3.33f);串口助手里只会显示volt 或者volt 0.00浮点部分直接消失。解决办法是在Options for Target - C/C页面的Misc Controls里加上-u _printf_float这个选项的作用是强制链接浮点printf支持代码。用标准库方案时很少遇到这个问题因为标准库的printf通常已经包含了浮点支持而MicroLIB为了体积把它砍掉了要按需手动链接回来。加了-u _printf_float之后代码体积会增加几KBMicroLIB的体积优势会被吃掉一部分但通常还是比标准库小。如果加了这个选项还是不行别死磕直接切回方法一的标准库方案用浮点就舒服了。4. 方法三vsnprintf格式化 自定义串口发送异步/DMA场景4.1 什么时候必须用第三种方法前两种方法是绝大多数入门教程的内容也是99%项目够用的方案。但有两种场景你会发现直接printf不好使第一个场景是自己实现了DMA发送或者中断发送。fputc是每来一个字符同步发送一次属于“阻塞等待每一个字节发完”的节奏。如果你想让CPU从“逐字节等待”中解放出来就需要把大量数据一次性丢给DMA外设让它自己搬运。这个时候你没法在fputc里做文章因为fputc的设计就是逐字符的。第二个场景是你在RTOS里跑多个任务多个任务同时printf。标准库的printf本身有重入性问题MicroLIB在这方面的保护更弱容易出现日志互相穿插的乱象。与其跟这个较劲不如让每个任务把日志写到自己独立的缓冲区再统一交给队列去发送。这两种需求都指向同一个方案不用printf的底层钩子而是自己写一个带变参的日志函数先用vsnprintf把格式化结果写进缓冲区再手动启动串口发送。4.2 核心代码实现格式化到缓冲区再发送先看基础版本#include stdio.h #include stdarg.h #include string.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; void uart_log(const char *fmt, ...) { char buf[128]; va_list args; int len; va_start(args, fmt); len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len 0) { HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 0xFFFF); } }用的时候把printf换成uart_log即可uart_log(adc value %d, temp %.2f\r\n, adc_val, temp);原理其实非常简单vsnprintf按传入的参数列表将格式化结果写入buf缓冲区然后你主动调用发送函数把缓冲区里的数据一次性发送。整个过程不是你依赖printf的钩子而是你用标准库的格式化工具生成字符串发送的时机、方式、通道都攥在自己手里。这带来的第一个好处是发送一个字节能和发送100字节用同等的CPU开销DMA模式下CPU几乎不参与。第二个好处是你可以在uart_log前后加锁解决多任务日志穿插问题。4.3 搭配DMA发送非阻塞串口日志如果项目里波特率低、日志量大比如115200波特率下一秒钟最多传约11.5KB而一段调试日志动辄几十字节逐字节轮询的HAL_UART_Transmit会占用大量CPU时间严重影响主循环的实时性。此时DMA就是标准解。void uart_log_dma(const char *fmt, ...) { static char buf[128]; // 静态缓冲区避免局部变量在DMA传输期间失效 va_list args; int len; va_start(args, fmt); len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len 0) { // 假设上一次DMA传输已完成实际工程需加发送完成标志保护 HAL_UART_Transmit_DMA(huart1, (uint8_t *)buf, len); } }这里有一个容易踩的坑而且非常隐蔽HAL_UART_Transmit_DMA是异步的函数返回时数据可能还没发完。如果你用局部变量char buf[128]作为数据源函数一退出栈空间被回收后续DMA搬运的就是不确定的内容串口收到的全是乱码。解决办法就是上面代码里的static char buf[128]让缓冲区活在整个程序生命周期里。但static缓存区也带来新的问题如果连续调用两次uart_log_dma第一次DMA还没搬完第二次就覆盖了缓冲区发出去的数据就串了。这点在RTOS里尤其常见。妥协方案是把缓冲区做大一点加一个发送完成标志位来判断能不能写入volatile uint8_t uart_dma_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart_dma_busy 0; } } void uart_log_safe(const char *fmt, ...) { static char buf[256]; va_list args; int len; if (uart_dma_busy) return; // 上次还没发完丢弃本次日志 va_start(args, fmt); len vsnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len 0) { uart_dma_busy 1; HAL_UART_Transmit_DMA(huart1, (uint8_t *)buf, len); } }这个版本适合绝大多数MCU日志场景代价是日志频率过高时会丢日志。但实际调试中与其把日志一股脑丢到串口倒不如在源头控制打印节奏反而更容易看清问题。方法三的思路同样适用于C51工程只需要把HAL_UART_Transmit换成51单片机自己的逐字节发送逻辑即可。5. MicroLIB深入对比省了什么丢了什么什么时候别用5.1 MicroLIB到底精简了什么前面提到MicroLIB是标准库的精简版但精简不是随意裁剪它主要针对嵌入式场景做减法。具体来说它移除了文件系统相关的全功能支持比如标准文件操作、半主机相关实现、以及大量面向桌面C程序的库函数。在C标准库函数里MicroLIB选出嵌入式开发最常用的一部分字符串处理、内存操作、基本的printf/sprintf、基本的数学函数等其余能省就省。实现上MicroLIB对很多库函数的实现也做了轻量化。比如printf的格式化核心实现比标准库更紧凑malloc的内存管理策略更简单文件描述符的数量上限被压到很低。这些都是以限制功能为代价的。5.2 关键对比标准库 vs MicroLIB对比项标准库不勾MicroLIBMicroLIB编译后代码体积较大printf最小工程约7KB较小同环境下约3KBRAM占用文件结构、缓冲较大更省适合RAM紧张的芯片半主机模式默认启用需手动规避默认不启用直接用printf重定向方式fputc 半主机处理代码fputc无需额外处理printf浮点支持相对正常较少需要额外操作默认不支持%f需加-u _printf_floatC99/C11特性较完整裁剪较多部分格式化行为有差异函数重入性一般需自行加锁较弱多任务并发printf更容易出问题适用场景Flash/RAM充足、功能复杂资源受限的裸机简单工程这组对比下来结论其实很清晰如果你的芯片Flash和RAM都够用浮点打印频繁且代码里用了RTOS、文件系统等较重组件直接上标准库是省心的选择如果芯片资源紧或者工程足够简单MicroLIB几乎是白捡的体积优势。5.3 实际工程中的选择建议我自己的选择习惯是这样的项目用的是STM32F103C8T6这类64KB Flash、20KB RAM的芯片我会直接勾上MicroLIB因为裸机工程用到的标准库功能很少省下来的资源可以给缓冲区、任务栈。如果芯片是STM32F407、H750这类Flash和RAM都宽敞的我倾向于不勾MicroLIB用标准库减少库函数行为差异引入的坑。有一个场景强烈不建议一上来就勾MicroLIB工程里用了带动态内存分配功能的RTOS比如FreeRTOS的pvPortMalloc封装了malloc。MicroLIB的malloc实现比较基础并发和碎片处理都不如标准库稳健日志系统、消息队列在这种组合下偶尔能看到莫名其妙的内存耗尽。当然这不是绝对不行只是排查起来更费力预算充足时就别给自己找这个麻烦。另一个场景是代码里有大量浮点格式化输出。虽然加-u _printf_float能解决但加上之后体积优势缩水浮点格式化本身在C库里的实现又很重MicroLIB的格式化边界情况还可能与标准库不同。这时直接上标准库你遇到的问题会少一个量级。6. 实际项目中的踩坑经验与调试技巧6.1 printf中文乱码编码与波特率两个源头“printf中文乱码”应该是串口调试里出现频率最高的求助话题之一这次的热搜词里也有它。结合我遇到过的案例乱码原因基本落在这两处第一是源文件编码和串口助手的解码方式不一致。Keil的编辑器默认编码通常是本地ANSI中文系统下是GBK但很多人从网页复制的代码或自己新建文件时文件被保存成了UTF-8。UTF-8的中文字节流和GBK的解码结果错位串口助手显示出来就是乱码。解决办法是固定一套编码要么在Keil中统一用UTF-8写入源文件串口助手选UTF-8解码要么源文件保持ANSI/GBK串口助手也选GB2312。两边不一致神仙也救不了。第二是波特率设置和实际跑偏。串口助手设置的波特率要跟代码里UART初始化的波特率完全一致这个大家都懂。容易忽略的是如果MCU主频配置错误或者UART时钟分频计算不准实际波特率和理论值之间会有较大偏差比如你配的115200实际只跑了110400这时能看到字符但偶尔有错位字符串长一点就乱。检查方法是在串口助手里发一个0x55二进制01010101看示波器或者逻辑分析仪测实际脉宽来校准。6.2 fputc里不要调用printf递归陷阱这个坑我在实际项目里踩过一次印象极深。当时为了排查串口发送是否卡住脑子一热在fputc里加了一行调试日志printf发送失败时打印错误信息。结果程序一跑直接硬件异常——原因很简单printf每格式化一个字符就会调用fputc而fputc里又调用printf这就成了无限递归。每一次递归都往栈上压帧栈一溢出程序就崩了。所以记住一条死规矩fputc里只能做最底层的字符发送动作不要再调用任何格式化输出函数。如果你需要在fputc里判断发送状态并提示错误请用另一个独立的调试函数比如GPIO翻转或者单独写寄存器的简单操作。另外用DMA发送时也不要在fputc里调用HAL_UART_Transmit_DMA然后立刻返回。想一想printf每格式化一个字符就调用一次fputc每次都启动一次DMA传输这不是“批量发送”而是“一个字节一个DMA”效率反而更低。DMA方案还是要走方法三的缓冲区思路。6.3 串口调试助手的选择与缓冲注意串口调试助手五花八门但核心功能就那么几个正确打开串口、收发出错率低、显示不乱码。我常用的有这几类XCOM、SSCOM这类轻量工具功能直接适合日常看日志Visual Studio Code里的串口插件适合一边写代码一边看输出省去切窗口还有Packed的MobaXterm如果你的板子输出的是ANSI转义序列像Linux控制台那种彩色日志它能正确解析颜色代码排查可视化问题特别有用。用串口助手时有个易被忽视的坑缓冲区太小。有些串口助手在高速率、大数据量下如果缓冲设计不好会丢数据甚至显示界面卡死。比如你从板子上一次性发了500字节日志助手的接收缓冲区只有256字节多出来的就丢了你以为是程序的问题其实工具也有责任。保险做法是调大接收缓冲区或者选一个以稳定著称的工具。6.4 最后再分享一个我自己用的组合拳用Keil做串口调试时我现在的固定套路是这样的裸机小工程直接勾MicroLIB用方法二的fputc重定向省心工程里如果跑RTOS且日志量大就上方法三的uart_log_safe配合DMA发送遇到浮点格式化需求先在方法二里加-u _printf_float试不行就切标准库不跟它较劲。这个组合用了两三年基本没在printf上浪费过时间了。做嵌入式调试手段越稳出问题时的定位速度就越快。串口printf这一关过去之后后面再遇到代码逻辑问题你就可以专注于问题本身而不是跟工具链搏斗了。
