资讯动态

CLion嵌入式printf重定向:重写_write而非fputc的完整解析

发布时间:2026/9/7 10:50:39 来源:尧图企业网站定制
在嵌入式开发里用 CLion 折腾 printf 重定向几乎每个人都会撞上同一个灵魂拷问网上教程有的让重写 fputc有的让重写 _write到底哪个才对为什么我在 CLion 里照着网上的 fputc 写法抄了一遍串口就是死活不出数据这篇东西就把这个坑彻底讲透。先给出最直接的结论在 CLion ARM GCCNewlib这个组合下printf 的底层输出最终走的是 _write 这个系统调用接口而不是 fputc。重写 fputc 在某些编译器、某些标准库里确实能生效但在以 Newlib 为 C 运行库的嵌入式工程里这条路经常走不通。你需要的不是背结论而是理解 printf 从格式化到字节输出的完整链路以及 CLion 构建系统里那套链接、库选择的逻辑。搞懂了这些换芯片、换工具链、换 IDE 都不会再被这种问题卡住。我最早踩这个坑是在做 STM32 的调试日志输出CLion 里用的是 arm-none-eabi-gcc Newlib。当时网上搜到的大多是 Keil MDK 或 IAR 的写法——重写 fputc因为那些 IDE 自带的标准库对 printf 做了特殊处理最终会回调 fputc。但我把这套代码原封不动搬进 CLion编译通过运行后串口助手干干净净一个字符都没有。后来一步步查汇编、查 map 文件才明白Newlib 的 printf 根本不会去调 fputc它走的是 _write 这条系统调用链。这篇文章不打算只讲一个结论而是把五件事拆开讲透printf 在 Newlib 里的完整调用链、fputc 和 _write 的本质区别、为什么 CLion 工程里重写 _write 才是通用解、具体代码和参数怎么填、以及排查这种“printf 不输出”问题的实操思路。适合刚好卡在这个问题上的新手也适合想彻底搞清楚重定向机制的老手。1. 先搞清楚 printf 到底是怎么把数据送出去的1.1 格式化、缓冲、底层写三个环节各干各的很多人对 printf 的理解停留在“printf 把字符串发出去”这个粗粒度层面但实际上一行 printf 从执行到字符真正出现在串口上要经过三个完全独立的环节。第一个环节是格式化。printf(hello %d\r\n, 100) 里的 %d 会被解析100 被转成字符串 100最终拼成一段完整的、待输出的字节序列。这个工作在 Newlib 里由 printf 家族函数自己完成核心实现位于 vfprintf.c。它不关心这段字节最终是进串口、进文件、进 LCD 还是进日志系统。第二个环节是缓冲。Newlib 的 printf 默认会走 stdio 缓冲机制尤其是当输出目标是文件、或者 stdout 被重定向到一个非终端设备时数据会先攒在内存缓冲区里攒满或者遇到换行、或者主动 fflush才会真正往外写。这个缓冲机制对嵌入式调试是个大坑后面我会专门讲。第三个环节才是真正的底层输出。格式化加缓冲之后库函数需要把最终的数据通过某种方式交给硬件或操作系统。在桌面 Linux 上这个动作是 write(1, buf, len) 之类的系统调用在裸机嵌入式环境里没有操作系统Newlib 就留了一个名为 _write 的弱符号给你接管你在自己的代码里实现它printf 的输出最终就会汇聚到这个函数里。也就是说printf 是一条流水线格式化 → 缓冲 → 底层写。你重写 fputc 想拦截的是“单个字符怎么写出去”这个动作但这条流水线压根没设计成必须经过 fputc。这就是问题的根源。1.2 Newlib 的 printf 为什么绕过了 fputcNewlib 是一个专门为嵌入式系统设计的 C 标准库实现被 ARM GCC 工具链、RISC-V 工具链等广泛使用。它对底层设备访问做了抽象所有需要和“外部世界”打交道的动作都通过一组可重定义的底层函数来完成这组函数统称为 syscall stubs系统调用桩。常用的包括 _write、_read、_sbrk、_close、_lseek、_fstat、_isatty 等。printf 在 Newlib 内部最终会调用 fwritefwrite 再调用 __sfvwrite而 __sfvwrite 会根据文件描述符类型走不同的路径。对于 stdout最终会调用 _write_r 这个内部可重入版本再由 _write_r 调用你重写的 _write。整个过程里找不到 fputc 的参与。那 fputc 是干嘛的fputc 是一个标准 C 库函数它确实存在但它的角色是“应用层 API”不是“底层输出钩子”。如果你自己写代码调用 fputc(A, stdout)那它最终也会走同样的底层路径。但如果你调用的是 printfprintf 内部的实现没有义务去调用 fputc。在 Keil MDK 的 ARM Compiler 里标准库的 printf 被专门实现成最终回调 fputc所以那个环境下的经典重定向写法是重写 fputc。但是这一套拿到 Newlib 下就不成立了Newlib 的 printf 根本不认 fputc。简单类比一下fputc 相当于你公司前台的一个接待员你专门打电话给前台说“我要寄一个字符出去”前台确实能帮你寄。但 printf 是另一个部门它干完活之后直接把包裹交给了底层物流系统_write完全没经过前台。你在前台拼命换人重写 fputc物流系统那边根本不知道。1.3 为什么 CLion 嵌入式工程默认走 Newlib 这条链CLion 的嵌入式开发支持本质上是一个构建系统 调试器的前端它本身不编译代码也不决定标准库行为。真正干活的是你配置的工具链通常是 arm-none-eabi-gcc配套的 C 库是 Newlib 或 Newlib-nano。CLion 只是帮你把 CMake 构建脚本和调试配置串起来它和 Keil、IAR 的“自带编译器 自带库 自带 IDE”那种强耦合模式完全不同。所以你在 CLion 里做 printf 重定向首先要想清楚底下的工具链是哪个、标准库是哪个。只要用的是 arm-none-eabi-gcc不管你在 CLion、Eclipse、VS Code 还是命令行里编译跑的都是 Newlibprintf 的输出最终都会去找 _write。这个事实决定了重写 _write 是通用解重写 fputc 是环境特例。顺便说一句如果你用的 STM32CubeMX 生成的工程HAL 库会提供一个名为 HAL_UART_Transmit 的函数很多人习惯在重定向函数里直接调用它。这个思路没问题关键是你把 HAL_UART_Transmit 放在哪个函数里——放在 _write 里printf 就能用放在 fputc 里printf 大概率不能用。原因就是上面分析的那条调用链。2. fputc 和 _write 的本质差异以及什么时候该用哪个2.1 两者签名不同职责不同影响范围也不同先看签名。fputc 的标准签名是int fputc(int ch, FILE *stream);它接收一个字符和一个文件流指针返回值是写入的字符出错返回 EOF。它属于标准 C 库的流 I/O 层面向的是 FILE 抽象理论上你可以对任何文件流调用 fputc不光是 stdout。_write 的签名是Newlib 环境下int _write(int fd, const void *buf, size_t count);三个参数分别是文件描述符、缓冲区指针、字节数返回值是实际写入的字节数。它属于 POSIX 系统调用层面向的是文件描述符fd 1 表示 stdoutfd 2 表示 stderr。这两个函数的职责层级完全不同fputc 一次处理一个字符_write 一次处理一整块缓冲区fputc 依赖 FILE 结构体和流缓冲_write 直接做底层搬运fputc 在 Newlib 里不是 printf 的必经之路_write 是。再往深一层说_write 的 buf 参数说明了一个关键事实你重写 _write 时接收到的往往是一整段已经格式化好的字符串。比如你调用 printf(hello\r\n)_write 收到的可能是 hello\r\n 一整段 7 个字节你要做的就是把这段数据完整地交给串口然后返回实际发送的字节数。这种“批量接收、批量发送”的模型天然也更适合串口外设的 DMA 传输或者 FIFO 发送。int _write(int fd, const void *buf, size_t count) { if (fd 1) { HAL_UART_Transmit(huart1, (uint8_t *)buf, count, 1000); return count; } return -1; }这个就是最典型的 Newlib 重定向实现简洁、直接、好用。fputc 那种“一次一个字符”的写法在这种场景下反而是低效的如果 printf 输出一整行 100 个字符fputc 方案会被调用 100 次每次都要重新进入串口发送函数每一次都可能产生不必要的等待和开销。2.2 哪些场景重写 fputc 确实管用不能说 fputc 重定向就是错的在特定环境下它是正确且唯一的选择。关键是搞清楚你手里的工具链用的是什么 C 库。如果你用的是 Keil MDK ARM Compiler或者 IAR IAR 编译器这两个 IDE 的 C 库在设计上专门做了处理printf 的底层字符输出最终会回调 fputc。这是它们为了兼容经典嵌入式教材而保留的特性所以你在那里面重写 fputc 是有效的。很多经典教材、开发板例程都是基于这些环境写的这是“重写 fputc”这个说法广泛流传的历史原因。另外如果你用的是“微库”MicroLIB它同样会把 printf 的输出牵引到 fputc。MicrLIB 是 Keil 提供的一个轻量级 C 库专门为资源受限的 MCU 设计它的 printf 实现链路短、依赖少fputc 就是它的底层钩子。但如果你用的是 arm-none-eabi-gcc Newlib无论从原理上还是实测上fputc 都不是 printf 的必经之路。这是目前 CLion 嵌入式开发最常见、也最推荐的组合所以“重写 fputc 在 CLion 里失效”才成了高频问题。我建议的判断方法很简单三步走确认你的工具链是不是 arm-none-eabi-gcc。如果是优先重写 _write。打开工程里的 map 文件搜索 printf 相关符号看它链接自哪个库文件。如果来自 libc.aNewlib那基本上就是 _write 这条路。在调试器里给 fputc 和 _write 分别下断点执行 printf看断点落在哪里。眼见为实一次就能确认。2.3 重写 _write 的本质是补全 Newlib 的系统调用层其实“重写 _write 让 printf 输出到串口”这件事本质上是你在给 Newlib 补全一个裸机环境缺失的底层系统调用。在桌面 Linux 上printf 的输出会通过 write 系统调用交给内核内核再把数据写到终端或文件。裸机 MCU 上没有内核、没有终端驱动Newlib 无法自动知道你的串口在哪个地址、怎么初始化于是它留了一个 _write 弱符号默认实现直接返回 -1表示失败。你重写它就是在告诉 Newlib“串口驱动我写好了你把数据往这里送。”这也解释了为什么重写 _write 时要注意 fd 的判断如果你同时把 stdout 和 stderr 都重定向到串口两个 fd 都会走同一个 _write你可以在函数里区分处理比如 stdout 走串口1stderr 走串口2或者都走同一个串口但加上不同的前缀。这在调试时非常实用错误信息和普通日志可以分流到不同通道。同样逻辑也适用于 _read你要用 scanf 或 fgets 从串口读数据就得重写 _read。它和 _write 是一对一个是输出通道一个是输入通道。Newlib 留出的这些桩函数就是你在裸机上自定义标准输入输出的入口。3. CLion 工程里的完整实操重写 _write 并让 printf 正常输出3.1 最小可用的重定向代码示例这里给一套 CLion STM32 HAL Newlib 环境下可直接用的代码基于 STM32CubeMX 生成的工程骨架。串口用 huart1 举例你可以替换成自己工程里的句柄。#include stdio.h #include stdarg.h #include main.h extern UART_HandleTypeDef huart1; int _write(int fd, const void *buf, size_t count) { if (fd 1 || fd 2) { HAL_UART_Transmit(huart1, (uint8_t *)buf, count, 1000); return count; } return -1; }这套代码的关键点有三个第一个是 fd 的判断。fd 1stdoutfd 2stderr一般情况下把两个都指向同一个串口printf 和 fprintf(stderr, ...) 都能正常输出。如果你只想让 printf 生效可以只处理 fd 1。第二个是返回值。HAL_UART_Transmit 发送成功返回 HAL_OK0但你这里返回的是 count也就是你要发送的字节数。Newlib 的 _write 契约要求返回实际发送的字节数返回 count 表示全部发送成功。如果你返回 0上层可能会认为发送失败导致数据被丢弃或出现异常行为。第三个是超时时间。HAL_UART_Transmit 的最后一个参数是超时时间单位毫秒。1000 表示最多等待 1 秒。如果你的波特率很低、单次发送数据量很大可能要适当调大这个值。不过 _write 每次收到的一般不会太长printf 的缓冲机制会控制1000 基本够用。这里顺便提一个容易被忽略的细节不要在这个函数里做复杂的事情比如等待某个事件、加锁、动态分配内存等。_write 可能被 printf 内部频繁调用而且在调试器中断、中断上下文里也可能触发重入保持函数足够“薄”是最稳妥的做法。3.2 CMake 里检查库设置避免 Newlib-nano 的坑CLion 嵌入式工程一般由 CMake 构建你在 CMakeLists.txt 里可能会看到类似这样的配置target_link_options(${PROJECT_NAME} PRIVATE -specsnano.specs -u _printf_float )-specsnano.specs指定使用 Newlib-nano。Newlib-nano 是 Newlib 的精简版体积小但某些功能也被精简了。比如默认情况下它不支持浮点格式化输出printf(%f, 1.23) 会输出 0.00 或什么都不输出。解法是在链接选项里加-u _printf_float强制拉入浮点格式化支持代码。这个点看起来和 _write 重写无关但实际调试时非常坑。如果你重写了 _writeprintf 输出字符串正常但一旦加 %f 就异常十有八九是 Newlib-nano 的浮点支持没打开。到时候你可能怀疑是 _write 写错了排查半天浪费大量时间。另外也建议在 CMake 里检查是否指定了--specs的路径确保用的确实是 Newlib 而不是其他库。可以用一个临时的 printf 测试代码验证printf(string test: %s\r\n, OK); printf(float test: %.2f\r\n, 3.14f); printf(int test: %d\r\n, 100);如果字符串正常、整数正常、浮点异常直接加-u _printf_float之后重新编译烧录。这个命令对 Newlib 和 Newlib-nano 都有效。3.3 缓冲问题为什么有时候复位后第一次有条数据之后就没了Newlib 的 stdout 默认是行缓冲还是全缓冲取决于输出目标是否为终端设备。这个判断通过 _isatty 函数完成。默认的 _isatty 返回 0表示“不是终端设备”这时 stdout 会走全缓冲数据会先在内存里攒着攒满 1024 字节或者调用 fflush(stdout) 才真正调用 _write。这就是很多人在嵌入式里遇到的诡异现象printf 偶尔有输出或者要等很久才有输出或者程序跑飞之前突然刷出一堆日志。原因就是缓冲区没满、没刷新_write 压根没被调用。最直接的解法是重写 _isatty让它返回 1告诉 Newlib“我的 stdout 是终端设备”。这样 printf 会走行缓冲模式遇到换行符就自动刷新日志输出的实时性会好很多int _isatty(int fd) { if (fd 1 || fd 2) { return 1; } return 0; }如果不想重写 _isatty也可以在每次 printf 后面手动加 fflush(stdout)或者临时用 setvbuf 把 stdout 改成无缓冲setvbuf(stdout, NULL, _IONBF, 0);但这几种方式各有取舍。_isatty 改行缓冲是最贴近桌面终端体验的setvbuf 改成无缓冲是实时性最高但性能较差的方式手动 fflush 则最麻烦漏一次就丢一段日志。按我的习惯串口调试日志工程里直接重写 _isatty 返回 1这是最省心的。3.4 中文乱码问题与编码设置CLion 用户常遇到 printf 输出中文变乱码这个问题一半在代码一半在 CLion 的设置。代码层面源文件的编码格式要和编译器、串口终端三方对齐。CLion 默认源文件编码是 UTF-8arm-none-eabi-gcc 处理 UTF-8 字符串一般没问题但串口助手或终端工具如果按 GBK 或者其他编码解析UTF-8 的中文字节流就会显示成乱码。CLion 里需要在 Settings → Editor → File Encodings 确认 Global Encoding 和 Project Encoding 都是 UTF-8。串口终端工具这边把解码格式也设置为 UTF-8。两边都对了中文输出一般就正常了。另外注意一个细节printf 输出中文时字符串里的中文字符是多字节的_write 收到的 buf 里就是这些多字节 UTF-8 序列HAL_UART_Transmit 会原样发送不会区分“中文字符”和“英文字符”。所以乱码问题几乎不可能出在你的 _write 实现里除非你自己在转发过程中做了字符集转换。排查时优先检查终端的解码格式。4. 通用排查思路printf 没输出到底该怎么一步步查4.1 断点法先确定 _write 到底有没有被调用当 printf 没有输出时第一步不是怀疑 _write 写错而是先确定 _write 到底有没有被调用。打开 CLion 的调试器在 _write 函数第一行下断点执行 printf然后停下看断点是否命中。如果断点命中说明调用链是通的问题出在 _write 内部——串口没初始化、句柄不对、超时太短、发送字节数不对等等。如果断点没命中说明 printf 的数据根本没走到 _write。这个时候要往上游查一是缓冲区机制可能是 stdout 全缓冲走 _isatty 那条线二是链接问题你的自定义 _write 符号可能没有被链接进去编译器用了库里的默认实现。第二个问题尤其隐蔽因为 printf 内部某个函数可能不需要 _write 也能“成功”地丢数据你根本看不见报错。排查链接问题有一个有效方法编译后生成的 map 文件里搜 _write如果看到类似*default*或者来自libc.a的 _write 符号说明你的重写被忽略了。正确做法是检查你的源文件是否参与编译链接以及是否有其他源文件里也定义了同名 _write 导致冲突。CLion 的 CMake 工程里常见问题是源文件没被 add_executable 包含或者源文件放在一个没有参与构建的目录里。这种情况编译依然通过因为编译器根本不知道你的 _write 存在。4.2 串口工具和数据波形从外设链路找问题如果 _write 断点命中了但串口助手还是没数据问题就出在外设链路。先用逻辑分析仪或者示波器看 TX 引脚有没有波形。没有波形查 UART 初始化、引脚复用配置或者确认是不是在 _write 调用前就卡死了。有波形但内容是乱的查波特率匹配、电平转换电路。还有一个非常经典的低级坑_write 里调用的 HAL_UART_Transmit 要求 huart 句柄有效但如果你在 main 函数的初始化序列里过早调用了 printf而 UART 还没初始化就会导致发送失败。排查方法是保证所有 printf 调用都在 HAL_UART_Init 之后。你可以在调试时给 HAL_UART_Init 下断点确认初始化路径的执行顺序。4.3 梳理一份快速自查清单按重要程度排个优先级方便你直接从问题的“最大嫌疑点”开始查层级检查项说明调用链_write 是否被链接在函数里下断点或查看 map 文件确认调用链stdout 是否全缓冲重写 _isatty 返回 1 或 fflush(stdout)调用链printf 浮点是否可用链接选项加 -u _printf_float外设UART 是否已初始化初始化顺序、句柄有效性外设TX 引脚配置是否正确复用功能、速度、上下拉外设波特率与实际是否匹配串口助手和代码一致编码中文乱码源码、终端、编辑器的编码统一为 UTF-8环境工具链/库类型arm-none-eabi-gcc 对应 Newlib走 _write这张表基本覆盖了 95% 的“printf 无输出”场景建议你在抓狂之前先过一遍。5. 从 fputc 到 _write这个问题的背后是库设计思维5.1 为什么会有“只重定向 fputc 就够”的错觉抛开纯粹的工程问题很多人产生“printf 只要重定向 fputc 就行”的错觉主要来自三个原因。第一个是历史惯性早期单片机教材非常依赖 Keil 和 IAR那两个环境里 fputc 就是终点所以一代代开发者传下来的经验就是改 fputc。第二个是代码模板的误导很多开发板例程里确实用 fputc 实现了 printf 重定向新手照抄后在自己的同样的环境里也能跑通便形成了一个局部正确的认知。第三个是很多博客教程没有写清适用条件拿某个具体环境的经验当成放之四海而皆准的结论。但一旦切换到 CLion arm-none-eabi-gcc Newlib 这个组合这套经验就失效了因为 Newlib 的底层输出钩子是 _write不是 fputc。理解了这个区别你就能明白不只是 CLion任何基于 Newlib 的裸机工程VS Code、Eclipse、命令行 Makefile都会有同样的规则。5.2 为什么底层接口设计成 _write 而不是 fputcNewlib 的设计目标是提供一个接近 POSIX 的标准库接口方便代码在桌面端、有 RTOS 的环境和裸机环境之间移植。POSIX 世界里向终端写数据就是 write(fd, buf, len)而不是 fputc(ch, stream)。fputc 是 C 标准库层面的 API它最终也会调用底层 I/O。Newlib 把“底层 I/O”定义为一组系统调用桩就是为了让上层保持标准兼容底层可以针对不同硬件替换实现。这个设计很聪明你把一套用了 printf、fopen、fgets 的业务逻辑从 Linux 交叉编译到 MCU 上底层只需要补上 _write、_read、_sbrk上层代码几乎不用改。所以重写 _write 不只是一个“让 printf 在串口输出”的小技巧它本质上是在嵌入式环境里复刻一个微型的 POSIX I/O 层。这也解释了为什么 ARM GCC 工具链配套的 Newlib 在没有操作系统时会默认提供那些返回错误的桩函数——它们在告诉你“这里需要你来自行实现”。5.3 更进一步RTOS 环境下的 _write 要加锁如果你用的是 FreeRTOS 或者其他 RTOS重写 _write 时还要考虑多任务并发的问题。多个任务同时调用 printf最终都会汇聚到你写的 _write 里。如果这个函数不可重入串口数据会互相穿插日志变成乱麻。常见做法是在 _write 里加一个互斥锁或者用 FreeRTOS 的队列把发送任务串行化。一个简单的加锁示例#include cmsis_os.h static osMutexId_t uart_mutex; static osMutexDef_t(uart_mutex); int _write(int fd, const void *buf, size_t count) { if (fd 1 || fd 2) { osMutexWait(uart_mutex, osWaitForever); HAL_UART_Transmit(huart1, (uint8_t *)buf, count, 1000); osMutexRelease(uart_mutex); return count; } return -1; }这些细节在单线程裸机工程里不会触发但在实际产品开发中迟早要面对提前写清楚免得后面返工。5.4 关于 printf 性能重定向只是第一步最后说一个很多人实测后会发现的现象直接重写 _write然后无脑用 printf 打日志在低波特率比如 9600下非常慢。因为 printf 可能一次传入几十字节串口一字节一字节发如果 HAL_UART_Transmit 是阻塞式的整个 CPU 都在等串口。实际开发中可以考虑用 DMA 空闲中断的方式但这属于另一个话题了。至少在你完成 fputc 到 _write 的认知升级之后已经具备了做进一步优化的正确底层接口。就我个人经验来说从重写 fputc 到重写 _write表面上只是换了一个函数名背后其实是把“工具链如何组织标准库”这件事彻底想清楚了。以后再换任何 IDE、任何工具链、任何新的 MCU只要遇到 printf 不输出我第一反应就是先看工具链和 C 库然后直接去查对应的底层钩子在哪里而不是盲目套网上模板。希望这篇总结也能帮你省下我当初踩坑的那一整个下午。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价