news 2026/9/7 15:42:10

CLion STM32 printf重定向:为什么必须重写_write而不是fputc?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CLion STM32 printf重定向:为什么必须重写_write而不是fputc?

“在 CLion 里用 arm-none-eabi-gcc 开发 STM32,串口重定向是绕不开的一道坎。很多人第一次都会按 Keil 教程重写 fputc,结果发现 printf 完全没反应,程序还动不动卡死;换成网上说的重写 _write,烧进去一秒钟就通。CLion 里为什么要重写 _write,而不是 fputc?这背后不是编辑器偏好,而是 C 运行库的调用链差异。完整理解这条链,以后换工具链、换芯片都不会再被这个问题卡住。”

1. 先复现一次现场:fputc 改了没反应,问题出在哪

1.1 代码照 Keil 教程写的,串口却纹丝不动

我之前在一个 CLion + STM32F103 的项目里做调试输出,第一个动作就是找熟悉的套路:重写 fputc,把字符丢给串口。代码大概是这样的:

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }

然后 main 里写:

printf("hello\r\n");

烧进去,打开串口助手,什么都没有。程序也没有跑飞,LED 正常闪烁,中断正常执行,只有串口像死了一样。我重新检查 HAL_UART_Transmit 的参数,波特率、引脚复用,全都没问题。单独把HAL_UART_Transmit拎出来发一个字节,又能正常发送。

也就是说,重定向本身没生效,而不是串口外设有问题。

1.2 真相浮出水面:链接的是 newlib,不是 MicroLIB

在 Keil MDK 里,多数教程默认启用 MicroLIB。MicroLIB 的 printf 实现比较“朴素”,打印字符时最终会回调 fputc,所以重写 fputc 就能接管输出。而 CLion 里最常见的 STM32 工具链是 arm-none-eabi-gcc,默认的 C 库是 newlib 或 newlib-nano。newlib 的 stdio 并不依赖 fputc 作为最终落点,它有一套更接近 POSIX 的底层接口,其中最关键的是这组函数:

  • _write
  • _read
  • _lseek
  • _close
  • _fstat
  • _isatty
  • _sbrk

printf 的数据最终会走_write,而不是 fputc。所以你在 CLion 里重写 fputc,本质上是在改一个 newlib 内部根本不走的旁路函数。旁路修得再漂亮,主路不通就是不通。

1.3 这里其实还藏了一个调用层次的问题

把 fputc 和 _write 放在一起对比,能看出两个完全不同的抽象层次。

对比项fputc_write
所属层次C 标准库的字符输出函数,操作 FILE 流系统调用层,通过文件描述符写数据
典型参数单个字符 + FILE 指针文件描述符 + 缓冲区指针 + 字节数
工作方式处理了缓冲、出错标志等 stdio 细节只负责把缓冲区里的数据“交出去”
printf 是否经过newlib 下不经过newlib 下最终必经
MicroLIB 下是否有效有效不适用或不经由

这也是很多从 Keil 转过来的开发者最困惑的地方:明明“写一个字符”这个动作是 fputc 干的,为什么 newlib 不认?因为 newlib 把问题分成了两层:上层 stdio 负责格式化、缓冲;下层 syscall 负责真正把数据送到哪里。printf 属于上层,_write 属于下层。fputc 虽然也叫“字符输出”,但它本质上是上层对单个字符的封装,不属于这个分层体系里的必经点。

2. 顺着调用链往下看:newlib 里的 printf 为什么不认 fputc

2.1 printf 并不是“一个字符一个字符往外吐”

很多嵌入式教程会给你一种感觉:printf 内部就是个循环,把格式化后的每个字符依次扔给 fputc。这个模型在 MicroLIB 里大致成立,在 newlib 里却不成立。

