
大约两年前我把STM32开发环境从Keil迁移到CLion时第一关就栽在printf重定向上。当时我照着网上绝大部分教程在代码里加了一个int fputc(int ch, FILE *f)例程能编译、能烧录但串口助手什么都收不到进了调试模式还经常卡死甚至弹出访问异常。折腾了一下午才找到原因在CLion GCC 工具链arm-none-eabi-gcc的体系里printf 最终消费的根本不是 fputc而是 newlib 库底层的_write函数。这篇文章打算把这个调用链彻底讲透说明为什么要重写_write而不是 fputc同时把我在实际工程里踩过的坑一并列出来。1. 一个从 Keil 转 CLion 的人为什么会被 printf 卡住1.1 按教程改 fputc 后的诡异现场很多人在 Keil 里第一次接触 printf 重定向都会在代码里写这么一段int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }然后就可以在串口助手里看到 printf 的内容这在 Keil 的 ARMCC 编译器下确实管用。问题是同一套代码拿进 CLion用 arm-none-eabi-gcc 重新编译结果就变得不可预测。我遇到过的现象有三种串口完全没有输出输出不完整只打印部分字符或者在调试器中一执行到 printf 附近就开始跑飞。这个问题的迷惑性在于fputc 明明在 Keil 里工作得好好的为什么换了工具链就失效很多人的第一反应是 CMake 配置错了或者是链接脚本有问题很少有人会想到是 C 标准库的底层实现逻辑不一样。我也是排查了大半天最后从反汇编窗口里才看到printf 内部根本没有调用我写的 fputc而是跳去执行了一个_write相关的符号由于我没有实现它程序掉进了默认的半主机处理流程。1.2 先讲清楚C 库、系统调用、重定向三者是什么关系要理解_write和 fputc 的区别得先分清三个词C 标准库、系统调用、重定向。C 标准库是编译器工具链自带的Keil 自带的是 ARMCC 的库GCC 工具链通常带的是 newlib 或 newlib-nano。printf、fputc、fwrite 这些函数都属于这个库层。系统调用是面向操作系统的接口抽象比如 open、read、write、close在嵌入式裸机环境下没有操作系统这些函数通常由移植层以空壳或弱符号实现。所谓重定向就是把这些标准输出通道从默认的调试器窗口改导向到串口、SWO 调试接口、LCD 屏幕或者其他设备。在 PC 上这三层分得很清楚应用程序调用 printfglibc 内部把格式化结果写到 stdout最终通过系统调用 write 交给内核再由内核驱动把数据送到终端。嵌入式环境没有内核但 newlib 仍然保留了类似的层次结构。printf 属于标准库高层逻辑_write属于底层 I/O 抽象只是这个_write默认是弱符号需要我们自己补一个强符号实现。所以“重定向”这个词在 GCC 工具链语境下最核心的动作是重写底层_write而不是高层 fputc。2. 断点打在 printf 里也看不到 fputc 被调用的原因2.1 newlib 内部 printf 的实际流转路径为了确认 fputc 到底在链路中的哪个位置我在 CLion 的调试器里给 printf、fputc 都打了断点同时开启了反汇编窗口。跟踪下来的调用路径大致是这样的printf() └─ vfprintf(_REENT, stdout, fmt, ap) └─ __sfvwrite() └─ _swrite() └─ _write_r() └─ _write(fd, ptr, len) // 用户需要实现这里要注意printf 的格式化输出并不像很多教程里说的那样“每个字符调用一次 fputc”。newlib 会把格式化结果先写入一个缓冲区然后按块调用底层写入函数。这个底层写入函数最终是以文件描述符为单位的_write而不是以 FILE 指针为单位的 fputc。我个人的理解是fputc 是 stdio 层面的接口它处理的是FILE *这个抽象而_write是更接近系统调用的接口它直接和文件描述符打交道。在裸机环境中stdout 对应文件描述符 1stderr 对应文件描述符 2。printf 走的是 stdout所以最终会转到文件描述符 1 的写操作也就是_write(1, ptr, len)。这里顺带提一句在 CLion 里配置 native 库或者 Java JNI 相关工程时如果底层涉及到标准输出重定向也会遇到类似问题。不同构建系统链接的 C 库不同符号解析路径就不一样很多“编译过但运行无输出”的问题根因都是绕了一层底层符号。2.2 ARMCC 和 GCC 工具链的分歧点为什么 Keil 的 ARMCC 里重写 fputc 有效因为 ARMCC 的标准库对 printf 的实现路径不同它把 stdio 层的字符输出通过 putchar/fputc 作为钩子开放给开发者。也就是说在 ARMCC 的库里printf 格式化后确实会逐字符或逐行回调 fputc。而 GCC 工具链的 newlib 不一样newlib 是给类 Unix 环境设计的一套 C 库它天然假设底层存在一个 POSIX 风格的系统调用层。在这个模型里文件描述符是最基本的管理单位缓冲区由库自身管理最终落盘才交给 write。所以即便你写了 fputc它也只在你显式调用 fputc 时才被执行printf 的内部路径并不会经过它。这个差异也解释了为什么同一段代码在 Keil 里能用在 CLion 里不行。不是 CLion 的 CMake 配置有问题也不是代码写错了纯粹是 C 库的接口约束不一样。很多初学者把“重定向 printf”和“实现 fputc”画上等号其实是把 ARMCC 的一个特例当成了所有工具链的通用规则。2.3 半主机这个“隐形坑”是怎么出现的如果只实现了 fputc 而不实现_write在 GCC 工具链下会发生什么newlib 会根据编译配置生成一个默认的_write弱符号这个弱符号有两种走向一种是直接返回 -1也就是告诉上层“写入失败”所以 printf 的结果会被静默丢弃另一种更麻烦它会触发 ARM 的半主机请求。半主机是 ARM 调试架构里的一种机制允许在开发板上运行的代码通过一组固定的软件中断让调试主机帮忙完成文件操作、终端读写、时钟获取等动作。如果_write的默认实现包含半主机请求程序执行到这里就会产生调试事件而你的调试器如果没有正确响应半主机请求CPU 就会卡在那个指令上。表现出来就是程序跑到 printf 附近停住看起来像死机单步执行也走不动。我在实际调试中遇到过的情况是程序连串口初始化都没跑完就在 printf 里死掉检查代码看了半天没有问题最后用调试器看 PC 指针停在一个异常向量附近。后来在链接选项里加上--specsnosys.specs再实现自己的_write问题才彻底消失。所以导航到这个话题重点从来不是要不要实现_write而是必须实现并且不能把半主机机制留给默认行为。3. 在 CLion 里正确重定向直接实现 _write3.1 最精简的 _write 实现如果你只是想在 CLion 工程里把 printf 的输出转到串口最简单的方式是这样#include stdio.h #include stdarg.h #include main.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }这里的参数有三个file是文件描述符对于 stdout 一般是 1对于 stderr 一般是 2ptr是待写入数据的起始地址len是数据长度。返回值应返回实际写入的字节数。我通常在函数末尾直接返回 len因为我的实现是同步发送要么全部发出去要么触发错误不存在“写一半成功一半失败”的中间状态。还有一个细节是_write这个函数名前的下划线。在 ARM GCC 工具链中newlib 期望的符号名就是_write单下划线开头。如果你用的是write链接时不会匹配到 newlib 内部要调用的弱符号代码依然会走默认实现。用 HAL 库的HAL_UART_Transmit时我建议把超时时间设为一个较大的值比如HAL_MAX_DELAY。这样在调试阶段不会出现“串口发送一半因为超时被打断”的误导性问题。真正要处理性能的时候再把阻塞发送换成中断方式或 DMA 方式。3.2 使用文件描述符区分 UART、SWO、RTT 等输出设备_write的好处在于它天然支持多路输出。你可以通过file参数判断当前写的是 stdout 还是 stderr再决定把数据送到哪个设备。我实际用过的工程里有三种输出通道调试串口 UART1、SEGGER RTT、以及用于性能信息输出的 SWO。实现大致这样int _write(int file, char *ptr, int len) { if (file 2) { // stderr 走 SWO ITM_SendChar(ptr[0]); // 简单示例实际可循环发送 len 字节 return len; } // stdout 走 UART HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); // 同时送往 RTT SEGGER_RTT_Write(0, ptr, (unsigned int)len); return len; }有人会问既然 HAL_UART_Transmit 已经一次性发送了 len 字节为什么还有必要区分文件描述符因为在实际工程里日志级别是很常见的设计。普通信息打 stdout错误信息打 stderr两者可以分别重定向到不同介质。打印日志时不需要关心底层但排查问题时把错误日志单独抽出来走一条独立通道能省下太多时间。另外文件描述符还能让你把某个通道对接到自定义外设比如网络调试助手、SD 卡日志文件等。只要你的 “write” 动作是面向数据块的_write就足够通用。3.3 _read 的配套实现和返回值规范如果你不仅想重定向输出还想从串口读数据配合 scanf 或 getchar 使用那就需要实现_readint _read(int file, char *ptr, int len) { HAL_StatusTypeDef status; status HAL_UART_Receive(huart1, (uint8_t *)ptr, 1, HAL_MAX_DELAY); if (status HAL_OK) { return 1; } return 0; }这里有个约定_read返回 0 表示读到文件末尾或者没有数据可读返回正数表示实际读到的字节数返回 -1 表示出错。很多初学者在实现_read时会把HAL_UART_Receive的返回状态直接返回这实际上是错的。因为 HAL 库返回的是HAL_StatusTypeDef它的 0 表示成功和_read的约定正好相反会导致上层逻辑判断混乱。我自己在写串口命令交互程序时会做一个非常简单的环形缓冲区把 UART 接收中断收进来的数据先存起来然后_read从环形缓冲区取数据。这样 scanf 就不会因为 HAL 的阻塞接收卡死整个程序结构也更清晰。4. 实践中几类高频问题的排查思路4.1 输出丢失缓冲与 fflush很多人实现完_write后发现printf 的某些输出没有及时出现在串口里程序一复位就丢掉了。这个问题的根子往往在缓冲策略。newlib-nano 对 stdout 的缓冲策略取决于_isatty和setvbuf的设置。默认情况下它可能让你认为面向的“终端”是非交互设备从而启用全缓冲。全缓冲意味着 printf 的内容会积攒在内存缓冲区里只有缓冲区满或者显式调用 fflush 时才会真正走到_write。如果你的程序恰好一直没凑满缓冲区又突然崩溃那缓冲区的数据就全丢了。我在工程里一般会在初始化阶段显式关闭 stdout 缓冲setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stderr, NULL, _IONBF, 0);这样每次 printf 都会立刻触发_write方便调试也避免丢数据。如果输出量很大、性能有要求也可以保留缓冲但务必在关键位置调用fflush(stdout)或者在异常复位前把日志落盘。还有一点如果你的字符串末尾只写了\n没有\r\n很多串口助手上看到的效果是换行但光标不回到行首。这是终端行为差异不是_write的问题。可以在_write里做一层转换把单个\n自动替换成\r\n或者统一用\r\n编写日志。4.2 中文乱码编码而非 printf 的锅在 CLion 里用 printf 输出中文串口助手看到的经常是乱码。很多人第一反应是_write实现有问题实际上这大概率是编码不一致导致的。CLion 默认用 UTF-8 编码源文件而很多串口助手默认用本地编码 GBK 显示。UTF-8 的中文三个字在 GBK 终端里自然是一堆乱码。解决办法有两个要么把工程源文件编码改成 GBK/GB2312要么把串口助手的显示编码切换成 UTF-8。我个人的建议是统一用 UTF-8因为现代工具链对 UTF-8 支持更友好CMake、Git 等工具也默认 UTF-8。如果要在不同编码之间做转换可以在_write里加入编码转换层但这种做法会引入额外的依赖。除非你的产品确实需要 GBK 编码的协议报文否则不建议在底层做转换太容易出问题。4.3 多任务/中断中写串口死锁和重入当项目进入 RTOS 阶段或者你在中断回调里调用 printf原来单线程环境下很正常的_write实现可能突然变得危险。先说中断。在中断上下文里调用阻塞式HAL_UART_Transmit会给整个系统带来不可预测的延时。如果中断优先级比 UART 中断低还可能造成死锁低优先级中断阻塞等待 UART 发送而 UART 中断又无法触发。我遇到过一次现象是系统偶发死机调试了很久最后发现中断回调里有一行 printf。再说多任务。两个任务同时调用 printf_write内部调用 HAL_UART_Transmit如果 UART 外设本身不可重入就可能出现发送交错或数据混乱。我的做法是把调试输出封装成一个专用服务模块内部用一个互斥信号量保护 UART 外设把日志写入一个临时环形缓冲再由专门的任务负责统一发送。这样即使多个任务同时 printf也只会竞争信号量不会破坏底层状态。4.4 多个 main、测试工程里的符号冲突CLion 里做嵌入式开发时经常会有多套可执行目标一个真正的固件 main一个本机测试 main甚至还有用于快速实验的独立目标。和 PC 开发不同嵌入式工程里的_write是一个强符号如果你在多个源文件里都实现了它链接阶段就会报告重复符号。我见过不少人把_write实现在某个随机 C 文件里结果 CMake 同时编译了另一个也包含_write的文件链接时报错还找不到原因。这种问题最好从一开始就规划好把和板级相关的重定向代码单独放到一个文件里比如retarget.c只参与固件目标的编译本机测试目标则链接标准 PC 库根本不需要也不应该包含这个文件。如果你用 CLion 跑 PC 本地测试比如用 CMake 创建了一个 host 可执行文件底层链接的是 glibc 或者 MSVC 运行库_write符号本身就不可用因为 PC 平台的系统调用层已经由操作系统管理了。这时候再谈重定向 printf思路完全不同不能用嵌入式那套来套。5. 那 fputc 就完全没用了吗我的兼容写法5.1 什么时候 fputc 仍然有存在意义说了这么多_write的必要性并不代表 fputc 就应该被丢进垃圾桶。在某些工具链、某些封装层次下fputc 依然有它的位置。第一如果你将来会把代码移植回 Keil或者给 IAR 用户提供同样的日志接口保留一个 fputc 实现可以保证跨工具链的兼容性。第二有些中间件源码内部会显式调用 fputc 来输出调试信息比如某些网络协议栈、文件系统组件。它们不一定会调用 printf但可能直接调 fputc。这种情况下没有 fputc 实现那些中间件的日志就是空的。所以我现在的习惯是保留 fputc同时也保留_write两者不冲突。fputc 给那些显式调用的代码用_write给 printf 的底层用。5.2 同时兼容 fputc 和 _write 的模板给你一个我目前在 STM32 工程里常用的模板#include stdio.h #include main.h extern UART_HandleTypeDef huart1; static void uart_putchar(uint8_t ch) { HAL_UART_Transmit(huart1, ch, 1, HAL_MAX_DELAY); } int fputc(int ch, FILE *f) { uart_putchar((uint8_t)ch); return ch; } int _write(int file, char *ptr, int len) { for (int i 0; i len; i) { uart_putchar((uint8_t)ptr[i]); } return len; }这个模板简单、直接没有过度设计。_write里用循环逐字节调 uart_putchar效率比直接调用一次 HAL_UART_Transmit 低但胜在逻辑统一如果后来换了串口驱动或者增加了数据过滤只需要改 uart_putchar 一个函数。对大多数调试场景来说这种性能损耗完全可以忽略。如果你的输出量很大可以优化成按块发送int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }两种写法我都用过。按块发送效率高但每次调用只能在一个串口上输出逐字节发送灵活可以方便地嵌入调试开关或协议包过滤逻辑。你可以根据团队习惯灵活选择。5.3 这条经验对 CLion 开发其他库的启示在 CLion 里配置 JNI 环境、导入 FFmpeg、写多个 main 入口的时候其实也会遇到类似的“符号重定向”问题只是大家通常不会把这几件事联系到一块。比如写 JNI 时Java 层的 System.out 输出到了 JVM 提供的控制台但如果你自己用 C/C 代码调用 printf这个输出走的是本地运行库。不同操作系统下运行库对标准输出的实现完全不同有些环境里你根本看不到 printf 的输出因为它是定向到服务进程而不是你的终端。这和嵌入式里 printf 默认走半主机、没有实现_write就看不到输出的逻辑是一模一样。我的经验是碰到任何“改标准库行为”的问题先搞清楚自己用的是哪套 C 库再搞清楚那套库到底预留了哪些可覆盖符号。Keil 用 ARMCCGCC 用 newlibMSVC 用 UCRT三者的底层接口各不相同。照搬网上的教程时一定要先确认教程所针对的工具链而不是盲目地“抄作业”。文中这套_write重定向方法是我在 CLion STM32 工程里稳定用了很久的方案。期间经历过串口乱码、数据丢失、RTOS 死锁、链接符号冲突等一系列问题最后都在把调用链弄清楚之后逐一解决。以后如果还有人问你“CLion 里 printf 重定向是不是写 fputc”你可以把这篇文章分享给他直接把调用链和半主机问题讲明白省得再折腾。