
在 CLion 里折腾 JNI 工程时难免会遇到一类需求第三方 .so 里到处是 printf可这些输出要么进不了 logcat要么在 CLion 的 Console 里根本看不到。我第一反应是“把 printf 钩住”。搜了一圈有人扔下一句“重写 CRT 的 _write 就完了”评论区马上有人问“为什么不重写 fputcprintf 不是靠 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/WriteConsoleLinux 的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判断 fd1 是否连接终端设备发现不是终端就把 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 的 fd1 和你认为的“标准输出句柄”不一定一致。用_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。但如果你在 fd1 分支里再用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