重写_write函数解决CLion中STM32 printf中文乱码问题
写这篇文章的起因是我在 CLion 里调一个 STM32 项目时发现 printf 输出的中文全是乱码而且不管怎么改编码设置都无济于事。后来我把目光从 IDE 设置挪到了标准库实现上一顿排查之后真正解决问题的居然是重写了一个叫_write的底层函数。这个标题看起来很小但其实牵扯到 CLion 的编码机制、C 标准库的分层设计、嵌入式重定向、甚至工具链的链接顺序我尽量一次讲透。1. 先搞清楚问题CLion 里输出乱码到底卡在哪一层1.1 乱码不只是编码问题更是输出通道问题很多人在 CLion 里遇到中文乱码第一反应是去 Settings 里改编码把 File Encodings 从 GBK 改成 UTF-8或者反过来。这个思路本身没错但如果你试过所有组合都无效那你遇到的往往不是编码设置错误而是输出通道根本不按字符编码走。我用一个例子说明白。如果你在代码里写printf(温度%d 度\n, 25);在 CLion 的 Run 窗口里输出可能是娓╁害锛?25 搴?也可能直接是空白、问号甚至根本看不到字符串只看到数字。出现这种半乱码半正常的现象说明字符串的字节内容已经进入了输出通道但通道用错误的规则解释了这个字节序列。这时候很多人会去改 CLion 的 console encoding甚至有人建议在启动配置里加-Dfile.encodingUTF-8。但这些操作都建立在同一个前提上你的程序是通过正常的、带编码信息的方式输出文本的。而如果你的程序跑在嵌入式设备上或者跑在自定义的终端重定向场景里printf 的最终落点根本不是CLion 的 console而是你自定义的串口发送函数、文件写入函数、或者调试通道。这种情况下CLion 的编码设置管不到你。那到底是谁在管答案是标准库的底层输出函数。在 Windows GCC 工具链尤其是 CLion 默认的 MinGW里这个底层函数就是_write在嵌入式工具链里通常是_write或_write_r取决于 newlib 版本。你所有 printf、puts、putchar 的输出最终都汇聚到这个函数上。这就是问题的关键。1.2 fputc、printf、_write 三者的关系先把层次理清楚。一个典型的 C 程序使用 printf 输出数据流动大概是printf() └─ vfprintf() // 格式化 └─ 输出缓冲管理newlib 的 FILE 结构 └─ 底层写函数 └─ fputc() 或 _write()如果你用的是 glibc、newlib、或者 MinGW 的 msvcrt 兼容层printf 最终走的是文件描述符级的写操作而不是 C 标准库的流级函数 fputc。换句话说fputc 只是标准库内部实现的一个可能环节而不是必经之路。这在什么情况下区别特别明显当你使用printf(hello)时标准库把格式化后的字节放入 FILE 的缓冲区。缓冲区何时真正落到底层可能是填满、可能是 flush、可能是遇到换行。而这个底层在 newlib 或 MinGW 环境下是_write。当你使用fputc(A, stdout)时fputc 同样先把字符放入 stdout 的缓冲区然后最终还是会调用底层写函数_write去真正输出。所以结论已经很清楚了如果你在_write层面做输出重定向那 printf、puts、fputc、putchar、fwrite 全都绕不过去但你如果在 fputc 层面做重定向那你只能覆盖到显式调用 fputc 的路径覆盖不到 printf 内部的输出路径。2. 为什么重写 fputc 是半个正确的方案2.1 fputc 钩子的由来老式 ARM 库留下的习惯你可能在网上看到过很多 STM32 教程它们会这样写int fputc(int ch, FILE *f) { // 把 ch 通过串口发送 HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }然后在 main 函数里加上setvbuf(stdout, NULL, _IONBF, 0);这套写法是典型的 Keil MDK 老版本环境、或者早期标准外设库时代的写法。它的逻辑是这样的printf 内部在输出字符时会调用 fputc 作为输出一个字符的钩子所以你只要重定义 fputc就能把 printf 的输出重定向到串口。这套思路在非常老的 ARMCC 库比如 C 标准库的 microlib里确实成立因为那时 printf 的实现确实是基于 fputc 逐步输出字符的。但你把这套代码搬到 GCC newlib 环境CLion STM32CubeMX 生成的项目就是这样大概率不生效。为什么因为 newlib 的 printf 走的是_write系统调用层不是 fputc 层。我在实际项目中见过不少这样的情况代码里明明重写了 fputcprintf 就是不输出或者只在调用 fputc 时能看到串口有数据。原因就是这个层级错配。2.2 fputc 方案的三个边界问题如果你执意用 fputc 做重定向你还会撞上三个边界问题。第一它管不到 fwrite。fwrite(hello, 1, 5, stdout)在 newlib 里通常直接走底层写函数而不会逐字符调用 fputc。你重写的 fputc 对 fwrite 是透明的。第二它管不到多字节输出。fprintf 输出宽字符或 UTF-8 编码的多字节序列时即使底层真的调用了 fputc也是一个字节一个字节地调用效率低而且如果中间有串口流控或者超时很容易丢字节。你在 fputc 里一个字节一个字节地发送和你在_write里一次性拿到一个缓冲区再发送可靠性和效率完全不是一个级别。第三它管不到多线程。如果你的项目用了 RTOS比如 FreeRTOS两个任务同时 printffputc 层面无法感知一个完整输出序列的边界很可能出现 A 任务的半行字符串和 B 任务的半行字符串交叉输出。而_write接收的是缓冲区和长度你可以在这一层加互斥锁把一整段输出作为临界区保护起来。所以fputc 方案不是完全不能用但它只适用于单线程、单输出源、老工具链的简单场景。放到 CLion newlib 的现代环境里它就是一个典型的看起来对、实际上没接住的半个方案。2.3 printf 内部不一定走 fputc缓冲机制看明白再往深处挖一层。printf 到底怎么调用到底层取决于标准库的缓冲策略。在 newlib 里printf 先把格式化后的内容写入缓冲区缓冲区的类型由 setvbuf 设置。默认情况下如果你没有调用 setvbuf(stdout, NULL, _IONBF, 0)那 stdout 可能是全缓冲的如果重定向到文件也可能在终端环境下是行缓冲的遇到换行就 flush。但请注意这个 flush 动作背后调用的还是_write而不是 fputc。我做过一个实验来验证这一点。在一个用 CLion MinGW 构建的 Windows 控制台程序里我分别重写了 fputc 和_write然后在 fputc 里设置一个断点再运行 printf。结果发现除非我显式调用 fputc 或 setvbuf 设置了无缓冲否则 printf 根本不会触达我的 fputc 断点。但_write断点每次都必然命中。在嵌入式环境里也是如此。CLion 搭配 STM32CubeMX ARM GCC 工具链printf 最终触达的是 syscall 层。CubeMX 生成的工程里通常会有一个syscalls.c文件里面就是一系列_write、_read、_sbrk、_close之类的底层系统调用实现。你注意看syscalls.c里根本不会有 fputc。这里再补一个细节有些新版本的工具链底层函数名是_write有些是_write_r。_write_r是带 reentrancy 结构的版本即_write_r(struct _reent *r, int fd, char *ptr, int len)。如果你在链接阶段遇到undefined reference to _write或undefined reference to _write_r别慌说明你重写的函数名和工具链的期望不一致换一个名字就好。3. 重写_write才是底层锚点从 newlib syscall 说起3.1_write在标准库里的位置_write是 C 运行库和操作系统之间的系统调用接口。对于 newlib 这种专门为嵌入式设计的 C 库来说_write是一个 weak symbol也就是说工具链默认提供了一个弱定义的实现你在自己的代码里重新定义一个强符号链接时就会覆盖掉默认实现。这个设计的意义在于你把底层的输出动作换掉了但上层所有格式化逻辑、缓冲逻辑都不需要改。你不需要动 printf 的源码不需要动 vfprintf只需要告诉标准库输出 N 个字节到文件描述符 fd这一件事怎么执行。所以重写_write的思路几乎可以概括为一个核心原则拿到缓冲区和长度一次性把数据送到目标设备返回实际发送的字节数。3.2 重写_write的三个关键收益收益之一全路径覆盖。重写_write之后printf、fprintf、puts、putchar、fwrite、甚至 cerr/cout如果你混用了 C 流在底层都会汇聚到你的_write。你只需要在一个函数里做重定向就能控制所有输出。收益之二缓冲区批量发送。因为_write接收的是(fd, ptr, len)三个参数你可以直接把ptr指向的缓冲区整段交给串口 DMA、网络 socket、文件系统或者你自定义的调试通道。你不需要像 fputc 那样一个字节一个字节地循环发送。举个例子STM32 上用串口 DMA 发送_write的伪代码可以是这样int _write(int fd, char *ptr, int len) { HAL_UART_Transmit_DMA(huart1, (uint8_t *)ptr, len); // 等待 DMA 完成或返回前确保数据已送入发送寄存器 return len; }这个函数写完你的 printf 就是 DMA 批量发送效率和中断占用都比重写 fputc 好太多。收益之三在新库和旧库之间兼容。如果你的项目将来从老工具链迁移到新工具链或者你需要同时支持多个开发环境重写_write的代码可移植性远高于重写 fputc。因为 fputc 只在部分标准库实现里作为钩子存在而_write是所有 Unix-like 系统调用体系的标准接口不管在 newlib、MinGW、还是 Linux 交叉编译环境里这个函数名都是稳定的。3.3 一个完整示例STM32 CLion 的 printf 串口重定向我直接上一个我实际在 CLion 里验证过的完整流程你照着做基本不会踩坑。工程环境是这样的CLion 2023.2STM32CubeMX 生成的 CMake 工程ARM GCC 工具链gcc-arm-none-eabi芯片是 STM32F103C8T6串口 USART1第一步在 CubeMX 里打开 USART1波特率设 115200生成代码。工程里会自动带上Core/Src/syscalls.c这个文件里面有_write的默认实现但它是空的而且末尾还有个for(;;)死循环表示没实现。第二步把syscalls.c里的_write函数体替换掉。注意不要直接把函数删掉而是在原函数框架里补实现。下面是修改后的核心部分#include main.h #include errno.h #include sys/unistd.h // 注意huart1 这个变量在 main.c 里定义如果 syscalls.c 里没有 // 引用到 stm32f1xx_hal_uart.h可能需要 extern 声明 int _write(int fd, char *ptr, int len) { extern UART_HandleTypeDef huart1; if (fd STDOUT_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } // 其他 fd 暂时不处理 errno EBADF; return -1; }第三步在 main.c 的开头加了这样一行用来把 stdout 设为无缓冲避免串口端看不到数据setvbuf(stdout, NULL, _IONBF, 0);其实在嵌入式场景下我比较推荐设成无缓冲因为 MCU 的 RAM 有限而且串口本来就是低速设备缓冲带来的性能提升意义不大。但是如果你要打印大量日志无缓冲会让 CPU 频繁等待串口发送这时候可以考虑行缓冲或全缓冲然后定期 fflush。第四步在 CLion 的 CMakeLists.txt 里确认工具链选择的是 ARM GCC并且链接时没有把自己的_write和syscalls.c里的默认实现产生冲突。如果出现 multiple definition of_write说明你或者某个库文件里还有一个强符号实现需要删掉一个。我碰到过这种情况最后发现是自己在另一个文件里也写了一份_write两个文件同时编译导致冲突。这样配置完之后printf 输出的字符串会通过 USART1 发到串口助手中文不乱码因为你是从 MCU 直接发字节出去的编码取决于你在代码里写字符串时源文件的编码。CLion 默认 UTF-8你在 source 里写的中文是 UTF-8 字节串口助手里把接收编码设置为 UTF-8 就能正常显示。4. CLion 里的实操从工具链配置到中文乱码一次根治4.1 先在 CLion 侧把工具链和编码捋顺说实话很多 CLion 用户是刚刷完一遍 STM32CubeMX 就涌进来的工具链、编码、CMake 这些东西一团浆糊。我先把 CLion 侧该设置的地方说清楚。CLion 对 GCC 工具链的识别是自动化的你只要在Settings - Build, Execution, Deployment - Toolchains里选择正确的工具链路径。Windows 上我们常用 MinGW 做本机调试做嵌入式则手动添加一个 ARM GCC路径指向gcc-arm-none-eabi的 bin 目录。编码设置牵扯三个地方Settings - Editor - File Encodings把 Global Encoding 和 Project Encoding 都设为 UTF-8。Settings - Build, Execution, Deployment - Console这里有一个Default encoding也设为 UTF-8。如果你在 Run/Debug Configuration 里自定义了环境变量或 VM 选项需要确保没有强制指定编码。这三处设好CLion 的 console 就能正确显示从 MinGW 程序输出来的 UTF-8 字节。别全信网上说的改成 GBK 就好了那只是在特定 Windows 环境下的一种兜底。4.2 把 printf 重定向到串口、文件或调试通道前面讲了嵌入式场景。如果你做的是 Windows 本机程序想用 CLion 做调试却发现 printf 输出没进控制台、或者进了但中文乱码那大概率是 MinGW 的运行时和 Windows 控制台编码不匹配。Windows 控制台默认代码页通常是 GBK936而 MinGW 的 printf 默认按 UTF-8 输出字节。这就会导致你在 CLion 的 Run 窗口里看到的是一堆乱码。这种情况下方案有三条方案 A程序启动时调用SetConsoleOutputCP(CP_UTF8)把控制台代码页切成 UTF-8。方案 B不要用标准库的默认运行时换成 UCRT 或其他编码兼容层的运行时。方案 C重写_write在_write里做转码把 UTF-8 转成 GBK 再输出。这三种方案里方案 A 最简单但它其实也是在输出通道这一层做手脚跟我们在嵌入式里重写_write的思路是一模一样的。方案 C 适合那些依赖外部库、不方便改全局代码的情况你只需要在_write里对fd STDOUT_FILENO的路径做一次编码转换。但说实话我在 Windows CLI 项目里最推荐的还是方案 A因为改完_write之后你的输出函数就和标准实现解耦了后续如果要接第三方库的日志系统反而要多一层适配。不过如果你的目标是理解原理、掌控全局方案 C 的练习价值很高。4.3 符号冲突、链接顺序与 retarget 文件这部分属于实操中最容易栽跟头的地方。第一个坑弱符号和强符号冲突。newlib 的_write是 weak symbol你重写之后通常能正常覆盖。但如果你在代码里写了int fputc(int ch, FILE *f)又在同一个工程里重写了_write注意别让两者产生干扰。其实两者不冲突就像前面讲的printf 走_write显式调用 fputc 时才走 fputc两者可以共存。第二个坑链接顺序。CLion CMake 构建时如果你的_write实现放在一个静态库里而这个静态库在链接命令中排在了 libc 之后有可能出现链接器先找到了 libc 里默认的_write导致你的版本没有被链接进来。解决方法是确保包含_write定义的源文件直接参与编译或者用--whole-archive强制把库里的目标文件全部拉进来。第三个坑retarget 文件残留。很多 CubeMX 工程会自动生成syscalls.c但如果你在 CLion 里手动添加过其它 retarget 文件比如有些人会从老工程里复制一个retarget.c两个文件里的_write就会冲突。我建议做法是只保留一个 retarget 文件把其它文件从add_executable的源文件列表里移除。不要试图靠#ifdef去屏蔽容易留下隐患。5. 常见问题与排查技巧实录5.1 问题速查表我在做这个主题的调研和实际项目排查时整理出一批高频问题直接给你一张速查表。症状产生原因解决办法printf 不输出但 fputc 能输出printf 没走 fputc 路径走进了_write检查是否需要重写_write查看工具链的 libc 类型_write重写了但编译报 undefined reference函数名不匹配_writevs_write_r根据工具链版本实现正确的函数签名_writemultiple definition多处定义了_write清理重复的 retarget 文件CLion 控制台中文乱码CLion console 编码与程序输出字节编码不一致统一设置为 UTF-8或重写_write做编码转换串口输出正常但看不到字符串setvbuf 设置成全缓冲没有 fflush调用setvbuf(stdout, NULL, _IONBF, 0)设为无缓冲printf 能输出但偶尔丢数据底层串口发送没有等待完成在_write里等待发送完成或使用 DMA 完成回调代码里重写_write但调试时断点不命中链接到了库里的默认实现检查 CMake 源文件列表确认你的文件被正确编译和链接5.2 排查思路从哪个函数没被调用入手遇到输出问题我的第一个动作永远是定位路径。在_write和 fputc 里各打一个断点然后运行一句 printf看断点落哪个。这个动作基本能确定当前工具链的 printf 输出路径。之前我调试一个项目printf 完全不输出串口纹丝不动。我在_write和 fputc 里都打了断点结果一个都没触发。后来一查发现是因为我在链接时没有包含把 printf 完整实现的库编译器优化把 printf 折叠成了 puts而 puts 也没有被正确重定向。这时候你要看汇编或者用nm查看可执行文件符号确认 printf 最终调用了哪个底层函数。排查思路其实就一句话从调用链的顶端printf往下走逐层确认哪一层的钩子被替换了、哪一层没接住。不要一开始就怀疑编码或配置先把路径打通。5.3 我踩过的坑和心得经验一不要迷信网上旧的 fputc 模板。那些模板能工作是因为它的工具链和库版本刚好支持那种方式。你换到 CLion ARM GCC newlib旧的模板就失效了。看到 STM32 教程里重写 fputc先别急着抄看一眼它的工具链和启动文件再决定自己该怎么写。经验二_write返回长度必须正确。很多新手把_write写成了发送完就返回 0这会导致 printf 认为没有写入任何字节从而反复重试或直接丢弃数据。正确的做法是发送成功就返回 len发送失败返回 -1 并设置 errno。经验三CLion 的控制台和 Windows CMD 是两回事。CLion 的控制台虽然跑的是 Windows 命令但它的码率设置和编码解析有自己的逻辑。如果你在 CMD 里输出正常但 CLion 里乱码建议直接看 CLion 的 Run 窗口右下角编码指示必要时可以在启动配置里强制加一个环境变量PYTHONIOENCODINGutf-8之类的设置虽然这不是 C 程序的标准做法但能帮你排除干扰项。经验四先接一个dump通道再排查。如果你连printf 到底有没有发数据都不确定最快的办法是重写_write在里面调用系统级的fwrite(ptr, 1, len, stderr)或直接写一个固定格式的日志到文件里先把字节内容抓出来。有了原始字节你就能判断是没有数据还是有数据但编码错这是完全不同的两个问题。这组问题研究到最后其实就一个核心在什么层面接入你的输出钩子决定了你能控制多大范围的输出行为。fputc 是一个字符层面_write是一段缓冲区层面后者明显更底层、更全面、更接近数据流的本质。后面再做类似的题目我都会把优先级定为先看底层系统调用层再看库函数层最后才考虑应用层补丁。