newlib 的 printf 最终会走进 vfprintf,vfprintf 负责解析格式串,然后把结果字符写到 stdout 对应的 FILE 结构里。写的过程并不是直接调用外部符号 fputc,而是把字符放进 stdio 的缓冲区,或者通过内部的刷新路径把缓冲区内容交给底层。这个底层刷新动作最终会调到 _write。

换句话说,newlib 里 printf 的完整路径大致是这样的:

  • printf 解析格式串,生成输出内容
  • 内容写入 stdout 的用户态缓冲区
  • 遇到换行、缓冲区满,或者调用 fflush 时触发刷新
  • 刷新函数把缓冲区指针、要写的长度交给 _write
  • _write 拿到文件描述符和字节块,真正往串口发送

这条链上确实会出现“把单个字符写进某个地方”的动作,但那个动作在 newlib 内部用宏和函数指针完成,不会回头去调用你重写的 fputc。你在 fputc 里下的断点,根本不会被触发。

2.2 从 printf 到串口 TX 的完整数据流

为了把路径说得更实在,我用一个具体场景描述:执行printf("hello\r\n")

第一步,编译器生成调用 printf 的代码,printf 再调用 vfprintf。这里传入的流指针是 stdout。

第二步,vfprintf 处理字符串。对于普通字符,它会调用内部的 putc 逻辑。putc 宏先检查 stdout 缓冲区有没有位置,有就直接放进去;如果没有,就触发缓冲区刷新。

第三步,刷新函数拿到 FILE 结构里的写函数指针。在 newlib 中,stdout 的底层写函数并不是 fputc,而是一个从文件描述符出发的写操作,它最终走到_write_r,再转到你实现的_write

第四步,_write 收到三个参数:文件描述符 file(stdout 通常是 1,即 STDOUT_FILENO),缓冲区指针 ptr,以及需要发送的长度 len。你的实现要把 ptr 指向的 len 个字节一个不剩地交给串口,然后返回实际发送的字节数。

所以你会看到一种现象:在 _write 里打断点,printf 执行时会命中;在 fputc 里打断点,永远不命中。这不是优化把你代码吃了,是调用路径根本没经过它。

2.3 newlib 为什么把“底”留在 _write

这个问题背后是 newlib 的设计原则:它希望标准库代码与具体硬件完全解耦。printf、vfprintf、fputc 这些都要符合 C 标准,代码逻辑可以是通用的;但“往串口写数据”这种操作,不同开发板、不同操作系统、不同场景完全不同。newlib 把这类平台相关的操作集中到一组 syscall stub 上。

这组 stub 的作用有点像“操作系统调用接口”。在 Linux 上,printf 最终会通过 write 系统调用写到文件描述符;在 MCU 上没有操作系统,newlib 就把这个“系统调用”留给开发者自己实现。你实现 _write,就相当于告诉库:当程序往 stdout / stderr 写数据时,把这些字节送到我这里。

fputc 之所以不适合当这个“底”,是因为它一次只处理一个字符,而且必须绑定 FILE 流。底层设备驱动更自然的接口是“给我一块数据,帮我发出去”,而不是“一个一个字符地挤牙膏”。一次传一整块,驱动可以做循环发送、DMA、中断控制;一次传一个字符,效率和灵活性都会大打折扣。

2.4 什么时候重写 fputc 才是对的

并不是说 fputc 重写永远没用。当你的工具链和 C 库是下面这些情况时,重写 fputc 是标准做法:

  • Keil MDK 里勾选了 MicroLIB
  • 使用 ARM Compiler 的某些 stdio 库配置
  • 使用 IAR 的默认 printf 重定向方案
  • 某些第三方轻量级 printf 实现,比如把 putchar 作为唯一底层接口的 printf 库

但在 CLion 默认使用的 arm-none-eabi-gcc + newlib/newlib-nano 环境里,重写 fputc 大概率没有效果。这也是为什么网上一搜,答案全是“重写 _write”——因为提问的人几乎都在用 GCC 工具链。

