一句话结论
在 CLion + GCC + Newlib/Newlib-nano 这套组合里,printf() 的标准输出通常最终会下沉到 _write() ,而不是你自己写的 fputc()。 所以在这类工程中,把 printf 重定向到串口,最稳妥、最符合这套库设计的方法是实现 _write()。
而在 Keil + ARM C Library / MicroLib 体系里,很多教程之所以让你重写 fputc(),是因为那套库更常见的输出钩子就是 字符级接口。
注意:这不是绝对到"所有版本、所有配置、所有库内部实现都完全一样"。 但对 STM32 开发里最常见的工程形态来说,这个结论是非常实用的。
1. 先把 4 个概念分开:IDE、工具链、标准库、硬件
很多初学者容易把这些东西混成一团:
-
觉得 CLion 决定了
printf的行为; -
觉得 Keil 能
fputc,所以 GCC 也应该能; -
觉得标准库只是几个头文件;
-
觉得
printf直接就是编译器内建能力。
其实都不是。

1.1 IDE 是什么?
IDE(集成开发环境)就是你的"工作台"。
比如 CLion 负责:
-
编辑代码
-
语法高亮
-
跳转定义
-
CMake 工程管理
-
调试配置
-
调用编译器 / 链接器去真正构建工程
IDE 本身通常不决定 printf 最后走 fputc 还是 _write。 真正决定这一点的,是你接在 IDE 后面的:
-
工具链
-
C 标准库
所以一定要记住一句话:
CLion 只是前台操作界面,不是标准 I/O 机制的真正制定者。
2. 工具链到底是什么?
很多文章说"GCC 工具链""ARM 工具链",但没解释清楚它到底包含什么。
2.1 工具链不是单个编译器
工具链(Toolchain)是一整套把源码变成 MCU 可执行镜像的工具集合。

一个典型嵌入式工具链通常包含:
-
预处理器 :展开宏、处理
#include -
编译器:把 C 代码翻译成汇编或目标代码
-
汇编器 :把汇编变成目标文件
.o -
链接器:把多个目标文件、启动文件、库文件合成最终程序
-
启动文件:中断向量表、复位入口、数据段初始化
-
库文件 :例如
libgcc、C 标准库 -
二进制转换工具 :把 ELF 转成
.bin/.hex -
调试工具 :如
gdb、烧录与调试器适配工具
所以你说"我用 GCC",严格说通常指的是一整套类似:
bash
arm-none-eabi-gcc
arm-none-eabi-as
arm-none-eabi-ld
arm-none-eabi-objcopy
arm-none-eabi-gdb
以及与它配套的运行时库和标准库。
2.2 CLion 里常见的嵌入式工具链
如果你在 CLion 中开发 STM32,常见组合是:
-
IDE:CLion
-
编译器/链接器体系:GNU Arm Embedded Toolchain
-
编译器前端命令 :
arm-none-eabi-gcc -
C 标准库 :
Newlib或Newlib-nano
这里最容易被忽略的一点是:
arm-none-eabi-gcc不只是"把 C 代码编译一下",它背后还会自动把合适的库、启动文件、链接参数串起来。
2.3 Keil 里的工具链又是什么
Keil 常见组合是:
-
IDE:Keil MDK
-
编译器体系:Arm Compiler(AC5 / AC6)
-
C 标准库:ARM C Library / MicroLib
所以 Keil 和 CLion 的差异,并不是"两个 IDE 在斗法",而是:
-
后面接的 编译器体系不同
-
配套的 C 标准库不同
-
标准 I/O 的底层落点不同
3. C 标准库到底是什么?
很多人知道 <stdio.h>、<string.h>、<stdlib.h>,但容易误以为:
- 头文件 = 标准库
这其实不完整。
3.1 头文件只是声明,标准库是"实现"
比如你写:
bash
printf("hello\r\n");
memcpy(dst, src, len);
malloc(128);
这些函数之所以能工作,不只是因为你 #include 了头文件,而是因为链接阶段把对应的库实现带进来了。

