CLion 中为什么要重写 _write,而不是 fputc?

一句话结论

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 标准库NewlibNewlib-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 这类常见基础函数

  • FILEstdoutstderr 这些标准 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 教程里那种写法:

bash 复制代码
int 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 最小可用版本

如果你只想先跑通:

bash 复制代码
int _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. 推荐的文件组织方式

建议把重定向逻辑单独放一个文件,例如:

bash 复制代码
Core/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 现象

bash 复制代码
printf("pi = %f\r\n", 3.14f);

可能出现:

  • 什么都没显示

  • %f 对应内容为空

  • 输出异常


13.2 原因

如果你的工程用了 Newlib-nano,为了节省代码体积,浮点格式化常常默认没被链接进来。

这说明问题不在 _write(),而是在更上层:

  • _write() 负责"把字节发出去"

  • 浮点支持负责"先把浮点数格式化成字符串"

这两层不是一回事。


13.3 解决方法

CMakeLists.txt 中增加:

bash 复制代码
add_link_options(-u _printf_float)

如果你还要支持 scanf 浮点输入,还可能需要:

bash 复制代码
add_link_options(-u _scanf_float)

14. 常见坑位汇总

14.1 串口还没初始化就先 printf

确保顺序类似:

bash 复制代码
HAL_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 句话

  1. 不是 CLion 决定重写谁,而是"工具链 + C 标准库"的组合决定重写谁。

  2. 工具链 是把源码变成固件的一整套工具,不只是编译器。

  3. C 标准库printf/malloc/memcpy/FILE 等功能的真正实现,而不只是头文件。

  4. GCC + Newlib/Newlib-nano 中,printf 常见路径是:

bash 复制代码
printf -> vfprintf -> write -> _write
  1. Keil + MicroLib 的很多常见工程中,printf 更常走字符级钩子,因此很多教程重写的是 fputc

  2. 在 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")

bash 复制代码
add_link_options(-u _printf_float)

测试代码:

bash 复制代码
printf("Hello CLion + GCC!\r\n");
printf("num = %d\r\n", 123);
printf("pi = %f\r\n", 3.14f);
相关推荐
Freak嵌入式3 小时前
MicroPython 开发避坑:用 Signal 类解决不同电路的电平兼容问题
嵌入式
智码看视界19 小时前
Edge AI 全栈1:ESP32 传感器采集到云端大模型推理的完整链路
人工智能·物联网·嵌入式·esp32·aiot·全栈开发·edgeai
Freak嵌入式1 天前
告别硬件差异!MicroPython Signal 类:让你的 GPIO 代码跨板运行无压力
嵌入式
v_for_van1 天前
C语言__attribute__
服务器·c语言·开发语言·mcu·嵌入式·嵌入式实时数据库
xx~t2 天前
嵌入式——进程与线程1
linux·c语言·学习·嵌入式·进程与线程
潘潘的嵌入式日记2 天前
协议旧指令该不该删?4 个检查决定删除还是保留兼容
重构·嵌入式·协议·经验·代码维护
虎王物联3 天前
工业Modbus TCP协议深度实战:STM32 HAL库实现从机通信与数据上云
stm32·物联网·网络协议·tcp/ip·嵌入式
小柯博客3 天前
06 · 统一 libcamerasrc 与架构定型:3A、CPU 归因与一次黑屏回归
c语言·stm32·单片机·嵌入式硬件·架构·嵌入式·视频编解码
fanged5 天前
Yocto1--环境搭建和验证
大数据·搜索引擎·嵌入式