CLion嵌入式printf重定向串口:为什么用_write而不是fputc

发布时间:2026/9/8 18:19:47
CLion嵌入式printf重定向串口:为什么用_write而不是fputc 最近群里又有人问 CLion 里重定向 printf 到串口的事现象很典型代码照着老教程写了fputc串口就是没反应换成_write立刻能出字了。这问题其实不只在 CLion 里出现但用 CLion 搭嵌入式工程的用户比 Keil 用户更容易踩中因为很多人是从 Windows 桌面开发或者老的 ARMCC 工程转过来的。如果你也在纠结“为什么是_write而不是fputc”这篇文章就按我实际排查的思路把底层逻辑和落地步骤一起讲清楚。先说结论fputc和_write不在同一个层级printf 也没义务经过fputc。大家习惯性写fputc多半是被 Keil、IAR 或者某些教学模板带偏了。在 CLion 默认那套 arm-none-eabi-gcc newlib 工具链下printf 最终要触达的底层钩子是_write不是fputc。下面我会从最底层调用链开始解释为什么再给出可以直接抄的示例代码顺便把几个最常见的坑也整理出来。1. 先把问题拆开看一条 printf 到底是怎么跑到串口 TX 引脚的1.1 printf 并不是一个函数而是一条“流水线”很多新人会把printf当成一个立刻把字符送出去的函数。实际上在 C 运行时里printf 是格式化入口它后面还挂了一串东西格式化生成字符、按照缓冲策略暂存字符、刷新缓冲、真正调用设备驱动。越靠后的环节越靠近硬件。如果你在 PC 上跑一个 Linux 程序内核视角看到的是程序调用write(1, buf, len)这样的系统调用把进程里的数据写到标准输出文件描述符。C 库在里面做的是类型转换、对齐、缓冲区管理。嵌入式裸机环境下没有操作系统也没有系统调用于是编译器工具链要求你提供一个底层函数来代替系统调用。这个函数的符号约定是什么完全取决于工具链的 C 库实现。有一个很多人都忽略的事实printf是一个“格式串解释器”它把%d、%s、%f解释成文本但最终这些文本怎么落到设备上格式串解释器并不关心。用什么函数做最终落点是 libc 实现决定的。绝大多数学术代码和教学书会写“printf 的实现最后会调用 putchar / fputc”这只是为了讲清楚流程不是标准强制要求。所以如果你问“为什么重写fputc不管用”本质上是因为你的工具链没有把fputc放在 printf 的执行链路上。它可能只是 stdio 层里一个公开的流操作函数没有人规定 printf 必须调用它。真正管用的是当前 C 库指定的底层 write 回调。1.2 重定向 printf 的本质是“替换设备驱动层”嵌入式里大家常说的“重定向 printf”实际做的是把运行库认为的“标准输出设备”——比如显示器、终端、调试器半主机通道——改成自己的 UART、USB CDC、SPI LCD 或者日志文件。因为是改设备驱动所以你要锁定的函数应该是最贴近设备的那一层。设备驱动层的函数往往不是fputc而是_write。_write在原生的类 Unix 体系里对应的就是 syscall write它接收一个文件描述符、一段连续内存的起始地址和长度。实现它的代码逻辑很简单把这段内存里的数据交给某个外设发送出去然后返回实际发送的字节数。fputc 则不同它面向的是“一个 FILE 流”。如果你操作的是一个真实文件调用 fputc 经过标准库的缓冲机制没问题。但要让裸机上的 printf 把每一路输出都流进 UART绕一大圈经过 fputc 反而更容易出问题。真正短平快的路径是在底层把_write接好。这也是为什么绝大多数 STM32CubeIDE、CLion 嵌入式工程和 RISC-V GCC 工程都采用_write而不是fputc作为重定向入口。2. 为什么在 CLion 的嵌入式工程里_write 才是真正的钩子2.1 newlib 的调用链从 printf 到 _writeCLion 做 STM32 或其他 MCU 开发时CMake 工具链通常指向arm-none-eabi-gcc。这个 GCC 默认的 C 库是 newlib 或 newlib-nano。newlib 是为嵌入式系统设计的 C 库它的 printf 实现也有底层系统调用约定。从原理上讲printf 进入 newlib 后处理流程大致是printf - vfprintf / vfiprintf - 格式化并写入 stdout 对应的缓冲 - 需要刷新时走到刷新函数 - 最终进入底层输出回调 _write当然newlib 不同版本内部函数名、宏展开方式有差异比如可能经过内部结构体的函数指针最终对外可见的弱符号大多统一为_write。你不需要把每个中间函数名都背下来只需要知道这条链路的终点是_write。你提供_write函数定义就相当于在流水线最末端接了一台 UART 发送机。所以 GCC 的嵌入式工程师经常写这种代码int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }这个例子没有处理错误码也没有判断文件描述符但对大多数串口日志场景已经够了。它意味着以后 printf 里所有格式化好的输出最终都会按 ptr 指向的连续内存一次性交给 UART 发送。这也是_write比逐个字符回调高效的主要原因它一次给你一整段数据你可以整包发送而不是每个字符调一次外设。2.2 fputc 走的是另一条“流”路径fputc的签名是int fputc(int ch, FILE *f)它写的不是裸设备而是一个已经打开的FILE。在标准 C 里这个函数确实可以被用户重新实现但前提是你的 C 库在实现 printf 的时候真的绕到fputc上。在我接触过的工具链里Keil MDK 的 ARMCC/AC5 时代很多教程就是让用户重写 fputc那是因为 ARMCC 的标准库确实走了 fputc。IAR 也类似很多老嵌入式工程师在 IAR 里就是这么干的。CLion 里用 GCC newlib情况就变了。如果你一边链接 GCC 的 newlib一边只重写 fputc链接器是完全不会理会你的因为 printf 的执行路径上根本没有 fputc 的符号引用。在 newlib 里fputc 更多是一个公共 API你自己调 fputc 才会触发你的实现。printf 不走它。你可以把两者简单类比成fputc 是“给某个流文件写入一个字节”的公共接口_write 是“把一段缓冲一次性送给操作系统/裸机底层”的最终出口。printf 不会去调用前者它只会把成果打包好交接给后者。2.3 什么时候写 fputc 能生效为什么容易误导人我见过不少人在 CSDN 和公众号文章里写 fputc 重定向 printf而且还管用。那是因为作者的环境是 Keil、IAR或者某些把标准输出重定向到 fputc 的老式 GCC 工程模板。还有人用的是 STM32CubeMX 早期生成的模板里面要求你重写__io_putchar那玩意事实上也被很多库当成 fputc 的底层回调。但请注意环境变了链条就变了。CLion 连接嵌入式项目时底层编译和链接由 CMake 驱动工具链往往就是 arm-none-eabi-gccC 库是 newlib。这个组合不会默认在你的工程里安排一条 fputc 回调链。你要是不信可以在链接后查看 map 文件搜一下 fputc多半只能看到标准库的实现你自己的版本压根没被引用进去但你搜_write链接器报 undefined reference 或让你确认符号来源。这就是最直接的证据。我还遇到过一种情况工程里同时定义了__io_putchar和_write但编译的时候_write写错了返回逻辑导致输出全没。很多人以为是 fputc 的问题最后发现是 _write 返回了错误字节数。所以讨论“为什么写 fputc 没用”之前先确认自己的 target 到底是谁。3. 一步一步在 CLion STM32 工程里实现 _write 重定向3.1 默认用 GCC 工具链创建工程时先确认三件事在动手之前我一般会先看一眼 CMakeLists.txt 和链接选项避免在错误的工具链假设下写代码。第一确认编译器是不是 arm-none-eabi-gcc。CLion 本身支持很多工具链如果你新建的是 Embedded Development 工程可能默认就会用 arm-none-eabi-gcc如果你只是把 STM32CubeMX 生成的 Makefile 工程导进来也可能是 arm-none-eabi-gcc。第二确认链接脚本里包含标准库比如用了-specsnano.specs -specsnosys.specs还是纯粹-nostdlib。如果完全不用标准库printf 都进不来那就没有_write什么事了。第三确认 UART 初始化代码已经被调用并且句柄名要和你代码里的huart1对应上这里的低级错误特别多。如果你用的是 STM32CubeMX 自动生成的工程UART 句柄一般是huart1、huart2这样全局变量。写_write时可以直接引用。如果你改了名字注意同步。3.2 最小可用 _write 代码可直接复制进项目很多出厂例程会要求你新建一个文件比如retarget.c然后在里面实现底层函数。下面这段是我的常用模板适合 STM32 HAL代码量不大但比网上那种只发一个字节的版本更实用#include errno.h #include sys/unistd.h #include main.h #include usart.h int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } errno ENOSYS; return -1; }HAL_UART_Transmit的第二个参数类型是uint8_t *如果你收到的是char *最好显式强转。第三个参数长度类型在 HAL 里通常是uint16_t而_write的 len 是 int理论上 len 更大的时候会有截断问题。在对日志场景来说一般不会触发但如果以后你要用 DMA 发大包建议拆包处理。如果你希望程序启动后立即让 printf 不经过缓冲、实时输出可以在 main 函数里加一行setvbuf(stdout, NULL, _IONBF, 0);这一行告诉运行库标准输出不要缓冲有字符立刻交给底层_write。嵌入式裸机上没有 exit 帮你刷新缓冲区所以这个操作能避免“printf 看起来没执行”的错觉。3.3 如果你的工程已经有一份 syscalls.c注意“不要重复定义”STM32CubeMX 在 GCC 工程里经常生成一个syscalls.c里面已经实现了一批 newlib 所需的系统调用桩函数。有些版本的 syscalls.c 里是有_write弱实现的有些则没有。CLion 导入这类工程时如果你自己在别处又写了一个_write链接阶段就可能报 multiple definition。遇到这种报错别着急硬改链接参数先打开 syscalls.c 看一眼。如果里面的_write是空的或者返回错误建议直接注释掉系统文件的实现保留你自己的重定向版本。工程内自己维护的重定向文件应该比自动生成的桩函数优先级更高但这需要你在编译时不要重复包含两个同名函数。常见的困惑是“既然 syscalls.c 已经写了一个 _write为什么我的 printf 还是不出来” 那是因为 syscalls.c 里很多函数只是返回-1的占位实现根本没有连接实际硬件。你的目标不是让程序能链接过而是让_write真正把数据交给串口。4. 实际操作中常遇到的问题和排查方法4.1 串口没输出但程序明明在跑这是出现频率最高的问题。首先确定你的_write是否真的被链接进去了。最笨也最有效的方法是在_write函数里打一个全局标志位或者在函数开头设置某个全局变量为 1然后在 main 里通过调试器观察。如果函数压根没被调用那就不是“没输出”而是 printf 根本没调它。接着检查输出缓冲。裸机环境下printf 可能把内容暂存在 stdio 的缓冲区里没有 flush 你当然看不到。在 main 最前面加一行setvbuf(stdout, NULL, _IONBF, 0);基本能排除这个因素。最后再检查 UART 的波特率、引脚复用、时钟树。一个非常反直觉的坑是UART 发送完一个字符串后你没有等待发送完成就进入低功耗模式导致最后一个字节还没完全送出就断了。HAL_UART_Transmit 是阻塞发送一般发完才返回这个问题相对少见但用中断DMA的时候特别容易犯。4.2 用 OpenOCD 调试时程序跑到 printf 后卡住甚至 HardFault如果你没有实现_write而链接参数里又选了支持半主机的规格printf 最终会走到半主机调试通道。接上调试器可能出现“程序等待调试器响应”的现象如果不接调试器可能直接 HardFault 或者 CPU 卡在某个 SVC 指令上。这也是为什么很多人强调嵌入式工程里要么避免半主机要么完整实现自己的系统调用。使用 CLion 的 OpenOCD 配置调试时如果遇到这种卡死建议先检查链接选项或者干脆在你的工程里无条件实现_write把输出直接导到串口绕开半主机。CLion 的 Run/Debug 里输出窗口并不是串口终端你要么用 OpenOCD 的 telnet、要么用外部串口工具观察。如果在 IDE 窗口里盯着看可能会觉得“没有输出”其实数据早就从 TX 引脚发走了。还有一点如果你调用printf打印浮点数例如%f而你的工具链没有启用_printf_float或者你没有链接 newlib-nano 的浮点支持格式化阶段可能直接失败或输出空。这个和_write没关系是另一个常见坑。需要启用浮点打印时在链接选项里加-u _printf_float但也要权衡固件体积。4.3 编译报错 trouble with multiple definition of _write报错原因我在 3.3 提过最常见的就是 syscalls.c 里面有自己也写了一份。另一个可能是你在一个 C 文件里定义_write在另一个文件里用宏又映射成了其他函数导致链接器看到重复符号。用nm或者查看 map 文件能快速定位。我用 CLion 的嵌入式插件时还遇到过一种情况项目里保留了 CubeMX 生成的syscalls.c但它的_write调用的是HAL_UART_Transmit而 HAL UART 句柄还没初始化所以一进 printf 就卡死在等待发送完成标志位。这种问题的排查方式就是检查调用顺序先MX_USART1_UART_Init()再printf。千万别在初始化之前打 printf否则即使_write写得好好的也会因为外设没准备好而卡住。5. 为什么说 fputc 方案更适合“流文件”不适合裸机写设备5.1 从设计理念上理解 fputc 的适用场景fputc 的参数是 FILE 指针这意味着它天然面向“已经打开的文件流”。在 PC 上stdin、stdout、stderr 都是 FILE 指针fputc 可以写进这些流里。你在 PC 上重新实现 fputc相当于把自己的实现挂到了标准流对象上这当然能改变 stdout 的输出走向。但在没有完整文件系统的 MCU 上stdout 这个 FILE 对象往往很“虚”。newlib 在裸机上初始化 stdout 对象时并没有像操作系统那样准备一套完整的底层驱动指针。printf 内部在向 stdout 对象写缓冲的时候最终刷新动作要被送到另一个函数去完成这个函数必须知道如何把一段内存写到当前设备。newlib 规定的就是_write。所以 fputc 只是在文件流系统工作正常时一个更上层的“公共字符出口”。如果你的目标设备不是文件系统而是 UART、SPI 屏这类字符设备直接在上层 fputc 层面做文章等于把驱动逻辑硬塞进 stdio 层一旦库内部不调用它就会失效。5.2 _write 的块设备式参数更适合串口批量发送_write的第三个参数是长度这意味着运行库可以把一整行、一整段格式化后的字符串一次性交给你。你可以直接调用HAL_UART_Transmit整包发送也可以丢给 DMA发完再返回。fputc 一次只有一个字节如果某种 libc 让 printf 逐字符走到 fputc你每一次格式化输出都要等待 UART 发送单个字节。波特率 115200 时单个字节耗时约 87us满屏日志时会拖慢执行速度。用_write批量发送能显著减少函数调用次数和等待时间这也是它更适合作底层重定向入口的重要原因之一。在需要实现环形缓冲和异步日志时_write也很适合作为生产者的入口因为入参天然是“一整块 buffer”。你可以在_write里把指针和长度交给 DMA 或者环形队列然后立刻返回不用把每个字符往里塞。fputc 在这方面很别扭需要自己维护一个状态机代码也更容易出错。5.3 如果你确实需要用 fputc也有办法让它“间接生效”不管是为了兼容老代码还是因为某些中间层库必须调用 fputc最终你还是可以实现一个 fputc然后把它接到 UARTint fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }但这个代码只有在你的 C 库确实从标准 I/O 路径调用它时才有意义。在 GCC newlib 的裸机工程里如果你只写这段代码printf 依然不会理它。你可以额外通过putchar或write等方式调用它但想直接让 printf 输出重定向到串口最省事、最通用的还是_write。我个人在 CLion 里做 STM32 工程的习惯是默认只维护_write把 UART 发送逻辑都放进这块串口日志从 main 到中断处理都走它。只有需要兼容某个第三方库内部的打印接口时才顺手加一个 fputc 或者 putchar 的转发。这样责任清晰排查问题也容易。6. 我踩过几次坑之后给你留下几个排查建议先用一句话回答标题的问题CLion 本身并不强制你重写_write而是你用的 GCC newlib 工具链把 printf 的最终输出口定义成了_write。fputc是面向文件流的字符函数它和裸机 printf 的调用链在大多数现代 GCC 嵌入式工程里根本不沾边。最后分享一个我自己的排查顺序如果你下次遇到 printf 无输出的问题可以按这个顺序走一遍。第一确认编译链接有没有报undefined reference to _write如果报了说明你连底裤都没穿printf 的底层没着落。第二确认_write内部有没有把数据真的发到外设可以使用固定字符串测试而不是依赖 printf 格式化结果。第三确认程序是否有缓冲把setvbuf(stdout, NULL, _IONBF, 0);放到 main 开头。第四确认 MCU 的 UART 引脚、时钟和波特率没有问题最好先直接用HAL_UART_Transmit发一段固定字符验证硬件。第五再回去检查 fputc 和其他库函数的符号覆盖不要在系统生成的 syscalls.c 里重复定义。这套流程能解决九成以上的串口 printf 问题。