CLion中STM32 printf重定向:为何要重写_write而不是fputc
2026/9/6 10:13:07 网站建设 项目流程

最近在几个嵌入式开发群里,同一个问题被反复问起来:在 CLion 里做 STM32 开发,用 printf 往串口打印日志,明明照着网上的教程写了 fputc 重定向,编译、烧录都正常,可串口就是一片空白。折腾小半天,最后被人点了一句“你要重写 _write,而不是 fputc”才解决问题。为什么?难道那些教程写错了?

其实谁都没写错,只是环境不同,底层调用链不同。这事的本质是工具链和标准库实现方式的问题。这篇内容我尽量讲透,让还在 CLion 里被 printf 折腾的朋友少走弯路。

1. 先从一次串口打印失败说起

1.1 你看到的教程为什么各不相同

搜索“printf 重定向”、“STM32 printf”,你能看到两大流派。一派是重写 fputc,代码大概长这样:

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }

另一派是重写 _write:

int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }

两派教程都斩钉截铁地说“这样写就能用”。你两个都试过,结果可能是一个有效、一个无效,甚至两个都无效。这不是教程在骗你,而是它们默认的“前台环境”完全不同。写 fputc 方案的人,多半用的是 Keil MDK,而且开了 MicroLIB。写 _write 方案的人,默认用的是 ARM GCC 工具链,配合 newlib 或 newlib-nano。

CLion 本身不负责编译,它调用你配置的 CMake 工具链。嵌入式开发里最常见的搭配是 arm-none-eabi-gcc + newlib。在这个组合下,重定向的入口就是 _write。你在 Keil 里顺手写的那套 fputc 逻辑,搬到 CLion 工程里失效,几乎是可以确定的事。

1.2 我先说结论

这里有必要先把结论钉死,后面再拆原理。在 CLion 常见的 ARM GCC 工具链环境中,printf 的底层输出路径根本不经过 fputc。它格式化完数据之后,最终会调用到底层设备输出函数 _write。如果你只重写了 fputc,printf 发送的数据压根不会从你写的 fputc 里走,自然没有输出。

反过来,如果你在代码里直接调用 fputc 往 stdout 写单个字符,那它最终也会走到 _write。所以无论你是用 printf、puts、fwrite 还是 fputc,只要目标是 stdout/stderr,最后都会汇聚到 _write 这个“总闸口”。

换句话说:重写 fputc 是一种“支线方案”,重写 _write 才是“主线方案”。在 CLion 的 ARM GCC 环境下,你应该走主线。

2. printf 到底怎么把字符送出去的

2.1 newlib 中的输出调用链

要理解为什么是 _write,得先搞清楚 newlib 内部 printf 的执行路径。newlib 是嵌入式领域常用的 C 标准库实现,ARM GCC 默认带的那套标准库就是它的变体。当你调用 printf 时,内部一层层递进,大概是这样一个走势:

printf → vfprintf → _vfprintf_r → __sfvwrite_r → _write_r → _write

第一层 printf 是给应用层看的对外接口,负责接收格式串和可变参数。它内部会调用 vfprintf 来处理格式化逻辑,把“%d”、“%s”这些占位符解析成真正的字符串。格式化结果会被写入 stdout 对应的缓冲区。缓冲区一旦满了,或者遇到换行、强制 flush,就会触发底层的写入动作。这个写入动作在 __sfvwrite_r 这一层把数据交给系统设备层,最终落到 _write 这个函数上。

newlib 中的 _write 是一个“弱符号”。这意味着库里面有一个默认实现,但链接的时候如果你自己定义了一个同名强符号,链接器会自动用你的版本覆盖掉默认版本。所以重写 _write 本质上不是“魔改库函数”,而是利用弱符号机制,补上用户自己平台的设备输出逻辑。

为什么会有“stdout 是一个文件描述符”这种概念?因为在 newlib 的设计里,stdout 不是一个抽象的“串口”,而是文件描述符 1。串口、LCD、网口、SD 卡,在你没有操作系统的情况下,都被抽象成文件描述符。你在 _write 里拿到 file 参数,可以判断它到底是 1(stdout)还是 2(stderr),再分别做处理。这样可以做到“调试信息走串口,错误信息走另一个外设”。

2.2 fputc 在这个链条里扮演什么角色

fputc 是 C 标准里定义的函数,作用很直白:往一个 FILE* 流里输出一个字符。注意,它是一个“用户层函数”,不是一个“底层钩子”。printf 内部不会调用 fputc。printf 的格式化结果会直接交给 __sfvwrite_r,再到 _write,完全不经过 fputc。

