年前一个朋友从 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,然后重写一个 fputc,printf 的输出就会自动转到串口。这套操作在很长一段时间里几乎是"标准答案",网上大量 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 + newlib,printf 最终会把格式化好的整段数据交给一个叫_write的系统调用钩子,而不是逐个字符调用 fputc。你重写 fputc,相当于修好了前台,但后台出水的总闸没打开,水照样出不去。
| 开发环境 | 编译器/标准库 | printf 输出终点 | 需要重写的函数 |
|---|---|---|---|
| Keil MDK + MicroLIB | armcc / armclang + microlib | fputc(字符级) | fputc |
| CLion + CMake | arm-none-eabi-gcc + newlib(-nano) | _write(系统调用级) | _write |
| STM32CubeIDE | arm-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,最终就会带着 fd=1 走进你的_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 里那个空 _write
newlib 的设计思路可以概括成:核心逻辑全部用标准 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 6(AC6)在不选 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对应 stdout,fd == 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 通常已经带了-specs=nano.specs,默认使用的就是裁剪版的 newlib-nano。如果你想确保行为一致,可以在工程 CMakeLists.txt 里加:
add_link_options(-specs=nano.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 落的是 fputc,GCC + newlib 落的是_write,这是环境差异,不是谁对谁错。
既然已经写了_write,最后再分享一个小技巧:顺手把setvbuf(stdout, NULL, _IONBF, 0)和-u _printf_float都加上,一个保证日志实时输出不丢,一个让你能直接打印浮点数。这两个小配置能省掉后续一大半调试糟心事。