
在 CLion 里用 GCC 工具链折腾 STM32网上搜了一圈 printf 串口重定向的教程照着 Keil 时代的老经验重写 fputc编译、烧录、打开串口助手——什么都没有。这个坑太典型了我自己也踩过。后来才搞明白同样是重定向 printfKeil 的库和 GCC 的 Newlib 走的完全是两条链路CLion 默认工具链下正解是重写_write而不是fputc。这篇文章就把这个“为什么”拆开讲清楚从 Newlib 的调用链、_write的实现方式到 CLion 工程里的编译选项、中文乱码、多 main 文件、UART 初始化顺序导致的 HardFault一条条说。适合正在用 CLion 做嵌入式开发、被串口输出折磨过的朋友也适合想理解 GCC ARM 工具链底层重定向逻辑的人。1. 从 CLion 串口没有输出说起CLion 作为 IDE 做嵌入式开发这几年用的人越来越多。典型的组合是 CLion STM32CubeMX ARM GNU Toolchain CMake OpenOCD也就是直接用 GCC 编译、用 GDB/OpenOCD 调试。这套组合本身很成熟但真正让很多人卡住的第二关就是把 printf 重定向到串口。第一关一般是“项目怎么建起来”CubeMX 生成代码之后导入 CMake改几个路径编译烧录点灯没问题。第二关就是想用串口输出调试信息了结果发现 printf 打印的东西死活不出来。我在社区和群里看到过太多次同样的求助“CLion 重定向 printf 到串口重写了 fputc没反应”“Keil 里能用的方法拿到 CLion 里就不行”“printf 没输出但 HAL_UART_Transmit 单独发数据是好的”几乎所有人一开始的思路都是从 Keil 搬经验。Keil MDK 的 C 库设计里用户可以通过重写fputc来接管 printf 的底层输出这是 Keil 环境里一项非常经典的操作。于是到了 CLion大家自然也先试这个。我也一样。当时在main.c里写了一个看起来完全标准的fputcint fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }编译没问题下载没问题程序跑起来串口助手一片空白。更诡异的是如果直接在 main 里调HAL_UART_Transmit串口又能正常发数据。也就是说串口配置没问题问题出在“printf 和我的发送函数之间断了联系”。如果你也走到这一步不要怀疑是自己代码写错了。问题出在工具链的 C 库实现上CLion 默认使用的 ARM GCC 工具链自带的是 Newlib 或 Newlib-nano这套库内部对 printf 的底层输出处理和 Keil 的 ARM C 库完全不一样。后续内容会证明一件事在这个环境下真正需要关心的底层函数是_write。2. 为什么 fputc 方案在 CLion 工具链里是失效的Newlib 的调用链分析2.1 Keil 的库和 GCC 的 Newlib 是两套完全不同的 C 库体系很多人写嵌入式代码时不关心 C 库因为平时只用到 printf、memcpy、memset 这些函数感觉”库“就是个黑盒子。但重定向这类操作恰恰是黑盒子内部的机制差异造成的。Keil MDK 默认使用的是 ARM 自家的 C 库分为标准库和微库 microlib。这套库里printf 输出字符的底层回调点确实是通过fputc来做的。你重写fputcprintf 内部会把每个字符交给你的fputc于是串口输出就通了。这个机制在 ARM 编译器的 stdio 实现里是有意设计的就是为了方便重定向到不同的硬件设备。但 CLion 的嵌入式工程通常不调用 Keil 编译器而是用 ARM 官方 GNU Toolchain里面的 C 库是 Newlib。Newlib 是面向嵌入式系统的一套开源 C 库被广泛用于 ARM GCC 工具链、RISC-V 工具链等。它在设计上和 ARM 自家的库没有关系printf 的底层输出流程也完全不同。2.2 printf 在 Newlib 内部到底走了一条什么路为了讲清楚为什么不重写fputc这里需要展开 printf 在 Newlib 里的实际调用链。虽然不同 Newlib 版本的内部函数名会有些差异但整体链路是稳定的printf() - vfprintf(stdout, format, args) - _vfprintf_r / _svfprintf_r - __sprint_r 或 __sfvwrite_r - _write(file, buf, len)核心结论在这条链路里已经能看到了printf 的字符输出最终是通过_write这个系统调用级的函数出去的中途不会经过fputc。stdout在 Newlib 中是一个FILE结构体。当printf被调用时格式化后的字符会先进入一个缓冲区缓冲区满、遇到换行、或者程序正常退出时会通过__sfvwrite_r把整块数据交给更低层的读写函数最终调用到_write。_write是 ISO C 标准中定义的系统调用层接口是 POSIX write 系统调用的底层实现。Newlib 把 I/O 相关的硬件操作都抽象到这一层希望移植者通过重定义这个函数来对接具体硬件。2.3 fputc 在 Newlib 中只是一个普通 API而不是钩子那么 Newlib 里有没有fputc有。但不叫“钩子”就是一个普普通通的标准库函数提供给上层应用程序用的。它内部大概会做这样的流程fputc(ch, stream) - putc / __sputc - 缓冲区逻辑 - __sfvwrite_r - _write也就是说如果你在自己的代码里直接调用fputc写字符最终确实会调用_write所以如果你想“重定向 fputc 本身”那也还是绕不开_write这一层。更关键的是printf根本不会调用fputc所以你在main.c里重写的那份fputc对 printf 来说形同虚设。如果你把工程反汇编一下或者在_write里打断点就会发现 printf 发出的数据确实进入了_write而不是你写的那份fputc。2.4 一个生活化的类比可以这样理解Keil 的库像一个定制发货站发货员拿货后必须经过你指定的窗口送出去所以你把fputc这个窗口改了货就跟着走新通道。而 Newlib 更像一个大型物流仓库printf 这条流水线有自己固定的总出货口_write你去改旁边一个叫fputc的小窗口对主流水线没有任何影响。很多从 Keil 转过来的朋友思维惯性太大总以为 C 库的底层机制都差不多。实际上换一套工具链就得重新理解它的库设计。2.5 一张表看清两种环境的差异对比项Keil MDK (ARMCC/ARMClang)CLion ARM GCC (Newlib/Newlib-nano)底层 C 库ARM C 标准库 / microlibNewlib / Newlib-nanoprintf 底层输出回调fputc_write支持重写fputc影响 printf支持经典做法无效推荐的 printf 重定向方式重写fputcfgetc重写_write_read文件描述符概念较弱强stdout 是 fd 1搞明白了这一点前面“串口没输出”的现象就解释通了。接下来看正解。3. 重写 _write正解的做法与实现3.1 _write 是谁为什么它才是真正的出口_write的函数签名是int _write(int file, char *ptr, int len)三个参数的意思分别是file文件描述符。标准输出 stdout 是 1标准错误 stderr 是 2。嵌入式里一般不区分直接忽略。ptr指向要输出的字符缓冲区的指针。len打算输出的字节数。返回值应该是实际写入的字节数。如果成功发送全部字节就返回len。如果失败可以返回 -1通过 errno 报告错误。在裸机环境下我们一般只关心“把所有字节发出去”所以最基本实现就是调用一次串口发送函数然后返回len。3.2 最简单的 _write 实现以 STM32 HAL 库为例一个最基础的_write长这样#include stdint.h #include unistd.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { (void)file; if (HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY) ! HAL_OK) { return -1; } return len; }几个细节需要解释extern UART_HandleTypeDef huart1;是必须的因为_write在独立 C 文件里定义需要引用 main.c 里 CubeMX 生成的全局变量。(void)file;用来压制未使用参数的编译警告。HAL_UART_Transmit的第三个参数是长度类型是uint16_t。这里直接传len没问题但要注意如果len超过 65535需要分片发送否则 HAL 会截断上报错误。正常情况下printf单次输出很少会超过这个值但保险起见我习惯在工程里做一层分片。返回值一定要给len。如果返回 0 或负数调用printf的上层逻辑会认为输出失败可能中断后续输出或者产生错误状态。重写完这个函数再把printf打开串口就应该能收到数据了。3.3 配套函数_read、_sbrk、_isatty、_exit 等只写一个_write够不够看情况。如果你的工具链链接默认的 syscalls例如从 STM32CubeMX 生成的工程里通常会有一份syscalls.c里面已经提供了_read、_sbrk、_exit等一堆函数的弱定义你只需要强定义_write覆盖它即可。但很多精简工程里不存在这些默认实现链接时就会报出一堆 undefined reference。我在实际工程里需要补充的最小集合如下int _write(int file, char *ptr, int len); int _read(int file, char *ptr, int len); void _exit(int status); int _kill(int pid, int sig); int _getpid(void); caddr_t _sbrk(int incr); int _close(int file); int _fstat(int file, struct stat *st); int _isatty(int file); int _lseek(int file, int ptr, int dir);这里面最容易被忽视的是_sbrk。_sbrk是堆内存管理的核心。newlib 中的malloc、calloc以及 printf 内部的一些格式化操作如浮点转字符串都会通过_sbrk向系统申请堆内存。如果_sbrk没实现好程序在调用 printf 时可能直接崩溃或返回错误。比较通用的_sbrk实现会依赖链接脚本里的end符号extern caddr_t _end; caddr_t _sbrk(int incr) { static unsigned char *heap_ptr NULL; unsigned char *prev_heap_ptr; if (heap_ptr NULL) { heap_ptr (unsigned char *)_end; } prev_heap_ptr heap_ptr; heap_ptr incr; return (caddr_t)prev_heap_ptr; }这段代码逻辑很简单从_end开始把堆指针往上抬。实际工程里最好加上对堆栈是否冲突的检测不过作为起步实现够了。_read同理如果你要做串口接收重写它int _read(int file, char *ptr, int len) { HAL_UART_Receive(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }3.4 代码组织方式与链接提示我推荐把所有这些重定向函数单独放到一个retarget.c文件里不要混在main.c中。理由是职责单一方便复用到其他工程。避免 CubeMX 重新生成代码时把你手动加的函数覆盖掉。调试的时候只需要在这个文件里打断点。放到独立文件之后不需要配置额外的东西编译链接时会自动使用你强定义的_write、_read、_sbrk覆盖同名的弱定义符号。这里有一个容易踩的坑如果你不是自己手写这些函数而是在链接选项里加了--specsnosys.specs那确实能解决链接报错因为 nosys 这个 spec 会把一组“什么都不干但存在”的 syscall 函数链接进来。但它的_write是空实现不做任何输出。所以只加nosys.specs而不同时重写_writeprintf 依然不会有输出。nosys.specs是解决链接问题的不是解决重定向问题的。4. CLion 工程里的完整落地配套从编译选项到乱码问题4.1 CLion 项目的基本配置回顾先回顾一下 CLion 嵌入式工程的结构。一般用 STM32CubeMX 生成 CMake 工程目录结构大致是├── CMakeLists.txt ├── Core │ ├── Inc │ └── Src ├── Drivers ├── retarget.c └── ...CLion 通过 CMake 调用 ARM GCC 工具链完成编译。CMakeLists.txt里最关键的几个变量是工具链前缀、链接脚本和编译选项。如果你的 CMake 是 CubeMX 生成的通常已经设置好了大部分内容。我在实际使用中会在CMakeLists.txt的末尾补一段target_link_options(${PROJECT_NAME} PRIVATE -specsnano.specs -u _printf_float )4.2 编译链接选项怎么加才能避免各种奇怪的库问题-specsnano.specs的作用是把 Newlib 换成精简版 Newlib-nano。Newlib-nano 对代码体积很友好很多嵌入式工程默认会选它。但它有几个副作用printf默认不支持浮点格式输出也就是%f打出来不会有正确内容。一些复杂格式的支持会被简化。如果你在项目里偶尔要用%f打印传感器数据或电池电压就需要加上-u _printf_float。这个选项会强制链接器把 printf 的浮点转换函数包含进来。如果你的程序对体积不敏感也可以不加nano.specs直接用完整 Newlib。此时%f默认就是支持的但代码体积和内存占用会明显变大。对于 STM32F103 这种 64KB Flash 的芯片完整 Newlib 的 printf 链可能会吃掉十几 KB 甚至更多所以大部分情况下我还是建议用-specsnano.specs。另外注意如果你之前用了--specsnosys.specs来消除 syscall 链接报错并且自己已经写了_write、_read、_sbrk那nosys.specs可以去掉避免引入一堆空实现干扰你的判断。nano.specs和nosys.specs是两个独立的东西前者管库大小后者管 syscall 默认实现。4.3 中文乱码问题的真凶和解决方案热词里有个高频问题CLion 中文输出乱码。这个和重定向_write关系密切因为很多人重写成功后第一件事就是打印中文调试信息然后看到串口里一堆乱码。乱码的原因通常不是发送端的问题而是编码不匹配。在 CLion 默认设置下源文件编码是 UTF-8编译器按 UTF-8 处理字符串字面量。比如你写printf(电池电压%f\n, voltage);那段中文在内存里是 UTF-8 编码的多字节序列_write会老老实实把它们原样发送到串口。如果你的串口助手默认用 GBK 或者 GB2312 解码就会显示成乱码。解决方案有两种把 CLion 的文件编码设置为 UTF-8同时把串口助手的解码方式也设置为 UTF-8。如果你的串口工具不支持 UTF-8那就把源文件编码改成 GBK让编译器生成 GBK 字节流。但 GBK 不是 GCC 的默认输入编码比较容易产生各种警告我不推荐这条路。我自己的习惯是不管什么硬件平台代码文件一律 UTF-8串口助手一律 UTF-8。这样在 Windows 和 Linux 之间拷贝工程也不会出问题。这里还有一个容易忽视的细节_write按字节发送不会感知 UTF-8 的多字节边界。发送 UTF-8 字符时每个字符的两个或三个字节是连续发出去的串口终端只要按时接收并按 UTF-8 解码就能正确还原。如果你在_write里做了分片发送比如每次只发一个字节且有间隔也基本不会有问题因为串口传输本身是字节流终端会自行拼装。真正要注意的是不能自己把 UTF-8 字节数组拆开交给不同格式的终端做解码。比如有些调试工具会把串口数据按每包单独解码那多字节字符被分开后就会显示成乱码。4.4 多个 main 文件与多个调试目标CLion 工程里多个 main 文件也是个常见问题。CubeMX 生成的工程基本就是一个 main.c但有的人喜欢拆分测试代码或者想在一个工程里放好几个示例程序于是目录里出现了main_a.c、main_b.c编译时报 multiple definition ofmain。解决方式不是删文件而是建多个 target。在CMakeLists.txt里每个 target 指定自己的源文件add_executable(project_a Core/Src/main.c retarget.c ... ) add_executable(project_b Core/Src/main_other.c retarget.c ... )然后在 CLion 的 Run/Debug Configurations 里选不同的 target 进行编译、烧录和调试。这样同一份底层驱动代码可以服务多个入口程序不用反复改 CMake。如果你不想建多个 target只是想让某个 main 文件暂时不参与编译可以用 CMake 的EXCLUDE_FROM_ALL属性把这个文件从目标中排除。但处理起来比多 target 麻烦不推荐长期使用。4.5 一个 _write 不够用怎么办很多实际场景里调试输出不只是发串口还想同时输出到 RTT、LCD 或者保存到日志区。这时候可以在一开始就做一个分发层static void debug_output_broadcast(const char *buf, int len) { HAL_UART_Transmit(huart1, (uint8_t *)buf, len, HAL_MAX_DELAY); SEGGER_RTT_Write(0, buf, len); } int _write(int file, char *ptr, int len) { debug_output_broadcast(ptr, len); return len; }这样_write依旧是 printf 的唯一出口但它内部可以自由分发。以后想加蓝牙透传、USB CDC 输出只需要在这个函数里加一行上层代码完全不用改。5. 重写 _write 之后的隐藏坑UART 初始化顺序与 HardFault5.1 现象与根因重写完_write后printf 有输出了但随之而来一个很经典的隐藏坑程序一启动就 HardFault或者卡死在某个位置。我第一次遇到时_write没问题串口配置没问题但程序在SystemInit后第一次调用 printf 就进硬件错误中断。调试器定位到_write内部的HAL_UART_Transmit看寄存器发现 USART1 外设时钟都没开甚至外设地址的寄存器内容完全不对。根因是printf 运行的时机早于串口初始化。很多工程会在main()比较靠前的位置先写几个 printf 看看效果但 CubeMX 生成的MX_USART1_UART_Init()在main()里通常位于后面。当你先调用 printf 时_write会直接操作一个尚未初始化的 UART 外设相当于往一片空白寄存器上写配置不崩才怪。5.2 一个完整的排查过程如果你也遇到类似的问题可以按这个顺序排查在_write入口打断点确认程序是否真的进入了_write。如果进入了单步执行HAL_UART_Transmit看它是不是返回 HAL_ERROR。查看huart1结构体里的gState和Instance值如果gState是 HAL_UART_STATE_RESET说明 UART 实例还没初始化。回到main()确认MX_USART1_UART_Init()的调用位置与 printf 的位置谁先谁后。如果 printf 在初始化之前把 printf 移到初始化之后或者干脆在_write里做保护。最直接的验证方式把 UART 初始化代码挪到 printf 之前重新编译运行如果问题消失就是顺序问题。5.3 给 _write 加一道“保险丝”除了调整调用顺序我习惯在_write里加一个状态标志位用最简单的方式避免未初始化时误操作static volatile uint8_t uart_ready 0; int _write(int file, char *ptr, int len) { if (!uart_ready) { return len; } extern UART_HandleTypeDef huart1; HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }在MX_USART1_UART_Init()成功完成后手动置位uart_ready 1;这样做有个副作用初始化完成前调用 printf数据会被吞掉。但因为那段时间本身也不该出现有效调试信息所以影响不大。好处是非常安全无论什么环境下都不会因为_write访问未初始化外设导致 HardFault。我更常用的方案是初始化前先用 SEGGER RTT 作为兜底输出但这要引入 RTT 库放在后续文章里细说。5.4 中断和 RTOS 环境下的 HAL_UART_Transmit 隐患另一个容易被忽视的坑是在中断服务函数里调用 printf或者在 RTOS 任务里用同一个串口做发送但_write里使用阻塞式HAL_UART_Transmit。阻塞式发送会一直等待上一个字节发送完成发送完成依赖 UART 中断。如果你在发送完成中断里调用了 printf就会出现“等中断完成但中断正在等发送完成”的死锁表现为程序卡死或中断风暴。RTOS 环境下如果多个任务同时调用 printfHAL_UART_Transmit 本身不是线程安全的可能造成串口数据交错、丢失。解决方案有几种按复杂度排序非调试阶段避免在中断里调用 printf。给串口发送加一个互斥信号量保证同一时刻只有一个任务能发。用 DMA 环形缓冲区实现异步发送_write只负责把数据写入缓冲区由 DMA 中断或发送完成中断持续把数据发出去。如果只是简单调试我建议先用前两种稳。等调试需求大到“必须在中断里输出日志”时再上异步发送方案。5.5 _sbrk 和堆冲突另一个“printf 不输出”的原因还有一个容易迷惑的问题_write重写正确、UART 初始化顺序也对但printf还是不输出或者偶尔输出偶尔卡死。这往往不是_write的问题而是_sbrk或堆管理出了问题。printf 在执行浮点转换、长字符串格式化时内部会走 malloc 申请临时内存。如果_sbrk没有正确实现或者堆和栈地址重叠malloc 会返回空指针printf 的内部处理就直接失败了根本走不到_write。排查方法在_write入口打断点printf 调用了但没进_write就说明问题出在格式化阶段也就是_sbrk或 buffer 分配上。此时去查_sbrk实现和链接脚本里的_end符号地址确保堆区域有足够的空间。我见过有人在重定向 printf 时绕了半天最后发现是_sbrk返回的地址有问题。链接脚本里_end如果被某个大数组覆盖了堆从错误的位置开始增长malloc 立刻失败printf 直接静默失败。所以你在做整个重定向时不要只盯着_write把_sbrk一起写好、检查好能省掉很多后续排查时间。写在最后的经验从 Keil 转过 CLion 后我最开始的几个月一直保留着“出问题先怀疑是不是编译器有问题”的习惯后来发现大部分问题都出在 C 库和工具链的机制差异上。重读那一次踩坑经历让我真正明白了底层函数覆盖的意义fputc在 Keil 里是库设计给你的扩展点在 Newlib 里只是普通 API_write才是 Newlib 体系下所有 stdio 输出绕不过去的那道门。现在我做新的嵌入式工程时会直接在模板里放好一份retarget.c把_write、_read、_sbrk等函数一起配好从源头上避开这些坑。如果你也打算长期用 CLion 做开发建议从一开始就把这套重定向逻辑固定下来省得每次新建工程都要重新折腾一遍。