打个比方:printf 是一条直达总仓的运输专线,_write 就是总仓发货口。fputc 是普通快递点,你往普通快递点塞了包裹,但 printf 这条专线根本不从快递点绕路,它自己直接开到总仓。你只在快递点增加人手(重写 fputc),当然影响不了 printf 专线的运输效率。

那为什么网上那么多 fputc 重定向教程是有效的?原因有几种。第一,Keil MDK 的 MicroLIB 内部实现不同,它的 printf 实现最后会调用 fputc 来输出单个字符,所以重写 fputc 切中了它的路径。第二,有些教程面向的不是 printf,而是纯粹的 fputc 调试场景,比如代码里自己写 HAL_UART_Transmit 加 fputc 封装。第三,有些早期编译器或定制库确实把 printf 的底层输出安排成了 fputc,于是形成了“重写 fputc 可以重定向 printf”的民间共识。

但在 CLion 的 ARM GCC + newlib 组合里,这条路走不通。你重写 fputc,对于 printf 来说完全无关痛痒。printf 的数据只会从你定义的 _write 里出去。

3. CLion 环境下为什么通常选 _write

3.1 工具链决定一切

CLion 是一个 IDE,它没有自己的编译器,它只是一个“编排者”。你用 CLion 做嵌入式开发,通常会在 CMakeLists.txt 里指定工具链,比如:

set(CMAKE_TOOLCHAIN_FILE arm-none-eabi-gcc.cmake)

或者直接在 CLion 的 Toolchains 设置里指定 arm-none-eabi-gcc 的路径。这套工具链的 C 库默认是 newlib 或者 newlib-nano,它们把底层输出收敛到 _write。

如果你之前从 Keil 工程迁移到 CLion,代码里的 fputc 重定向全部要改写成 _write。这不是 CLion 的特殊癖好,而是工具链更替后的必然结果。很多人在 CLion 里导入 STM32CubeMX 生成的工程,第一步就是配置 GCC 工具链,第二步就遇到 printf 串口输出异常。这时候最该检查的就是你重定向的入口对不对。

顺带说一个热词里出现过的“在 CLion 中配置 JNI 环境”。JNI 的底层逻辑其实和 printf 重定向有相似性:Java 层调用 native 方法,需要 C 库提供对应符号;printf 重定向需要库调用你的底层写函数。核心思路都是“工具链 + 库 + 符号导出”的问题。桌面 JNI 场景里,你同样要保证你编译出来的共享库符号没有冲突、导出正确。只是 JNI 方向的符号是 JNIEXPORT,嵌入式里是 _write,但排查逻辑是同一套:先确认调用链,再确认符号覆盖。

3.2 为什么不建议“顺手写一个 fputc”

我见过不少工程,为了保险,把 fputc 和 _write 都重写了。fputc 直接调用 HAL_UART_Transmit,_write 内部循环调用 fputc。代码也能跑,但这会给后续维护埋隐患。

最直接的问题是你可能某天在代码里直接调用 fputc 输出一个字符,然后发现字符被发送了两次。因为你的 _write 是循环调用 fputc,而 printf 走到 _write 后,每个字符都会被 fputc 发送一次。如果你又在其他位置直接调用 fputc,那这个字符就发了两遍。排查起来非常恶心,因为不是每次都复现,只有手动调用 fputc 的路径上才偶尔出现。

更麻烦的是,这种写法会让新手误以为 fputc 就是 printf 的底层,于是后续加入项目的新人会在 fputc 里塞自己的业务逻辑,比如状态标志、锁操作、超时控制,越写越复杂。

我个人的建议是:明确重定向入口,只重写 _write。如果你真的需要 fputc 这个函数,也建议在 fputc 里调用 _write,而不是反过来。

3.3 重定向 _write 时的关键设计

重写 _write 并不是把 HAL_UART_Transmit 抄进去就完事。你需要考虑几个问题。

第一个是 file 参数。_write 的原型是 int _write(int file, char *ptr, int len)。file 可能是 0、1、2,分别对应 stdin、stdout、stderr。你需要判断 file 是否为 1 或 2,至少不要对 0 执行发送,否则标准输入也被你当成输出,行为会很奇怪。

第二个是返回值。_write 必须返回“实际发送的字节数”。如果 HAL_UART_Transmit 因为超时或其他原因没能完整发送,len 和返回值不一致,上层库会认为写失败,可能进入错误分支。

