说个挺有意思的经历。前阵子在 CLion 里做一个 STM32 项目想把 printf 重定向到串口照着网上一堆教程写了fputc编译下载一气呵成结果串口助手什么都没有。折腾了整整一下午最后把fputc删掉换成重写_write世界立刻安静了。后来想明白一个问题fputc是谁的钩子_write是谁的钩子根本上取决于你用哪套 C 运行库。CLion 搭配 ARM GCC 工具链时默认走的是 newlib 或者说 newlib-nano 的路线printf 的底层落点就是_write而不是大多数人习惯写的fputc。今天这篇文章就想把这个事彻底讲透标准库里的 printf 到底怎么调到这两个函数的、为什么 Keil 里写fputc管用而 CLion 里不管用、_write的正确实现方式是什么以及我在实际移植中踩过的一堆坑。1. printf 的最后一公里从格式化字符串到串口发送寄存器1.1 标准库 I/O 层的分层结构先理清一个基本认知。printf 绝不是一个直接操作硬件寄存器的函数它是 C 标准库提供的格式化输出接口。当你调用printf(hello %d, 2024)时发生的事情大致是这样的printf 解析格式字符串把结果逐步写入一个输出缓冲区缓冲区的内容通过标准 I/O 层的字符输出函数逐字或成批送出标准 I/O 层最终会把数据交给一个底层输出钩子由这个钩子完成实际的外设写入。在桌面系统上这个底层钩子最终会走到操作系统的文件描述符写入。在裸机嵌入式环境里没有操作系统所以标准库必须留出接口让你把自己的串口发送函数接进去。这个接的动作就是重定向retarget。关键问题来了这个底层输出钩子到底长什么样不同标准库实现给出的答案完全不同。这就是为什么我们会看到 fputc 和 _write 两种截然不同的重写方案。1.2 为什么重写 fputc有时候确实管用很多人第一次接触重定向是在 Keil MDK 工程里。Keil 的 ARMCC 工具链默认支持两种运行时库标准库和微库microlib。如果你在 Keil 中勾选了 Use MicroLIB那么 printf 的输出路径会被简化fputc成为一个可覆盖的弱函数。微库内部本身就提供了一个不做任何操作的fputc空实现编译器允许你重新定义同名函数把它覆盖掉。你在新函数里调用HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100)printf 每产生一个字符就会经过一次你的fputc字符也就从串口出去了。这套机制简单直观所以大量 STM32 教程都会教你这么写int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }在 Keil MicroLIB 环境下这个写法是完全正确的。但注意这个正确性建立在微库将其字符输出实现为弱符号 fputc这一前提下。一旦换了工具链、换了运行库这个前提就不成立了。1.3 新的 C 运行库会有自己的系统调用约定ARM GCC 工具链用的运行时库是 newlib或者它的精简版 newlib-nano。newlib 的设计思路和微库不一样它更接近 Linux 上 glibc 的层次标准 I/O 之上是 stdio之下是一层系统调用接口syscall。在裸机环境下newlib 要求用户提供若干底层函数作为系统调用其中就包括_write、_read、_sbrk、_close等。printf 生成的输出数据最终会通过_write这个接口被送出。所以你在 CLion 里新建一个 ARM GCC 工程默认链接的就是这套 newlib/newlib-nano 运行库。你在代码里写一个fputc重定向实际上根本没有接住 printf 的输出路径。程序真正需要找到的是一个叫_write的函数而你却给了它一个fputc。它当然不会理你。2. CLion 的默认工具链决定了你只能走 _write 这条路2.1 GCC Arm 工具链与 newlib 的挂钩位置搞清楚工具链差异就能明白标题的答案。CLion 做嵌入式开发要么直接配置 ARM GCC 工具链要么依赖 STM32CubeMX 生成的 CMake 工程。无论哪种方式底层编译器都是arm-none-eabi-gcc而它默认的 C 库正是 newlib 或 newlib-nano。newlib 的输出链条是printf - vfprintf - stdio buffer - fputc (stdout 的字符写出函数)这看起来很像是可以重写 fputc 的对吧但微妙的地方在于newlib 中 stdout 的底层字符写出函数并不是一个隐含的弱符号它是 newlib 内部自己实现的最终会调用到_write_r或_write。你重写的fputc并不会被 newlib 内部的 stdio 缓冲机制调用因为你的函数被链接器认为是普通函数而 newlib 内部并没有一个调用用户自定义 fputc的钩子。换句话说在 GCC 环境下printf 的输出最终流向了_write这个系统调用层接口你写不写 fputc对输出路径一丁点影响都没有。2.2 _write 与 fputc 在 newlib 中的真实调用关系有人可能会问newlib 里不是也有 putchar、fputc 这些标准函数吗它们之间没有关系吗有但关系不是你想象的那种fputc 被 printf 逐字调用。newlib 的 printf 经过vfprintf格式化后会把结果写入 stdout 关联的缓冲区缓冲区刷新时遇到换行、缓冲区满或主动 flush会调用底层的__sputc和__sflush这些函数在系统调用层最终会走_write_r带重入参数的内部版本再去调用你重写的_write。也就是说在 newlib 中 fputc 自己本身也是一个库函数你可以在业务代码里调用它但 printf 并不会把你重写的fputc当作输出终点。正确的挂钩点是_write或_write_r。这个区别导致了很多从 Keil 迁移到 CLion 的工程出现编译通过、运行没输出的尴尬。工具链 / 运行库需要重写的函数原因ARMCC MicroLIBKeil 常见配置fputc微库将字符输出做成弱符号允许用户覆盖ARM GCC newlib / newlib-nanoCLion 常见配置_write/_write_rnewlib 的标准输出最终走系统调用层_writeGCC glibcLinux 环境无法通过用户代码接管输出由内核管理应用层没有挂钩点2.3 Keil 习惯在 CLion 里不生效工程迁移中的隐蔽差异这里多说一句如果你是从 Keil 工程迁移到 CLion或者两边同时维护最容易犯的错就是只拷贝了fputc重定向代码没注意到工具链差异。Keil 中同样的fputc重写在 ARM GCC 下编译阶段不会报错因为fputc本身就是标准库声明的函数你可以声明一个同名函数链接阶段也不一定报错——newlib 中的 fputc 符号是强符号正常情况下重定义会报 multiple definition但有些工程配置下又没报错这取决于链接器对符号的处理策略。更隐蔽的是有时候 fputc 重写后能够工作是因为有人额外用了--wrapfputc之类的链接选项或者在启动代码里做了特殊处理。所以遇到这种问题第一步永远是确认我当前用的工具链到底是谁它背后的运行库里 printf 的底层落点是什么3. 手写一个稳定可用的 _write 重定向实现3.1 看穿 _write 的签名fd、ptr、len 三个参数到底怎么用我现在给出_write的原型很多人第一次看到可能发蒙因为和fputc的形态差太远了int _write(int fd, char *ptr, int len);三个参数分别是fd文件描述符。在裸机环境中我们一般只关心1stdout和2stderr。调试串口输出时通常对两者做同样的处理。ptr指向待发送数据缓冲区的指针不是单个字符而是一段连续内存。len本次要发送的字节长度。返回值规定是实际写入的字节数。正常情况下返回len就行。如果返回 0 或负值调用方会认为写入失败。如果返回的值小于len标准库会尝试继续写剩下的数据这可能导致输出循环但因为串口没有真的失败的概念一般直接返回len是最靠谱的做法。这个设计思路和 fputc 有本质区别fputc 是按字符来的_write是按缓冲区来的。它为内核或底层驱动提供了成批写入的可能性效率更高。你在裸机上完全可以一次把len长度的数据全部交给HAL_UART_Transmit也可以为了兼容内部发送缓冲大小按字节循环发送。哪种方式都可以关键是自己心里清楚。3.2 完整代码与逐行注释下面是我在 STM32 项目中常用的一套实现兼容 STM32CubeMX 生成的 HAL 工程#include errno.h #include stdint.h #include stdio.h #include sys/stat.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int _write(int fd, char *ptr, int len) { // 需要输出的 fd 一般是 stdout(1) 或 stderr(2) // 我们不需要区分那么细统一走串口1 if (fd 1 || fd 2) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } errno EBADF; return -1; }如果你遇到的是 Cortex-M33 或需要支持多线程重入的环境有可能会链接到_write_r它的实现几乎一样只是多了一个struct _reent *r参数int _write_r(struct _reent *r, int fd, char *ptr, int len) { (void)r; if (fd 1 || fd 2) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } errno EBADF; return -1; }为什么需要_write_rnewlib 为了支持多线程内部很多函数都带_reent结构体参数用于保存每个线程独立的错误码和状态。裸机环境一般不需要考虑这点但如果链接器报错找不到_write_r你就需要把上面这个函数补上。3.3 处理换行符、多输出通道与中断安全实际使用中_write里面只有一个HAL_UART_Transmit往往不够舒服尤其是你还要面对一些特殊需求。这几点是我自己反复调过的换行符问题。终端和串口助手对\n和\r\n的处理逻辑不一样。很多串口助手里如果你只发\n光标不会回到行首输出会变成阶梯状。解决方式有两个要么在应用层写串口助手脚本处理要么在_write里做一层转换。我实测下来在_write里做转换最省心应用层不用管目标终端是哪一种int _write(int fd, char *ptr, int len) { if (fd ! 1 fd ! 2) { errno EBADF; return -1; } // 如果缓冲区含 \n自动在前面补 \r // 先把 \n 全部替换成 序列 再发送 for (int i 0; i len; i) { if (ptr[i] \n) { uint8_t cr \r; HAL_UART_Transmit(huart1, cr, 1, HAL_MAX_DELAY); } HAL_UART_Transmit(huart1, (uint8_t *)ptr[i], 1, HAL_MAX_DELAY); } return len; }这个字节循环发送版本效率不高但裸机调试场景完全够用。如果你追求吞吐量可以扫描出所有需要插\r的位置拼一个更大的 buffer 一次性发送。多输出通道问题。调试阶段我习惯同时启用串口和 ITM/SWO 调试通道。_write可以做成一个分发器void console_write(uint8_t *buf, uint32_t len); int _write(int fd, char *ptr, int len) { if (fd 1 || fd 2) { console_write((uint8_t *)ptr, len); return len; } errno EBADF; return -1; }console_write内部可以同时做串口发送和 ITM 发送。ITM 在调试器连接时能看到字符串口在独立供电时也能看到输出两者互不干扰排查问题非常方便。中断安全与 RTOS 安全。HAL 库的HAL_UART_Transmit是阻塞发送如果在中断服务函数里调用它且发送时间过长会阻塞整个中断流程严重时还会造成中断嵌套异常。更麻烦的是printf 本身不是线程安全的在多线程 RTOS 环境下并发调用输出可能互相穿插甚至触发断言。裸机或者简单轮询环境下问题不大但一旦引入 FreeRTOS我建议给输出加互斥锁或者直接改用 DMA 环形队列的方式。DMA 方案不在本文讨论范围内但你要有这个意识。4. 在 CLion 里接住 printf 输出的完整配置链路4.1 工具链与 CMake 文件准备CLion 的嵌入式开发流程一般是STM32CubeMX 生成 CMake 工程或者直接用 CMake 自己搭。无论哪种都需要确保工具链、调试器、CMake 配置是通的。关键配置点在 Settings - Build, Execution, Deployment - Toolchains 中选择 arm-none-eabi-gcc并指定编译器路径。接着在 CMake settings 中选择对应的工具链CLion 就会用 ARM GCC 来编译工程。值得注意的是CLion 内置的嵌入式调试支持依赖 OpenOCD 或 JLink这些调试工具的路径也要配好。链接选项中稍微关注一下 newlib-nano。STM32CubeMX 生成的工程默认可能没有启用 newlib-nano而标准 newlib 体积较大。我们可以在 CMakeLists.txt 中加上add_link_options(-specsnano.specs -u _printf_float)-specsnano.specs会启用 newlib-nano-u _printf_float强制链接浮点打印支持不然 printf 里打浮点数只会输出空串或?。4.2 用 J-Link / OpenOCD 串口调试助手的组合验证写好_write之后在 CLion 里按常规方式编译下载然后把串口调试助手接到对应串口上。验证逻辑很简单程序里先来一句printf(Hello CLion\r\n)。如果_write正确挂钩串口助手立刻会收到数据。如果没收到有几个排查点要按顺序确认确认_write是否真的进到了链接结果里。可以在函数第一行打断点然后单步执行看程序是否跳进来。确认HAL_UART_Transmit使用的 UART 句柄是否是当前实际初始化的句柄。很多工程里存在多个串口句柄比如huart1、huart2你重定向的是huart1但板子上的调试串口接的是huart2自然没输出。确认串口工具的参数波特率、数据位、停止位和代码里 UART 初始化的参数要一致。速率不匹配是最常见的完全没输出的原因。确认供电和地线不要笑这个问题真的消耗过我一个晚上。4.3 顺带解决 CLion 中文输出乱码与编码设置热搜词里有一条是 clion 中文输出乱码重定向后确实很常见。这个乱码通常不是因为串口波特率而是源代码文件编码和编译器/控制台编码不一致。CLion 默认使用 UTF-8而 Windows 上的串口助手很多默认使用 GBK。你在代码里写printf(温度%.2f, temp)源文件是 UTF-8中文字符在内存里就是 UTF-8 字节流发给 GBK 的串口助手显示出来必然是乱码。有两个解法把串口助手编码切换到 UTF-8这是最推荐的方式或者把 CLion 的文件编码改成 GBK但工程内其他文件、Git 协作可能会受影响不推荐。另外代码文件内部的字符串字面量最好统一清理掉中文字符或者只在 UI 层保留。嵌入式日志里用英文或者拼音缩写是老传统了它不仅能避免编码问题还能避免不同编译器对源文件编码的误判。5. 我实测过的 5 个重定向崩溃现场与修复方案这一章我想直接给出几个真实的踩坑案例。你大概率也会遇到其中一两个。5.1 输出乱码 / 换行异常\r 与 \n 的恩怨这个前面提过串口助手不认\n作为换行标志。实测下来是程序里每行printf(value %d\n, val)串口显示阶梯状文本第一行正常第二行开始叠在行中间。原因就是只发了\n没有\r。在_write里做转换把单个\n扩展为\r\n问题立马解决。但也别全部转换你不希望转换的地方二进制日志帧中恰好出现0x0A时会被多插一个0x0D破坏协议帧。所以如果_write同时用来输出普通日志和二进制协议帧建议分开设计 API不要在_write里无脑转换。5.2 浮点数打不出来nano.specs 与 _printf_float测过一种典型情况printf(voltage %.2f\r\n, 3.3)编译正常下载后串口输出却是voltage ?或者什么都没有整数输出正常。原因是启用 newlib-nano 之后printf 默认不包含浮点格式化功能为了省空间这种功能被裁剪掉了。要用它必须显式让链接器包含进去。我实测在 CMake 编译器选项或者链接选项中加上-u _printf_float就好了。有时候还需要加-u _scanf_float如果你用了scanf读浮点的话。同样的问题也会出现在 Keil 的微库中只是报错表现不一样Keil 那边是链接错误undefined symbol __initial_argv之类。5.3 程序卡死 / 硬件错误write access violation 的排查链路搜热词时看到 Write to location 0000000000000020 caused an access violation这我太有同感了。重定向_write后程序启动阶段就进 hard fault调试器弹出来一个访问违例的对话框。这类问题绝大多数是写到了无效地址或者使用了一个尚未初始化的外设句柄。真实现场是这样的有人在_write里用了huart1但huart1是 main 函数中的局部变量或者是在MX_USART1_UART_Init()被调用之前就有人调用了 printf。printf 触发_write_write去访问 UART 外设寄存器结果外设时钟和引脚还没配置好甚至句柄本身还是空指针于是直接访问违例。排查思路要按这个链路来在_write入口打断点查看传入的 fd、ptr、len 是否正常查看调用栈确认是从哪里触发的 printf是不是过早检查huart1是否为全局变量初始化顺序是否正确检查 UART 底层HAL_UART_MspInit是否有 GPIO 时钟使能。一句话重定向本身很简单触发时机和资源初始化顺序才容易让人掉坑。5.4 在中断或 RTOS 任务里调用 printf 的隐藏炸弹这个我要重点提。很多人以为 printf 在嵌入式里只是有点慢没意识到它可能在中断里造成严重问题。实测过的案例是 UART DMA 接收中断里调用 printf 做调试日志程序运行一段时间后随机卡死。原因很简单HAL_UART_Transmit是阻塞轮询发送如果 UART 波特率是 115200发送 100 个字符大约需要 8.7ms在中断里阻塞 8.7ms 是完全不可接受的。这个时间足够让更高优先级中断堆积也足够让看门狗超时如果喂狗任务被这个中断抢占的话。正确做法是中断里不要直接用 printf而是把日志放进一个环形缓冲区等主循环或低优先级任务来消费。如果在 RTOS 中更稳妥的做法是加互斥锁或者用专门的日志任务。如果你非要在中断里输出至少改用 DMA发送并把_write实现改为写入 DMA 的循环缓冲区后立即返回。5.5 调试器输出管用、串口不输出到底是谁抢了 stdout另一种很有意思的场景在 CLion 的 Debug Console 里能看到 printf 的输出但外部串口助手上什么也没有。这说明_write确实被调用了但输出被调试器接管了。原因在于部分 ARM 调试环境默认开启了 semihosting。当程序执行到_write时newlib 的默认实现会触发一个断点指令调试器检测到这个指令后把字符串转向调试主机的控制台显示。对你只是链接了 newlib 而没有重写_write调试器会兜底处理它。你重写了_write后调试器控制台就看不到内容了因为_write现在被你接管了输出没有进调试通道而是直接去了串口。换句话说你的串口没输出不是_write的问题而是你的_write还没被链接器选用程序走了 newlib 默认的 semihosting。这种情况下检查一下链接脚本中是否定义_sbrk确保没有半主机相关的符号残留或者在启动代码里禁用 semihosting。6. 那到底什么时候才应该重写 fputc6.1 当你用的是非 newlib 环境时这句话看起来很绕其实很现实。如果你把 CLion 的构建系统换成了 Keil 的 ARMCC或者用 IAR或者用其他基于 ARM Compiler 6 的 IDE并且启用了微库那么重写 fputc 依然是正确的做法。ARMCC 的微库和 newlib 是两套完全不同的标准库实现它们的底层抽象策略不一样。微库把所有字符设备输出抽象成fputc这是一个明确的弱符号。用户只需要覆盖这一个函数就能让库的每个输出动作都走自己的串口驱动。这套模型的好处是简单缺点是效率低每一次输出即使只有一个字节也会经过一次函数调用甚至一次寄存器操作。如果你的目标平台用的就是微库别被 CLion 的 GCC 工具链惯性带偏了。先看清楚工具链再写钩子函数。6.2 当你使用自研轻量级 printf 时很多项目为了追求资源占用和可裁剪性会引入第三方轻量级 printf 实现比如 mpaland/printf还有各类只做整数输出的精简实现。这些库的设计往往把字符输出这个动作抽象成用户需要提供的一个宏或函数。有些库要求你实现_putchar(char c)有些默认调fputc。这时候重写 fputc 就说得通了因为它不再是 newlib 的库函数而是你的轻量级 printf 库的用户接口。如果你在这个库的配置头文件里看到类似#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)那说明这个库确实期望你提供 fputc。这种情况和标准库的挂钩逻辑无关纯粹是库作者的接口设计。6.3 我的判断标准与最终建议到底重写哪个我的判断标准很简单就三步看你用的编译器链ARMCC MicroLIB优先 fputcARM GCC newlib / newlib-nano必须 _write。看你有有没有引入第三方 printf 库如果 printf 不是标准库的而是自研库听库的作者他说要你实现什么你就实现什么。如果编译环境不确定直接在源码里写两个函数一个 fputc一个 _write。这样无论编译器选哪个至少有一个会生效。虽然看起来有点粗暴但移植性极强我在多平台项目里就这么干过。int fputc(int ch, FILE *f) { // 仅对特定库调用链生效 uint8_t b (uint8_t)ch; HAL_UART_Transmit(huart1, b, 1, HAL_MAX_DELAY); return ch; } int _write(int fd, char *ptr, int len) { // newlib 调用链 if (fd 1 || fd 2) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } return -1; }多写这几个函数顶多多占一点点空间节省的是几小时排查时间。我之后在新项目的第一天就把这两个函数写进 console 模块后续再也没因为printf 没输出这种问题浪费过时间。最后再分享一个习惯重定向完成后第一句 printf 建议用固定格式打一个版本号或者编译日期比如printf(BUILD %s %s\r\n, __DATE__, __TIME__)。如果这条能看到说明整个链路通了一半如果看不到就按上面章节的链路逐级排查。好的日志输出是整个嵌入式开发效率的地基它值得你多花半小时把它一次配好。