最近被一个问题反复问到,就是标题里这句:CLion 里做 STM32 串口 printf 重定向,为什么网上满屏都是重写 fputc 的教程,到了 CLion 这边却都得改成 _write?我第一次从 Keil 切到 CLion 时也在这上面卡了两天。当时 CubeMX 生成的 CMake 工程能编译、能下载,串口就是不吐字。后来把 newlib 的调用链翻明白才意识到,这不是玄学,是两套标准库的"数据通路"不一样。
这篇文章不打算只给结论。我会从 printf 在 newlib 里真实的调用链开始拆,对比 fputc 和 _write 这两个函数的职责边界,再说清楚 CLion 里的 arm-none-eabi-gcc 工具链为什么放大了这个问题,最后给一套可以直接抄的 _write 重写方案。如果你也正卡在 printf 串口没输出、字符串乱码、加了 -O2 就丢字符这类问题上,读这篇应该能少走不少弯路。
1. 同一个问题,两套答案:网上printf重定向教程为什么打架
1.1 最常见的那段 fputc 代码
STM32 串口重定向 printf,随便一搜就是这种代码:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }这段代码在 Keil 里确实能用。很多人跟着教程在 Keil MDK 里点一下 MicroLIB,串口就能输出 printf 的内容。于是大量早期博客积累了"重写 fputc"这个经验,并且不断被转载。
问题在于,这个经验绑定的是 Keil 的 ARM Compiler 标准库。你换个 IDE、换个编译器,同样的代码就不一定有效。很多从 Keil 转过来的开发者第一次在 CLion 里做同样的事情,就会被"明明照着教程写了 fputc,串口依然什么都没输出"搞到怀疑人生。
1.2 另一拨人贴出来的 _write 代码
而 CLion 相关的教程,尤其那些围绕 STM32CubeMX 生成的 CMake 工程展开的文章,给出的往往是另一个方案:
int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }从函数签名就能看出来,这俩做的事情完全不一样。fputc 一次只发一个字符,_write 一次处理一整段缓冲区。照着这个方案改,CLion 里的 printf 立刻就能用了。
于是刚接触的人就困惑了:到底该重写哪个?为什么两套教程打架?
1.3 核心矛盾:看着都在做同一件事,底层逻辑却完全不同
要回答这个问题,得先搞清楚一件事:printf 这个函数,在标准库内部到底是怎么把字符一步步送到硬件设备上的。不同标准库的实现路径不一样,决定了你该拦截哪个环节。
fputc 和 _write 并不是同一个功能的两个名字,它们是两套不同层级的东西。很多人把它们当成"两种写法"来抄,结果就是:在 Keil 里抄 fputc 能通,在 CLion 里必须抄 _write,一旦理解不了背后的差异,就只能靠试错碰运气。
下面我从 newlib 的视角把这条链路拆开。
2. printf 到串口之间,藏着一条你没见过的调用链
2.1 printf 实际走的路径
在 arm-none-eabi-gcc 使用的 newlib 标准库里,printf 的完整链路大概是这样:
printf -> _vfprintf_r // 格式化字符串解析 -> 内部字符输出宏 // 把格式化结果写入 FILE 缓冲区 -> __sfvwrite // 批量把缓冲数据写到底层 -> _write_r // 带重入结构的系统调用包装 -> _write // 我们重写的函数 -> 串口驱动 // HAL_UART_Transmit 之类的底层操作也就是说,printf 最终一定会落到_write这个函数上。_write把缓冲区指针和长度一起交给你的硬件驱动,硬件驱动把这一整块数据发出去。
所以重写_write相当于在"系统调用"这一层把标准库和硬件接上。无论你调 printf、puts、fwrite,最终都会汇到_write这里,是一条必经之路。
2.2 fputc 为什么不在 printf 的默认链路上
fputc 在 newlib 里的作用是"往一个 FILE 流里写入单个字符"。它的实现大致是先把字符塞进 FILE 结构的缓冲区,等缓冲区满了或者触发了 flush,再把数据往下层送。
关键在于,printf 在 newlib 内部不是靠逐字符调用 fputc 来完成输出的。printf 走的是_vfprintf_r直接操作 FILE 缓冲区,再用__sfvwrite批量下发数据。fputc 是给用户代码直接调用的公共接口,比如你手动写fputc('A', stdout)时才会走它。
所以,你重写 fputc,只能覆盖"别人直接调用 fputc"的那条路。printf 默认不走这条路,你写了也是白写。
这也解释了一个常见现象:同一个工程里,如果重写了 fputc,然后调用fputc('A', stdout),串口可能有输出;但调用printf("hello"),却什么都没有。因为两条路在标准库内部根本不是一个出口。
2.3 缓冲区:很多"没输出"其实是卡在中间层
newlib 退出后 printf 是否立刻把数据送到_write,还受缓冲区策略影响。默认情况下,stdout 可能是行缓冲或全缓冲。遇到换行符\n时行缓冲会 flush,没有换行符时数据可能一直攒在缓冲区里。
这会导致一个非常典型的坑:printf("hello")串口没输出,printf("hello\n")就有输出。看起来像"少了一个换行符",其实是数据被缓冲层扣住了,还没走到_write。
解决这个问题的标准做法是在 main 开头加一行:
setvbuf(stdout, NULL, _IONBF, 0);把 stdout 设为无缓冲,让 printf 每次输出都立刻穿透到_write。调试阶段强烈建议加上,能省掉大量"是不是串口坏了"的排查时间。
3. fputc 和 _write 的接口设计差异,决定了它们各归谁管
3.1 函数签名、语义与调用者对比
先把两个函数摆在一起看:
| 对比项 | fputc | _write |
|---|---|---|
| 层级 | C 标准库公共接口 | newlib 系统调用层 |
| 原型 | int fputc(int ch, FILE *stream) | int _write(int file, char *ptr, int len) |
| 输出单位 | 单字符 | 数据块 |
| 是否可被 printf 路径命中 | 默认不经过 | 必经过 |
| 适用工具链 | Keil ARM Compiler | GCC / arm-none-eabi-gcc |
| 硬件设备语义 | 文件流操作 | 文件描述符写入 |
有个细节值得注意:_write的第一个参数是文件描述符,0 代表标准输入,1 代表标准输出,2 代表标准错误。这意味着你可以在_write里按 file 参数做分流:
int _write(int file, char *ptr, int len) { if (file == 1) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); } else if (file == 2) { HAL_UART_Transmit(&huart2, (uint8_t *)ptr, len, HAL_MAX_DELAY); } return len; }printf 默认输出到 stdout,也就是 file 为 1。这样一套代码就能把普通日志和错误日志分到两个串口,很实用。
3.2 为什么 Keil 里改 fputc 能用,GCC 里不行
Keil 使用的 ARM Compiler 标准库,对 printf 的底层字符输出做了不同的处理。在 ARM Compiler 里,printf 的字符输出最终会回调到 fputc 这类函数,所以你在 Keil 里重写 fputc,就能让 printf 落地到串口。
而 arm-none-eabi-gcc 使用的 newlib,选择把系统调用_write作为标准 I/O 的下层出口。这是两种标准库的设计差异,没有谁对谁错,但对开发者来说意味着"同样一个工程,换编译器就要换重定向方案"。
理解了这一点,以后再看到两个教程打架,就不会觉得奇怪了。它们各自基于不同的工具链,经验都是局部的,只有理解了标准库的调用链,才能不被教程带偏。
3.3 只重写 fputc 时,printf 常见的几种病征
我在实际调试中遇到过不少只改了 fputc、printf 依然不正常的案例,症状基本集中在这么几类:
| 症状 | 真实原因 |
|---|---|
| printf 完全没输出 | printf 没有经过 fputc,数据卡在 newlib 内部 |
加\n才有输出 | 缓冲区策略导致 flush 延迟 |
| 调试器里能输出,脱机就死 | 链接了 semihosting 半主机实现,脱机后进 HardFault |
| 浮点格式化 %f 没输出或输出异常 | newlib-nano 默认裁剪了浮点格式化 |
| 字符串最后一段丢失 | 全缓冲模式下程序崩溃/复位前没 flush |
这些坑在只改 fputc 时特别容易踩,因为 fputc 并没有真正接管 printf 的底层出口。只有把_write接住,这些问题才谈得上有统一的解法。
4. CLion 带来的新变量:GCC 工具链与 newlib-nano
4.1 CLion 里的"MicroLIB"并不存在
Keil 工程里有个 MicroLIB 的勾选项,很多人勾了之后 printf 就能用重定向后的 fputc。这个选项本质上是在选择使用一套精简的 C 运行库,而 ARM Compiler 的精简库对 printf 的底层出口做的是 fputc 回调。
CLion 里没有 MicroLIB 这个概念,它背后是 CMake 加 arm-none-eabi-gcc 工具链,用的是 newlib 系的标准库。如果你想追求类似的"精简"效果,实际上对应的是 GCC 的 newlib-nano:
--specs=nano.specs这个参数会链接 newlib-nano,减小代码体积。但注意,newlib-nano 对 printf 浮点格式化的支持是默认关闭的,后面会单独说。
4.2 semihosting 半主机模式是 printf 卡死的隐形元凶
newlib 里_write的默认实现和半主机(semihosting)模式绑定。半主机是什么意思?就是程序运行的时候,通过调试器把标准 I/O 转交给调试主机去处理。你在调试器里跑,printf("abc")的内容可能显示在调试环境控制台里,看起来像正常工作了。
一旦断开调试器独立运行,代码还在尝试通过半主机机制访问调试主机,结果就是跑飞或者进 HardFault。这是很多 CLion 工程移植后最隐蔽的问题:在调试会话里一切正常,一脱机就死。
要避免这个问题,链接时需要同时加上:
--specs=nosys.specsnosys.specs 提供了一套不与半主机捆绑的系统调用 stub,但它不会帮你做真正的串口输出。真正干活的是你自己重写的_write。所以正确组合是:去掉默认半主机实现,同时自己实现_write。
4.3 用一条命令确认自己链接的 _write 到底是谁
有时候代码写对了,但链接顺序或者链接参数不对,导致你的_write被标准库的同名符号覆盖,这时候排查起来很要命。有一个直接的办法,用 nm 看最终生成的目标文件里的符号:
arm-none-eabi-nm build/项目名.elf | grep _write正常情况你会在输出里看到类似这样一行:
08002d34 T _write地址落在你片子的 Flash 或 RAM 地址段内,说明你自己的_write已经被链接进来。如果看到的是:
U _write或者符号落在无法解释的地址,说明链接的_write不是你想用的那个。还可以用 objdump 反汇编确认:
arm-none-eabi-objdump -d build/项目名.elf | grep -A 20 "<_write>:"这个命令非常实用,尤其是当你改了代码却发现没有生效时,能很快判断是不是链接参数的问题。
5. 一套可以直接抄的 _write 重写方案与验证清单
5.1 HAL 库下的完整代码
以 STM32 标准 HAL 库为例,最稳的_write重写是这样:
#include <stdio.h> #include <stdint.h> #include "main.h" #include "usart.h" extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file == 1 || file == 2) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); } return len; } int _read(int file, char *ptr, int len) { if (file == 0) { HAL_UART_Receive(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); } return len; }HAL_UART_Transmit是阻塞发送,直接把 len 长度的数据交给它,它会等到所有字节发送完才返回。所以整个缓冲区一次性就能发出去,效率比 fputc 一个字符一个字符地循环高很多。
如果你的工程里有惯用的__io_putchar,也可以把_write改成逐字符调用它:
int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; } int _write(int file, char *ptr, int len) { for (int i = 0; i < len; i++) { __io_putchar(*ptr++); } return len; }两种写法都能跑通。核心只有一件事:printf的数据必须通过_write进入你的串口驱动。
5.2 CMake 链接参数要跟着一起改
代码写完了,CMake 里的链接参数也得跟上。CLion 的 STM32 工程里,通常能在 CMakeLists.txt 里找到类似这样的链接选项:
target_link_options(项目名 PRIVATE --specs=nano.specs --specs=nosys.specs -u _printf_float -u _scanf_float )前两个参数前面说了,关掉半主机并用精简库。-u _printf_float和-u _scanf_float是强制把 newlib-nano 裁剪掉的浮点格式化功能链接进来。
这里有个很多人踩过的坑:启用了 newlib-nano,却没有加-u _printf_float,然后 printf 里的%f输出不出来。你以为是串口波特率不对,折腾半天发现是标准库把浮点格式化整个裁掉了。
5.3 缓冲、浮点、多串口这些连带问题
代码和链接参数都改好后,回到主线逻辑,在 main 函数最开始加一行:
setvbuf(stdout, NULL, _IONBF, 0);不加这行的后果是:printf("hello")可能不吐字,printf("hello\n")就正常,容易让人误判成"换行符导致的问题"。加了这个设置后,printf 的数据会直接穿透到_write,调试期间的行为更可控。
如果你需要多个串口输出不同内容,可以在_write里按 file 参数分流,我在第三章给过示例。实际上更常见的做法是根据调试阶段的需求,固定让 stdout 走调试串口,stderr 走另一个串口,这样普通日志和错误日志在硬件上就分开了。
如果项目对实时性有要求,不想让HAL_UART_Transmit阻塞在那里等发送完成,可以改成中断发送或者 DMA 发送。但要注意返回值的语义:_write返回的是"成功写入的字节数",如果你用非阻塞方式把数据丢进 DMA 就立刻返回,标准库会认为数据已经写完,缓冲区可能被复用,引发数据错乱。所以用非阻塞发送时,要么等 DMA 完成后再返回,要么自己维护发送队列。
5.4 验证步骤:从串口助手到逻辑分析仪
改完之后怎么确定真的通了?建议按下面顺序验证:
- 编译烧录,打开串口助手,波特率与你
HAL_UART_Init里配置的一致。 - 调用
printf("hello\r\n"),看串口助手是否显示 hello。 - 调用
printf("%d %f\r\n", 123, 3.14),确认整数和浮点都能输出。浮点输出不了就检查-u _printf_float。 - 连续发送多项数据,确认不会丢字符。
- 断开调试器,单独上电,确认脱机环境下 printf 也能正常工作。这一步专门用来排查半主机残留问题。
如果串口助手能显示 hello,但%f显示不出来,几乎可以肯定是链接参数少了-u _printf_float。如果连 hello 都没有,先用arm-none-eabi-nm查一下_write符号,再用调试器看一下程序是否卡在 HardFault。
6. 那 fputc 就彻底没用了吗?也不全是
6.1 自研轻量 printf 或虚拟文件接口时,fputc 仍有用
虽然标准库的 printf 不认 fputc,但在你自己的代码里,完全可以拿 fputc 作为一个统一的字符输出抽象。比如你写了一个自研的简易格式化输出函数,底层不想直接依赖 HAL,就可以定义这样的接口:
int _out_char(int ch) { // 你的硬件输出 return ch; } int fputc(int ch, FILE *f) { return _out_char(ch); }这种情况下,fputc 是你自己的虚拟文件接口,不是要去拦截 printf。还有一种情况是接第三方库时,对方约定通过 fputc 回调做日志输出,那确实需要重写 fputc。关键要搞清楚:你是在重定向标准库的 printf,还是在给某个库提供回调。
6.2 中断与多任务环境:fputc 和 _write 都要注意的锁问题
不管用哪个函数做重定向,只要你的系统里有 RTOS 或者中断里也有打印需求,就会遇到"锁"的问题。printf 本身不是可重入的,HAL_UART_Transmit的阻塞等待在中断上下文里更是一个致命的操作。
我的建议是:中断服务函数里不要直接调用 printf。可以把要输出的数据放进一个环形缓冲区,由主循环或者 DMA 空闲中断去真正发送。_write里做的是"把数据从标准库搬到你的发送队列",而不是真的死等串口发完。
这里也顺带解释一个现象:同一段重定向代码,开了-O2后偶尔丢字符,其实是优化改变了时序,让阻塞发送和中断抢占了同一个 UART 外设。加互斥锁、改成 DMA 发送,通常能解决。
6.3 选型建议:按工具链决定,别按习惯决定
总结一下我实际项目的选型逻辑:
- 工具链是 arm-none-eabi-gcc,也就是 CLion、VS Code + CMake、STM32CubeIDE 的 GCC 工程,直接重写
_write。 - 工具链是 Keil ARM Compiler,沿用 fputc 方案,再配合 MicroLIB,这是 Keil 生态内成熟的路径。
- 如果你在移植一个跨工具链项目,尽量把输出抽象成自己的模块,把 fputc 或 _write 的差异封装在底层,上层代码统一调用你自己的输出函数。
说到底,fputc 和 _write 不是竞争关系,它们属于标准库的不同环节。CLion 为什么要重写_write而不是 fputc?不是因为 _write 更高级,而是因为你用的 arm-none-eabi-gcc / newlib,把 printf 的最终出口设计在了_write这里。找准这个出口,你才算真正接管了串口输出,而不是在那里反复试错。