第三个是重入问题。如果你的串口发送使用中断方式,而 printf 在主循环里频繁调用,要防止发送尚未完成时下一次 _write 又开始往 UART 数据寄存器里写。最简单粗暴的方案是查询方式(阻塞发送),只要超时设得合理,日志场景完全够用。如果你用中断 + 队列,就要确保 _write 在下一个字符来之前,上一个字符已经通过中断发送完毕,否则需要排队。

4. 在 CLion 里完成一次干净的 printf 重定向

4.1 最小可用实现代码

这里给一套我自己在 STM32 系列上验证过很多次的写法。基于 HAL 库,阻塞发送,通过 _write 重定向。

#include <stdio.h> #include "stm32f1xx_hal.h" extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file == 1 || file == 2) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); } return len; }

注意几点:一是 extern 声明了 huart1,你要根据自己工程里实际生成的 UART 句柄名称调整;二是 HAL_UART_Transmit 的第三个参数是 uint16_t,而 len 是 int,如果 len 超过 65535,理论上会截断。但 _write 的单次 len 通常不会那么大,一般几百字节已经顶天了,实际风险不大,可为了严谨你也可以加长度判断;三是阻塞发送超时时间我习惯写成 0xFFFF,而不是 HAL_MAX_DELAY,因为 0xFFFF 足够覆盖日志场景且不会永久卡死。

如果你用的是寄存器版本而不是 HAL 库,就把发送部分换成你自己的串口字节发送函数。原理一样,_write 只关心“能把这 len 个字节送出去”,具体怎么送是你的事。

4.2 CMake 与链接器配置

在 CLion 里,你还需要确认编译参数里是否使用了 newlib-nano。一般在 CMakeLists.txt 中会有类似这样的设置:

add_compile_options(-mcpu=cortex-m3 -mthumb -mfloat-abi=soft) add_link_options(-mcpu=cortex-m3 -mthumb --specs=nano.specs)

细说下这里的关键点。--specs=nano.specs 会让链接器使用 newlib-nano 代替完整版 newlib。newlib-nano 的特点是体积小,printf 默认不支持浮点输出。如果你在代码里写 printf("%f", 3.14) 这类语句,得到的结果往往是输出一个空字符串或者一个奇怪的占位符。解决办法是额外加一个链接参数:

add_link_options(-u _printf_float)

这个参数会强制把 printf 的浮点支持代码链接进来。代价是固件体积增加,大约增加几 KB 到十几 KB,取决于优化等级和芯片。对绝大多数 STM32F103 级别的芯片来说完全没问题,但如果你的 Flash 只剩几百字节,就要慎重了。同理,如果要用 scanf 读浮点,可以加 -u _scanf_float。

还要提醒一个细节:配置了 nano.specs 和 _printf_float 之后,_write 的重定向符号依然是 _write,不会因为库变了就变成别的名字。但在某些 GCC 版本里,你会看到 _write_r 这个变体。它和 _write 的区别是多了个 reent 参数。如果你发现你的工程链接的是 _write_r,那你应该实现 _write_r 而不是 _write,或者同时实现 _write,让库内部的默认 _write_r 继续调用 _write。不过常规情况下,直接实现 _write 就能正常链接,因为库内部的 _write_r 默认会回调 _write。

4.3 在 CLion 中快速验证

代码写完别急着写业务逻辑,先跑一个最简单的测试。

printf("hello clion printf\r\n"); printf("float value = %.2f\r\n", 3.14f);

烧录后,用串口助手或者 CLion 自带的串口终端查看。正常情况你应该看到两行输出,第二行的浮点数能正确显示为 3.14。

如果你用的是 CLion 自带的串口终端,注意波特率设置要和代码里 UART 初始化一致。很多“为什么乱码”的问题,一半是波特率不对,另一半是时钟配置不对。另外,CLion 的串口终端默认编码环境在不同平台表现不太一样,如果出现中文乱码,优先把代码里的字符串暂时改成英文,确认链路通不通,再回头处理编码问题。

5. 常见问题与排查记录

5.1 典型故障速查表

我自己在实际项目中整理过一批常见的 printf 重定向故障,这里列成表格,方便你按图索骥。

现象常见原因解决方案
printf 完全没有输出没有重写 _write,只重写了 fputc按 4.1 的方式重写 _write
printf 输出浮点数为空用了 nano.specs 但没加 -u _printf_float链接参数加 -u _printf_float
输出乱码波特率不匹配、时钟配置错误检查 UART 初始化参数、系统时钟频率
输出中文乱码源码编码与串口终端编码不一致统一使用 UTF-8,或在终端选择对应编码
输出一段后卡死_write 里使用阻塞发送,遇到中断冲突改为发送前等待上次发送完成,或者加队列
程序跑到 _write 附近就崩溃栈溢出、外设时钟未使能检查启动文件和时钟配置,可能还涉及内存访问违例问题
编译报错 bad file descriptorstdout/stderr 宏定义冲突检查是否有自定义 FILE 相关内容干扰

