1. 问题背后的真正逻辑:CLion 与 Keil 的"底层差异"之争
如果你在 CLion 里写过 STM32 或者其它嵌入式 C 工程,那你大概率在某个调试深夜遇到过这个困惑:printf 死活不输出,串口助手一片空白。网上一搜,十篇教程里有八篇会让你"重写 fputc",再配合一句"勾选 Use MicroLIB"之类的操作。你照着做了,结果在 Keil 里跑得飞起,一换到 CLion 就原形毕露——编译能过,链接能过,下载能过,就是不说话。
问题出在哪?不是你的代码写错了,而是你被"Keil 的思维定式"绑架了。
CLion 的嵌入式开发通常不走 Keil/ARMCC 那套工具链,而是用 GCC ARM Embedded(arm-none-eabi-gcc)配合 CMake 构建系统,链接的 C 运行库是 Newlib 或者 Newlib-nano,而不是 Keil 的 MicroLIB。这两套运行库对 printf 底层的重定向接口定义完全不同。Keil 的 MicroLIB 和 ARMCC 标准库走的是 fputc 这个"应用层钩子",而 GCC 的 Newlib 库则是用 _write 这个"系统调用层钩子"。你死磕 fputc,等于在一个根本不存在的岗位上投简历——编译器压根不认识你要重定向的那个"入口"。
从实际开发角度看,这个问题看着小,却是 CLion 嵌入式开发中"第一个真正卡住新手的暗礁"。它背后牵扯到对 C 运行库层次的理解,对 printf 调用链的认知,以及对不同 IDE/编译器生态的区分。搞懂了它,你就搞懂了嵌入式串口调试的基本盘,后面不管换什么芯片、什么工具链,都能在一分钟内定位问题。
2. fputc 和 _write:名字一字之差,层级天差地别
要搞懂为什么要重写 _write,得先认清这两个函数在 C 运行库里的"编制"完全不同。这是一个典型的"应用层"与"系统调用层"的关系问题。
2.1 函数签名差异:一个面向字符,一个面向文件描述符
先看两个函数的原型:
int fputc(int ch, FILE *f);fputc 的语义很明确:往某个 FILE 流里写一个字符。它属于 C 标准库的"流 I/O"层,处理的是 FILE 指针这种上层抽象,干的是"把一个字符塞进缓冲区"这种细活。
再看 _write:
int _write(int file, char *ptr, int len);注意,这是"旧版"签名,以 arm-none-eabi-gcc 的 Newlib 为例,实际定义一般是:
int _write(int file, char *ptr, int len); // 或者较新工具链中可能是: int _write(void *file, char *ptr, int len);它属于"系统调用层"(syscall layer),处理的是文件描述符 fd(0 是标准输入,1 是标准输出,2 是标准错误),干的是"把一段缓冲区整体交给底层设备"这种粗活。我们可以把 fputc 理解成"单个送货上门",而 _write 是"直接整车发货"。
在 ARMCC 的 MicroLIB 环境里,printf 最终会调用 fputc 来逐字符输出。而在 Newlib 环境里,printf 经过一系列缓冲处理后,最终会调用 _write 来把整段文本一次性"刷"到设备上。两者的调用链层级完全不同。
2.2 调用链对比:谁在背后支撑 printf
以 Keil MDK + ARMCC 为例,printf("Hello\r\n") 的走向大致是这样:
printf() -> fputc('H', stdout) -> fputc('e', stdout) ... -> fputc('\n', stdout)每个字符独立走一次 fputc,实现简单,但效率不高。ARMCC 标准库本身也有缓冲机制,但 MicroLIB 模式下为了省资源,路径相当"直白"。
再看 CLion + arm-none-eabi-gcc + Newlib 环境:
printf("Hello\r\n") -> vfprintf(stdout, "Hello\r\n") -> 内部缓冲处理 -> _write(1, "Hello\r\n", 7) -> 底层设备驱动这里体现了一个关键差异:printf 把格式化好的字符串交给 vfprintf,vfprintf 根据 stdout 的类型决定是否缓冲,最终当缓冲区满了、遇到换行符或者主动 fflush 时,会把整段数据交给 _write,由 _write 负责把数据真正发到串口或其他设备。
之所以要有 _write 这一层,是因为操作系统环境下,printf 是标准库函数,它不知道"屏幕"是什么,只知道"文件描述符 1"。_write 就是标准库和底层硬件之间的"翻译官"——标准库说"给我写 7 个字节到 fd 1",_write 就负责把这 7 个字节真真切切地交给某个能接收的东西,比如串口的发送寄存器。
2.3 为什么 Keil 里的 fputc 经验一换到 CLion 就失灵
原因说到底就一句话:CLion 默认的 GCC 工具链压根就不调用你重写的 fputc。
Newlib 的 printf 实现里确实有 fputc 这个概念,但那只是 itoa/格式化过程中的内部函数,不是给你重定向用的"钩子"。你重写了 fputc,链接器可能都感知不到——没有人调用它,或者调用了但你的实现和 Newlib 内部符号冲突导致链接错误。
我在第一次从 Keil 迁移到 CLion 时,就犯了同样的错。在 CLion 里按老经验重写 fputc,编译顺利通过,串口却一个字都不吐。翻遍了论坛帖子后,才意识到 Newlib 根本不理睬 fputc,它在等待我提供 _write。这个"平台切换"的认知鸿沟,是无数嵌入式新手在 CLion 上栽跟头的最常见原因。
3. CLion + GCC 环境下的正确重定向姿势
既然明确了要重写 _write,接下来就是实操环节。我以 STM32F103 + STM32CubeMX + CLion 的典型工程为例,从零演示完整的 printf 重定向流程。
3.1 底层串口发送函数:先打好地基
别一上来就写 _write。先确认你的串口发送函数是出门可用的。我这里用 HAL 库的阻塞发送函数:
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);如果你用的是 LL 库或标准外设库,对应换成 LL_UART_TransmitData8 或者自己操作 DR 寄存器。核心要求是:有一个能一次发一个字节或一段字节的函数。无论哪种方式,底层发送函数的可靠性直接决定重定向是否成功。我在调试中发现,如果底层发送函数没有正确等待发送完成标志,数据会丢失,表现为printf输出乱码或随机丢字符。
3.2 完整重定向代码与关键注释
在 main.c 或者单独新建的 retarget.c 里,加入以下代码:
#include <stdio.h> #include <stdarg.h> #include <string.h> #include "stm32f1xx_hal.h" // 声明你实际使用的串口句柄,以 UART1 为例 extern UART_HandleTypeDef huart1; // 新版 Newlib 的 _write 签名(注意第二参数类型是 char *) int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }这里有几处"坑"必须说明:
第一,函数名必须叫_write,且前面不要加 static。Newlib 在链接阶段需要一个全局可见的_write符号。如果你写成了 static 函数,链接器找不到,会保留 Newlib 的默认弱实现,导致你的代码根本没被调用。
第二,签名必须严格匹配。旧版工具链里_write的第二个参数可能是char *ptr,新版 Newlib 在某些配置下可能是void *ptr。如果你在编译时报"conflicting types for '_write'"错误,优先检查签名是否和工具链头文件 sys/unistd.h 里的声明一致。
第三,返回值的坑。_write必须返回实际写入的字节数。我见过有人返回 0,结果 printf 的循环认为"写入没成功",不断重试或干脆跳过后续字符,输出就千奇百怪。最安全的写法就是return len;。
第四,关于__io_putchar的旧写法。网上很多教程会让你重写:
int __io_putchar(int ch) { ... }这是旧版 Newlib 的接口。新版工具链已经不再调用它,而是直接调用_write。如果你的工具链版本较新,写了__io_putchar也不会报错——因为没人调用它,它就是个"死代码"。这就是很多教程"看起来对了但没效果"的根源。
3.3 禁用以太网/文件系统所需的其它系统调用
_retarget 不只是 _write 一个函数。Newlib 的某些功能(比如 malloc、printf 的某些路径等)在无操作系统环境下会尝试调用 _sbrk、_close、_lseek、_read、_fstat、_isatty 等系统调用。如果你不做任何处理,链接时可能遇到 undefined reference 的报错,或者在运行时因为系统调用缺失而 crash。
最简单粗暴的方案,把这些"野指针"全部堵上:
int _close(int file) { return -1; } int _fstat(int file, void *st) { return 0; } int _isatty(int file) { return 1; } int _lseek(int file, int ptr, int dir) { return 0; } int _read(int file, char *ptr, int len) { return 0; } caddr_t _sbrk(int incr) { return (caddr_t)0; }注意,_sbrk 的返回值类型在不同工具链里可能是void*或者caddr_t,按报错信息调整即可。如果你嫌麻烦,还有一种极简方案:在编译选项里加上-specs=nano.specs -specs=nosys.specs,用 nosys.specs 提供一组"什么都干不了但不会报错"的默认系统调用实现,这样你只需要重写 _write 就行了。这个方案在 CLion 的 CMakeLists.txt 里特别实用。
3.4 在 CLion 里配置链接选项
打开项目的 CMakeLists.txt,在 target_link_options 或 set(CMAKE_EXE_LINKER_FLAGS) 中加上 nano.specs 与 nosys.specs:
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -specs=nano.specs -specs=nosys.specs")加 nano.specs 的意义是使用 Newlib-nano,它比完整版 Newlib 更省 flash,同时 printf 的浮点支持默认是关闭的。如果你需要打印浮点数,有两个选择:加-u _printf_float,或者调用printf前在 CMake 里链接-Wl,-u,_printf_float。这里我建议用-u _printf_float,不然 printf("%.2f") 会输出"f"之类的乱码。这个坑我踩过,调了半天才发现是浮点打印没有链接进去。
4. _write 与 fputc 的"三不同":性能、兼容性与场景选择
很多人会问一个问题:既然 fputc 也能用(在某些 IDE 里),为什么非要用 _write?这背后还有性能层面的考量。
4.1 单字符 vs 批量:缓冲机制的巨大差异
前面说过,Keil MicroLIB 环境下 printf 的每个字符都可能直接走一次底层发送。假设你要打印 "Hello, World!\r\n" 这 15 个字符,意味着要调用 15 次串口发送函数,每次发送函数都有可能经历"等待上次发送完成→写入数据寄存器→再次等待完成"的完整流程。这中间的开销不容忽视,尤其是在低主频单片机上,打印 100 个字符的耗时可能会让实时性需求崩溃。
Newlib 的 printf 则不一样。vfprintf 先把格式化结果放进一个缓冲区,缓冲区满或遇到换行时才调用一次 _write。比如同一句话,可能只调用 1 次 _write,把 15 个字符一次性交给底层驱动。底层驱动可以连续写入 15 个字节,显著减少等待时间。这就是为什么在无操作系统环境下,Newlib 的 printf 重定向更适合高频打印的原因。
当然,这里有一个例外:如果你在重定向代码里不使能缓冲,也就是 stdout 是"行缓冲"或"无缓冲"模式,那么 _write 的调用次数会变多。Newlib 内部默认情况下 stdout 可能是全缓冲或行缓冲,取决于你是否有 tty 标志。我通常用setvbuf(stdout, NULL, _IONBF, 0)把 stdout 设为无缓冲,这样每次 printf 内容立刻通过 _write 输出,适合调试场景的实时性要求。代价是性能下降,但换来的"所见即所得"体验值得。
4.2 重定向的兼容性差异:fputc 只能活在"自家生态"里
fputc 的接口是 C 标准定义的,理论上任何 C 标准库都提供。但"接口存在"不等于"重定向有效"。Keil ARMCC 标准库内部确实把 printf 的输出导向了 fputc,所以重写 fputc 就能劫持输出。但 GCC Newlib 的设计更贴近"类 Unix"的操作系统抽象,应用程序通过 printf 输出,libc 调用 write 系统调用(在这里就是 _write)。所以 Newlib 的"钩子"天然是 _write。换个角度说,你在 Keil 里重写 fputc 是被 ARMCC 库"照顾"的便利;在 CLion/GCC 里重写 _write 是 Newlib 遵循的"标准协议"。
这种差异还有一个更隐蔽的坑:当你把代码从 Keil 工程复制到 CLion 工程时,如果保留重写的 fputc 但不重写 _write,printf 很可能依然输出不了。我在实际项目里遇到过这种情况,当时是把 Keil 的串口驱动源码直接拖到 CLion 工程里,折腾了一下午才发现 fputc 是罪魁祸首。所以代码迁移时,一定要检查重定向的接口是否匹配目标工具链。
4.3 什么情况下用 fputc 也可以?什么情况下必须用 _write
既然 _write 是 Newlib 的正道,是否意味着 fputc 就毫无用处?也不尽然。
如果你用的是回调式串口驱动,比如某些 SDK 提供了int USER_UART_PUTCHAR(int ch)这种自定义弱函数,那就没必要纠结 _write——直接改这个 SDK 的回调即可。又或者你只用了 GCC 但禁止了全部库函数,完全自己实现putchar通过正则表达式替换来实现输出,也能绕开。但这些都属于"特例"。
绝大多数情况下,在 CLion + arm-none-eabi-gcc 的标准路径里,正确的做法只有一个:重写_write。这样不管是 printf、puts 还是 putchar,最终都汇聚到 _write 一个入口,代码干净,行为统一。
5. 实战踩坑:CLion 串口重定向高频问题排查手册
这部分分享几个我在实际项目里反复遇到、且特别有代表性的问题,整理成速查表供各位参考。
| 现象 | 最可能原因 | 排查与解决 |
|---|---|---|
| printf 无输出,串口完全空白 | 没有重写 _write,或重写了但未被链接 | 用 nm 或 map 文件确认_write符号是否被你定义;确认函数非 static |
| 编译报 "undefined reference to _write" | 没有提供 _write,且没有链接 nosys.specs | 添加 -specs=nosys.specs,或自己实现 _write |
| 编译报 "conflicting types for '_write'" | 签名与工具链头文件不一致 | 打开工具链的 sys/unistd.h,按声明修改签名 |
| 输出乱码或丢字符 | 底层串口发送函数未等待发送完成标志 | HAL_UART_Transmit 的 Timeout 加大,或改为阻塞等待 TC/ TXE 标志 |
| printf 打印浮点显示为 f/v | Newlib-nano 裁剪了浮点打印支持 | 链接选项加 -u _printf_float |
| 程序一调用 printf 就死机 | _write 调用了未初始化的串口句柄,或者 _sbrk 未实现导致堆分配失败 | 确认串口初始化在 printf 之前;补全 _sbrk 实现 |
| 输出有几百毫秒延迟才出现 | stdout 是全缓冲模式,缓冲区满才输出 | 调用 setvbuf(stdout, NULL, _IONBF, 0) 设为无缓冲 |
| 输出一段后卡死 | 使能了中断优先级冲突或中断嵌套问题 | 检查串口中断是否和 printf 发送路径冲突;或用轮询发送 |
| UTF-8 中文显示乱码 | 串口助手终端编码设置不正确 | 在 CLion 的串口终端或外部串口助手里设置 UTF-8 编码 |
5.1 最容易忽视的一点:串口句柄的可见性
上面表格里 "程序一调用 printf 就死机" 是最常见的隐蔽问题。很多人把 _write 放在 retarget.c 里,但这个文件访问不到 main.c 中定义的huart1,于是在 _write 里直接写了一个不存在的句柄,或者文档中标记的句柄名比实际定义的少了个字母。这类问题编译时通常不会报错(extern 声明是你自己写的),但运行时就崩。
我的建议是:_write 里不要直接依赖某个全局句柄,可以抽象成你自己的发送函数,比如在 retarget.c 中留一个弱函数:
__attribute__((weak)) int uart_write_buf(uint8_t *buf, uint16_t len) { // 默认实现:空 return -1; }然后在 main.c 里实现这个函数,内部调用 HAL_UART_Transmit。这样你换板子、换串口时不用改 retarget.c,只改 main.c 的 uart_write_buf 实现就行。
5.2 不要忽略链接顺序和 map 文件
CLion 里使用 CMake 时,链接顺序偶尔会冒出来捣乱。比如你重写了 _write,但某个库文件(比如 libc.a)的顺序排在你的目标文件之前,链接器可能先看到库里的弱 _write 符号,于是阴影覆盖了你实现的强符号。最终行为要么是链接报错,要么是你实现的 _write 不生效。
排查方法不靠猜,打开构建目录里的 .map 文件,搜索_write的符号解析路径,看看最终使用的是哪个地址、哪个文件的定义。如果发现链接到了 libc 内部实现,检查你的目标文件是否真的参与了链接,以及 target_link_libraries 的顺序。让用户代码的 .o 尽量排在库文件之前即可。
5.3 HAL_UART_Transmit 超时参数别用 0
可能有人图省事把 Timeout 设成 0。在阻塞发送中,超时 0 意味着"立刻超时",如果串口正忙,可能还没发完就返回错误。这会导致数据截断或乱码。我一般用 0xFFFF,简单够用。
如果你在中断环境下调用 printf(比如在定时器中断或 DMA 中断里),要注意阻塞发送可能引发死锁。这种情况下建议换非阻塞方案,或者加临界区保护。当然,这块已经超出重定向本身,属于"printf 安全调用范围"的问题了。
6. 在 CLion 中配置 JNI/交叉语言调试的相近启示
这个话题可能看起来跳脱,但理解 _write 重定向的逻辑对你做 JNI(Java Native Interface)调试也有帮助。原因在于,JNI 环境下的日志输出同样存在"标准输出被重定向到哪"的问题。
很多 Java 开发者调 JNI 时,在 C/C++ 代码里写 printf,发现 Android 的 logcat 里看不到。原因和 Newlib 的 _write 场景类似:Android 的 C 运行库把 stdout/stderr 重定向到 /dev/null 或者 logcat 的某种机制,printf 的输出没有走到你期望的"终端"上。你需要在 JNI 层调用__android_log_print()这类专门的日志接口,而不能依赖 printf。这和嵌入式里"printf 要重定向到串口"是同一个思想:标准输出函数(printf/fputc)本身不知道"设备"在哪,你必须给它指定一条实际的通路。
所以当你理解了 _write 的作用,你就理解了一切"printf 不输出"问题的通用解法:找到标准库与实际硬件之间的那层接口,然后实现它。无论这个接口叫 _write、fputc、__android_log_print,还是 CDC_Transmit_FS,本质都是一样的。
7. 少走弯路的个人经验总结
我从 Keil 迁到 CLion 做嵌入式开发这几年,关于 printf 重定向这条路由,实实在在踩了不少坑,分享几条个人体会。
第一条,拿到新工程的第一件事,先跑通串口打印。不要先写业务逻辑,先把板子的"声带"打通。唇亡齿寒,没有 printf 的嵌入式调试就像蒙眼开车,效率低十倍。只要串口打印通了,后续所有问题都能靠"打印大法"定位。
第二条,CLion 的 CMake 工程一定要学会看构建输出。遇到 undefined reference,不要慌,复制完整错误信息去搜索,而不是只看"undefined reference"几个字。80% 的问题在完整的错误上下文里已经给出了答案。
第三条,重定向代码尽量独立成文件,不要和业务代码混在一起。我习惯建一个uart_retarget.c,专门放 _write、_read 以及其它系统调用的桩实现。这样当工程跨平台复用(比如从 STM32 换到 GD32)时,只需改这一个文件里的串口句柄,其它地方不用动。
第四条,调串口打印时务必保留至少一个"硬开关"。比如在 main.c 中定义一个宏DEBUG_UART_ENABLE,关掉它就能全局禁用所有调试打印。产品量产时把宏关掉,代码里的 printf 自动变为空操作,既不用删代码,又能避免串口打印拖慢系统。
回到最初的问题:CLion 里为什么要重写 _write,而不是 fputc?本质上是工具链和 C 运行库的生态差异决定的。GCC + Newlib 的标准输出路径就是printf -> vfprintf -> _write,你选对了入口,一切自然通畅。希望这篇"从原理到坑位"的记录能给正在 CLion 串口重定向泥潭里挣扎的你一个明确的攀登路径。