你真正需要判断的是:自己项目链接的是哪个 C 库。查看 CMake 或 Makefile 里的编译选项,如果看到--specs=nano.specs--specs=nosys.specs,那基本就是 newlib 体系,直接重写 _write 就好,不用再碰 fputc。

3. CLion + STM32 工程里真正可用的 _write 实现

3.1 环境假设:ARM GCC + newlib + CMake

我下面的实现基于最常见的 CLion 嵌入式开发环境:arm-none-eabi-gcc 工具链,工程由 STM32CubeMX 生成或手动搭建,C 库使用 newlib-nano,调试器用 ST-Link 或 J-Link 配合 OpenOCD。

这种工程通常会有一个 syscalls.c 文件,里面已经放了 _write、_read、_sbrk 等函数的弱实现或桩实现。你在自己的文件里再写一个强符号的 _write,链接时就能覆盖掉原来的版本。但要注意,如果 CubeMX 生成的 syscalls.c 里 _write 是强符号而不是弱符号,链接会报重复定义。

我遇到过好几个人在这个地方卡住。解决方案很简单:直接打开你的 syscalls.c,把里面 _write 函数体改成自己的实现;或者注释掉原函数,在自己的文件里写。哪个文件方便维护就改哪个,问题是要保证链接后只有一份 _write。

3.2 _write 的标准实现模板与返回值逻辑

这是最常用、最稳的一个实现,直接用寄存器操作串口:

#include <errno.h> #include <stdint.h> #include <unistd.h> extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { errno = EBADF; return -1; } for (int i = 0; i < len; i++) { while ((huart1.Instance->ISR & USART_FLAG_TXE) == 0) { } huart1.Instance->TDR = (uint8_t)ptr[i]; } while ((huart1.Instance->ISR & USART_FLAG_TC) == 0) { } return len; }

有几个细节想强调。第一个是返回值。printf 会根据 _write 的返回值判断输出是否成功。你要返回实际发送成功的字节数,这里发送了 len 个字节,就返回 len。如果你在循环中因为某个错误提前退出,返回一个小于 len 的值,printf 会认为写入不完整;如果返回 0,在有些实现下会反复重试,这看起来就像程序卡在 printf 里。

第二个细节是文件描述符判断。常规情况下 stdout 是 1,stderr 是 2。很多人的 _write 实现完全不判断 file 参数,直接往串口发,这样也能跑,但不够严谨。我习惯把 STDOUT_FILENO 和 STDERR_FILENO 都接收下来,这样任何时候用 printf、perror 输出错误信息,都能从串口看到。

第三个细节是最后等待 TC 标志。TXE 置位只表示数据从数据寄存器搬到了移位寄存器,并不代表发送完成。如果 _write 返回后紧接着执行了低功耗指令或者关闭串口时钟,最后一个字节可能丢掉。等一下 TC 能保证数据真正从 TX 引脚发完,代价是阻塞几个微秒,对调试输出完全不影响。

3.3 用 HAL 还是寄存器操作,怎么选

如果不想碰寄存器,直接用 HAL_UART_Transmit 也可以:

int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { errno = EBADF; return -1; } if (HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY) != HAL_OK) { return -1; } return len; }

用 HAL 的优点是代码可读性好,不用关心具体寄存器差异;缺点是 HAL_UART_Transmit 内部会做状态检查、上锁、超时判断,开销比直接操作寄存器大。对于调试输出这种低频场景,其实无所谓。

但有一个很隐蔽的问题:HAL_UART_Transmit 的第三个参数是 uint16_t 类型,len 如果超过 65535 会被截断。printf 单次输出一般不会这么长,但如果你用 _write 做比较底层的数据导出,可能踩到。我自己的做法是,能用寄存器就直接寄存器,少一层封装,少一个坑。

