
在 CLion 里做嵌入式开发尤其是 STM32、GD32、ESP32 这类基于 ARM Cortex-M 内核的单片机项目时串口重定向 printf 是一个绕不开的经典需求。我自己最初接触这个问题的时候也被网上的各种说法绕得云里雾里有人说重新实现 fputc 就行有人信誓旦旦说必须重写 _write。更迷惑的是两种答案在自己的机器上都能跑通但换一个工程、换一台电脑效果就完全不一样了。这篇东西我就把这个来龙去脉讲透顺便聊聊为什么在 CLion 的环境下重写 _write 通常才是那个“正确”的答案。CLion 的嵌入式开发场景底层工具链承接的是 ARM GNU Toolchain编译器是 arm-none-eabi-gccC 标准库实现为 Newlib 或 Newlib-nano。这套组合与 Keil MDK 的 ARMCC/AC6、IAR EWARM 的 IAR 库在 printf 的底层输出设计上有很大差别。你在 Keil 里重写 fputc 之所以能生效是因为 ARM 的 microlib 和标准库在设计上就把 stdout 的字符输出回调指向了 fputc但在 GNU 工具链下这条链路是断开的printf 最终落脚点是 _write 这个底层系统调用接口。用一句话概括的话fputc 是标准库层面的流回调_write 是系统调用层面的设备输出原语二者所处的层级完全不同CLion 默认用的 GNU 工具链决定了你得在设备原语这一层动刀。如果你正好卡在“为什么我重写了 fputc 但 printf 死活没有输出”这个问题上或者你正想搞清楚 CLion 里 JNI、串口重定向、中文乱码这些连带的坑这篇文章应该能给你一个比较完整的答案。1. printf 底下到底是谁在干活——一次完整的输出调用路径追踪很多人在刚开始学嵌入式串口重定向的时候都会有这样一个直觉printf 是一个 C 标准库函数它往标准输出 stdout 打印字符那我只要想办法把字符送到串口发送寄存器不就行了吗基于这个直觉第一个想到的切入点就是 fputc——因为 fputc 在 C 标准里被定义为“把一个字符写入指定流”printf 内部如果逐个字符输出那不就会调用 fputc 吗这套推理方向没错但它默认了一个前提printf 内部使用 fputc 来输出单个字符。这个前提在经典的桌面 C 库、Keil/ARM Compiler 提供的 microlib 里成立但在 GNU 工具链下的 Newlib 里并不成立。要想彻底搞清楚这件事必须顺着 printf 的调用链逐层往下看。1.1 从 printf 到 _write 的四层调用结构标准的 C 标准库实现里printf 这类格式化输出的调用路径可以概括为下面这样printf() - vfprintf() // 处理格式化逻辑%d、%s、%f 都是在这层解析 - __sfvwrite() // 把格式化完的字节流写入 FILE 结构体对应的缓冲区 - _sputc/flush // 缓冲区满或遇到换行时触发 flush - _write() // 最终把缓冲区里的字节一次性交给底层设备关键点在于__sfvwrite() 处理的是“一段字节”而不是“一个字符”。printf 经过格式化后输出内容在内存里是一块连续的字节序列Newlib 的默认行为是尽量把这块内容先放进 stdout 流自带的缓冲区等缓冲区满、显式 fflush、或者遇到某些特定控制条件后再整体下发。这个“整体下发”调用的就是 _write()。// 来自 Newlib 内部实现的示意代码非完整源码 int _write(int file, char *ptr, int len) { // 这是 printf 最终调用的底层原语函数 // 在移植版中这里应该把 ptr 指向的 len 个字节逐个发送到串口 }也就是说Newlib 里的 printf 根本不会逐个字符去调用 fputc它把整段字符串格式化完成之后一次性交给 _write 处理。_write 的名字和 Unix 系统调用里的 write(2) 对应本身是用于“向文件描述符写入数据”的接口。在 embedded 环境下这个“文件描述符”可以是串口、SWO 调试口、片外 Flash、网络 socket 等任意设备具体是什么完全取决于你在这个函数里怎么写。1.2 fputc 和 _write 各自的家——阶段性的归属认知纠偏fputc 在 C 标准里的定义是向指定文件流写入一个字符它做的事情本质上是对 FILE 结构体里的缓冲区进行操作如果缓冲区没满就把字符放进缓冲区并返回如果缓冲区满了就触发一次底层写操作把缓冲区内容真正送出去。真正执行“把内存里的数据写到外部设备”这个最终动作的是底层函数。问题来了这个“底层函数”是什么在不同的 C 运行库实现里它可以是不同的函数。我画了一张对应的关系表在改代码前最好对号入座一下工具链C 运行库printf 最终调用常见重定向入口arm-none-eabi-gcc NewlibNewlib_write()重写 _write() 函数arm-none-eabi-gcc Newlib-nanoNewlib-nano_write()重写 _write() 函数Keil MDK ARMCC/AC5microlibfputc()重写 fputc()Keil MDK AC6 使用 microlibmicrolibfputc()重写 fputc()IAR EWARMIAR 标准库__write() / fputc()按版本不同通常重写 __write()GCC glibc桌面环境glibcwrite() 系统调用通常不重定向CLion 配合嵌入式工具链时默认的 C 运行库就是 Newlib 或 Newlib-nano所以按照上表要重定向 printf 输出正解就是重写 _write()而不是 fputc()。这也就是标题所问的答案的核心在 CLion GNU 工具链这套技术栈下fputc 根本不在 printf 的关键调用路径上你重写它自然就没效果。1.3 一个很容易误解的名词重定向与半主机模式的纠缠在讨论 _write 重定向之前还必须先搞清楚半主机模式。ARM 的 C 运行库有一种调试通道叫 semihosting它允许开发板上的代码通过调试器把 I/O 操作“转发”给宿主机也就是跑着 CLion 的那台电脑实现 printf 到 IDE 控制台的效果。默认情况下Newlib 里面的 _write 存在一个 semihosting 的实现版本如果你直接下载官方标准固件库编译跑 printf大概率输出会卡死在半主机模式相关的代码里表现为程序在调用 printf 之后进入 HardFault或运行到 BKPT 指令停止。重写 _write 的意义之一就是用“直接操作 UART 外设寄存器”的实现在链接期覆盖掉 Newlib 自带的 semihosting 版本把输出从“通过调试器传给电脑”转向“从板载串口发出”。这个操作同时也关掉了半主机模式带来的副作用。很多教程里写着“需要禁用半主机模式”本质上就是在做 _write 的重定向而 fputc 的重定向方式不具备这个能力因为 fputc 只是流缓冲区的一层封装真正的底层 _write 还是那个会咬人的 semihosting 实现。2. 重写 fputc 为什么也会成功——论不同 C 运行裤架下的行为差异可能有读者会问网上明明有很多人发帖说自己在 CLion 里重写 fputc 也能出串口数据啊你怎么把这条路说成死路这确实值得解释。我自己早期折腾 STM32 的时候也碰上过这种“玄学成功”后来追溯了一下发现能成功的几种情况都离不开特定前提。2.1 情况一你的链接脚本或构建配置兜底重定向了底层调用第一种情况比较隐蔽工程里或者库文件里恰好有别的代码实现了 _write并且这个 _write 内部又调用了 fputc。举例来说如果某个 BSP 包或者初始化代码里已经写了类似下面这样的东西// 某个底层驱动里的一种常见做法 int _write(int file, char *ptr, int len) { for (int i 0; i len; i) { fputc(ptr[i], stdout); // 内部再转发给 fputc把底层 IO 转交给流接口 } return len; }这样一来你在自己的应用层里重写 fputc就间接影响了 printf 的最终输出路径。但要注意真正改变调用链顶层的恰恰是那个 _write而不是你重写的 fputc。如果你把 BSP 里的 _write 去掉光靠自己的 fputc马上原形毕露。这个嵌套实现很容易误导人我在检验代码时第一件事就是全局搜索有没有第二个 _write。2.2 情况二早期库版本或特殊编译选项改变了 Newlib 的执行路径如果你用的 GCC 版本比较旧、或者链接选项选择了某些特殊的重定向宏定义_write 层的调用也可能因为不同的配置而改变。特别是早年的一些 STM32 标准外设库工程会在编译选项里手动加入 -D__gnu_printf 之类的宏配置如果配合了把 stdout 设置成无缓冲模式那么 __sfvwrite 在输出时确实可能走到 __sputc 这条更细粒度的路径从而间接和 fputc 产生联系。但这种行为非常依赖库版本和环境参数不具备可移植性。今天你在 CLion 2023.1 里能跑通明天换到 2024.2 可能就失效了。因为它依赖的是 libc 内部实现细节而不是对外契约。_write 的调用关系则是从早期 Newlib 一直延续到现在的公开接口稳定得多。2.3 情况三你把 stdout 强制设为了无缓冲或行缓冲模式如果你在代码里调用了 setvbuf 把 stdout 设为 _IONBF无缓冲Newlib 的 printf 内部处理路径会变成“每收到一个字符就尝试直接输出”这种情况下某些内部实现会逐字节调用底层输出。这时候如果你恰好重写了 fputc同时底层输出函数又是从一个很老的模板代码演变而来的它可能会去调用 putc 宏而 putc 宏在无缓冲模式下又会进一步下探到 fputc——绕了一圈又把 fputc 拉进了调用链。这里面的逻辑很绕但它不是标准行为。正常推荐做法依然是直接把 _write 写明白不加这些复杂的前置条件。提示在嵌入式环境里不要依赖 stdout 默认是行缓冲或无缓冲的这种假设。用上 setvbuf 是另一个独立话题但如果你做串口调试时发现 printf 输出滞后或丢失先检查缓冲区设置再检查 _write。2.4 情况四自定义 _write 模板已经藏在了你的工程模板或 SDK 里现在很多开源工程模板比如 STM32CubeMX 生成的代码、RT-Thread 的 bsp、ESP-IDF 的某些示例其实已经默认把底层输出接口写好了只是函数名可能不叫 _write而是叫 HAL_UART_Transmit、console_write 之类的底层封装。如果你新建工程时参考了这些模板printf 已经能正常输出了然后你再顺手加上一个 fputc 的实现看起来就好像“加个 fputc 就能用了”其实真正起作用的早就不是它了。判断方法很简单在你的 fputc 函数里设一个断点或加一个辅助标志看程序是否真的会进入这个函数。绝大多数情况下你会发现这个断点根本不会被触发而字符串已经从串口出去了。这个实验我做过很多次足以说明问题。3. 实操在 CLion 嵌入式工程里正确重写 _write 的完整姿势聊完了原理和坑下面直接上一套在 CLion 里可以照抄的 _write 重定向方案。这套方案同时适用于 STM32 HAL 库、标准外设库、以及直接操作寄存器的方式核心思想是一致的。3.1 第一步先确认你的工具链和 C 运行库型号在动手之前先通过 CLion 的 Settings - Build, Execution, Deployment - Toolchains 看一下当前项目的工具链是不是 arm-none-eabi-gcc。然后在 CMakeLists.txt 里检查编译选项看是否有 -specsnano.specs 这类链接选项它们决定了你用 Newlib 还是 Newlib-nano。不管是用标准 Newlib 还是 nano 版本_write 的重定向方法都是一样的只是 nano 版本对浮点打印等功能的支持程度不同。推荐在调试初期直接用标准 Newlib不加 nano.specs等确认 printf 功能完全正常后再切换到 nano 版本节省 Flash 空间。3.2 第二步按下面的模板实现 _write在工程里新建一个文件比如 retarget.c内容如下。注意要包含必要的头文件并用 extern C 避免 C 编译时的名字改编问题。#include stddef.h #include stdint.h #include unistd.h // 假设使用 STM32 HAL 库引入你的 UART 句柄 #include main.h extern UART_HandleTypeDef huart1; // 自定义输出到串口 1 static void uart_send_bytes(const uint8_t *buf, size_t len) { for (size_t i 0; i len; i) { while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE) RESET) { // 等待 TX 数据寄存器为空 } __HAL_UART_SEND_REGS(huart1, buf[i]); } } // 重定向 _write int _write(int file, char *ptr, int len) { (void)file; // 这个参数用于区分 stdout/stderr 等可忽略 uart_send_bytes((const uint8_t *)ptr, len); return len; }如果你的工程使用的不是 HAL 库而是直接寄存器操作可以替换 uart_send_bytes 内部实现核心是“把 len 个字节逐个发送到 UART 数据寄存器”。上面用的 __HAL_UART_SEND_REGS 宏实际上是把字节写入发送数据寄存器 DR轮询 TXE 标志位保证前一字节已经移到移位寄存器。3.3 第三步确认链接器能正确找到你重写的 _write需要注意链接器的符号解析规则是链接命令行里先出现的目标文件中的符号优先满足未定义引用。如果你希望自己重写的 _write 生效必须确认在你的可执行文件链接过程中retarget.o 在 libc.a 的 _write 定义之前被链接进来。在 CMake 构建体系下只要你把 retarget.c 添加进 add_executable 目标它通常会被放在链接命令开头这满足符号覆盖要求。如果出现“multiple definition of _write”这样的错误说明工程里已经存在另一个人写的 _write这时你需要全局搜索出旧定义把它删掉或注释掉。千万别留着两个定义那是未定义行为。3.4 第四步验证是否真的走通了重定向路径编译烧录后在代码里调用 printfprintf(CLion printf redirect OK, %d\r\n, 100);然后用串口助手或者 CLion 的 Embedded Serial Monitor 插件查看输出。如果串口没反应先用调试器确认是否进入了 HardFault如果完全没有数据大概率是 _write 没有被链接进来检查符号覆盖的情况。如果数据输出异常乱码、缺字节先检查串口波特率是否与工程里串口初始化参数匹配。3.5 补充中文乱码的根源与解决很多人在 CLion 下输出中文串口数据会看到乱码这个锅通常不全是 _write 的问题而是编码问题。Keil 的编辑器默认可能用 GB2312 或 GBK 保存源文件CLion 默认用 UTF-8两边的编码不一致会导致字符串字面量在底层的字节序列不同。解决的方法是要么统一把源文件编码设为 UTF-8串口助手的解码方式也调成 UTF-8要么在代码里用转义序列输出中文字节避免直接写汉字字面量。工程实践中我更推荐前者因为后续代码维护、版本管理对 UTF-8 更友好。CLion 里可以通过 File - File Encoding 全局设置 UTF-8并建议把串口工具也调成 UTF-8 解码。4. 从串口到多路输出_write 重定向的进阶玩法与边界问题一旦理解了 _write 的本质是一个“把内存缓冲区写到某个设备”的底层原语你的思维就不应该再局限于串口了。很多实际项目里printf 可能需要同时往串口、SWO 调试口、RTT、甚至文件系统里输出日志这时候重写 _write 可以给你极大的灵活性。4.1 通过 file 参数区分 stdout / stderr_write 的原型里有一个 int file 参数这个参数在 Unix 系统中用来区分文件描述符0 是 stdin1 是 stdout2 是 stderr。嵌入式环境里Newlib 通常也会按 0/1/2 来定义标准流。你可以利用这个参数实现不同的输出路径int _write(int file, char *ptr, int len) { if (file 1) { // stdout - 串口 1 uart1_send(ptr, len); } else if (file 2) { // stderr - 串口 2 或者 SWO uart2_send(ptr, len); } return len; }实际工程中常见的用法是把调试信息输出到串口 1把错误信息输出到串口 2 或者用 LED 灯闪烁来表示。如果你用 fprintf(stderr, ...) 打印错误信息就可以在 _write 函数里把不同级别的日志送到不同目的地这在裸机调试阶段非常有价值。4.2 断点调试中断会拖垮 _write 输出——解决串口输出与调试器的冲突另一个常被忽略的边界问题当你用 CLion 的调试器在代码里下断点并且暂停运行时程序整体停住_write 里面的轮询等待。恢复运行时轮询会继续看起来就像 _write 被卡了很久。但实际上这不会造成问题因为串口数据是在硬件缓冲区里排队轮询恢复后依然能把字节发出去。要注意的是另一种情况断点停在 _write 内的发送循环中间恢复执行时如果串口外设的时钟被调试器复位或者外部设备断开发送可能永远等不到 TXE 置位导致程序卡死在 while 循环里。这种情况下要么在 _write 里加一个超时机制要么在调试时把断点设置避开 _write 内部。4.3 从 _write 出发扩展出多协议日志系统如果你有日志分级需求可以不用在 _write 里做文章而是在 _write 上层封装一个日志模块。不过 _write 提供了很干净的生命周期边界所有要输出的字节最终必然会经过这里。你可以在生产版本里为 _write 加一个开关直接把它空实现int _write(int file, char *ptr, int len) { // 生产版本日志关闭 return len; }这比在源码里到处加 #ifdef 要优雅得多。有输出需求时再把对应日志模块打开。这种写法也是很多产品代码里静默串口日志的通用做法。4.4 定时器中断、DMA 与 _write 的互斥问题最后必须提到一个非常实际的问题如果你在串口发送中用了 DMA且 DMA 中断和 _write 里的轮询发送同时存在就有可能出现寄存器竞争。最简单的方案是统一用一种发送方式。如果一定要混用建议在 _write 内部加临界区保护把发送期间可能被中断抢占的那一段代码关掉中断。int _write(int file, char *ptr, int len) { uint32_t primask; __disable_irq(); __get_PRIMASK(primask); // 简化写法实际使用 primask __get_PRIMASK(); // 发送代码... __enable_irq(); return len; }不过关中断会让系统实时性受影响如果串口发送的数据量大比如 1KB 的日志中断关闭时间会非常长这显然不合理。所以更推荐的做法是不要直接在中断服务函数里调用 printf而是把日志字符串先放入一个环形队列主循环里再调用 printf 输出。这样 _write 的执行时间很短不会与中断产生严重冲突。5. 常见问题排查重写了还是不行该往哪个方向查就算你完全按上面的代码来写有时候问题依旧会冒出来。下面整理一套排查检查点按顺序走一遍基本能定位出问题。5.1 检查点 1编译链接阶段有没有报错或警告先看 CMake 构建信息里有没有 “multiple definition of _write” 之类的报错。如果有说明工程里存在两个 _write删除一个。如果链接警告提示你使用了 semihosting 相关指令那说明 _write 重定向没有成功覆盖原实现。5.2 检查点 2UART 外设本身有没有初始化成功这是一个最容易忽略的基础问题。很多人把注意力全放在 _write 上却忘了确认串口引脚、时钟、波特率是否配置正确。最快捷的验证方法是在初始化完成后直接调用一次底层串口发送函数发送一个测试字节比如 HAL_UART_Transmit(huart1, A, 1, 1000)如果这个能在串口助手里看到 A说明 UART 外设没问题问题才可能出在重定向上。5.3 检查点 3printf 是否真的执行到了底层 _write用调试器在 _write 函数入口打断点运行程序触发 printf看断点是否命中。如果断点命中但串口没输出说明 _write 内部实现有毛病如果断点根本不命中无论如何都是重定向失败或 printf 本身没被触发。还有一种可能是编译器把 printf 优化成了 puts当格式化字符串不包含格式符时此时你该在 puts 底层链路上找问题而不是继续盯着 printf。5.4 检查点 4缓冲区问题导致的数据丢失如果串口输出时断时续、内容不完整、或者必须等待一段时间才能打印出来很大概率是由于 stdout 流缓存没有被刷新。Newlib 下stdout 默认如果是全缓冲数据会攒一批再输出当你程序一直在跑、但没有触发 flush 机制时printf 的内容可能迟迟不出现。解决方法是setvbuf(stdout, NULL, _IONBF, 0);放在 main 函数初始化阶段执行。这一行把 stdout 设为无缓冲这样每次 printf 都会直接落到 _write输出即时可见。但代价是性能下降因为原本可以积攒合并的多次输出变成了逐次系统调用。在嵌入式调试中性能通常不是瓶颈所以推荐直接加。5.5 检查点 5使用了 RTOS 时的线程安全问题如果你的工程引入了 FreeRTOS 或 RT-Thread需要额外考虑并发问题。多个任务同时调用 printf_write 内部的串口轮询发送不是一个原子操作两个任务交替执行会导致字节交错、输出混乱。最简单的方式是在 _write 周围加一个调度锁比如 FreeRTOS 的 taskENTER_CRITICAL 与 taskEXIT_CRITICAL或者使用互斥量。但要注意互斥量本身不能在中断中获取所以这种方案只适用于任务级调用。若是中断里也打印日志建议用一个独立低优先级任务专门消费日志队列。6. 回到根本为什么很多人总是陷入“重写 fputc”的惯性思维里写到最后我想聊聊方法论层面的一点体会。很多人在学习嵌入式重定向时都有一种惯性就是死盯着 fputc 不放。原因是网上绝大多数教程是以 Keil MDK 为背景写的Keil 的 C 库设计让 fputc 成为了标准输出键入口。大家抄了 Keil 时代的代码放在 CLion GNU 工具链的工程里运行效果不对又开始怀疑编译器、怀疑硬件。实际上C 标准库是一个抽象的中间层printf 调用流内部最终落到哪个函数是由具体的 C 运行库实现决定的。Keil 的 ARMCC 把底层的字符输出钩子放在 fputc 上而 GNU 工具链下的 Newlib 则把它放在更符合 POSIX 风格的 _write 上。这就像两套 API 文档给出的入口不同你用 A 文档的接口去调 B 文档的库不出问题才奇怪。所以面对 CLion 这类以 GNU 工具链为底座的集成开发环境正确的学习路径应该是先确认自己的工具链、链接脚本、C 运行库型号理解 printf 的调用路径找到对应的底层原语函数重写底层原语函数而不是纠结于某个固定名称的函数实践验证再深入调试。如果你是从 Keil 转战到 CLion建议把脑子里的“fputc 入口”这个观念主动替换成“_write 入口”这会省掉后面很多的排查精力。如果从一开始学嵌入式就用的 CLion那直接以 _write 为正确入口就好没有包袱反而更容易上手。最后再分享一点个人习惯我在 _write 里固定加一条超时保护逻辑避免 UART 硬件异常导致整个程序死锁。虽然它看起来略繁琐但在产品化的代码里有非常高的性价比。具体做法是用 DWT 计数器实现微秒级超时循环超时后强制跳出发送循环可以避免因为串口外设故障导致系统卡死在等待标志位的死循环里。这个小改动在实验室没感觉在现场调试时能救命。int _write(int file, char *ptr, int len) { for (int i 0; i len; i) { uint32_t timeout 100000; // 简单自我调整按 CPU 主频折算成时间 while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE) RESET) { if (--timeout 0) { return i; // 已发送的字节数 } } __HAL_UART_SEND_REGS(huart1, ptr[i]); } return len; }重定向 printf 这件事本身不难但背后的机制恰恰串起了编译原理、C 标准库实现、嵌入式系统调用等好几个知识面。把 _write 的来龙去脉搞明白了以后不管是换芯片、换工具链、还是加各类输出后端你都不会再被某个特定函数名限制住思路。这就是我在 CLion 里反复折腾串口重定向之后收获最大的一点东西。