1. 从一次“printf 不出字”的 CLion 调试说起
如果你用 CLion 搭过 STM32 或者其它 ARM Cortex-M 工程,大概率会碰到这种情况:代码下载到板子,串口助手打开,波特率也设对了,结果printf("Hello\r\n")之后串口屏幕上什么都没有。更气人的是,工程是用 STM32CubeMX 生成的,配置没问题,编译不报错,调试器也能连上,就是没有输出。
于是你开始上网搜方案,翻来覆去看到两类答案。第一类说“重写 fputc”,还给你贴一段代码,核心就是把字符通过串口发出去。第二类说“重写 _write”,代码量看起来差不多,但解释得更底层一些。很多 CLion 用户的第一反应是:既然 fputc 是 C 标准库提供的函数,重写它应该最直接,为什么老手们宁愿碰系统调用层的东西?
我最初也这么想,结果在 CLion 里反复验证之后,发现一个扎心的现实:在 arm-none-eabi-gcc + newlib 这条主流工具链组合下,重写 fputc 经常是无效的,真正管用的是 _write。这不是玄学,而是 C 库实现路径和开发环境共同决定的。这篇文章就把我踩过的坑、查过的源码、验证过的结论一次讲清楚。
2. 先搞清 printf 到串口的调用链:fputc 和 _write 各自站在哪一层
要回答“为什么是 _write 而不是 fputc”,第一步是把调用链完整拉出来。很多人对 printf 的认识停留在“printf 会调用 putchar,putchar 会调用 fputc”,这个理解在 PC 上大体成立,但放到嵌入式 ARM GCC 环境里就不一样了。
2.1 标准库内部不是“符号调用”那么简单
我们先看一个典型场景:CLion 里建了一个 STM32 裸机工程,工具链是 arm-none-eabi-gcc,链接时用了--specs=nano.specs或者默认的普通 newlib。你调用printf("Hello\r\n"),这条链路大致如下:
printf -> _vfprintf_r(newlib 的格式化函数) -> stdio 内部字符输出宏/函数 -> 当前 FILE 流(stdout)的写函数指针 -> _write_r / _write(系统调用层) -> UART 发送寄存器或 HAL 函数关键点在于中间那层“FILE 流的写函数指针”。在 newlib 的实现里,fputc和printf最终都会走到FILE结构体里的写函数指针,而这个指针最终指向的是系统调用层的_write。也就是说,fputc 和 printf 是同一棵树上的两个分支,并不是一上一下的直接调用关系。
当你只重写 fputc 时,如果没有其它机制把 printf 内部的字符输出路由到你的 fputc 上,那么 printf 根本不会经过你写的那个 fputc。这也是为什么不少人在 CLion + ARM GCC 工程里重写 fputc 后,断点打在 fputc 里却发现永远进不去。
2.2 fputc 和 _write 在职责上的本质区别
从职责上看,这两个函数根本不在一个层面:
| 维度 | fputc | _write |
|---|---|---|
| 所属层次 | C 标准库的流式接口(stdio 层) | 系统调用接口(syscall 层) |
| 操作对象 | FILE 流 | 文件描述符(fd) |
| 是否依赖缓冲区 | 是,fputc 写入 stdout 的缓冲区,缓冲区满或 flflush 才真正输出 | 直接接收一段内存指针和长度,本质是“把一段数据交给底层设备” |
| 被谁调用 | 显式调用 fputc/write 的用户代码 | printf/fputs/fwrite 底层最终都会汇集到它 |
| 在 ARM GCC 中的默认实现 | 标准库自带 | syscalls.c 提供,半主机模式或空实现 |
_write的原型是:
int _write(int file, char *ptr, int len)它接收的参数是“文件描述符 + 内存地址 + 字节数”。在嵌入式系统里,标准输出 stdout 的文件描述符通常是 1。你不需要关心传入的 file 到底是啥,只需要把 ptr 指向的 len 个字节发送出去即可。
对比之下,fputc 每次只处理一个字符,原型是:
int fputc(int ch, FILE *f)虽然实现起来也很简单,但它对流层的依赖让它的行为变得很微妙:标准库内部可能已经把 stdout 设成缓冲模式,fputc 写完只进缓冲区,要是没触发刷新,串口上就什么也看不到。
2.3 不同工具链生态的“历史原因”
再补充一个背景,很多人并不知道“重写 fputc”这个做法是从哪来的。它主要源自 Keil MDK 的 ARM Compiler 生态,尤其是使用了 MicroLIB 之后。在 MicroLIB 的 stdio 实现里,printf 最终会回调 fputc,所以重写 fputc 是有效且方便的。
但 CLion 嵌入式开发默认走的是 GCC arm-none-eabi 工具链,使用的 C 库是 newlib 或 newlib-nano。newlib 的字符输出路径更接近 Unix 传统,即便没有操作系统,也保留了一整套系统调用风格的底层接口(_open、_close、_read、_write、_lseek、_sbrk 等)。printf 最终落到 _write,而不是 fputc。这就是“平台不同,答案不同”的核心逻辑。
3. 为什么重写 fputc 在 CLion + ARM GCC 里经常“失灵”:缓冲、符号、半主机三个维度
这部分我打算把几个真实原因拆开讲。只给结论不给原因,下次换个工具链版本你照样会懵。
3.1 维度一:缓冲机制让 fputc 的输出被“吞掉”
先做个实验。你在 CLion 工程里重写 fputc,并且用如下方式发送字符:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }然后主程序里调用:
printf("Hello\r\n");结果串口没有任何反应。当你加上fflush(stdout)之后再看,Hello 出来了。这说明重写本身生效了,但 printf 先把数据放进了 stdout 的缓冲区,并没有逐字符去调用你的 fputc。
在 newlib 中,stdout 默认可能不是无缓冲模式。对于嵌入式终端类设备,C 标准要求是行缓冲,但在裸机环境下,很多工具链实现并不会开行缓冲,数据全堆在缓冲区里,直到缓冲区满或者程序退出才 flush。而 _write 位于缓冲区之下:当缓冲区需要刷出时,最终是通过 _write 把整块数据交给设备。所以重写 _write 至少能保证“数据从标准库内部出来”的最后一公里是通的,但缓冲问题依然需要配合 setvbuf。
我建议在初始化代码里加上:
setvbuf(stdout, NULL, _IONBF, 0);这样 stdout 变成无缓冲模式,每次 printf 都会直接向底层写函数提交数据。在嵌入式系统里做串口调试,这一行能省掉大量“数据延迟出现”的困惑。
3.2 维度二:newlib 的字符输出不一定会绑定到外部 fputc 符号
这是最容易被忽略的一点。很多人以为“printf 内部肯定调用了 fputc”,但 newlib 的实现并不是这种简单的符号依赖关系。
在 newlib 中,printf 经过_vfprintf_r后,会使用 FILE 结构体中的函数指针完成实际输出。标准库内部有一套名为__sputc或_putc_unlocked的快速路径宏,它们的实现直接在内部操作 FILE 缓冲区,并在需要时调用底层写入函数。那个底层写入函数,通常不是符号层面的 fputc,而是通过文件描述符路由后的_write。
所以说到底,你重写的 fputc 只是个普通的用户函数,如果 newlib 内部从未以“调用外部 fputc 符号”的方式去输出字符,你的函数自然不会被触发。某些 GCC 版本、某些编译选项、某些优化级别下,也许 printf 真的会因为某个内部路径去调用 fputc,但这种行为是不可控的、非常依赖库版本。把它当作“可能会生效,也可能不生效”的方案,风险太高。
3.3 维度三:默认 _write 被半主机截胡,fputc 根本无法“抢跑”
还有一个更深层的坑。在 ARM GCC 工具链中,标准的 syscalls.c 文件提供了默认的 _write,它的默认行为是调用半主机(semihosting)机制,也就是把输出发送到调试器,而不是你的 UART。
如果你重写了 fputc,却没有关闭半主机,也没有重写 _write,数据的流动路径可能是:printf 进缓冲区,缓冲区刷新时调用了默认的 _write,而默认 _write 通过半主机把数据扔给调试器。串口当然什么都没有。
很多人会碰到一个奇怪的现象:在调试器窗口或者 IDE 的 semihost console 里能看到 printf 输出,串口助手却收不到。这就是半主机在作怪。要根治,必须在链接层面排除半主机实现,并自行提供 _write。用 CubeMX 生成的工程,一般在链接选项里加上--specs=nosys.specs,或者直接修改 syscalls.c 中的 _write。
4. 在 CLion 里正确重写 _write 的完整做法:从链接选项到串口发送
下面进入实操部分。以下步骤基于 CLion + STM32CubeMX + arm-none-eabi-gcc + OpenOCD 的典型环境,其它 ARM MCU 原理一样,寄存器名和 HAL 函数名需要照自己的型号改。
4.1 首先要确保链接器不使用半主机的默认实现
检查 CMakeLists.txt 或 Makefile 的链接选项。CubeMX 生成的 CMakeLists.txt 通常是这样的片段:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -mcpu=cortex-m4 -mthumb -ffunction-sections -fdata-sections") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -mcpu=cortex-m4 -mthumb -specs=nosys.specs -specs=nano.specs -T${LINKER_SCRIPT}")如果已经有nosys.specs,说明工具链会链接一个不调用半主机的空 _write,正好方便你自己覆盖。如果只有 nano.specs,没有 nosys.specs,那默认的半主机实现仍然可能被拉进来。建议显式加上:
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -specs=nosys.specs -specs=nano.specs")nano.specs对应 newlib-nano,体积更小,但默认不支持浮点格式化输出。如果需要 printf 打印浮点数,必须在链接选项里加-u _printf_float,否则输出会是空字符串。这一点很多人到后期才踩到。
4.2 实现 _write 函数
在 main.c 或者专门建一个 retarget.c,写如下代码:
#include <sys/stat.h> #include <stdio.h> int _write(int file, char *ptr, int len) { (void)file; HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }这里需要注意几点:
HAL_UART_Transmit的第三个参数是发送长度,直接传len即可。newlib 会把一串字符打包给 _write,一次性发送比逐个字符发送效率高得多。HAL_MAX_DELAY表示无限等待,有利于保证数据发送完整。它的代价是阻塞,如果想做非阻塞或 DMA,需要换成带超时的方案。- 如果多个输出源共用一个 UART(比如日志系统里同时往串口和文件写),可以通过
file参数区分不同目标。平时调试直接忽略它。
如果不想依赖 HAL,也可以直接操作寄存器。例如 STM32F4 系列:
int _write(int file, char *ptr, int len) { (void)file; for (int i = 0; i < len; i++) { while (!(USART1->SR & USART_SR_TXE)); USART1->DR = (uint8_t)ptr[i]; } return len; }寄存器方式的好处是轻量、不依赖 HAL 初始化,但前提是你已经自己初始化好 UART。大部分 CubeMX 工程里 UART 初始化不用你操心,直接用 HAL 库函数即可。
4.3 关闭 stdout 的缓冲
在 main 函数初始化 UART 之后,加上这句:
setvbuf(stdout, NULL, _IONBF, 0);这样每次 printf 都会立刻把数据通过 _write 丢给 UART。如果你希望使用缓冲来提高效率,也可以不加,然后在关键位置手动fflush(stdout)。但从调试体验来看,无缓冲模式最直观,不用总是怀疑输出丢了。
4.4 在 CLion 里设置串口终端
CLion 2023.1 之后的版本自带串口监视器(Serial Monitor),也可以使用插件。打开位置在 Tools -> Serial Monitor,选择一个正在使用的串口,波特率设为和代码里一致(通常 115200),按 Enter 之后就能看到 printf 的输出。
这里有个细节值得单独说:CLion 的串口监视器对 UTF-8 的支持比较友好,但如果你发送的是中文,可能出现乱码或者一次显示不完整。这通常不是 _write 的问题,而是串口工具把 UTF-8 多字节字符当成了多个单字节数据,接收端又用 GBK 之类的编码去解读。解决方法是:调试信息尽量用英文,或者把串口工具的编码改为 UTF-8。
4.5 编译链接后如何确认用的不是半主机实现
如果链接后不确定 _write 到底用的是你自己写的还是半主机的,可以看编译链接日志,也可以反汇编检查。更简单的做法是:在函数里加个变量,串口输出时同时把一个 GPIO 翻转,如果 GPIO 状态变了,说明确实走的是你的实现。这个方法土,但非常有效。
5. 重写 _write 之后,比 fputc 多出来哪些“隐性收益”和边界问题
既然说到这了,我再把 _write 方案背后的一些细节补完。这些内容不看源码很难有体感,但对实际项目设计很有帮助。
5.1 文件描述符体系让 printf 的“目标”可以动态切换
因为 _write 带有file参数,你可以实现一个简单的输出路由:
int _write(int file, char *ptr, int len) { if (file == 1) // stdout { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); } else if (file == 2) // stderr { HAL_UART_Transmit(&huart2, (uint8_t *)ptr, len, HAL_MAX_DELAY); } return len; }这样 stdout 走串口1,stderr 走串口2,调试时候把错误信息单独分离,非常直观。fputc 虽然也有 FILE * 参数,但你很难在 printf 链路里手工控制它走到哪个串口,因为 printf 内部是把 stdout 流转给输出函数的。
5.2 天然适配 DMA 和日志系统
如果日志数据量很大,逐字符用 fputc 发送会严重拖慢 CPU。_write 一次性拿到整段数据指针和长度,很适合直接丢给 DMA:
int _write(int file, char *ptr, int len) { // 伪代码:等待上一次 DMA 完成,再开启下一次传输 while (dma_busy); dma_busy = 1; HAL_UART_Transmit_DMA(&huart1, (uint8_t *)ptr, len); return len; }当然直接返回 len 意味着调用方认为数据已经写完,如果 DMA 还没发送完就继续调用 printf,可能产生数据覆盖。更稳妥的做法是加一个简单环形缓冲区,或者用一个“忙标志”阻塞发送。这属于工程层设计,至少 _write 这个接口给这种设计留出了空间。
fputc 也不是说不能接 DMA,但每次发一个字节,DMA 的启动开销远大于收益。
5.3 重写 _write 对 CLion 中文输出乱码问题的实际帮助
前面提到“clion中文输出乱码”这个热词,它和 _write 的关系很微妙。很多人以为是重定向函数写错了导致乱码,但实际原因通常是两种:
第一种,代码里写的是printf("中文"),源文件是 UTF-8 编码,而串口助手用 GBK 解码,显示自然乱。这是编码不匹配问题,和 _write 无关。
第二种,_write 实现里按 len 一次性发送,但发送函数内部做了超时截断,导致多字节字符只发了一半。比如一个中文字符 UTF-8 编码占 3 个字节,如果 len 是 6 但只发出 3 个字节,接收端拿到的就是残缺字符。用 HAL_UART_Transmit 并且 timeout 给足,一般能避免这类问题。
我自己的经验是,调试信息不要输出中文。串口调试是给机器看、给人看的,UTF-8 的中文在早期嵌入式项目里就是给自己找麻烦。如果实在要输出中文,务必确保 UART 发送一次性完整写完 len 个字节,且接收端编码一致。
5.4 newlib 重入(reentrancy)与中断打印
裸机环境下,如果主程序和中断里都调用 printf,可能出现重入问题。newlib 内部有一些全局状态,但 _write 本身是独立的系统调用,它不依赖 printf 的全局缓冲区状态。相比之下,fputc 对 FILE 流的操作更容易被中断重入打断。
实际项目里,我不建议在中断里直接调用 printf,哪怕你重写了 _write。比较好的做法是中断里只置标志位,主循环里统一输出。但如果真的需要在中断里打印调试信息,_write 至少比 fputc 更接近底层,配合无缓冲 stdout 使用,踩坑概率低一些。
5.5 别忘了 _read 的对称问题
重写 _write 解决的是输出,但很多人接着会问:要从串口接收数据(scanf / getchar)怎么办?答案是重写_read:
int _read(int file, char *ptr, int len) { (void)file; HAL_UART_Receive(&huart1, (uint8_t *)ptr, 1, HAL_MAX_DELAY); return 1; }这个函数不是本文主角,但当你理解了“printf 为什么走 _write”之后,自然能推出 scanf 为什么走 _read。二者是同一体系内的对称接口。
6. 从 Keil 迁移到 CLion 的用户,最容易忽略的 fputc 惯性
最后我想专门聊一类人:从 Keil 迁移到 CLion 的开发者。他们在 Keil 里用 fputc 重定向用了很多年,换到 CLion 后第一件事就是把那套代码搬过来,发现不生效,很容易归咎于“CLion 有问题”或者“GCC 编译器有 bug”。
真相比这简单:Keil 的 ARM Compiler 5 配合 MicroLIB,printf 的字符输出就是通过 fputc 这条路径实现的,这是它的库实现细节。而 GCC 的 newlib 走了另一条路。每个工具链各有各的底层约定,谈不上谁对谁错。
判断一个重定向方案是否适合当前工具链,最直接的方法不是查网上零散的教程,而是看工具链自带的库源码,或者用反汇编看 printf 编译后的实际调用目标。比如在 CLion 里,编译完代码后可以用arm-none-eabi-nm查看符号表:
arm-none-eabi-nm firmware.elf | grep -E " (fputc|_write)$"如果最终链接的_write是自己实现的地址,基本就稳了;如果fputc符号变成了强符号覆盖了弱符号,说明你重写的版本被链接进来了,但 printf 是否真的调用它,还要再确认。
这种排查方式,比反复改代码试错要高效得多。
7. 我个人在实际项目中的最终选择
回到标题的问题:CLion 里重写 _write 还是 fputc?
我的回答一直是:在 ARM GCC + newlib 体系下,重写 _write。这不是因为 fputc 一定不行,而是 _write 是这个工具链指定的底层入口,行为更稳定、路径更清晰、扩展性也更好。fputc 的重定向方案在其它工具链里也许工作得很好,但在 CLion + GCC 的环境里,它更像是一个“看运气”的做法。
如果你的工程里确实绕不开 fputc,比如某些第三方库显式调用了 fputc 并依赖你的实现,那 fputc 还是可以写的。但要注意,这只是补上一个函数层面的缺口,并不代表 printf 就会因此走通。更保险的组合是:重写 _write 负责真正的数据出口,同时提供一个 fputc 包装 _write,兼顾兼容性。
int fputc(int ch, FILE *f) { _write(1, (char *)&ch, 1); return ch; }这样两边的需求都满足了。
CLion 的嵌入式开发环境在越来越好用,但底层这些“C 库如何实现标准 IO”的问题,不会因为 IDE 的便利而消失。把调用链吃透一次,以后不管是在 STM32 上跑 RTOS,还是在其它 MCU 上移植调试组件,遇到类似问题都能举一反三。我最后再给你一个建议:在工程里保留一个简单的 retarget.c,把 _write、_read 连同 setvbuf 的配置集中放在一起,每次新开项目直接复制,省下的时间足够你多跑几个实验。