CLion中printf重定向:_write与fputc的区别及正确做法

📅 发布时间:2026/9/7 10:38:22
CLion中printf重定向:_write与fputc的区别及正确做法
群里刚有人问了句CLion 里重定向 printf为什么都在写 _writefputc 不才是标准做法吗这一下把我拉回第一次在 CLion 里调串口日志的夜晚。当时我照着网上老教程往工程里塞了个重写 fputc 的函数信誓旦旦地确认了三次寄存器配置结果串口助手就是一片寂静。折腾到深夜最后把 fputc 换成 _write立马就好了。这个现象不是个例。很多从 Keil、IAR 转过来的朋友第一次在 CLion 里做嵌入式开发都会在这里卡一下。这篇文章就把这个“为什么”彻底讲明白C 标准库的 printf 到底怎么把数据送出去fputc 和 _write 分别在哪个环节干活CLion 的嵌入式工具链又有什么特殊之处以及碰到乱码、报错、卡死时应该往哪个方向排查。1. 问题现象重写 fputc 明明是标准做法为什么在 CLion 里不生效1.1 一个非常典型的开发场景先描述一下我这个项目当时的状态芯片是 STM32F103IDE 是 CLion工具链是 ARM GCC工程用 STM32CubeMX 生成通过 CLion 的 Embedded Development 插件加载。我要做的事情非常简单就是把 printf 的输出重定向到 USART1方便看调试信息。按照网上八成教程里的写法我先加了这么一段int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }这段代码在 Keil 的工程里、在 IAR 的工程里几乎是标准答案。逻辑也很直观printf 最终会逐个字符调用 fputc我只要把 fputc 指向串口发送数据就能出来。所以我当时的判断是问题只可能出在别处比如串口没初始化、时钟没配好、杜邦线松了。1.2 现象记录编译通过串口没有输出我把 HAL_UART_Transmit 的返回值打出来也看不到任何报错因为整个发送链路根本没被触发。诡异的是我用调试器在 fputc 里下断点printf 执行到一半断点压根没进来。后来我意识到一个关键线索printf 是库函数它内部不一定调用我这个 fputc。不同的 C 库对 printf 的实现方式不同有的库里 fputs、fwrite、fputc 是一套完整的缓冲机制有的库为了省空间干脆让 printf 直接往底层 write 接口塞数据绕过了 stdio 的字符级接口。我的 fputc 等于写了一个没人调用的空函数编译不报错运行没效果纯属自我安慰。更麻烦的是在某些库里printf 被编译成了半主机模式semihosting版本代码会尝试往调试器的“虚拟终端”输出数据。听起来很美好问题是开发板上的调试器没接 RTT、没开 semihosting 支持于是 printf 调用直接进入一个死循环或者空跑表现就是“程序没崩但不输出”。这种问题排查起来特别坑因为它不像硬件错误那么明显。1.3 排查思路先弄清 printf 身后到底是谁我用一个很笨的办法定位了问题反汇编看 printf 内部调用。在 arm-none-eabi-objdump 里拉起工程生成的 .elf 文件搜索 printf 的实现段发现它确实没有调用我的 fputc而是跳到 _write 这个符号。看到那行代码的瞬间我基本就明白了这版的 C 运行库里stdio 往上走的是系统调用抽象层而不是老的字符设备层。做一个小类比。fputc 就像一个门店的前台专门接待“单个字符”这种小请求_write 则是后台仓库处理“一段字节”这种批量请求。printf 在有些体系里会老老实实把每个字符递给前台在另一些体系里它直接把一整块数据甩给仓库省得前台一个个搬。你重写 fputc就是把前台换成了自己人但仓库那边压根不认识你货自然发不出去。这个类比其实也解释了为什么两种做法都对但适用的“库版本”和“编译环境”完全不同。搞清楚当前工具链里 printf 到底跟谁对接才是解决问题真正的钥匙。2. 核心原理_write 和 fputc 在标准库里究竟差在哪一层2.1 C 标准库的分层架构要把这个问题说透得先捋一遍 C 标准库的层级结构。我们平时写 printf调用的是标准库提供的“格式化输出”接口。这个接口内部大致分三层第一层是格式解析层负责把 %d、%s、%f 这些占位符替换成实际字符串这一层在库里已经实现好了用户碰不到。第二层是字符/流输出层对应 fputc、fputs、fwrite 这类函数它们管理缓冲、维护 FILE 结构体的状态。第三层是系统调用抽象层对应 read、write、open 这类 POSIX 风格函数标准库不实现它们的实际功能而是留给你或者运行环境去实现。在桌面 Linux 上第三层最终由内核实现write 就是真的写文件、写终端。在嵌入式裸机环境里没有内核write 就需要你手动“对接”硬件。所以嵌入式里的重定向本质上就是补齐第三层缺失的那部分系统调用。理解了这层关系就不难明白fputc 只是第二层的接口_write 才是第三层的地基。printf 的实现者可以选择只依赖第三层也可以选择结合第二层不同库选择不同表现自然不同。2.2 ARM GCC 生态里 printf 的调用链细节具体到 CLion 常用的 ARM GCC 工具链它默认携带的 C 库是 newlib 或者体积更小的 newlib-nano。这两个库里printf 的实现最终指向 _write 这个底层函数。也就是说不管你是不是只输出一个字符在最终落地的路径上printf 都会把数据组织成缓冲区然后交给 _write 统一发送。那 fputc 去哪了在某些配置下fputc 也会被链接进去但它的角色变成了类似“中间代理”标准库内部会默认提供一个 fputc 实现它调用 _write 来真正的输出。如果你重写了 fputc但没有重写 _write那 printf 走 _write 这条路时用的还是库自带的 _write它要么是空实现要么是半主机模式反正是不会把数据送到你的串口里。这里还有个编译选项可以佐证。newlib-nano 在编译时有个配置叫--specsnano.specs它主要影响的是 printf/scanf 的功能裁剪对底层 _write 的调用关系影响不大。真正决定走 _write 而不是 fputc 的是库对 stdio 整个管线的设计而不是某一个裁剪选项。所以哪怕你开着 nano.specs也没法靠重写 fputc 绕过这个调用链。2.3 为什么很多老教程都是重写 fputc它错了吗老教程写 fputc不能简单说错只能说它们描述的是另一种环境下的行为。比如老的 Keil MDK 自带的 ARM C 库对 printf 的实现就更贴近“逐字符输出”的模式fputc 正是出口。IAR 的库也有类似设计所以用户把 fputc 一重写printf 就跟着走了。这也是很多人在不同工具链间迁移时机翻车的原因教程本身没变变的是库的实现策略。你把 Keil 里的经验原封不动搬到 CLion等于拿着前任公司的门禁卡去刷现任公司的门刷卡姿势再标准也进不去。还有一点要提醒即便在同一个工具链里换了一个库版本调用链也可能变化。所以最稳妥的做法不是“背一个可以重写的函数名”而是学会自己查工具链的文档和反汇编确认。我后来习惯性地在 switch 到新工具链时先看一眼库里 printf 的实现这比在网上试各种旧模板高效得多。实际做项目时尤其是团队协作统一工具链版本非常重要有一个成员用了不同版本的新库重定向代码的表现就可能不一致。提示在 Clion 里做 printf 重定向前先确认两件事工具链用的哪个 C 库默认多为 newlib-nanoprintf 对应的是哪个底层 write 接口。这两点确定了写出来的重定向代码才具备可移植性。3. 实操方案在 CLion 里正确重定向 printf 串口输出3.1 推荐做法重写 _write 的标准模板既然调用链已经清楚直接看标准做法。以 STM32 HAL 库为例在 C 文件里加上这一段就能把 printf 的输出送到 USART1#include stdio.h #include stdarg.h #include main.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }这段代码的核心就三件事声明好串口句柄、把 ptr 指向的缓冲区原样发出去、返回实际发送长度。需要注意返回 len 不能随便写 0 或者省略返回值printf 内部会通过返回值判断是否发送成功返回值和传入长度不一致可能导致 printf 认为发送失败做重试或丢弃处理。对于字符输出也就是原来想用 fputc 的场景可以额外加一个小函数让字符最终也走 _write既不破坏原有语义也不影响链路int fputc(int ch, FILE *f) { return _write(0, (char *)ch, 1) 1 ? ch : EOF; }这样写的好处是假如你项目里加了某些日志库它内部调用了 fputc也能正确输出到串口。两个口子都堵上了后面不会再出现“为什么 printf 正常但 putchar 没反应”这种问题。3.2 关于 fputc 的补充处理之前提到过有些库里 fputc 默认会作为 _write 的“上层”被调用但有些库不一定。我自己习惯是 _write 和 fputc 都写上原因很简单在混合使用标准库和第三方库的时候不能保证每个库都走同一条输出链路。比如某个日志组件把数据通过 fwrite 写到一个文件流而文件流底层又依赖 fputc如果我只重写 _write日志组件的数据还是会丢。反过来如果只重写 fputcprintf 链路又断了。两个都写覆盖的场景更全。不过双写也有个隐患如果库的内部实现里 fputc 调用了 _write而我又在 _write 里什么事都没做那么 fputc 发送的数据会在 _write 层被截获并处理没问题。但如果你在 fputc 和 _write 里都做了 HAL_UART_Transmit 调用又恰好库内部把同一个数据传了两遍就可能出现一个字符发两次的现象。所以双写时一定要确认库的实现关系不要盲目叠加。3.3 参数计算与配置配合 CubeMX 串口初始化写好重定向函数还得保证串口本身没问题。很多人只盯着 _write 的写法忘了检查 CubeMX 生成的串口初始化。最典型的问题有三个。第一个是波特率对不上。比如 GPIO 初始化时把串口设为 115200但终端或串口助手却开的是 9600输出必定是乱码。这种问题排查很快看 CubeMX 的 USART 配置页把波特率记下来再去串口助手里选同一档基本就对了。第二个是时钟树导致的波特率误差。CubeMX 有时候根据用户在 Clock Configuration 里的输入自动计算分频系数填的值不同实际波特率会有偏差。偏差超过百分之二长一点的日志就会出现零星乱码。检查方法很简单把波特率调成整数对应的分频值让 USARTDIV 算出来不含小数或者开启 oversampling by 8容错更高实测下来稳定性会明显提升。第三个是 DMA 和中断配置。HAL_UART_Transmit 是阻塞式发送在简单的日志场景下没什么问题。但如果你开了中断或者 DMA又同时调用了同一个串口可能发生发送冲突。稳妥的做法是给 _write 里的发送加一点简单的状态判断比如用__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE)确保上一次发送完成再继续否则可能出现第一个字节被覆盖这种诡异现象。我遇到过一次特别难查的问题就是在 _write 里调用 HAL_UART_Transmit但串口助手收到的前两个字节老是丢。排查很久发现是主循环里一个高频中断也在用同一个串口发送调试信息两者没有互斥导致缓冲区被覆盖。后来我在 _write 里加了一个简单的信号量保护问题立刻消失。这种细节不踩坑真的想不到。4. 常见问题与排查技巧实录4.1 典型问题与解决方案速查表我整理了在 CLion 社区和实际项目中遇到频率最高的几个问题按现象、原因、解决思路列个表方便快速对照。现象可能原因解决思路printf 无输出程序正常运行printf 走了半主机模式或底层 write 没重写重写 _write并关闭 semihosting输出乱码波特率不匹配或时钟分频误差大核对 CubeMX 和终端波特率调整时钟树输出丢失前几个字符发送未等待上一位完成被下一位覆盖在 _write 里加发送完成判断或互斥程序进入 HardFaultprintf 内部使用堆堆栈空间不足增大 Heap Size检查启动文件输出重复一次或回显异常fputc 和 _write 同时被调用只保留 _write确认库内部调用关系中文输出乱码英文正常源文件编码非 UTF-8终端不支持对应编码源文件转 UTF-8串口助手选 UTF-8write to location 0x20 caused an access violation重定向函数操作了非法地址检查指针是否为 NULL确认串口句柄正确初始化表格里这几个问题基本覆盖了我在 CLion 开发过程中遇到的大部分坑。剩下几个需要展开说因为它们背后的原理不是一句两句话能讲透的。4.2 乱码问题不只是波特率的锅很多人一看到串口输出乱码习惯性怀疑波特率。实际上一旦波特率差了通常整段输出都会乱而不是只乱一部分。如果你看到的是一半正常一半乱码大概率是时钟树配置带来的分频误差。STM32 的 USART 波特率寄存器是 16 位整数加 4 位小数不同主频下某些波特率不能精确分频误差积累以后长字符串末尾就容易乱。另一个不太容易想到的原因是源码编码。CLion 默认使用 UTF-8而很多老工程是 GBK 编码。编译器把源码里的中文字符串按 GBK 解释printf 把它原样发给串口助手串口助手又按 UTF-8 解码两边不对齐中文就成乱码了。英文不受影响因为 ASCII 在两个编码里是一致的。排查方法最简单用十六进制显示串口数据如果看到的字节序列是 GBK 编码的汉字字节基本就是编码问题。解决办法是统一成 UTF-8并在串口助手里也选 UTF-8双端对齐乱码自然消失。4.3 重写 _write 之后程序崩了怎么定位崩的问题比乱码严重。我见过比较多的场景是printf 放在一个很深的函数嵌套里局部变量特别多库内部实现 printf 时需要一定栈空间而嵌入式工程默认的栈很小只有 1KB 左右叠加上重定向函数的调用直接越界进入 HardFault。定位方式很简单在调试器里打开 Fault Analyzer 或者查看栈回溯看程序最后停在哪一行。如果停在 printf 或 _write 附近基本可以断定是栈空间不足。解决方式是在链接脚本里把 Stack Size 调大比如从 0x400 改到 0x1000。有的项目还开了 FreeRTOS每个任务单独分配栈printf 任务的栈要额外留足因为 printf 是出了名的“栈吃掉大户”格式化浮点数时尤其明显。另外如果你重写 _write 后程序一开始运行就报write to location 0000000000000020 caused an access violation这类错误十有八九是 HAL_UART_Transmit 的参数有问题。比如指针用错了、句柄是 NULL或者是芯片上电后时钟没稳定就调用了 printf。我建议在 main 函数最开始加个简单的延时或者确保 SystemClock_Config 先于第一个 printf 执行很多初始化顺序导致的疑难杂症都能避免。注意重写 _write 时不要顺手把返回值改成 len 以外的值。我见过有人图省事直接return 0结果 printf 的缓冲区永远认为写入失败频繁重试程序速度被拖慢好几倍。返回 len 是最符合语义的做法既高效又不容易出错。4.4 多 main 文件场景下的输出管理CLion 有个特点它不像一些 IDE 那样靠文件后缀或者文件名识别入口而是通过 CMake 的 add_executable 来管理源文件。如果你在同一个工程里放了多个带 main 函数的文件链接时会报重复定义。这个和 printf 重定向本身没直接关系但很多人搜到《CLion 写多个 main》时其实是因为想搞一个测试代码集合里面既有 printf又有串口输出。我的经验是不要硬搞多 mainCLion 的多配置方案很成熟。用 CMake 的 option 控制编译哪套代码或者直接用 C 的 namespace 隔开逻辑比复制粘贴多个 main 文件优雅得多。如果你只是想在多个 demo 之间切换最省事的办法是给每个 demo 建一个可执行目标再让 IDE 只构建当前选中的 target。这个操作在 CMakeLists 里就是加一行 add_executable然后在 CLion 的 Target 选择器里切换比注释掉整个 main 函数干净多了。还有一种情况是你在做 JNI 或者 Android NDK 开发CLion 会生成一个 externalNativeBuild 的 target这种情况下 main 函数属于 Java 层C 代码里只有 JNIEXPORT 函数。此时你想加一个自动测试入口通常会用#ifdef包一个临时的 main只在 debug 构建里启用。这个思路可以但要注意别让临时 main 里的 printf 和 JNI 侧的 HAL 串口初始化冲突否则排查起来比较闹心。我的建议是让临时 main 直接复用 JNI 代码的初始化函数保证两边跑的是同一套硬件配置。4.5 一个容易忽略的“缓冲”陷阱以前在桌面环境写 C大家习惯让标准库自动缓冲 stdout。在嵌入式里这会是个大坑。如果你在 printf 后没有及时刷新缓冲区它可能不会立刻通过 _write 发出去。CLion 的嵌入式开发里有时调试时打印的信息滞后或者在程序跑飞前最后几条日志消失就是这个原因。我一般会在重定向代码里同时关闭标准库的缓冲区方法有两种。一种是在 main 开头调用setvbuf(stdout, NULL, _IONBF, 0)关掉缓冲让每个 printf 立即触发 _write另一种是启动文件里已经配置了 newlib-nano 的无缓冲模式那基本不用管。实测下来setvbuf 的方式最直接也最容易理解。不过 setvbuf 在某些精简库版本里实现得不完整调用后没效果。这时候退而求其次在关键的调试节点手动加一个fflush(stdout)也能保证数据及时发出。这个思路对于产品代码里的日志系统一样适用把 fflush 和 _write 的互斥锁配合好即使后面跑 RTOS 也不会烦你。4.6 解决“const memory write”类警告的细节还有个小众但恶心的警告write access to const memory has been detected, the output may be wrong!。这个警告一般出现在你用一个 const 指针或者字符串字面量作为 _write 的入参时。编译器认为你传给 _write 的缓冲区可能是只读的而 _write 内部如果做写操作就会越权。底层的根源是 printf 内部某些格式化实现里会尝试把临时结果写入一个内部缓冲区这个缓冲区的类型和你传入的字符串类型不匹配。处理方式不复杂_write 的函数签名保持原样不要自己额外加 const 限定调用时用一个可修改的 char 数组去接收而不是直接传字符串字面量。如果你在 HAL_UART_Transmit 的第二个参数看到了 const uint8_t *而你的 _write 是 int _write(int file, char *ptr, int len)那就在 _write 内部做一次显式转换。这样既消除了警告也避免了在运行时访问非法地址的风险。5. 后续扩展从串口重定向到更完整的调试体系5.1 用 CLion 的 Debug 模式配合串口日志把 printf 重定向搞定之后开发体验会提升一大截但别停在这里。CLion 的调试器可视化、内存查看等功能和串口日志配合在一起才是完整的嵌入式调试体系。我通常的做法是代码里保留面向业务功能的调试日志通过 _write 输出到串口同时利用 CLion 的断点和变量窗口跟踪运行时数据。两者互补串口日志回答“程序走到哪了”“数据大概是什么”断点回答“当前状态的精确细节”。这种组合在三方库封装很深、调用栈很长的时候特别有用。你要是只靠串口打日志一个问题可能要加十几条打印才能定位配合断点通常两步就找到了。5.2 从单串口输出升级到多通道日志工程复杂度上来以后一个串口往往不够用。比如主逻辑日志和底层驱动日志要分开看或者串口被别的外设占用了。我后来把重定向函数改成了可以通过宏或者全局变量切换目标通道的形式底层调用 HAL_UART_Transmit 时选择不同句柄。这样同一个 printf 的输出可以被轻松导向不同串口、不同调试终端。如果你用的是 FreeRTOS还可以把 _write 里的发送放到一个专门的中断优先级的队列里让调试日志不阻塞主任务。但这里有个度的问题调试代码太复杂反而会破坏时序引入了新的 bug。我的取舍原则是正式版本把重定向函数替换成空实现或者关掉调试日志开关只在 debug 构建里保留完整输出。量产的板子跑起来以后你绝不会希望 printf 在那边影响实时性。5.3 把重定向代码做成模板资产踩过这次坑之后我把这套重定向代码整理成了一个模板文件包含 _write、fputc、setvbuf 以及一个简易的日志宏后续开新工程直接复制。模板里还写了注释标注哪些地方需要根据芯片型号和串口句柄调整。这样团队里其他人用 CLion 开发时也不会再遇到同样的“为什么没输出”问题。做模板的时候有一点要记住不同芯片的 HAL 库发送函数不完全一样。STM32 是 HAL_UART_Transmit某些国产芯片或者旧版标准外设库可能是 USART_SendData。所以模板里最好把发送函数单独包装一层比如static void uart_send_byte(uint8_t ch)以后换平台只需要改这一个函数。这个封装一开始不显眼项目多了以后你就知道有多香了。提示模板里建议顺手加一个掉线保护比如发送返回值检测失败时连续 N 次失败就停止输出避免卡死主循环。这个在长时间无人值守的设备上特别重要否则一次串口异常就能把整个系统拖住。6. 写到最后的一点经验文章写到这里该讲的原理和操作都讲完了。我从个人体会的角度再说几句。克制的调试输出对嵌入式开发非常重要打印不是越多越好。重定向做对了只是万里长征第一步真正决定开发效率的是你拿这些日志能快速定位问题还是被日志淹没。串口重定向遇到问题时优先确认调用链再动手改代码。与其在 fputc 上反复尝试不如花几分钟查一下工具链用的 C 库和 printf 的实现方式。这个检查习惯养成以后你在任何 IDE、任何工具链里做重定向都不会慌。CLion 只是其中一个环境它的漂亮界面和调试体验解决的是操作层问题底层的库原理才是所有环境通用的硬核知识。