CLion中printf重定向为何要重写_write?fputc失效的底层原理与完整方案
年前一个朋友从 Keil 转到 CLion 写 STM32第一件事就是把老工程里那段 printf 重定向代码原封不动搬过去int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }结果串口助手一片空白printf 一点反应都没有。换成_write三行代码立刻就能打印。这不是玄学也不怪 CLion而是两套工具链的 C 标准库在 printf 输出路径上有根本差异。今天这篇文章就把这个差异讲透为什么在 CLion 环境里我们必须重写_write而不是fputc以及从代码到链接参数完整的做法是什么。1. 从一次翻车现场说起fputc 重定向在 CLion 里为什么失效1.1 Keil 时代养成的习惯在 Keil MDK 里做嵌入式开发只要选中了 Use MicroLIB然后重写一个 fputcprintf 的输出就会自动转到串口。这套操作在很长一段时间里几乎是标准答案网上大量 STM32 教程也是这么教的。int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }它的逻辑很直接printf 格式化完一个字符标准库就把这个字符丢给 fputc我们在 fputc 里把字符塞进 UART 发送寄存器输出就完成了。Keil 的 ARM Compiler microlib 就是这么设计的fputc 是整个 printf 输出链路的终点站改它就能接管一切。1.2 搬到 CLion 之后串口一片寂静CLion 是个 IDE它不是编译器。你在 CLion 里开发 STM32真正干活的是 CMake 调用的arm-none-eabi-gcc而 ARM GCC 自带的 C 标准库是 newlib 或 newlib-nano不是 Keil 的 microlib。这两套标准库对 printf 的输出路径设计完全不同。你把 Keil 的 fputc 代码原封不动搬到 CLion 工程里编译器不会报错链接也能过但 printf 的数据根本不会经过你的 fputc自然就什么都打不出来。我见过不少人卡在这里第一反应是怀疑 CLion 的配置有问题又是改 CMake 又是重装工具链折腾一圈回来发现问题就是找错了重定向的钩子。1.3 问题不在代码在工具链的出水口不同一句话回答标题的疑问在 CLion 这个场景里底层用的是 ARM GCC newlibprintf 最终会把格式化好的整段数据交给一个叫_write的系统调用钩子而不是逐个字符调用 fputc。你重写 fputc相当于修好了前台但后台出水的总闸没打开水照样出不去。开发环境编译器/标准库printf 输出终点需要重写的函数Keil MDK MicroLIBarmcc / armclang microlibfputc字符级fputcCLion CMakearm-none-eabi-gcc newlib(-nano)_write系统调用级_writeSTM32CubeIDEarm-none-eabi-gcc newlib(-nano)_write_write这套对比表基本解释了你看到的绝大部分有人说 fputc 能用、有人说_write才能用的争论。后面我展开讲原理。2. printf 的输出链路格式化、流缓冲与最终落地点2.1 从 printf 到最底层到底经过了谁很多嵌入式开发者对 printf 的理解停留在格式化函数这一层觉得它就是把字符串整理好输出。实际上在标准库里printf 的工作远比这复杂它的完整链路大概是这样调用方执行printf(hello %d, 42)。标准库把格式串和参数交给内部的 vfprintf 函数做格式化。格式化后的字符被写入 stdout 这个 FILE 流对象的缓冲区。缓冲区满足刷新条件比如遇到换行、写满、手动 fflush 或程序正常退出时触发底层写操作。底层写操作拿到文件描述符 fd、缓冲区指针 ptr、长度 len调用系统写入接口。在桌面 Linux 程序里最后一步调用的是操作系统的write系统调用在裸机嵌入式环境下没有操作系统标准库就留了一个同名钩子_write等你自己实现。newlib 里这个钩子就是int _write(int fd, char *ptr, int len)。注意这里的 fd 是文件描述符不是 stm32 里的那个 fd。标准输出 stdout 对应 fd 1标准错误 stderr 对应 fd 2。printf 的数据经过 stdout最终就会带着 fd1 走进你的_write。2.2 裸机环境里 _write 为什么是必经之路newlib 是很经典的嵌入式 C 库为了能在各种环境下运行它把所有跟环境相关的操作全部抽象成一组接口包括_sbrk堆内存、_read输入、_write输出、_close、_lseek等。这些接口在裸机环境下默认是空的或者直接返回错误。STM32CubeMX 生成 GCC 工程时会附带一个 syscalls.c 文件里面就有这些接口的弱定义比如__weak int _write(int fd, char *ptr, int len) { (void)fd; (void)ptr; (void)len; return -1; }问题就出在这里。printf 格式化完数据辛辛苦苦走到_write发现这个函数啥也不干直接返回 -1。数据链在这里断掉串口自然什么都没收到甚至 printf 还会因为底层写入失败而返回错误。相比之下fputc 在 newlib 里只是一个普通的字符级输出函数printf 的主要输出路径并不经过它。你重写一个 fputc对 printf 的主链路没有任何影响数据照样在_write处断掉。这就是为什么必须打通_write这个最后一公里。2.3 一个前台、传达室与送货员的类比如果你觉得上面这些链路有点抽象我常用一个类比printf 是公司前台负责把所有请求格式化、排好队stdout 是公司内部的内部通道_write是传达室和送货员负责把东西真正从公司大门送出去。重写 fputc 相当于你把前台的话术改了或者把前台门口的一个小信箱换了。内部通道确实走了那段路但传达室的门关着送货员进不来东西还是堆在里面出不去。重写_write才是把传达室的大门打开让送货员直接把货物搬上车的动作。一旦你从_write把整段数据发出去你甚至不需要关心 fputc 写了什么、有没有被调用。printf 输出的所有内容最后都会在这里汇合。3. 工具链的分水岭microlib 与 newlib 选择了不同的钩子3.1 同是 C 代码标准库底座完全不同你说 C 语言标准只有一套为什么 Keil 和 GCC 的 printf 行为差这么多因为标准规定的是函数行为而不是内部实现路径。到底让谁做最后的输出是标准库实现者决定的。microlib 是 ARM 官方为嵌入式环境裁剪出来的精简库它的目标是代码体积极小、依赖极少。在 microlib 的实现里printf 的底层输出点就设计成了 fputc所以你在 Keil 里重写 fputc 就能接管输出。newlib 是另一套为实现嵌入式 POSIX 类环境而生的库它保留了比较完整的文件描述符抽象把底层环境操作留给系统层。裸机下没有系统层那就由用户自己填。所以 newlib 的 printf 最终落到_write而不是 fputc。这两套设计没有谁对谁错只是选型不同。CLion 默认和你用的是 ARM GCC带的是 newlib那就必须遵守 newlib 的游戏规则。3.2 newlib 的 syscall stub 机制以及 CubeMX 里那个空 _writenewlib 的设计思路可以概括成核心逻辑全部用标准 C 实现只留一小部分系统相关接口给外部环境。这样一套代码既能在 Linux 上跑也能在裸机上跑只要外部环境提供这些接口。裸机工程要提供的接口正是 syscalls.c 里那一堆函数。CubeMX 生成的 syscalls.c 之所以存在就是为了满足 newlib 的链接需求。里面的_write默认是被__weak修饰的意思是如果你在别处定义了一个强符号_write链接器就会用你的实现覆盖掉这个空函数。所以正确的重定向方案就是在工程任意一个源文件里实现一个强符号_write让 CubeMX 的弱符号实现被自动覆盖。这个过程在 CLion 里不需要额外配置只要你的源文件参与编译链接就行。3.3 为什么网上那些重写 fputc 成功了的说法也有依据你在网上搜 printf 重定向会看到两种答案并存很多人因此被误导。我梳理下来重写 fputc 成功的说法通常来自以下几种情况第一种是旧版本的 ARM GCC 或某些特定开发板库它们在底层实现里把 fputc 当作输出点这种情况现在比较少了但还是存在于一些老旧教程代码中。第二种是用户不仅写了 fputc还改了链接脚本或启动文件或者是项目里恰巧存在能兜住 stdout 的其他实现fputc 只是恰好被某种方式接上。第三种是 ARM Compiler 6AC6在不选 microlib 时的行为跟 AC5 也不同网上很多教程没区分编译器版本直接把旧经验复制过来。所以遇到这种争论我的建议是不要死记到底哪个函数而是去看你当前这个编译器 标准库组合里printf 最终调用的底层函数是谁。CLion ARM GCC newlib 这个组合答案就是_write。4. 在 CLion 工程里重写 _write从代码到链接的完整操作4.1 先确认你的构建用的是哪一套工具链动手之前先确认 CLion 当前用的确实是 ARM GCC而不是其他编译器。打开 File - Settings - Build, Execution, Deployment - Toolchains看 C Compiler 路径是不是指向arm-none-eabi-gcc。也可以在项目 CMakeLists.txt 里查看编译器的设置或者直接在终端执行arm-none-eabi-gcc -v如果显示的是 GNU Arm Embedded Toolchain那就是标准库 newlib 体系走_write重定向没跑。如果你是用 CLion 自带的编译器配置插件或远程编译配置也先确认底层工具链身份这决定了后面所有操作的走向。4.2 两种重写方式改 syscalls.c 与强符号覆盖第一种方式最直观打开 CubeMX 生成的 syscalls.c找到_write函数把内容改成你要的串口发送逻辑。好处是逻辑集中坏处是 CubeMX 重新生成代码时该文件可能被覆盖你要重新改一遍。第二种方式更推荐新建一个 retarget.c 之类的文件在里面写一个不带__weak的强符号_write让链接器自动覆盖 syscalls.c 里的弱符号。这样 CubeMX 再生成代码也不影响你的实现。#include stdio.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int _write(int fd, char *ptr, int len) { if (fd 1 || fd 2) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); } return len; }这里的要点有几个fd 1对应 stdoutfd 2对应 stderr两个都可以接上len是要发送的字节数直接把整段数据交给 HAL 库的串口发送函数比 fputc 一个字符一个字符循环高效得多返回值必须等于len否则标准库认为写入失败可能重复调用或导致 printf 返回错误。如果你的 CubeMX 版本生成的 syscalls.c 里_write没有加__weak强符号覆盖会报 multiple definition 错误。这时候要么删掉 syscalls.c 里的实现要么给它手动加上__weak二选一。4.3 CMake 里的两个必要链接参数在 CLion 的 CMake 工程里由于 CubeMX 生成的 CMakeLists.txt 通常已经带了-specsnano.specs默认使用的就是裁剪版的 newlib-nano。如果你想确保行为一致可以在工程 CMakeLists.txt 里加add_link_options(-specsnano.specs) target_link_options(${PROJECT_NAME} PRIVATE -Wl,-u,_printf_float)第一行是选择 newlib-nano 版本体积更小资源占用低第二行是启用 printf 的浮点格式化支持。没有-u _printf_float的时候printf(%.2f, 3.14)这种代码编译能过运行结果却是空的或输出异常这是很多人踩过的坑。改完 CMakeLists.txt 后CLion 右上角通常会出现一个加载变更的提示点一下 Reload CMake Project链接参数就生效了。4.4 烧录验证与三个最常见异常代码和链接参数都准备好后烧录程序在 main 函数里调用printf(Hello from CLion\r\n);串口助手应该能看到输出。如果没看到常见情况有三种第一种是_write没有被调用。可以在这个函数里打断点或者用 GPIO 翻转引脚辅助判断确认数据有没有走到这一步。如果根本没进来检查你的源文件是否真的参与编译链接以及是否有其他_write定义抢占了符号。第二种是程序卡死。HAL_UART_Transmit 最后一个参数是超时时间如果串口外设没有完成初始化或者配置的 UART 句柄不对这个函数会一直等到超时才返回表现为程序卡住。解决方法是确保调用 printf 之前MX_USART1_UART_Init() 已经执行过了。第三种是输出乱码。多半是波特率、系统时钟或 GPIO 复用配置问题跟_write本身关系不大但很多人在刚切到新工具链时会同时踩上排查时先检查串口助手的波特率是否与代码一致。5. 缓冲区、浮点打印和多串口_write 的进阶玩法5.1 裸机调试强烈建议关闭 stdout 缓冲用上_write之后你会发现 printf 的输出时机不完全受你控制。原因是 newlib 的 stdout 默认是带缓冲的数据可能不会立刻到达_write而是攒在缓冲区里遇到换行、缓冲满或程序结束时才真正刷新。这在你单步调试或者程序突然跑飞时非常致命你以为 printf 已经输出了实际上数据还在缓冲区里没来得及发送复位后那几行日志直接丢了。解决方式是程序初始化早期调用setvbuf(stdout, NULL, _IONBF, 0);_IONBF表示无缓冲printf 一旦格式化完成立刻触发_write数据马上上串口。代价是每次 printf 都会直接调用一次串口发送频繁打印时效率略低但对于调试阶段完全值得。5.2 让 printf 支持 %f 的链接参数前面提到-u _printf_float这个链接参数这里展开说明。ARM GCC 的 newlib 为了控制体积默认不包含浮点格式化的实现导致%f不会真正输出数字。加上参数之后代价是固件体积增大一般会增加几 KB 到十几 KB。对 STM32 来说几乎可以忽略但换来的是调试模拟量时的直观体验。printf(adc value: %.2f\r\n, voltage)这种语句就能直接看到电压值。类似的参数还有-u _scanf_float如果你用 scanf 接收浮点数需要加。大多数字节换调试便利性值不值自己判断。5.3 用 _write 统一管理多串口与不同日志级别_write的入参里带有 fd这个参数可以做得很有讲究。常规做法是 fd 1 走调试串口fd 2 走另一个串口或者同时打到同一串口但加个前缀区分。如果你有多个板子、多个外设需要打印可以维护一个全局变量UART_HandleTypeDef *debug_uart huart1; void set_debug_uart(UART_HandleTypeDef *huart) { debug_uart huart; } int _write(int fd, char *ptr, int len) { if (fd 1) { HAL_UART_Transmit(debug_uart, (uint8_t *)ptr, len, 0xFFFF); } return len; }之后在代码里切换输出通道只需要调一次set_debug_uart(huart2)所有 printf 自动改道不用去改每一个打印语句。这个技巧在多板联调时特别实用。5.4 顺带把中文乱码问题说清楚CLion 的源文件默认是 UTF-8 编码你写的中文日志字符串在编译后是 UTF-8 字节流。_write会把整段字节发给串口如果串口助手用的是 UTF-8 解码显示就正常如果用 GBK/ASCII 解码中文就会变成乱码。所以中文乱码不一定是你代码错了很可能是串口助手的编码设置跟源文件编码不一致。解决方法有两个一个是把串口助手切到 UTF-8另一个是日志全用英文省去编码层面的麻烦。另外一个小细节printf 字符串建议写成\r\n而不是只写\n。很多终端或串口助手遇到单独换行不会把光标移到行首显示起来很乱。这个跟_write无直接关系但也是从 Keil 切到 CLion 后经常看到的输出排版问题。6. 后来我还踩过的几个坑一起写在这里6.1 多重定义冲突CubeMX 的 syscalls.c 里可能已经有 _write前面提到过CubeMX 生成的 syscalls.c 里_write通常是弱符号但也有版本或者用户手动改动后变成强符号的情况。一旦你定义的_write跟它冲突链接器会直接报 multiple definition 错误。遇到这个错误的排查思路先看报错信息里列出的是哪两个文件如果是你自己的源文件跟 syscalls.c 冲突就把 syscalls.c 里的_write注释掉。我一般不用移除 syscalls.c 文件这种操作因为里面还包含_sbrk、_read等接口贪图省事把它删掉会带来新的链接问题。6.2 HAL_UART_Transmit 卡死与中断并发HAL_UART_Transmit是阻塞式发送如果 UART 外设没初始化或者总线上有异常它会一直等到超时时间走完才返回这就是程序卡住的直接原因。另一个更隐蔽的问题是并发。如果主循环正在 printf里面又来了一个串口中断中断服务函数里也调用 printf两个_write会同时对同一个串口操作数据交错甚至死锁。我的做法是中断里不直接 printf只把要打印的数据丢进一个环形缓冲区主循环负责取出缓冲区内容统一发送。这样_write永远只有一个调用者数据不会打架。6.3 遇到 __io_putchar 别慌追到底层还是 _write有些 HAL 例程或者老版本适配层里会出现__io_putchar这个函数名并且网上部分教程说重定向要写它。这个函数跟 fputc 类似也是标准库层面的字符输出钩子某些工具链组合下通过它也能生效。但在 CLion ARM GCC newlib 这个组合里你真正需要兜底的还是_write。有些工程会把__io_putchar内部实现封装成调用_write也有些工程反过来看源码就清楚了。遇到这种情况别被名字吓住追一下符号定义处看它的最终走向是什么然后决定重写哪个。我自己在 Keil、IAR、CLion、STM32CubeIDE 之间来回切了很多次总结出一条经验拿到一个新的 IDE 或编译链组合先别急着抄网上现成的重定向代码看一眼标准库里 printf 最终落到哪个函数。Keil microlib 落的是 fputcGCC newlib 落的是_write这是环境差异不是谁对谁错。既然已经写了_write最后再分享一个小技巧顺手把setvbuf(stdout, NULL, _IONBF, 0)和-u _printf_float都加上一个保证日志实时输出不丢一个让你能直接打印浮点数。这两个小配置能省掉后续一大半调试糟心事。