另外要注意,HAL_UART_Transmit 是阻塞发送,在调用期间如果来一个更高优先级的中断,而那个中断里也调用 printf,会出现字节交错或者死锁。后面第 4 章会专门说这个。

3.4 顺带处理 isatty 与输出缓冲

_write 重写完,很多人还会碰到一种诡异情况:单独写printf("hello")没有输出,加个\n或者手动fflush(stdout)就正常了。这个锅不在 _write,而在 stdio 缓冲策略。

newlib 判断一个文件描述符是不是“终端设备”,会去调用 _isatty。如果返回值是 0,库就认为你是普通文件,可能启用全缓冲;如果是非 0,就按行缓冲或交互设备处理。CubeMX 生成的 syscalls.c 里 _isatty 通常返回 1,但如果你用的是其他模板,有可能返回 0 或者报错。

简单粗暴的解决办法是在 main 开头加一行:

setvbuf(stdout, NULL, _IONBF, 0);

把 stdout 设置成无缓冲。这样只要 printf 被执行,数据会立刻进 _write,不依赖换行符。代价是单字节发送模式效率变低,但在 MCU 调试场景里无所谓。

如果你希望在 stderr 上也生效,也可以顺手处理一下:

setvbuf(stderr, NULL, _IONBF, 0);

3.5 编码问题:中文乱码别让 _write 背锅

热词里有个“CLion 中文输出乱码”,这里单独说一句。CLion 的编辑器默认是 UTF-8 编码,而很多串口助手软件在 Windows 下默认用 GBK 解码。你在 CLion 源码里写中文,编译后字符串在 flash 里是 UTF-8 字节序列;信号到串口助手里,如果 PC 端用 GBK 解,就会显示成乱码。

这不是 _write 的错,你把同样的字符串用 HAL_UART_Transmit 直接发,一样乱码。解决方式有两种:

  • 把串口助手的字符集改成 UTF-8
  • 把源码编码转成 GBK,同时告诉编译器字符串用 GBK 编码

我个人建议第一种,因为源码用 UTF-8 是趋势,CLion 里处理 UTF-8 最方便,串口助手选 UTF-8 解码即可。不要在 _write 里做编码转换,那是在错误的地方解决问题。

4. 四个几乎每个人都会踩的坑:半主机、缓冲、乱码与中断重入

4.1 半主机模式:为什么 printf 会触发 HardFault

有一种更令人崩溃的现象:重写 _write 之前,printf 不仅没输出,程序还会进 HardFault。很多人以为是自己时钟配置错了,其实根因往往是半主机模式。

arm-none-eabi-gcc 的 newlib 在缺少用户提供的系统调用时,部分 stub 会走 semihosting,也就是半主机模式。它的实现方式是在代码中触发一个断点指令,然后让调试器来处理,比如把字符交给开发主机的调试终端。如果调试器没有开启半主机支持,或者现场没有连接调试器,这个断点指令就会触发一个异常,最终进 HardFault。

CubeMX 生成的 syscalls.c 里其实已经规避了大部分半主机依赖,_write、_sbrk 这些都有独立实现。但如果你用的是自定义启动文件、自定义链接脚本,或者不小心链接了 libnosys.a,半主机问题就会冒出来。

一个额外需要注意的警告:不要试图在 _write 里调用 printf 之类的函数。你已经在输出路径里了,再进入 printf 相当于无限递归,很快栈就爆了。调试输出函数里只做最底层的发送动作。

4.2 断点验证:确认程序真的进了我的 _write

写完了 _write,下一步别急着烧录,先在 CLion 的调试模式里验证一下调用链。

在 _write 函数的第一行设一个断点,然后运行,在某个地方触发 printf。如果断点命中,说明整条链路是通的,剩下的只是数据内容和格式问题。如果断点一直不命中,先检查三件事:

  • _write 的定义是否被编译进工程,确认源文件在 CMakeLists 的 add_executable 列表里
  • 链接时是否真的链接到了你的 _write,可以用nm 你的elf文件 | grep _write查看符号地址
  • 是不是从来没有执行到 printf,这个可以用普通行断点验证