C 标准库大致负责这些事情:
-
printf / scanf这类格式化输入输出 -
malloc / free这类内存管理 -
memcpy / memset / strcmp这类常见基础函数 -
FILE、stdout、stderr这些标准 I/O 抽象 -
缓冲机制
-
错误码
errno
所以一句更准确的话是:
编译器决定"你的 C 语法如何翻译",标准库决定"常用函数如何实现"。
这也是为什么同样都是 printf,不同标准库的底层行为可能不一样。
3.2 标准库为什么会影响 printf 重定向?
因为 printf 不是一个"只在源码层存在的名字",而是库里的真实实现。
也就是说:
-
谁实现了
printf -
它内部怎么处理
FILE -
它最后调用什么底层输出接口
这些都取决于你链接进来的那套标准库。
因此:
-
在 Keil 的某些常见工程里,
printf更容易走到fputc -
在 GCC + Newlib 工程里,
printf更容易走到底层_write
4. 为什么 Newlib 会有 _write 这种接口?
这个问题非常关键,因为它直接通向原理本身。
4.1 在桌面系统里,write() 是 OS 提供的
在 Linux / Unix 世界里,标准输出并不神秘,它最后会走向操作系统的 I/O 接口。 例如:
-
stdout常对应文件描述符1 -
stderr常对应文件描述符2
标准库把格式化好的数据交给 write(fd, buf, len),操作系统再决定把它输出到:
-
终端
-
文件
-
管道
-
设备
4.2 但裸机 MCU 没有完整操作系统
STM32 裸机环境通常没有:
-
完整文件系统
-
终端
-
进程
-
内核系统调用入口
那标准库怎么办?
它只能把最底层这一步 留给用户自己补。

于是 Newlib 这类库会提供一组"你来实现的底层接口",常见包括:
-
_write -
_read -
_close -
_lseek -
_isatty -
_fstat
这些常被称为:
-
system call stubs
-
syscalls 重定向接口
-
系统调用桩函数
你可以简单把它理解为:
标准库只负责把高层行为组织好,真正落到 UART/USB/SWO 的那最后一步,要你自己接上。
5. CLion 和 Keil 的根本区别到底在哪?
这里可以直接看图。

5.1 CLion 常见组合:GCC + Newlib
常见链路是:
bash
printf
-> vfprintf
-> FILE / stdout 缓冲
-> write
-> _write
-> UART / SWO / USB CDC
也就是说:
-
printf先格式化字符串 -
标准库处理
stdout -
然后以"缓冲区 + 长度"的形式交给底层
-
底层重定向点通常是
_write
5.2 Keil 常见组合:ARM C Library / MicroLib
很多时候更接近这种思路:
bash
printf
-> 格式化输出层
-> fputc
-> UART
也就是说:
-
每形成一个字符
-
就可能调用一次
fputc -
由你把这个字符发出去
这里我说的是"常见模型"和"多数教程的实践路径"。 不同版本、不同库配置、是否开启 MicroLib,细节可能略有差异。
6. printf 到底是怎么走到串口的?
把调用路径彻底看懂,就不会再纠结"为什么我写了 fputc 却没反应"。

6.1 GCC + Newlib 常见路径
bash
printf()
-> vfprintf()
-> stdout 缓冲区 / FILE 处理
-> write()
-> _write()
其中:
-
printf():接收可变参数 -
vfprintf():完成格式化工作 -
stdout/FILE:处理标准流和缓冲区 -
write():抽象为统一写接口 -
_write():最终由你对接到底层设备
最关键的一点
在这条路径中,底层拿到的是:
-
ptr:一整段内存地址 -
len:这一段有多少字节
也就是说它更像:
bash
把整段字符串交给底层发送
而不是:
bash
一个字符一个字符慢慢吐出去
6.2 某些版本里你可能看到 _write_r
为了线程/重入支持,Newlib 内部某些实现可能先走:
bash
_write_r(...) -> _write(...)
但对大多数 STM32 裸机项目来说,你只需要把握一个实践重点:
最终你最常需要自己实现的,依然是
_write()。
6.3 Keil / MicroLib 常见路径
bash
printf()
-> 格式化层
-> fputc(ch, file)
此时你的 fputc() 常常就会被当成输出钩子。
所以 Keil 教程里那种写法:
bashint fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }
在它自己的生态里,是非常顺手的。
7. 为什么在 CLion/GCC 中,重写 fputc() 往往不生效?
根本原因就一句话:
因为 Newlib 的
printf主通路通常不是经由你自定义的fputc()。
更具体地说:
-
你当然可以自己写
fputc(); -
但
printf()不一定会调用你这个函数; -
它更可能走
vfprintf -> write -> _write这条路径; -
所以你会看到"代码能编译,但就是没打印出来"。
这不是你语法写错了,而是钩子挂错层了。
可以用一个很形象的说法:
-
在 Keil 常见路径里,
fputc更像主干道出口; -
在 GCC + Newlib 里,
_write才是更核心的主出口; -
你守错路口,自然等不到车。
8. _write() 比 fputc() 好在哪里?
除了"更符合库设计",它在性能和扩展性上也更占优。