5.2 那些“神秘”崩溃背后的原因

热词里有一条特别有代表性:“write to location 0000000000000020 caused an access violation”。这种 0x20 附近的地址访问违例,在嵌入式场景里往往是空指针或未初始化外设导致的。如果你在 _write 里直接调用 HAL_UART_Transmit,而对应的 UART 外设时钟没使能,或者句柄没有初始化,HAL 库内部会访问到无效寄存器地址,表现就是程序跳飞到硬件错误中断,或者在某条内存访问指令处崩溃。

排查思路是:先用调试器在 _write 入口打断点,确认程序是否进入了 _write。如果根本没进 _write,那就是 printf 之外的问题;如果进了 _write,单步执行,看卡在哪一行。大概率要么是句柄没初始化,要么是波特率参数结构体异常。

另一个高频问题是串口发送过程中频繁关中断导致的时序怪问题。你在 _write 里用阻塞发送,发送过程中如果来一个高优先级中断,中断里又调用 printf,就会造成嵌套重入。轻则输出错乱,重则死锁。建议在中断服务函数里不要调用 printf,用标志位代替,主循环里检测到标志位后再输出。

5.3 CLion 中文乱码的编码处理

CLion 的“中文输出乱码”几乎人人都会遇到,我把它单拎出来说。它分为两层:第一层是串口输出中文乱码,第二层是 IDE 编译日志或控制台中文乱码。

串口输出中文乱码:先看源码文件编码。CLion 默认使用 UTF-8,但很多人从 Keil 或旧工程拷贝代码时,源文件可能是 GBK 编码。字符串字面量按 GBK 存储,在以 UTF-8 发送的串口终端上自然会乱。解决方法是把源文件统一转成 UTF-8,然后串口终端也选择 UTF-8 解码。工具方面,CLion 自带 File Encoding 设置,右下角可以快速切换。

如果你是往 Windows 自带的超级终端类工具上输出,可能更支持 GBK,那就反过来:源码存成 GBK,串口工具选 GBK。原则就一句话:发送端和接收端编码必须一致。

CLion 控制台中文乱码:通常是 Windows 平台下控制台代码页导致的。你可以在 CLion 的运行配置里把控制台编码设置为 UTF-8,或者在系统层面把代码页切到 65001。这个问题不影响嵌入式目标板输出,但会干扰你本地调试工具的输出查看。

6. 我最后想补充的小经验

6.1 我踩过的一次最典型的坑

早期我还在用 Keil 做 STM32 开发,重写 fputc 输出 printf 一直很顺利。后来 Mesh 项目要求统一用 CLion + GCC,我把代码迁过去,跑了一次串口日志,半天没输出。当时第一反应是 CMAKE 配置错了,又去查 UART 初始化,反复折腾了很久。最后对着反汇编和链接脚本翻库源码,才意识到是 newlib 的 printf 路径跟 MicroLIB 完全不同,根本不是工程配置的问题,是标准库实现的问题。

那次之后我做了一个习惯性动作:每次新工程配置完工具链,先写一个最简单 printf 测试,确认重定向链路通了再写业务代码。这个习惯帮我避免了很多无意义的排错时间。

6.2 给你的三个建议

第一,确认你的重定向入口和工具链匹配。在 CLion 里,默认按 _write 来写,基本不会错。如果从别的 IDE 迁移过来,先改 fputc 为 _write。

第二,不要在中断里调用 printf,哪怕你加了锁。串口输出的本质是耗时操作,在中断里做会让系统失去实时性。正确做法是中断里置标志位,主循环查询标志位再打印。

第三,_write 里不要调用 printf。这是一种隐蔽的递归行为。如果你需要打印错误信息,直接在 _write 里调用底层 UART 发送函数,不要再绕回 printf。

这个内容后续还可以扩展的方向是:如果你在做 RTOS 移植,多个任务同时 printf 时,_write 里就涉及临界区保护;如果你想把日志输出到 TCP、Flash、SD 卡,也只需要改 _write 里的设备操作部分。理解了这一层,重定向就不只是“抄代码”,而是真正掌握了嵌入式日志系统的入口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询