还有一个技巧是看反汇编。在 CLion 的调试界面里打开 Disassembly 窗口,找到 printf 的调用点,看它跳转到哪个函数,就能确认库里的 printf 是不是链接了标准实现。大多数情况是你自己代码根本没走到 printf,或者 printf 被某个宏替换掉了。

如果断点命中但串口还是没数据,再看 _write 参数里的 len 是不是 0,以及 ptr 指向的内容是否正常。有时候 printf 传入的字符串本身就是因为各种原因变成空串了。

4.3 打印顺序错乱与缓冲延迟

用 _write 做输出后,另一个常见问题:多个 printf 输出的顺序不对,或者某条日志迟迟不出来,过一会儿突然一下子全出来。

这通常是缓冲造成的,不是 _write 的问题。newlib 的 stdout 如果被判定为块设备,就会启用缓冲区,printf 的内容先攒在内存里,等缓冲区满或者遇到显式刷新才发出来。由于 _write 可能一次收到一大块数据,发送顺序是连续的,但多条日志之间没有明显的界限,看起来就好像“挤在一起”。

我建议在开发阶段直接设置无缓冲,也就是前面那行:

setvbuf(stdout, NULL, _IONBF, 0);

这样每条 printf 执行时会立刻进入 _write,日志的时序更接近真实执行顺序。代价是频繁的写操作会拖慢执行速度,但对定位 bug 来说,这个损失完全值得。

4.4 中断上下文里调用 printf 的互斥问题

最后这个坑比较隐蔽。如果你的串口输出不仅在主循环里用,还会在中断服务函数里用,那 _write 就必须考虑重入问题。

考虑一个场景:主循环里正在执行printf("main"),_write 循环发送到一半,定时器中断触发,中断服务函数里又调用了printf("irq")。这个新的 printf 会再次进入 _write,两个发送流程同时操作同一个 UART,会造成字节交错,打印出来就是乱码。

更糟的是如果 _write 里的等待逻辑没有超时,而且发送接口用的 HAL 锁被占用,可能直接卡死。

我的经验是,在 _write 里加一个简单的临界区保护:

int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { errno = EBADF; return -1; } uint32_t primask = __get_PRIMASK(); __disable_irq(); for (int i = 0; i < len; i++) { while ((huart1.Instance->ISR & USART_FLAG_TXE) == 0) { } huart1.Instance->TDR = (uint8_t)ptr[i]; } while ((huart1.Instance->ISR & USART_FLAG_TC) == 0) { } __set_PRIMASK(primask); return len; }

这样可以保证一次 _write 的发送过程不会被中断打断。但要注意,关中断时间不能太长。如果 len 很大,或者波特率很低,发送一次可能吃掉好几毫秒,这对实时性要求高的系统是不能接受的。更好的方案是用 DMA 加环形缓冲区,把发送动作放到后台,但这个复杂度就高很多,不适合作为默认调试方案。

如果你只是普通调试,我建议主循环用 printf,中断里用不带锁的简化发送函数,或者干脆在中断里只置标志位,把 printf 留到主循环。

5. 验证手段与经验清单

5.1 快速检查清单:从“没反应”到“正常输出”

如果你现在正被 printf 重定向折磨,按照下面这个顺序排查会很快:

检查项判断方法常见问题
串口本身是否可用直接用 HAL_UART_Transmit 发固定数据引脚复用、波特率、时钟没配置好
调用链是否走到 _write在 _write 打断点源文件没编译、链接到旧库
半主机是否干扰看是否 HardFault,反汇编看 BKPT缺少系统调用 stub
缓冲是否吞输出加 \n 或 setvbuf 无缓冲stdout 被当成块设备
中文乱码换串口助手编码为 UTF-8编辑器与串口助手编码不一致
中断交错关中断保护发送过程多上下文同时调用 printf

