说个挺有意思的经历。前阵子在 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 + MicroLIB(Keil 常见配置) | fputc | 微库将字符输出做成弱符号,允许用户覆盖 |
| ARM GCC + newlib / newlib-nano(CLion 常见配置) | _write/_write_r | newlib 的标准输出最终走系统调用层_write |
| GCC + glibc(Linux 环境) | 无法通过用户代码接管 | 输出由内核管理,应用层没有挂钩点 |
2.3 Keil 习惯在 CLion 里不生效:工程迁移中的隐蔽差异
这里多说一句,如果你是从 Keil 工程迁移到 CLion,或者两边同时维护,最容易犯的错就是只拷贝了fputc重定向代码,没注意到工具链差异。Keil 中同样的fputc重写,在 ARM GCC 下编译阶段不会报错,因为fputc本身就是标准库声明的函数,你可以声明一个同名函数,链接阶段也不一定报错——newlib 中的 fputc 符号是强符号,正常情况下重定义会报 multiple definition,但有些工程配置下又没报错,这取决于链接器对符号的处理策略。更隐蔽的是,有时候 fputc 重写后能够工作,是因为有人额外用了--wrap=fputc之类的链接选项,或者在启动代码里做了特殊处理。
所以遇到这种问题,第一步永远是确认:"我当前用的工具链到底是谁,它背后的运行库里 printf 的底层落点是什么?"
3. 手写一个稳定可用的 _write 重定向实现
3.1 看穿 _write 的签名:fd、ptr、len 三个参数到底怎么用
我现在给出_write的原型,很多人第一次看到可能发蒙,因为和fputc的形态差太远了:
int _write(int fd, char *ptr, int len);三个参数分别是:
fd:文件描述符。在裸机环境中我们一般只关心1(stdout)和2(stderr)。调试串口输出时通常对两者做同样的处理。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_r?newlib 为了支持多线程,内部很多函数都带_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(-specs=nano.specs -u _printf_float)-specs=nano.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. 那到底什么时候才应该重写 fputc
6.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,优先 fputc;ARM 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__)。如果这条能看到,说明整个链路通了一半;如果看不到,就按上面章节的链路逐级排查。好的日志输出是整个嵌入式开发效率的地基,它值得你多花半小时把它一次配好。