ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CLion中文乱码:为什么重写_write有效而fputc无效?

CLion中文乱码:为什么重写_write有效而fputc无效? 如果你在 CLion 里用过 MinGW 环境跑 C/C 程序大概率遇到过这个经典场景printf(中文)打印出来全是乱码或者被网友告知“在代码里重写_write就能解决”。但问题来了大多数 C 语言教程都告诉我们输出字符可以用fputc为什么网上所有人都在说要去动_write这个看起来非常底层的函数改fputc不行吗我第一次看到这个建议时也是一头雾水直到自己把 CRT 的源码、反汇编和实际调试结果摊开之后才明白这里面的门道比想象中要多。这篇文章就完整讲清楚这个“为什么”顺便给你一套能在 CLion 里直接用的方案。1. 先还原现场CLion 控制台输出乱码的真实原因1.1 一个典型的 CLion 乱码场景我用 CLion MinGW 跑一个最简单的程序源码文件用 UTF-8 编码保存#include stdio.h int main() { printf(中文测试\n); return 0; }在 CLion 的 Run 窗口里输出往往是涓枃娴嬭瘯这一串“涓枃”看着就像把 UTF-8 字节流按 GBK 解码的结果。实际上很多情况下正是这样CRT 输出的字节本身就是 UTF-8MinGW 环境下字符串字面量就是源码编码但 CLion 控制台在 Windows 上读取管道内容时恰好用了当前系统的 ANSI 代码页GBK去解码两边对不上就乱码了。这个问题的本质不是“打印机坏了”而是“字符编码在进程输出、管道传输、IDE 解码这条链路上发生了错位”。网上最常见的解决方案有三个方向在源码里写SetConsoleOutputCP(CP_UTF8)强制控制台代码页。修改 CLion 的 Console 编码设置把默认编码改成 UTF-8。在程序里重写_write让输出字节在最后一步变成你想要的编码。前两个方案不是不好而是很多时候不生效。CLion 的 Run 窗口其实是一个 IDE 集成终端它捕获的是子进程的标准输出这套东西和传统 Windows 控制台窗口不完全是一回事。SetConsoleOutputCP只能影响真正的控制台屏幕缓冲区对管道重定向场景效果有限。真正可靠的办法是在进程内部把所有输出字节统一处理后再写出去这就是_write重写的意义。1.2 为什么很多人第一反应是 fputc很多初学者看到输出乱码第一反应是“我换个字符输出函数试试”于是写了个循环用fputc逐个输出字符for (int i 0; s[i]; i) { fputc(s[i], stdout); }结果发现乱码依旧。然后有人告诉他“你要重写fputc”他就真的在程序里定义一个同名函数fputc期望所有字符串输出都走自己的逻辑。测试后你会发现这个方案对printf几乎完全无效。原因很简单printf、fputs、fwrite这些函数的内部实现根本不会一个字节一个字节地去调用fputc。它们直接操作FILE这个结构体里的缓冲区把数据整块写入缓冲区等到缓冲区满或调用fflush时才真正落到底层输出。fputc只是标准库提供给你的一个“单字符写入口”不代表所有输出都会经过它。你重写fputc最多只能影响那些确实调用了fputc的代码而printf压根不走这条路径。1.3 重写 _write 的核心逻辑_write则完全不同。它是 CRTC Runtime Library最底层的一个写入口几乎所有标准输出函数在把缓冲区数据刷出去时最终都会调用到它。printf的数据写满缓冲区或遇到换行刷新时会走到 CRT 内部的刷新逻辑刷新逻辑再调用_writefwrite同理std::cout最终也绕不开这个入口。你重写了_write就相当于在所有输出路径的“最后一公里”处设置了一个关卡。无论上面用的是什么函数、什么 IO 库所有字节到达操作系统之前都要先经过你这个函数。这才是它能解决乱码问题的根本原因。2. 剖析 _write 与 fputc 的本质差异2.1 标准库的层级结构缓冲区、刷新与底层写想要彻底理解这个问题得先厘清 C 标准库的 IO 分层。C 语言的stdio体系大概可以分成三层用户层函数printf、fputs、fputc、fwrite等等。你调用的函数。缓冲区层FILE结构体里维护的一块内存缓冲区。所有用户层函数的数据先放这里。底层写函数在 Windows 的 CRT 里这个函数就是_write。缓冲区内容刷新时数据通过它送进操作系统。缓冲区存在的意义是减少系统调用次数。比如你要向文件写 10 万次单个字符如果每次调用都直接触发系统调用性能会非常差。CRT 的做法是先把这些字符攒在缓冲区里等缓冲区满、显式调用fflush、程序正常退出或者流被关闭时再把整块数据交给_write。所以fputc做了什么事情它只是把字符放进缓冲区顺便做了一些状态检查。它根本不负责真正的输出。你把fputc重写成任何样子也只是在“往缓冲区放字符”这个环节做手脚并没有碰到底层输出。2.2 printf 的调用路径里没有 fputc我当年为了验证这个问题特地去看了一下 MinGW 的 stdio 实现。printf函数内部会经过vprintf、vfprintf之类的函数它们主要负责格式化字符串把结果写到一个临时缓冲区里然后调用fwrite之类的函数把这个缓冲区的内容复制进FILE的输出缓冲区。整个过程中完全没有逐字符调用fputc的动作。更直白地讲fputc是标准库给你用的“积木”但标准库自己盖楼时不一定会用这块积木。它内部可能用_putc_nolock之类的优化版本或者直接操作缓冲区指针。你在外边重定义一个fputc对内部实现毫无影响。那fputs和fwrite呢它们同样不会一个字符一个字符地调用fputc。它们的逻辑是把一整块内存的内容搬进FILE缓冲区。所以如果你用fputs输出字符串重写fputc同样无效。2.3 重写 _write 为什么能覆盖所有路径_write的位置决定了它的覆盖面。我画过一张很粗糙的调用路径图大致是printf - vfprintf - 格式化字符串 - 写入 FILE 缓冲区 fputs - 把字符串写入 FILE 缓冲区 fwrite - 把内存块写入 FILE 缓冲区 fputc - 把单个字符写入 FILE 缓冲区 所有 FILE 缓冲区最终在刷新时 - _write - 操作系统也就是说_write是所有输出路径的下游汇合点。在这个点拦截你就能统一处理所有来源的数据。对于 CLion 控制台乱码这种问题你可以在_write里拿到即将被输出的字节流做编码转换后再交给操作系统这比改任何一个上层函数都彻底。2.4 还有一个隐蔽原因重写 fputc 容易破坏 FILE 状态重写fputc还有一个麻烦FILE结构体里的缓冲区指针、错误标志、未写字符计数等状态都可能被牵动。标准库内部很多函数会加锁操作FILE比如_lock_file/_unlock_file确保线程安全。你自己实现的fputc很难保证和 CRT 内部的状态管理完全兼容。可能你在里面对FILE做了一点多余的操作就会导致后续的fwrite或者fclose行为异常甚至出现缓冲区数据丢失。而_write的入参只有一个文件描述符、一个缓冲区地址和一个长度它不需要关心FILE结构体只管“把这串字节发出去”风险小很多。3. 在 CLion 里重写 _write 的完整实操3.1 MinGW 环境下的代码示例我先给出一段在 CLion MinGW 下验证过可用的代码。它解决的问题是让 UTF-8 源码字符串输出到 Windows 控制台时不乱码。做法是在_write里把 UTF-8 转成 GBK再调用 Windows APIWriteFile直接写入控制台句柄。注意这段代码适合在【控制台窗口】和【CLion 的 Run 窗口】都能看出效果但两种环境的解码方式可能有差异我会在后面的问题排查里细说。#include stdio.h #include io.h #include windows.h // 简单的 UTF-8 - GBK 转换实际项目里可以换成完整的转换库 static int utf8ToGbk(const char *src, int srcLen, char *dst, int dstLen) { if (src NULL || dst NULL) return 0; // MultiByteToWideChar: UTF-8 - UTF-16 int wlen MultiByteToWideChar(CP_UTF8, 0, src, srcLen, NULL, 0); if (wlen 0) return 0; wchar_t *wide (wchar_t *)malloc(wlen * sizeof(wchar_t)); if (!wide) return 0; MultiByteToWideChar(CP_UTF8, 0, src, srcLen, wide, wlen); // WideCharToMultiByte: UTF-16 - GBK int blen WideCharToMultiByte(CP_ACP, 0, wide, wlen, dst, dstLen, NULL, NULL); free(wide); return blen 0 ? blen : 0; } static int writeWithHandle(int fd, const void *buf, unsigned int count) { HANDLE h (HANDLE)_get_osfhandle(fd); if (h INVALID_HANDLE_VALUE || h NULL) { return -1; } DWORD written 0; if (!WriteFile(h, buf, count, written, NULL)) { return -1; } return (int)written; } int _write(int fd, const void *buf, unsigned int count) { // 只处理 stdout(1) 和 stderr(2) if (fd 1 || fd 2) { HANDLE h (HANDLE)_get_osfhandle(fd); if (h INVALID_HANDLE_VALUE || h NULL) { return -1; } // 判断当前控制台是否支持 UTF-8 代码页如果已经切到 UTF-8 就直接原样输出 UINT cp GetConsoleOutputCP(); if (cp CP_UTF8) { return writeWithHandle(fd, buf, count); } // 否则把 UTF-8 转成 GBK 再输出 char *outBuf (char *)malloc(count * 2 1); if (!outBuf) { return writeWithHandle(fd, buf, count); } int outLen utf8ToGbk((const char *)buf, (int)count, outBuf, (int)(count * 2 1)); if (outLen 0) { free(outBuf); return writeWithHandle(fd, buf, count); } DWORD written 0; WriteFile(h, outBuf, outLen, written, NULL); free(outBuf); return (int)count; // 返回原始字节数保持 CRT 的语义 } return writeWithHandle(fd, buf, count); } int main() { printf(中文测试\n); fprintf(stderr, 错误信息输出\n); fputs(fputs 测试\n, stdout); return 0; }3.2 代码逐段拆解先从入口_write说起。这个函数的签名在 MinGW 和 MSVC 里是一致的int _write(int fd, const void *buf, unsigned int count);参数含义很直白fd是文件描述符0 标准输入、1 标准输出、2 标准错误buf是要写的数据count是想写的字节数返回值是实际写出的字节数。C 标准库里的fflush、fclose、程序退出时的清理逻辑最终都会调用这个函数。代码里我做了这么几件事对stdout和stderr单独处理因为乱码问题只涉及这两个流。用_get_osfhandle把 CRT 层的小整数文件描述符转成 Windows 句柄HANDLE。这个句柄可以丢给WriteFile使用。判断当前控制台代码页。如果已经切到了 UTF-8CP_UTF8就是 65001我就原样输出不做转换。如果控制台还是默认 GBK就把 UTF-8 字节流转成 GBK再调用WriteFile。返回值尽量返回count保持“调用者认为全部写入成功”的语义。如果你返回的值小于countCRT 可能认为发生了写入错误。这里有一个非常重要的点不要在_write函数内部再次调用_write也不要在这个函数里调用printf、fprintf这类最终也会走到_write的标准输出函数。否则就是无限递归程序直接栈溢出崩溃。3.3 为什么这套方案在 CLion 中能生效CLion 的 Run 窗口本质上是一个伪终端模拟器它启动你的程序后会读取子进程的标准输出流然后按某种编码解码并显示。当你在_write里直接调用了WriteFile写入目标实际上是这个伪终端对应的管道句柄。伪终端读取到字节后按它自己的解析规则显示。大多数情况下 CLion 在 Windows 上会以 UTF-8 解析输出流。所以我们更推荐的做法是不要转成 GBK而是直接在_write里把原本就是 UTF-8 的字节原样写入。我把代码里的GetConsoleOutputCP判断放在前面就是这个目的如果 CLion 已经把控制台代码页设置成了 UTF-8就直接写原始字节。实测下来的结论是在 CLion 的 Run 窗口输出 UTF-8 原始字节通常就不会乱码。在 Windows 传统控制台窗口你得先调用SetConsoleOutputCP(CP_UTF8)或者让_write内部转成 GBK否则中文会乱。如果程序输出被重定向到文件那不要做任何编码转换直接写原始字节最省事。所以我最后的建议是如果你的主要运行环境是 CLion优先保证输出 UTF-8 原始字节如果要兼顾传统 Windows 控制台再在_write里按当前代码页动态决定是否转换。3.4 在 MSVC 工具链下有哪些区别CLion 除了 MinGW也能配置 MSVC 工具链。MSVC 的 CRT 里同样存在_write但重写方式稍有不同。MSVC 下你直接在源文件里定义同名_write有一定概率链接不上因为_write在 MSVC 的库文件里可能已经被强符号占用了。更稳妥的方式是使用/FORCE:UNRESOLVED或者直接不要走这条路而是用其他手段比如改用_setmode配合_O_U8TEXT。但如果你一定要在 MSVC 下重写函数签名不变里面同样使用_get_osfhandle和WriteFile。只是要注意 MSVC 对_write的调用约定和符号修饰可能需要在函数前加extern C避免 C 名字修饰导致找不到函数。这里没必要展开太多CLion 配 MinGW 才是大多数人的组合。记住一个结论原理相同MSVC 下的写法要额外注意符号链接问题。4. 常见问题与排查技巧实录4.1 重写 _write 后出现无限递归这是最容易踩的坑。很多人在_write里写了一句return _write(fd, buf, count);结果程序直接栈溢出。原因很简单你定义的_write已经把 CRT 内部的_write覆盖了再调用同名函数调到的还是你自己。这就成了死循环。正确做法是绕开这个符号直接调 Windows APIWriteFile。Windows API 是操作系统提供的不经过 CRT所以不存在递归问题。当然你也可以用_wsopen之类的底层函数但那会更复杂。我封装了一个writeWithHandle辅助函数就是专门干这个的。记住在重写的_write里不要调用任何最终会回调_write的库函数包括printf、puts、fwrite、fputs、fputc。4.2 重写 _write 后换行符变成方块Windows 文本模式默认会把换行符\n展开成回车换行\r\n这是 CRT 在_write层做的事情之一。当你完全绕过 CRT 用WriteFile输出时这个自动转换就没了。如果你输出hello\n在 Windows 控制台或某些伪终端里可能看到的是hello后面没换行或者换行表现异常。解决方式也很简单在_write里手动把\n替换成\r\n也就是把原字节流里的0x0A前面补上0x0D。static int writeWithNewlineConvert(int fd, const char *buf, unsigned int count) { // 遍历 buf遇到 \n 就多写一个 \r ... }其实我一般不建议在_write里做这么重的处理因为会影响所有输出流的性能。更优雅的方案是在main开头调用_setmode(_fileno(stdout), _O_BINARY);这样 CRT 就不会在文本模式和二进制模式之间做转换你输出的字节是什么管道收到的就是什么反而更容易控制。但要注意这样\n就不会自动变成\r\n了你需要自己保证输出内容符合目标环境的要求。4.3 重写 _write 后某些输出丢失或顺序错乱输出丢失常见于缓冲区和_write返回值不匹配。CRT 认为_write成功写入了count个字节但实际上你只写了转换后的outLen个字节而且返回值也返回了count。如果 CRT 维护的缓冲区偏移量和新写入长度不一致就可能出现下一次写操作位置错乱。所以我建议除非你做完整的字节流转换否则最好在_write里原样写入并返回count。转换逻辑只适合放在单独的、你自己控制的输出模块里不要轻易对流做长度不等的变换。如果你一定要变换长度就必须考虑同步问题。最简单的办法是每次写完就在_write里调用fflush(NULL)把所有流都刷一遍但这样性能会很差。更好的做法是确保所有输出都走同一个_write不要在程序里混用printf和 Windows API 输出。4.4 重写 fputc 后完全无效的排查如果你用了网上流传的“重写 fputc”方案但没有效果先不要怀疑自己写错了。先确认你的输出到底是通过什么函数产生的。在你重写的fputc里加一个断点或者写一行OutputDebugString看看它有没有被调用。实测发现printf根本不会走到fputc。就算你用循环逐个调用fputc它也只是往缓冲区里写字符不会立刻触发底层输出。想要用fputc触发底层调用你还得手动fflush(stdout)或者等缓冲区满。从设计上说标准库的高层函数和底层写函数本来就是两层接口fputc属于上层_write属于底层。你要拦截整个进程的输出只能在底层做。4.5 乱码仍然存在的几个排查方向重写_write之后如果乱码还在按这个顺序排查确认你的_write是否真的被调用了。在函数入口加一个OutputDebugStringA(_write called\n)然后在 IDE 的诊断工具里看输出。确认buf里的内容到底是什么编码。可以在_write里把前几个字节打印成十六进制比如中文的 UTF-8 是E4 B8 AD E6 96 87如果是D6 D0 CE C4那已经是 GBK 了。确认 CLion 的控制台解码编码。在设置里搜索 “Console”找到默认编码确认是 UTF-8 还是 GBK。确认你没有在别的地方又调用了SetConsoleOutputCP把代码页切走了。我遇到过一种特殊情况程序里同时用了printf和 Windows 的OutputDebugString乱码完全是OutputDebugString造成的和数据流无关排查了半天才定位到。4.6 实用技巧断点调试 _write在 CLion 里可以直接在_write函数上打断点。因为它是你自己的源代码断点肯定能命中。加上_write会被频繁调用如果你不想被疯狂打断可以在断点条件里写fd 1 || fd 2这样只有当标准输出或标准错误被写入时才会停下。然后你可以在调试器里查看buf指向的内存内容确认编码是否正常。这个调试技巧比盲改代码高效得多。5. 为什么这个方案不是万能的但却是最通用的一种5.1 只影响运行时输出不影响重定向到文件很多人担心重写_write会导致所有文件写入全部受影响。从代码里可以看出非stdout和stderr的 fd 会走writeWithHandle也就是还是会用WriteFile写文件。这会绕开 CRT 的文件缓冲但通常不会出大问题。如果你希望程序把输出重定向到文件时保持原来的行为可以在_write里判断目标句柄是不是控制台。用GetConsoleMode可以判断一个句柄是不是控制台句柄。不是控制台就说明是管道或文件这种情况下直接原样写入不做编码转换。DWORD mode; HANDLE h (HANDLE)_get_osfhandle(fd); BOOL isConsole GetConsoleMode(h, mode);如果是控制台再做转换如果不是控制台直接写原始字节。这样./a.out output.txt时输出不会带着 GBK 转换保存的文件还是 UTF-8。这个细节非常重要很多项目踩过坑。5.2 对 C 的 iostream 也有效你可能会担心std::cout不走_write。其实不用担心MinGW 的 libstdc 在底层仍然会把输出交给 C 标准库的fwrite最终走到_write。MSVC 的 iostream 最终也会通过其内部机制调用到_write或等价的底层入口。所以重写_write对 C 流式输出同样能覆盖到。唯一的区别是 iostream 可能自带 locale 处理和宽字符转换如果你同时用了std::wcout和std::cout情况会更复杂。但这个不是标题要讨论的核心知道它能覆盖到就够了。5.3 风险与替代方案重写_write本质上是在做“运行时热替换”库函数这属于比较 hack 的做法。虽然经典且有效但它有几个风险在 MSVC 工具链下可能符号链接不上去。函数内部一旦写出 bug会影响整个程序的输出系统。换一个编译器或者更新 CRT 版本后内部调用链可能变化需要重新验证。如果不想冒险替代方案是使用freopen重定向标准输出到自定义设备或者使用dup2配合管道。但对 CLion 这种“我需要快速让中文不乱码”的场景来说直接重写_write是最直接有效的方式它省去了查找标准库源码、理解内部机制的麻烦。我在实际项目里用过这个方案跑一个数据处理小工具档期紧、需求多重写_write帮我在五分钟内解决了输出乱码问题后续也没有再出现稳定性问题。相比之下改各种 locale 和代码页设置反而越搞越乱。写到最后的一些体会我自己最开始也试过重写fputc花了半天时间折腾最后发现printf根本不走那条路特别挫败。后来静下心去翻了 CRT 的源码才真正理解输出体系的分层思想上层函数负责格式化、缓冲底层函数负责真正把字节交给操作系统。如果你想拦截所有输出就必须在下游入口做文章而不是跟上层函数较劲。以后再遇到类似问题第一反应不应该是“换一个标准库函数试试”而是先想清楚这个函数在整个 IO 链条里处于什么位置。改错位置改的代码再多也没用。希望这篇从乱码现场写到 CRT 底层的记录能帮你少走一点弯路。
返回列表