资讯动态

STM32 printf重定向:CLion下为什么必须写_write而不是fputc?

发布时间:2026/9/7 10:58:37 来源:尧图企业网站定制
1. 从“printf 不输出”说起1.1 一个典型的 STM32 重定向翻车现场我接到过不少类似的移植需求项目原本在 Keil MDK 里跑得好好的调试信息一直靠fputc重定向到串口输出。某天团队决定统一开发环境把工程迁移到 CLion配合 STM32CubeMX 生成的 CMake 工程用 OpenOCD 下载调试。代码基本没改编译也过了唯独调试第一句printf(hello\r\n)的时候串口助手上一片死寂。这个时候绝大多数人的第一反应是查 UART 初始化、查波特率、查引脚复用甚至怀疑是不是 HAL 库版本变了。但真正的问题往往不在这。等你把网上的示例代码翻了一圈会发现很多 STM32CubeIDE、CLion 用户给出的重定向函数不是fputc而是一个看起来不太“标准”的函数int _write(int file, char *ptr, int len)于是就有了文章的标题在 CLion 这种 GNUGCC 工具链环境里为什么要重写_write而不是我们用了很多年的fputc1.2 一句话先回答标题里的问题因为 CLion 嵌入式工程最常用的工具链是arm-none-eabi-gcc它自带的 C 运行库是 Newlib 或 Newlib-nano。Newlib 把printf的最终输出点设计在底层系统调用_write上而不是 C 标准库的fputc。fputc是文件流层的单字符接口_write是运行库向底层硬件输出字节的总出口。你写的printf在 Newlib 内部根本不会去找外部的fputc符号所以单独重写fputc对printf来说等于没写。这个结论听起来有点反直觉尤其是习惯了 Keil MicroLIB 的人。但只要你理解了标准库的分层逻辑和工具链的调用路径这个问题就不会再困扰你了。2. 先分清两个函数fputc和_write真的不是一回事2.1fputc文件流层的“单字符搬运工”fputc是标准 C 库函数原型是int fputc(int ch, FILE *stream);它把单个字符ch写入一个FILE流对象中。FILE在标准库内部往往带了缓冲区、文件位置标志、错误标志等状态。你可以把FILE理解成一条“传送带”而fputc就是这条传送带上一次搬一个零件的工人。问题在于这条传送带不是最终目的地。对于嵌入式裸机程序来说字符最终要去串口 TX 引脚、SWO 调试引脚或者某个自定义的外设缓冲区。标准库的角色是把字符从printf格式化系统搬到一个“出口”这个出口通常就是系统调用层的某个函数。在很多桌面系统里这个出口是write()系统调用最终由操作系统驱动设备。在裸机嵌入式环境里没有操作系统所以这个出口需要你自己实现否则标准库不知道该把数据送到哪里。2.2_write标准库和硬件之间的“系统调用闸口”_write不是标准 C 库函数而是 POSIX 系统调用层在嵌入式 C 库里的投影。它的常见签名是int _write(int file, char *ptr, int len);这里file是文件描述符ptr是指向输出数据的指针len是要输出的字节数。标准库内部会通过文件描述符来判断你是往哪个流里写比如说1通常对应标准输出 stdout2通常对应标准错误 stderr。_write的职责就是把这len个字节交给实际的硬件设备然后返回实际写入的字节数。你可以把_write理解成水厂的“总阀门”。你修改fputc像是挨家挨户换水龙头而printf的数据流压根不经过你家那根水管你修改_write才是直接改总阀门所有流过来的水都能被你接管。2.3 不同工具链到底调谁不同嵌入式工具链选用的 C 运行库完全不同这直接决定了你该重写哪个函数工具链常用运行库printf 底层输出接口常见重定向点Keil MDK ARMCCMicroLIB简化 stdio最终落到fputcfputcIAR EWARMIAR DLIB与配置有关可能走__write__write或fputcARM Compiler 6 ARMCLANG标准 ARM C 库情况较复杂有些配置也走fputc取决于库配置arm-none-eabi-gccNewlib / Newlib-nano内部通过_write做底层输出_writeCLion OpenOCD 常用组合Newlib-nano同上_write看到这张表你应该就明白为什么网上资料会打架了。在 Keil 论坛里搜“printf 重定向”十有八九是让改fputc在 STM32CubeIDE 或 CLion 的讨论区里搜答案基本都会指向_write。不是谁对谁错而是工具链不同。3. 为什么 CLion 里的printf不认fputc3.1 CLion 嵌入式工程的实际工具链组合CLion 本身不是一个嵌入式编译器而是一个 IDE 壳。它通过 CMake 调用外部工具链。绝大多数 STM32 的 CLion 工程长这样STM32CubeMX 生成 CMake 模板或 MakefileCMake 里通过toolchain.cmake指定编译器路径编译器是arm-none-eabi-gcc链接时带上-specsnano.specs、-specsnosys.specs用 OpenOCD GDB 完成下载和调试这里的核心变量是arm-none-eabi-gcc。它默认配套的 C 库是 Newlib。Newlib 的 stdio 实现不是直接调用fputc的而是在格式化完成之后把整块缓冲区通过流对象内部的写函数最终交给系统调用层。所以CLion 工程里那个“应该被重写”的函数不由你的个人习惯决定而是由 Newlib 的源码设计决定。3.2 Newlib 的隐藏缓冲层和输出路径Newlib 的printf调用链简化来说是这样的printf - vfprintf - 把格式化字符写入 stdout 的 FILE 缓冲区 - 缓冲区满或 flush 时调用底层写函数 - _write在 Newlib 的实现里stdout本身就是一个带缓冲区的FILE流。格式化出来的字符会先进入这块缓冲区而不是一个字符一个字符地往硬件丢。等到缓冲区满了或者有人调用fflush(stdout)或者输出里包含了换行符且流被设置为行缓冲时缓冲区里的数据才会整体进入_write。那fputc在哪里如果在你的程序里主动调用fputc它也会最终走到_write。但问题在于printf内部并不会去调用外部重定义的这个fputc符号它使用的是 Newlib 内部专门为流写操作准备的一套机制。所以你在 Keil 里通过替换fputc来劫持字符输出的方式在 Newlib 这个体系里完全对不上。我经常打一个比方fputc是某个小区单元门禁的访客登记本printf是小区外的城市主干道。你改了单元门禁的登记规则主干道上的车并不会因此变道它们只会开到真正的主干道出口去。3.3 半主机不动_write的另一颗雷如果你在 CLion 工程里不写_write会发生什么一种可能是printf输出完全没反应。另一种可能更吓人程序一旦执行到printf就卡死或者直接跳进 HardFault。这两种情况背后往往是同一个原因半主机。半主机是 ARM 调试系统提供的一种机制允许开发板上的代码通过调试通道使用开发机上的资源。Newlib 在没有经过重定向裁剪时_write的默认实现可能指向半主机调用。也就是说你的printf数据被丢给了调试器而不是 UART。如果此时调试器没有连接或者 OpenOCD 没有配置相关半主机通道程序执行到半主机指令时可能直接触发异常。在旧的 STM32 开发经历里很多人会在工程里手动实现一整套 syscall 函数比如_sbrk、_read、_write目的就是打断半主机的默认路径。现在更方便的方式是在链接时使用nosys.specs它会提供一个不做实际事情的空 syscall 库。但如果你需要真实输出仍然要提供自己的_write。3.4 只改fputc的“幸存者偏差”你可能会说我在 CLion 工程里也试过只改fputc好像有时候也有输出这种情况确实存在但它很可能不是你以为的路径。比如你的printf其实被某些代码库包装过或者你用的是其他 C 库配置又或者你看到的输出其实是从调试器的 semihosting 控制台里出来的而不是串口。还有一种情况是你在中断服务函数里直接调用了putchar而某些移植层把putchar映射到了fputc这也会让人误以为重定向fputc对printf有效。换句话说只改fputc在 Newlib 环境下不是“绝对无效”而是“作用范围极窄”。你无法保证printf、fprintf、puts这些常用函数都会走你写的那个fputc。这种不确定性在工程里是很危险的所以正确做法是直接堵住总出口。4. 实操在 CLion STM32CubeMX 工程中重定向 printf4.1 最小可用的_write实现假设你已经用 CubeMX 生成好了工程UART1 初始化正常。新建一个retarget.c代码如下#include stdio.h #include unistd.h #include main.h #if defined(__GNUC__) !defined(__clang__) 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; } return -1; } #endif这段代码做了几件事判断file是不是标准的 stdout 或 stderr。如果是就把ptr指向的len个字节通过 HAL 库串口发送函数发出去。发送完成返回len告诉上层“我成功处理了所有字节”。return len这一步千万不要丢。如果返回 0 或者小于len运行库会认为写输出失败可能出现重复写、截断、错误状态等奇怪问题。如果你的工程用了 C 源文件记得加extern Cextern C int _write(int file, char *ptr, int len) { // ... }否则 C 编译器会对函数名做 name mangling链接时找不到符号。4.2 链接器选项与 CMake 配置光写代码还不够CMake 链接选项也要配合。典型配置是在CMakeLists.txt里给目标加上target_link_options(${PROJECT_NAME} PRIVATE --specsnano.specs --specsnosys.specs )如果你的printf里还要用浮点数格式比如%f而库用的是 Newlib-nano那还要加上target_link_options(${PROJECT_NAME} PRIVATE -u _printf_float )这里简单解释一下nano.specs告诉链接器使用体积更小的 Newlib-nano它是给嵌入式系统裁剪过的 C 库。nosys.specs告诉链接器使用一个不依赖半主机的空 syscall 实现可以把“程序跑进 semihosting 然后卡死”的问题挡在门外。-u _printf_float的作用是从库里强制拉入浮点格式化代码。Newlib-nano 默认不启用浮点打印用%f时会输出空白或者错误结果。如果你的工程是从 CubeMX 生成的 CMake 工程通常 CubeMX 生成的链接命令里已经带了--specsnano.specs但你仍要确认有没有nosys.specs以及你的_write文件有没有被编译进目标。4.3 换成 ITM/SWO 输出只改_write内部即可_write的好处是输出目标完全由你自己决定。同样是这段printf如果你不想走 UART想通过 SWD 调试接口的 ITM/SWO 输出只需要改函数内部#include core_cm4.h // 具体头文件按芯片系列 int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { for (int i 0; i len; i) { ITM_SendChar(ptr[i]); } return len; } return -1; }这就是为什么我建议统一用_write而不是fputc——你只需要维护一个出口所有标准输出函数就都被接管了。以后想从串口换成 SWO只要改这一个函数业务代码一行都不用动。4.4 从 Keil/MicroLIB 迁移代码时怎么平滑过渡如果你维护的代码同时要跑在 Keil 和 CLion 两个环境里可以用编译器宏做条件编译避免同一个文件两边打架#if defined(__ARMCC_VERSION) !defined(__GNUC__) int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; } #else 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; } return -1; } #endif这样在 Keil 下用 ARMCC 编译时走fputc在 CLion 下用 GCC/Newlib 编译时走_write两边都是正统路径不会互相干扰。这段代码也是我实际项目里常用的一种写法。5. 常见问题与排查清单5.1 printf 没有任何输出的排查路径如果你的printf还是没输出按下面的顺序检查首先确认_write是否真的被链接进最终程序。最简单的方法是给_write加断点或者在里面翻转一个 GPIO甚至塞一个for循环改变某个全局变量。如果断点没触发、GPIO 没反应说明你的_write没有被链接进去或者这个目标文件根本没被编译。其次检查stdout缓冲。Newlib 的 stdout 带缓冲如果程序在缓冲区满之前就终止了数据可能压根没机会进入_write。可以在main开头执行setvbuf(stdout, NULL, _IONBF, 0);这会关闭 stdout 缓冲让每个字符尽快进入_write。调试状态下这是最省心的做法。正式工程里如果性能要求高再改回缓冲模式。然后检查串口初始化。确认huart1这个变量名和你工程里一致确认 UART GPIO 复用配置正确确认串口助手的波特率和代码一致。这些问题虽然基础但发生频率非常高。最后检查接收端。ST-Link 的虚拟串口是否枚举成功、串口助手是否选中了正确的 COM 口、地线是否连接好。很多“程序没问题”的案例最后败在这种物理环节上。5.2 程序一执行 printf 就 HardFault这个症状十有八九和半主机有关。如果你在链接时没有使用nosys.specs也没有提供自己的_writeNewlib 的默认实现会尝试通过半主机通道输出。没有调试器配合时这会触发异常。处理办法很简单在CMakeLists.txt里加上--specsnosys.specs或者写一个真实可用的_write。两者可以同时做实际项目里也是这么做的。还有一个容易被忽略的点如果你自己写的_write里有问题比如HAL_UART_Transmit传入了一个未初始化的huart1指针或者 UART 外设时钟没开也可能触发异常或死等。排查时不要把所有锅都甩给半主机。5.3 输出出现中文乱码 / write failure 提示中文乱码的原因通常不在_write而在编码。_write是字节流接口它不知道什么 UTF-8、GBK。如果你在源码里写了一个中文字符串源码文件保存成 UTF-8编译器会把中文字符按 UTF-8 编码成多个字节。串口助手如果按 GBK 解码看到的自然是一堆乱码。解决办法是把串口助手编码切到 UTF-8或者干脆在嵌入式日志里优先用英文和 ASCII 字符。有些热词里提到的fatal: write failure on stdout: bad file descriptor在嵌入式重定位场景下其实就是在提示你_write收到了一个你没有处理过的文件描述符。比如你往stderr写入数据但_write里只判断了file 1没处理file 2于是返回了-1。虽然这种提示在很多桌面日志里才常见但嵌入式侧如果接了文件系统也会遇到类似问题。建议在_write里把STDOUT_FILENO和STDERR_FILENO都纳入判断避免误伤。5.4 速查表症状、原因、处理症状常见原因处理方式printf 没输出_write未实现或未链接实现_write确认目标文件加入编译printf 没输出stdout 缓冲未刷用\nfflush或setvbuf(_IONBF)跑到 printf 就卡死半主机调用缺少调试器链接选项加--specsnosys.specs浮点格式化输出空白Newlib-nano 裁剪了浮点 printf加-u _printf_float中文显示乱码源码编码与终端解码不一致统一 UTF-8或日志用 ASCII重写_write后报重复符号工程里已有 syscalls.c 或类似文件删除你没在用的那一个C 工程链接不到_write缺少extern C给_write加 extern C 声明6. 一点经验谈重定向 printf 不只是改函数6.1 选_write还是fputc先看工具链以后遇到“printf 重定向”这类需求我的建议是先问一句你手上到底是用哪套编译工具链如果是 Keil ARMCC MicroLIB那fputc就是正统路径网上那些经典例程可以直接用。如果是 CLion arm-none-eabi-gcc Newlib那_write才是你要接的地方。这个判断 10 秒钟就能做完但能省下你一整天的排查时间。我自己踩过最深的坑就是跨工程复制代码时把 fputc 从一个 Keil 工程原封不动搬进 CMake 工程然后反复在串口配置和时钟树上做无用功。后来静下心去看编译器标准库的源码路径才意识到问题根本不在硬件层而在运行库的接口层。6.2 重定向之外建议顺手做好日志缓冲和并发控制当你成功用_write打通 printf 之后建议再做两件事。第一把_write里阻塞发送改成非阻塞或加保护。HAL_UART_Transmit配合HAL_MAX_DELAY在调试阶段很好用但如果你在定时器中断或者高优先级任务里调用 printf长时间阻塞可能影响系统实时性。更好的做法是维护一个环形缓冲区_write只把数据塞进缓冲区后台用 DMA 或者中断慢慢发送。这个改动在日志量大的项目里收益非常明显。第二给日志系统留一个统一开关。比如在_write里加一个全局变量判断日志是否使能或者封装一个日志模块让你的业务代码不要直接散落printf。这样以后你要关闭日志、切换输出通道、增加时间戳都能集中处理。我个人在实际项目里体会最深的一点是重定向函数虽然只有几行但它决定了你整个调试链路是否稳定。先花 20 分钟搞清楚运行库的出口在哪比在串口助手里换十种波特率更有价值。

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

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

免费获取报价