这套清单我每次换开发板、换工程模板时都会过一遍,能省掉大量查资料时间。

5.2 在 CLion 中管理多个带 main 的实验工程

顺带说一个和 CLion 使用相关的经验。初学阶段经常想把多个教程代码放在一个项目里,每个文件都写一个 main,结果链接直接报重复定义。这不是 printf 的问题,但你排查代码时容易分心。

我的做法是一个工程只保留一个 main,其他实验代码做成独立源文件,配合 CMake 的预处理宏来切换。比如在 CMakeLists 里定义:

add_definitions(-DTEST_UART_PRINTF)

然后在 main 里用条件编译选择实验入口,或者用多个独立的 CMake 配置目录。如果想偷懒,也可以为每个实验单独建一个 CMake 工程,反正 CLion 打开工程切换不费什么时间。保持一个可运行的任务边界,调试起来思路清晰很多。

5.3 最后分享几个串口调试习惯

既然在做串口重定向,顺手分享几个我用下来很顺手的习惯。

第一个是单独留一个调试专用串口。如果硬件条件允许,把调试输出和其他通信分到不同串口。调试串口只连 PC,不会被业务报文干扰,日志看起来干净得多。

第二个是给 _write 加上可配置开关。不同编译阶段对输出的需求不一样:开发阶段需要详细日志,发布阶段可能需要完全关掉。可以用宏包一层:

int _write(int file, char *ptr, int len) { /* 日常调试输出 */ return len; }

平时不需要大改代码,只配置宏就能切换输出级别。如果你用了日志分级库,类似思路会更明确。

第三个是不要嫌轮询发送“低级”。在调试场景里,稳定可靠比性能重要。DMA 发送虽然省 CPU,但一旦没处理好半字对齐和回调,反而浪费你排查时间。先把轮询版跑通,再考虑优化。

我的体会是,串口重定向这问题,归根到底就是“你的 C 库把 printf 的出口放在哪”。搞清楚 newlib 的 syscall 分层,重写 _write 就是顺理成章的事;死记“必须重写 _write”这个结论,下次换到 MicroLIB 环境反而会一脸懵。工具链会变,芯片会变,但“printf 数据最终要经过某个底层写函数”这件事是不变的,找到那个函数,问题就结束了一半。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 15:37:17

游戏画面捕获与预处理实战:屏幕抓取、图像增强与ROI锁定全解析

做游戏自动化、实时识别或者类外挂分析的朋友&#xff0c;应该都绕不开一个问题&#xff1a;怎么把屏幕上那块游戏画面&#xff0c;稳定、高效、不失真地变成算法能吃进去的数据。很多教程一上来就直接扔给你一段OpenCV代码&#xff0c;告诉你这样写就能抓到图&#xff0c;但实…

作者头像 李华
网站建设 2026/9/7 15:36:38

M3U8 下载与资源嗅探完整指南:猫抓(cat-catch)实操

M3U8 下载与资源嗅探完整指南&#xff1a;猫抓&#xff08;cat-catch&#xff09;实操 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 页面能播&am…

作者头像 李华
网站建设 2026/9/7 15:36:27

TikTok技术架构解析:微服务与推荐算法的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 15:36:05

SSD主控SoC与DDR子系统架构解析:数据通路设计的关键

1. SSD主控SoC整体架构拆解&#xff1a;核心模块与数据流设计思路前段时间帮一个朋友调试一块SSD主控板&#xff0c;现象是4K随机写性能死活跑不上去&#xff0c;持续写入时掉到不到标称值的一半。换了固件版本&#xff0c;调了NAND的时序&#xff0c;都没用。最后翻到DDR子系统…

作者头像 李华
网站建设 2026/9/7 15:34:08

开源轻量WebGIS实战:GeoLibre部署、功能测试与二次开发指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华