8.1 fputc 的模型:逐字节调用
如果一条日志是 100 字节,那么底层可能执行 100 次:
bash
HAL_UART_Transmit(..., 1, ...)
这意味着:
-
函数调用开销重复 100 次
-
HAL 内部状态检查重复 100 次
-
超时判断重复 100 次
-
中断 / 临界区影响更明显
8.2 _write 的模型:整块交付
如果走 _write(ptr, len),标准库已经帮你把字符串准备好了,你只要一次性发:
bash
HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 50);
优势是:
-
一条字符串可能只调用 1 次底层发送
-
CPU 开销更低
-
和
stdout缓冲模型更匹配 -
后续更容易升级到中断 / DMA / 环形缓冲区
所以从工程实践角度说:
在 GCC + Newlib 体系中,
_write不是"将就能用",而是更自然、更正统的输出后端。
9. Newlib 和 Newlib-nano 到底是什么?
很多 STM32 GCC 工程都会看到这两个名字。

9.1 Newlib
Newlib 是面向嵌入式系统的一套 C 标准库实现,特点通常是:
-
功能相对更完整
-
stdio能力更充分 -
代码体积一般更大
它的核心思想之一,是保留一组底层接口给用户自己适配硬件。
9.2 Newlib-nano
Newlib-nano 可以理解为:
-
面向资源受限 MCU 的更精简构建
-
更重视体积
-
某些格式化能力默认被裁剪
它经常带来的典型现象就是:
-
%f默认不支持 -
需要额外链接选项启用浮点格式化
但注意:
即使换成
Newlib-nano,它的 I/O 下沉思路依然还是更接近write / _write这一层。
所以它不会改变本文的主结论。
10. 在 STM32 + CLion 中,标准做法怎么写?
下面给你一个最常见、最稳妥的实现方式。
10.1 头文件准备
bash
#include "usart.h"
#include <sys/unistd.h>
有些工程即使不包含
<sys/unistd.h>也能编译。 关键不是头文件本身,而是你提供了正确签名的_write()。
10.2 最常用 _write() 实现
bash#include "usart.h" #include <sys/unistd.h> extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file == STDOUT_FILENO || file == STDERR_FILENO) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 50); return len; } return -1; }
参数含义
-
file:输出目标编号-
1通常表示stdout -
2通常表示stderr
-
-
ptr:待发送缓冲区首地址 -
len:待发送长度
这段代码的核心意思是:
-
如果当前输出目标是标准输出或标准错误;
-
就把整段数据通过串口发出去;
-
成功后返回
len,告诉标准库这些字节已经写完。
10.3 最小可用版本
如果你只想先跑通:
bashint _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 50); return len; }
很多裸机项目里这样也能工作。
但更推荐上一个版本,因为它对 stdout/stderr 的语义更明确。
10.4 一个更完整的工程版
bash#include "usart.h" #include <sys/unistd.h> #include <errno.h> extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (ptr == NULL || len <= 0) { errno = EINVAL; return -1; } if (file == STDOUT_FILENO || file == STDERR_FILENO) { if (HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 50) == HAL_OK) { return len; } errno = EIO; return -1; } errno = EBADF; return -1; }
这个版本更适合长期维护工程,因为:
-
有空指针保护
-
有错误返回值
-
有
errno语义 -
更接近标准库预期
11. 推荐的文件组织方式
建议把重定向逻辑单独放一个文件,例如:
bashCore/Src/retarget.c
示例:
bash#include "usart.h" #include <sys/unistd.h> extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file == STDOUT_FILENO || file == STDERR_FILENO) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 50); return len; } return -1; }
这样做的好处:
-
重定向逻辑集中
-
换 UART 时改一处就行
-
迁移工程方便
-
比把代码塞进
main.c更清晰
12. 还想保留 fputc() 行不行?
可以,但它在 GCC + Newlib 工程里通常不是主要入口。
bash#include <stdio.h> #include "usart.h" extern UART_HandleTypeDef huart1; int fputc(int ch, FILE *f) { uint8_t c = (uint8_t)ch; HAL_UART_Transmit(&huart1, &c, 1, 50); return ch; }
它的意义一般是:
-
兼容老代码
-
兼容某些手动调用
fputc()的场景 -
帮助从 Keil 旧工程过渡
但要记住:
在 CLion + GCC + Newlib 下,真正优先应该保证的是
_write()。
13. 为什么 printf("%f") 可能打印不出来?
这是 STM32 + GCC 工程里非常常见的另一个坑。
13.1 现象
bashprintf("pi = %f\r\n", 3.14f);
可能出现:
-
什么都没显示
-
%f对应内容为空 -
输出异常
13.2 原因
如果你的工程用了 Newlib-nano,为了节省代码体积,浮点格式化常常默认没被链接进来。
这说明问题不在 _write(),而是在更上层:
-
_write()负责"把字节发出去" -
浮点支持负责"先把浮点数格式化成字符串"
这两层不是一回事。
13.3 解决方法
在 CMakeLists.txt 中增加:
bashadd_link_options(-u _printf_float)
如果你还要支持 scanf 浮点输入,还可能需要:
bashadd_link_options(-u _scanf_float)
14. 常见坑位汇总
14.1 串口还没初始化就先 printf
确保顺序类似:
bashHAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); printf("hello\r\n");
14.2 串口助手设置不一致
检查:
-
波特率
-
数据位
-
停止位
-
校验位
-
COM 口
14.3 huart1 写错了
比如:
-
实际接的是
USART2 -
代码却用
huart1
这是非常高频的问题。
14.4 忘了用 \r\n
很多串口终端习惯完整换行:
bash
printf("hello\r\n");
比只写 \n 更稳妥。
14.5 工程里已经有一个 _write
有些模板会带 syscalls.c。如果你发现:
-
自己写了
_write()不生效; -
或者链接时报重复定义;
请检查:
-
有没有现成的
syscalls.c -
里面是否已经实现了
_write -
是否链接进了多个 retarget 文件
原则只有一个:
整个工程里应该只有一个有效的
_write()。
14.6 printf 太频繁导致系统卡顿
因为 HAL_UART_Transmit() 默认是阻塞发送。 如果你在:
-
高频中断
-
实时任务
-
大量日志输出
里疯狂 printf,系统就会卡。
这不是 _write 本身的错,而是:
-
printf很重 -
串口阻塞发送也很重
-
两者叠加影响实时性
后续可以升级成:
-
环形缓冲区
-
中断发送
-
DMA 发送
-
日志分级开关
而 _write() 这种整块接口,正好更方便往这些方向演进。
15. 最后总结:把原理压缩成 6 句话
-
不是 CLion 决定重写谁,而是"工具链 + C 标准库"的组合决定重写谁。
-
工具链 是把源码变成固件的一整套工具,不只是编译器。
-
C 标准库 是
printf/malloc/memcpy/FILE等功能的真正实现,而不只是头文件。 -
在 GCC + Newlib/Newlib-nano 中,
printf常见路径是:
bash
printf -> vfprintf -> write -> _write
-
在 Keil + MicroLib 的很多常见工程中,
printf更常走字符级钩子,因此很多教程重写的是fputc。 -
在 STM32 + CLion 工程里,优先实现
_write()才是最稳妥的做法。
16. 可直接复制的最终示例
bash#include "usart.h" #include <sys/unistd.h> extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file == STDOUT_FILENO || file == STDERR_FILENO) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 50); return len; } return -1; }如需
printf("%f"):
bashadd_link_options(-u _printf_float)
测试代码:
bashprintf("Hello CLion + GCC!\r\n"); printf("num = %d\r\n", 123); printf("pi = %f\r\n", 3.14f);