在 CLion 里折腾 JNI 工程时,难免会遇到一类需求:第三方 .so 里到处是 printf,可这些输出要么进不了 logcat,要么在 CLion 的 Console 里根本看不到。我第一反应是“把 printf 钩住”。搜了一圈,有人扔下一句“重写 CRT 的 _write 就完了”,评论区马上有人问:“为什么不重写 fputc?printf 不是靠 fputc 一个字符一个字符输出的吗?”
这个问题看着基础,实际藏着一堆链接器、CRT 实现、缓冲策略的细节。我自己在 CLion 里用 MinGW 和 MSVC 各验证了一遍,又翻了 Android NDK 的 bionic 源码,折腾了大半天才把整条链路理顺。如果你也遇到过 printf 输出不及时、想截获第三方库日志、或者被 CLion 里各种 write failure 报错绕晕,这篇应该能帮你少走不少弯路。
先说结论:fputc只是 stdio 层处理“单个字符”的入口,而_write才是标准输出最终汇合的系统调用边界。重写_write相当于在所有人出大楼的唯一安检口拦人,重写fputc则像守着某个办公室门口,别人从侧门走你根本不知道。
1. 先理清 C 代码的输出到底是怎么走到屏幕的
很多人对 C 标准库的印象停留在《C 程序设计语言》里那套抽象模型:printf格式化字符串,putchar输出一个字符,最后终端显示出来。教材为了教学方便,把这条链路画得很简单,但真实工具链并不是这个结构。
1.1 输出链路的四层结构
把一次printf调用从用户态到内核态展开,大致是下面的分工:
- 第一层,应用代码:
printf、puts、fprintf(stdout, ...)、std::cout。这一层负责格式化,格式化结果是一段连续内存。 - 第二层,stdio 流缓冲区:
FILE结构体内部维护的缓冲区域。fputc、fwrite、putchar这些函数主要作用在这一层,把数据放进FILE的缓冲区,而不是直接写系统。 - 第三层,文件描述符写入函数:在 Windows 的 MSVC/MinGW 环境叫
_write,在 Linux/Android/macOS 上叫write。这一层拿到的是文件描述符 fd、内存地址和长度,负责真正把数据交给操作系统。 - 第四层,操作系统:Windows 的
WriteFile/WriteConsole,Linux 的write系统调用,最终落到终端驱动、日志文件、管道或者调试器的伪终端。
很多初学者的误解在于把第二层和第三层混成一团。fputc默认情况下确实会把一个字符放进 stdout 的缓冲区,但它并不负责触发系统调用。缓冲区满了、遇到换行并且 stdout 是行缓冲模式、或者你手动调用fflush时,stdio 才会把整块缓冲交给_write。
1.2 printf 内部并不是“一个字符一个字符调用 fputc”
教材里喜欢把putchar(c)写成putc(c, stdout),再把putc说成是fputc的宏版本,很多人就自动脑补成“printf 里面一定在循环调 fputc”。这个印象在小端场景下没有大问题,但跟真实源码差距很大。
随便翻一份 MSVC 或者 glibc 的 stdio 实现就能看到,printf会把格式化好的内容写进缓冲区,内部走的是_write路径,不是每个字符调一次fputc。如果printf真按字符逐个调用fputc,性能会非常难看,尤其输出几十 KB 日志时,函数调用开销完全不可接受。
正确理解是:fputc是 stdio 层给“单字符写入”提供的一个便捷入口,而printf、fwrite、fputs这类批量接口在多数实现里会直接往缓冲区写数据,根本不会经过fputc。等到要落盘或者落终端时,它们会聚到同一个底层函数,也就是_write/write。这就是“重写_write能拦截一切标准输出”的根本原因,它是真正的汇集点。
2. 同一个 printf,在 CLion 里为什么有时像“便秘”
在 CLion 里跑过 C 程序的人基本都遇到过:代码里连续printf了一堆,Console 窗口半天不显示,程序退出后全部一下子冒出来。这不是 CLion 显示延迟,而是标准输出被重定向到了管道,缓冲模式从“行缓冲”变成了“全缓冲”。
2.1 Run 窗口不是终端,缓冲策略全变了
终端环境里,标准输出通常会设置成行缓冲:遇到换行就把缓冲区刷出去,所以你在系统终端里跑printf("hello\n")能立刻看到结果。但 CLion 的 Run 窗口不是真正的终端,它用管道捕获子进程的 stdout。CRT 启动时用isatty判断 fd=1 是否连接终端设备,发现不是终端,就把 stdout 设成全缓冲。
全缓冲意味着数据先攒在缓冲区里,攒满 4KB 或 8KB,或者程序正常退出时,才真正调用_write。所以你看到的现象就是“跑完了才输出”。这和你改不改 fputc 没有任何关系,因为卡住的位置根本不在 fputc,而在缓冲区到_write之间。
想立刻看到输出,最直接的办法是自己加fflush(stdout),或者在main开头调用setvbuf(stdout, NULL, _IONBF, 0)把 stdout 设为无缓冲。无缓冲模式下,每次printf都会直接触发_write,输出就实时了。
2.2 Debug 模式下又加了一层“二传手”
如果是在 Debug 模式下跑,情况更复杂。CLion 默认通过 GDB 或 LLDB 启动程序,调试器要用自己的协议和 UI 交互,目标程序的 stdout/stderr 会被调试器捕获,再转发到调试器进程的 stdout,最后被 CLion 的 Console 接管。
中间多了调试器这个“二传手”,输出时机还会受调试器自身缓冲策略影响。有些调试器会等目标进程退出后才统一转发输出,看起来就像程序没输出一样。
所以同样的代码,在 Release 下直接跑正常,在 Debug 下却不显示,未必是你代码的问题,而是管道链路和调试器实现的问题。搞清楚这一点,你就明白为什么很多“截获输出”的需求要从_write下手,而不是在应用层到处插桩。你在任何一层对流做缓冲处理,都不如直接在最底层出口截获来得干净。
2.3 从这个角度看“重写谁”才有意义
现在再看标题的问题,答案已经清晰了一半。你想让 stdout 上的所有输出都被捕获、被重定向、被转发到日志面板,拦截点必须选在所有 stdio 输出最终都会经过的位置。
在 Windows CRT 里,这个位置就是_write。它拿到文件描述符、缓冲区地址和数据长度,然后才调用 Windows API。第三方的 printf、标准库的 fwrite、甚至某些底层库直接调_write(1, ...),都会从这个口子过。重写 fputc 则完全不同:你可能精心写了一个自定义fputc,结果程序里绝大部分输出跟它都没关系。
3. 完整示例:在 CLion 里用重写 _write 捕获所有 printf
说了这么多原理,不落地就是耍流氓。下面给一个能在 CLion 里跑通的完整示例。
3.1 核心思路和最终代码
目标场景很典型:你希望程序里所有向 stdout 的输出,都自动写一份到日志文件,同时继续显示到 Console 窗口。我用的环境是 CLion + MinGW-w64 工具链,Windows 10。
第一种方法,直接在源码里定义一个同名_write函数,让 CRT 的 stdio 在链接期选择你的版本。
#include <io.h> #include <fcntl.h> #include <stdio.h> #include <windows.h> static FILE* g_log = NULL; static int g_backupFd = -1; void setup_output_capture(const char* logPath) { if (g_log) return; // 备份原始 stdout 对应的系统句柄 g_backupFd = _dup(_fileno(stdout)); g_log = fopen(logPath, "w"); // 关键:让 stdout 变成无缓冲,保证 printf 能及时走到 _write setvbuf(stdout, NULL, _IONBF, 0); } // 重写 CRT 的 _write int _write(int fd, const void* buffer, unsigned int count) { if (fd == 1) { // 1 号描述符是 stdout // 1. 写入日志文件 if (g_log) { fwrite(buffer, 1, count, g_log); fflush(g_log); } // 2. 写回原始的 Console / 管道句柄 if (g_backupFd != -1) { HANDLE h = (HANDLE)_get_osfhandle(g_backupFd); if (h != INVALID_HANDLE_VALUE) { DWORD written = 0; WriteFile(h, buffer, count, &written, NULL); } } return count; } // 其他文件描述符:直接用 Windows API 写对应句柄,避免递归 HANDLE h = (HANDLE)_get_osfhandle(fd); if (h != INVALID_HANDLE_VALUE) { DWORD written = 0; WriteFile(h, buffer, count, &written, NULL); return written; } return 0; } int main() { setup_output_capture("output.log"); printf("hello from printf\n"); puts("second line"); fprintf(stdout, "third line\n"); return 0; }注意我在_write内部没有调用原始 CRT_write。如果调用了,会形成无限递归:CRT 内部所有指向_write的调用都会进入我这个函数,而函数里又调_write,等于自己调用自己。所以我用_dup把原始 stdout 句柄复制了一份,再用 Windows API 直接写入,绕开 CRT 层,这是这类“同名覆盖”写法里最常见也最稳妥的避坑手段。
3.2 为什么需要 _dup、无缓冲和 WriteFile
三个细节值得单独解释。
为什么要_dup?因为直接保存GetStdHandle(STD_OUTPUT_HANDLE)理论上也可以,但 CLion 的 Run 窗口可能在程序启动前就做了句柄重定向,CRT 的 fd=1 和你认为的“标准输出句柄”不一定一致。用_dup(_fileno(stdout))是站在 CRT 自身视角去复制当前 stdout 指向的下层句柄,最可靠。
为什么要setvbuf(stdout, NULL, _IONBF, 0)?不设置无缓冲时,printf 的内容会先积攒在 stdout 的缓冲区里,可能等程序退出才触发_write,你的日志文件也会变得“时有时无”。设置无缓冲后,每次 printf 结束都会立刻调用_write,捕获效果最接近实时。
为什么用WriteFile而不是fwrite去写回 Console?这里有个递归隐患。fwrite最终又要走_write,而_write又指向你的自定义版本。所以但在 logging 文件那里我用了fwrite和fflush,会不会递归?不会,因为在执行fwrite(g_log)时,实际最终调用的是_write(g_log 对应的 fd, ...),此时 fd 不等于 1,会进入 else 分支,用WriteFile直接写日志文件句柄,不会再碰 stdout。但如果你在 fd==1 分支里再用fwrite(stdout, ...)或者任何会往 stdout 写的库函数,就绕回自己了。
3.3 更不容易翻车的写法:链接器 --wrap
如果你觉得同名符号覆盖不够严谨,或者怕后面的工程里链接别的静态库时符号冲突,可以用 GNU 链接器的--wrap参数。这个方法在 CLion + MinGW-w64 和 Linux GCC 环境下都很好用,Android NDK 也可以。
原理是让链接器把所有对_write的引用解析成__wrap__write,同时把原始函数重命名成__real__write。你只负责实现__wrap__write,想用原始能力就调__real__write,完全不存在递归问题。
代码可以简化成:
#include <stdio.h> int __real__write(int fd, const void* buffer, unsigned int count); static FILE* g_log = NULL; void setup_output_capture(const char* path) { if (!g_log) { g_log = fopen(path, "w"); } } int __wrap__write(int fd, const void* buffer, unsigned int count) { if (fd == 1 && g_log) { fwrite(buffer, 1, count, g_log); fflush(g_log); } // 继续走原始逻辑,让输出正常显示到 CLion Console return __real__write(fd, buffer, count); } int main() { setup_output_capture("output.log"); printf("goes to both console and file\n"); return 0; }然后在 CMakeLists.txt 里加一行:
target_link_options(my_target PRIVATE "-Wl,--wrap=_write")在 MinGW 下,__real__write对应 msvcrt/UCRT 里的原始函数,链接器会替你把原始符号找出来。这个方法比同名覆盖更干净,因为它不需要你手动绕过 CRT,也基本不会出现 LNK2005 这类多重定义问题。
4. 反例时间:重写 fputc 会漏掉哪些输出
为了验证“重写 fputc 不够用”,我专门在 CLion 里做过一次小实验,把自定义 fputc 塞进去,然后调用各种输出函数。结果非常有代表性。
4.1 我实际跑出来的“漏网之鱼”
实验代码大致长这样:
#include <stdio.h> // 自定义 fputc int fputc(int ch, FILE* stream) { // 做个标记 return 0; } int main() { printf("hello\n"); puts("world"); fwrite("test\n", 1, 5, stdout); fputc('x', stdout); return 0; }在 MinGW-w64 工具链下,真正的输出结果是:
printf("hello\n")没有经过我的自定义 fputc。printf 直接往 stdout 缓冲区写格式化结果,然后整体走_write。puts("world")也没有经过自定义 fputc,原因相同。fwrite("test\n", ...)同样没有经过。fwrite 是按块写入缓冲区的。- 只有显式调用
fputc('x', stdout)的时候,我的自定义函数才生效。
也就是说,如果你想靠重写 fputc 捕获全程序的 stdout 输出,最多只能捕获到那些“显式调用了 fputc 或 putc 的代码”,像 printf、puts、fwrite、fprintf、C++ 的 cout 底层大概率都会绕过你。这个漏网范围实在太大了。
4.2 fputc 到底什么时候才值得重写
也不是说 fputc 重写完全没有价值。如果你在对某个嵌入式 SDK 做源码移植,那个 SDK 的日志层明确是单字符fputc输出,而且你能控制整个编译过程,那重写 fputc 就是简单有效地替换字符输出逻辑。
但在 PC 平台、JNI 平台这种大型 C/C++ 运行库里,fputc 多数情况下只是一个薄薄的库函数,它本身也不是所有单字符写入的唯一路径。编译器可能在头文件里把 putc 优化成宏,直接操作 FILE 内部缓冲区,让你定义的 fputc 根本不被调用。编译器还可能因为你开启了 LTO 或者内联优化,把库里某些小函数直接内联进调用者代码,符号覆盖也拦不住。
所以我的经验是:如果你控制不了第三方库的编译方式,只是想拦截运行时所有 stdout 输出,永远不要赌 fputc,一定要在_write/write这一层做文章。如果你控制源码编译,又明确可以改造调用方,那用宏替换、函数指针、或者直接换日志库都比重写 CRT 函数更安全。
4.3 不同平台、不同函数的写路径对照
我把几个常用输出函数在典型平台下是否经过 fputc / _write 总结成了一张表,方便日后排查:
| 调用方式 | 是否经过 fputc | 是否经过 _write / write | 说明 |
|---|---|---|---|
putchar(c) | 不一定,可能被宏处理 | 最终会 | 取决于编译器优化和缓冲策略 |
| `fput |