ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Keil MDK下将printf重定向到串口的两种方案及踩坑指南

Keil MDK下将printf重定向到串口的两种方案及踩坑指南 搞嵌入式的朋友应该都有过这种经历代码逻辑明明没啥问题但程序跑起来就是不对要么卡死、要么数值诡异你又不知道它到底执行到哪一步。这时候最高效的办法就是往串口打日志把关键变量、运行状态、甚至函数入口出口都打印出来。而真正用起来最顺手的就是直接把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. 两种重定向方案标准库与MicroLIB3.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、%.2fIDE编译都不报错运行结果却是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 TargetTarget选项卡勾选或不勾选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.14MicroLIB在某些版本下可能输出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 cleanDMA可能读不到最新数据。出现这种问题可以先关闭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倍变成整数打印P123就代表1.23日志立刻轻快很多。第四养成HEX显示和文本显示切换的习惯。有时候打印十六进制寄存器值、数组、通信帧用HEX显示比文本显示直观得多排查串口乱码时也先用HEX显示看字节是否和自己预期一致再考虑文本编码问题。第五给串口助手设置合适的接收缓冲区。如果一个高速输出的单片机连续打印几秒钟串口助手接收缓冲区如果太小或者没有选择“暂停显示”你会发现最后几行日志缺失或卡顿。这不是串口丢数据而是PC端接收程序丢显示。6.3 失败过一次之后的经验回头想想我自己第一次搞通printf重定向也花了不少时间当时卡住的问题不是fputc怎么写而是不知道要勾选MicroLIB、也不知道半主机模式这回事。每次一执行printf程序就HardFault在调试界面里看寄存器经常莫名跳到一个不知道的地址。后来查了不少资料才明白所谓的重定向本质上就是切断了printf和半主机调试通道之间的连接把它引导到数据库的串口上。这个过程分成两层第一层是把工具链的标准输出目标重新指向fputc第二层是把fputc的实现绑定到UART硬件。现在再回头看其实核心知识并不复杂但它的价值非常大。往小了说printf重定向能让你省掉无数个“猜程序跑到哪一步”的时间往大了说它让你理解了一个关键概念——编译器的标准库和底层硬件之间其实有一层可以自由改写的软件接口。搞懂这一层以后你看到任何库函数和实际设备之间的适配问题比如输出到LCD、输出到蓝牙、输出到日志文件都能很快找到切入点。所以如果你现在还处在“printf在单片机里跑不通”的阶段不用急按这篇文章的思路一步步来把硬件接线、串口驱动、fputc、MicroLIB选项这几个点都排一遍一定能把日志打出来。等你在串口助手里看到第一行hello、world的时候那种“终于通了”的感觉确实很爽。
返回列表