
在 CLion 里做 STM32 或者其他 ARM 单片机开发最绕不开的调试手段就是串口打印。printf 一封数据一刷整个世界都清晰了。但不少朋友第一次在 CLion 里跑通嵌入式的 Hello World都会卡在一个问题上网上搜到的重定向说法五花八门有的让你重写 fputc有的让你重写 _write。CLion 默认的工具链是 ARM GCC它背后的 C 库是 newlib 系在这个体系里 printf 的最终落点根本不是 fputc而是 _write。这篇就从头捋一遍调用链说说为什么在 CLion 里重定向要选 _write以及实际工程里怎么改才不会踩坑。1. 先搞清楚 printf 在单片机上的调用链再决定动哪里1.1 为什么 printf 到了单片机上就不“自动工作”在 PC 上写 C 语言printf 直接往终端里吐字符没人想过它底层是怎么把字符送到屏幕上的。但在单片机上情况完全不同。单片机没有操作系统没有标准输出设备的概念你的串口外设、LCD、SWO 口对于 C 标准库来说都是“不存在的东西”。printf 本身只是负责格式化把整数、浮点、字符串拼成一段字符缓冲区真正把字符“发出去”的动作需要由开发者自己提供给库函数的底层输出钩子。这个钩子在 PC 上是操作系统帮你接好的printf 写完缓冲区后会调用 write 之类的系统调用由操作系统把字符送到终端驱动。单片机上没有这层系统调用newlib 库只能给你留一个空壳 _write默认实现往往是直接返回错误或者什么都不干。所以你直接调用 printf格式化逻辑跑得飞起但字符一个都没出来问题就出在这个 _write 空壳上。1.2 断点验证printf 到底先踩到谁与其听我说不如自己动手验证。在 CLion 里建一个最小的 STM32 工程调 printf 之前先在 fputc 和 _write 两个函数上各打一个断点然后全速运行到 printf 那一行。我第一次这么干的时候印象很深断点只命中了 _writefputc 那边连影子都没有。这个结果其实说明了工具链的差异。ARM GCC 工具链默认使用的是 newlib 或 newlib-nano 作为 C 运行库。newlib 的 printf 体系是标准的 vfprintf 结构printf - vfprintf - 各种输出函数。在这些输出函数的底层字符最终要通过 _write_r 这个重入版本的函数调用到 _write。换句话说在 newlib 的体系里_write 是唯一的字符出口。而在 Keil 的 ARMCC 或者 IAR 的 DLib 环境里情况就反过来了它们的 printf 底层字符钩子叫 fputc所以你看 Keil 的老教程全是教你重写 fputc。这两套东西思路一样只是名字和平台不同。一旦你换了 CLion还在死磕 fputc那就是对错了暗号编译能过运行没反应容易让人怀疑人生。2. _write 和 fputc 的核心差异以及为什么 CLion 阵营必须选 _write2.1 不同工具链、不同 C 库的底层字符出口不一样把这段调用链理清楚之后问题的本质就很明确了。fputc 是 C 标准库提供的一个对外接口标准库内部其他函数调用它来输出单个字符。在 ARMCC 和 IAR 的环境里printf 实现到最后确实会调用 fputc所以重写 fputc 就能接管 printf。但 newlib 的设计不是这样newlib 选择把抽象层放在更底层的系统调用层也就是 _write 函数。_write 的原型是int _write(int file, char *ptr, int len)第一个参数是文件描述符第二个参数是字符缓冲区指针第三个参数是字符数量。它的抽象层次比 fputc 更高它不是一个字符一个字符地往外吐而是一段一段地往外送。这就带来一个很重要的实践结论在 newlib 里你重写 _write 就能同时接管 printf、puts、fprintf、stderr 输出甚至文件写入只要文件描述符对上。这里还要提一个细节newlib 还有重入版函数 _write_r。某些编译链接场景下标准库内部调用的可能是_write_r(int fd, char *ptr, int len)。但在实际 STM32 工程里由于我们往往用单线程裸机环境大多直接实现_write就能生效。如果你在两个函数之间摇摆不定有个简单的判断方法编译后用 nm 工具看目标文件的符号引用或者干脆两边都实现在 _write 里调用 _write_r这样无论库内部走哪条路由都能接住。2.2 文件描述符和具体外设怎么绑定在 PC 上文件描述符 0 是标准输入1 是标准输出2 是标准错误。在单片机裸机环境下并没有强制规定这些描述符对应什么设备。你完全可以自己定义往描述符 1 写数据就发到串口 1往描述符 2 写数据就发到串口 2往其他描述符写直接忽略并返回 len。我实际项目里就是这么干的。主调试串口是 USART1日志串口是 USART2_write 函数里做个 switch 判断 file 参数1 就调 USART1 发送2 就调 USART2 发送。这样 printf 默认走 USART1fprintf(stderr, ...) 走 USART2调试信息和错误信息分离开排查问题的时候效率特别高。fputc 那种单字符接口想做这种分发当然也能做但你必须引入一个全局变量来标记当前往哪个串口发处理起来别扭得多。从纯工程化的角度讲_write 的缓冲区 描述符模式比 fputc 的单字符模式更贴近底层驱动的批量发送模型也更容易实现高效发送。UART 发送一批字节是一个整体操作尤其是用 DMA 发送时你直接传一个缓冲区和长度给 DMA 控制器比一个字符一个字符地触发 DMA 要合理得多。3. 实操在 CLion 中重写 _write完成 printf 到串口重定向3.1 最小可用的 _write 实现下面这段代码我放在一个单独的 retarget.c 文件里避免把工程文件搞得乱七八糟。这个实现基于 STM32 HAL 库用的是阻塞发送。#include stdio.h #include stdarg.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { (void)file; if (HAL_UART_Transmit(huart1, (uint8_t *)ptr, (uint16_t)len, 1000) ! HAL_OK) { return -1; } return len; }如果你用的是标准外设库 SPL 而不是 HAL 库就把 HAL_UART_Transmit 换成对应的发送函数。实现的关键点是返回值语义成功时必须返回 len表示所有字节都发送完成。返回其他值会让 printf 认为写入失败后续行为就不好说了。HAL_UART_Transmit 的超时参数我习惯给 1000 毫秒太短的话在高波特率大数据量打印时可能超时太长则会在串口故障时卡住主循环。还可以加一层字符缓冲循环发送的策略。如果 len 很大比如你要打印一个几百字节的调试信息一次性传给 HAL_UART_Transmit 也没问题HAL 内部会循环发送。但如果你的驱动对长度有限制比如内部用了 uint16_t 计数那你就要在 _write 里切成小段循环发这段逻辑可以根据实际情况加。3.2 编译链接层面的关键设置nano.specs 和 nosys.specs代码写完了编译链接时还有两个设置缺一个都会踩坑。第一个是--specsnano.specs。这个选项告诉链接器使用 newlib-nano也就是精简版 C 库。精简版去掉了不少不常用的功能体积小很多。在 STM32 这种 Flash 资源紧张的芯片上这个选项几乎是必选的。注意精简版库对 printf 的浮点支持默认是关闭的需要配合下面要讲的-u _printf_float才能打印浮点数。第二个是--specsnosys.specs。这个选项非常关键。如果不加链接器会链接 newlib 的完整系统调用支持其中就包括半主机 semihosting 相关的默认实现。在半主机模式下_write 默认行为是通过调试器的特殊通道把数据送到 PC 端。如果调试器没有正确响应半主机请求程序一执行到 printf 就跑到故障中断里表现为程序跑飞或者卡死在 HardFault。加上 nosys.specs就会链接一个什么都不干的空 _write 默认实现之后你再自己重写 _write两边的符号冲突问题也就绕开了。CLion 的 CMake 配置可以这么写target_link_options(${PROJECT_NAME} PRIVATE --specsnano.specs --specsnosys.specs -u _printf_float )加上-u _printf_float是因为 newlib-nano 为了省空间默认不链接浮点转换代码。你需要通过-u强制链接器把浮点格式化函数拉进来否则 printf 遇到%f只会打印空字符串。用 ARM GCC 的兄弟肯定遇到过这个坑明明格式串写了对的浮点数就是打不出来多半就是少了这个参数。3.3 串口初始化别放错位置_write 写好了链接选项也加上了但程序上电后前几行代码必须把串口初始化好。很多人把串口初始化放在 main 函数中间某处而 printf 在初始化之前就已经执行了自然什么都打不出来。我之前调试过一个低功耗项目系统启动时要做很长的时钟配置和外设初始化期间我特别想在关键步骤之间加打印看看跑到哪里了。但早期时钟都没起来串口波特率都是错的。这种情况下我习惯这么做先用一个 GPIO 翻转来做早期阶段指示等串口和外设时钟稳定后再用 printf。调试信息分两阶段输出体验会舒服很多。串口初始化本身要用对时钟树配置。CLion 的嵌入式插件可以生成 STM32CubeMX 的时钟树一般默认配置没问题。但如果乱码先查波特率寄存器实际值和代码里配置的是否一致八成是时钟源选错了或者 PLL 配置不对。4. 高频问题排查乱码、没输出、输出错乱逐一击破4.1 常见现象速查表我把这几年在 CLion 里折腾串口打印的典型问题整理了一下写代码的时候照着排能少走很多弯路。现象大概率原因排查方向完全没输出没有重写 _write 或符号没链接进去在 _write 里打断点确认程序有没有走到完全没输出且程序卡死semihosting 默认实现被用了确认链接参数里有没有加 nosys.specs完全没输出但标准外设库正常串口外设没初始化或时钟没使能检查 main 里的初始化顺序和 GPIO 配置输出乱码波特率不一致或时钟配置错误核对串口波特率寄存器和外部晶振频率浮点数打不出来newlib-nano 默认不链接浮点转换加 -u _printf_float 链接选项输出一次后卡死HAL_UART_Transmit 超时时间太短或中断优先级冲突加大超时检查串口相关中断是否抢占冲突CLion 控制台显示中文乱码终端编码和源码文件编码不一致统一使用 UTF-8或者设置 CLion 控制台编码为 GBK4.2 独家的几个排查小技巧先来说 _write 里打断点没反应的情况。这个大概率是符号被优化掉或者链接器没把你写的 retarget.c 里的 _write 作为强符号解析。检查办法很简单编译后打开生成的 .map 文件搜索 _write 符号看它最终指向哪个地址是 0x00000000 附近这种空地址还是你自己 retarget.o 里那段代码。如果指向空地址说明链接器用了库里的默认实现你的版本没生效。此时检查文件是否真的参与了编译链接检查是否有重复定义把强符号变成了弱符号。再来说半主机引起的卡死。半主机模式是 ARM 调试环境里的一种通信机制调试器通过特定断点指令响应标准输入输出请求。以前 C 库默认把这套机制连着如果调试器没使能半主机通道程序跑到 printf 内部会触发一个未定义指令异常直接进 HardFault。症状就是程序开始还能跑第一次触发 printf 就死了。解决办法就是我上面说的链接时加--specsnosys.specs把这种默认行为替换掉。你重写的 _write 会覆盖这个空实现既不冲突也不会进 HardFault。最后说一下中文乱码问题。这个特别容易忽略。你的源码文件用 UTF-8 保存CLion 控制台默认也按 UTF-8 显示一般没什么事。但如果你用了 Windows 下的串口助手很多老牌工具默认按 GBK 解码UTF-8 的汉字在他们那里就会变成乱码。这不代表你的代码有问题纯粹是编码协议不一致。要么统一用 UTF-8 的串口工具要么在代码里把中文字符串改成转义后的 UTF-8 字节序列或者干脆日志全用英文写。还有 CLion 控制台里显示“write access to const memory has been detected”之类的警告多半是你往只读区域写数据了。比如你把某个字符串常量误当成可写缓冲区传入驱动或者编译器把字符串放到了 Flash 只读区而你的驱动想往里面填充内容。排查方法就是看看警告指向的函数把目标缓冲区改成局部数组或 static 数组。5. 几个容易让人纠结的细节一并说清楚5.1 到底要不要同时重写 _write_r有些老版本的链接脚本或者启动文件里newlib 内部的 vfprintf 调用的是 _write_r而不是 _write。你只重写 _write 时可能会遇到链接错误提示 _write 未定义或者 _write_r 未定义。这时候最简单的方式是同时实现两个版本让 _write_r 直接调用 _writeint _write(int file, char *ptr, int len) { // 实际输出逻辑 return len; } int _write_r(void *reent, int file, char *ptr, int len) { (void)reent; return _write(file, ptr, len); }这段代码看起来啰嗦但能覆盖不同 newlib 版本的路由差异。新版本里标准输入输出基本都走 _write但保留 _write_r 也不影响什么反而能省掉一些莫名其妙的链接问题。5.2 为什么有时候 fputc 重写了也能用网上确实有一些文章在 ARM GCC 环境里重写 fputc 也成功了这怎么解释。有一种情况是你用了某个封装库库里自己把 fputc 作为 printf 的底层钩子调用比如某些早期标准库实现或者第三方移植层。还有一种可能是你链接了不同风格的 C 库有些 C 库的 printf 实现确实会调用 putchar/fputc。如果项目里还引用了 C 的 iostreamiostream 的流对象可能另走一套路径。我个人的建议是在 CLion 里默认信任 _write 这条路径。如果 _write 重写了之后 printf 还不出东西再考虑是不是有什么中间层在作祟。此时用 objdump 反汇编生成的 .elf 文件找到 printf 的源码级汇编看它内部调用了哪个输出函数一目了然比瞎猜省时间。5.3 多个输出通道怎么设计更优雅前面提到过用 file 描述符区分串口。这里再分享一个设计思路如果你的项目要同时支持串口调试、蓝牙日志和文件系统输出可以做一个统一的输出分发层。int _write(int file, char *ptr, int len) { switch (file) { case 1: return uart1_write(ptr, len); case 2: return bt_log_write(ptr, len); default: return len; } }这样上层调用 printf 时输出默认走串口 1调用fprintf(stderr, ...)时输出走蓝牙日志调用fwrite(buf, 1, size, stdout)也能正确分发。这套模式不需要在应用逻辑里塞一堆条件编译扩展新通道只改 _write 一个函数就行。当然多个通道之间的互斥要注意。如果串口 1 和串口 2 共用同一个中断优先级发送过程中任何一方阻塞等待另一方也可能被拖住。裸机环境下简单做法是发送前关闭全局中断或者用不同的中断优先级把日志发送置为高优先级。5.4 从 printf 到 DMA性能优化思路阻塞式 HAL_UART_Transmit 是最简单的实现但它会一直占用 CPU 等待发送完成。如果打印频繁且数据量大就白白浪费了 CPU 周期。闭环思路是把 _write 改成 DMA 发送 信号量或者轮询标志的配合。static volatile uint8_t tx_done 1; int _write(int file, char *ptr, int len) { if (file ! 1) { return len; } while (tx_done 0) { // 等待上一次发送完成 } tx_done 0; HAL_UART_Transmit_DMA(huart1, (uint8_t *)ptr, (uint16_t)len); return len; } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { tx_done 1; }这个模式的好处是 _write 立刻返回调用方不会被串口速度拖死。但要注意 DMA 发送时缓冲区必须是稳定的内存不能用临时栈数组然后在函数返回后就被销毁。最好的做法是上层日志系统维护一个静态环形缓冲把待发数据拷贝进去再交给 DMA。裸机队列稍微复杂点但在高频率日志场景非常值得。一路踩坑之后的几个结论我自己从 Keil 转到 CLion 的头一个礼拜就在 printf 重定向上折腾了不少时间。一开始还拿 Keil 时代的 fputc 思路硬套后来翻了 newlib 的源码才彻底想明白工具链只见 API 不见链路的盲区。CLion 默认的 ARM GCC 工具链底层 C 库是 newlib 体系printf 的最终字符出口就是 _write。你重写 fputc 在大部分情况下等于白写程序根本不会调用它。链接时记得加--specsnano.specs和--specsnosys.specs需要浮点打印时再加-u _printf_float。这三点是 CLion STM32 printf 串口重定向的黄金组合。把这套链路想清楚以后再遇到 printf 不输出、乱码、卡死就不会没有头绪了。类似的问题同样适用于文件系统、网络协议栈里的打印调试因为它们的底层出口都是同一个 _write。这也是我建议大家专门建一个 retarget.c 来统一管理重定向逻辑的原因工程越往后做你越会发现这个文件的价值。