CLion中STM32 printf重定向:为什么是重写_write而不是fputc?
2026/9/9 6:14:31 网站建设 项目流程

上周有个做嵌入式开发的朋友问我:在CLion里调STM32的printf输出,网上的教程都让重写fputc,为什么你写的方案却是重写_write?这个问题其实很典型,尤其是从Keil转到CLion的开发者,十有八九都会撞上。CLion的嵌入式开发环境,默认的工具链是arm-none-eabi-gcc加上newlib,而不是Keil里那套ARM Compiler加microlib,printf的内部调用路径完全不一样。如果不搞清楚这一层,照搬网上的fputc重写方案,就算编译通过,串口上也什么都看不到。今天就把这个“为什么”背后的原理一次说清楚。

1. printf输出,到底谁在底层“干苦力”

1.1 先摸清函数调用链

想搞懂为什么重写_write而不是fputc,第一步不是打开代码编辑器,而是先要弄清楚printf这个函数在你项目里到底是怎么一层层调到底层硬件的。

很多从单片机裸机开发入门的朋友,对printf的理解往往停留在“格式化函数”这个层面:给它一个格式化字符串和一堆参数,它就能把整型、浮点、字符串转成可读的文本。但这个转好的文本最终是怎么从串口发出去的?不同环境里的答案是不一样的。

在桌面Linux或者Windows上用C语言写程序,printf最终会将格式化后的文本通过操作系统内核的write系统调用写到标准输出文件描述符上。如果你在终端里运行程序,这个文件描述符指向的是终端设备;如果你做了重定向,它指向的可能是一个文件。

但在单片机上,情况特殊得多。单片机上没有操作系统,也没有通用的设备文件,你调用printf时,标准库并不知道串口硬件在哪里,更不知道你的串口波特率、引脚配置、是否开了DMA。所以标准库只能把“最终输出到硬件”这个动作,抽象成一个可以由开发者自己定义的底层函数,让你来告诉它“字符该怎么发给串口”。

这个可自定义的底层函数,在不同的C库里有不同的名字和实现路径。这就是整个问题的核心。

1.2 newlib里printf的完整链路

在CLion中最常用的嵌入式工具链arm-none-eabi-gcc,自带的C标准库通常是newlib或newlib-nano。在这个环境里,printf的调用链大致是这样的:

printf -> vfprintf -> 内部缓冲区机制 -> _write

这里的_write,是newlib定义的“底层输出钩子”,它的原型是:

int _write(int file, char *ptr, int len);

注意,这个函数接收的不是单个字符,而是一个缓冲区指针和长度。这意味着newlib的设计思路非常明确:printf在格式化文本时会先把文本写入内部缓冲区,然后调用一次或多次_write,把缓冲区里的一整段数据交给底层输出函数。

举个例子,当你调用printf("Hello, %d\r\n", 42)时,newlib会先把"Hello, 42\r\n"这串字符格式化到一个内部缓冲区,然后调用_write,传入缓冲区首地址和字符长度。所以你的_write实现只需要负责把这串字符完整地送往串口即可,不需要关心格式化逻辑。

这是一种比较合理的设计:把“格式化”和“字符发送”两个职责分离,底层写函数可以一次处理一批字符,效率更高。如果你的串口驱动用了DMA传输,甚至可以在_write里直接把缓冲区地址交给DMA,实现异步发送。

1.3 fputc在newlib体系里扮演什么角色

那fputc呢?在newlib里,fputc确实存在,它是一个标准的C库函数,功能是向指定的文件流写入一个字符。它的实现大致是这样的逻辑:

int fputc(int ch, FILE *stream) { // 向stream指向的文件流写入一个字符 }

但在newlib的实际实现中,fputc并不在printf的调用路径上。printf内部走的是vfprintf加_write这条路,并没有经过fputc。也就是说,你就算把fputc重写了,printf也不会调用你的版本——除非你直接调用fputc本身。

很多网上的STM32教程为什么让你重写fputc?因为这些教程大多基于Keil MDK环境。Keil默认使用的是ARM Compiler自带的microlib,在microlib的实现里,printf的输出路径最终会经过fputc,所以重写fputc可以生效。这个经验在Keil环境里没错,但直接搬到CLion的gcc工具链下,就不适用了。

我在CLion里第一次做串口重定向时也踩过这个坑:照着Keil里的写法,定义了一个fputc,然后在代码里调用printf,编译完全通过,下载到板子上,串口终端什么都没有。后来在_write里加了断点,才发现程序压根就没进过我的fputc。

2. 网上教程大都写fputc,为什么你的CLion却不行

2.1 不同C库的内部实现差异

很多从Keil迁移到CLion的开发者,会先入为主地认为printf重定向是“一个标准做法”,到哪个环境下都一样。实际上,不同C库对标准IO的实现差别很大,尤其是底层输出钩子的设计。

Keil的ARM Compiler在使用了microlib时,printf输出路径可以简化为:printf -> fputc -> 你重写的fputc。这就是为什么你在Keil里重写fputc,调用printf就能在串口看到输出。

而CLion配合arm-none-eabi-gcc时,默认使用newlib或newlib-nano,printf的路径是:printf -> vfprintf -> _write。在这种情况下,fputc和printf基本是两条线:fputc是标准文件流接口的一部分,而printf走的是自己的格式化输出路径,最终统一进入_write。

我可以用一个简单的类比来说明:你给公司前台打电话说“把这份文件送到A办公室”,前台挂断电话后会走内部通道把文件送过去。在Keil的microlib里,前台门口就只有一道门,你改造门铃就能通知到前台。在newlib里,前台内部有专门的文件分发渠道,你光改门铃是没用的,得改分发渠道的出口。

2.2 ARM Compiler与GCC工具链的路径对比

为了减少误解,我把两种环境下printf最终输出到串口的前后路径整理成一个简表:

工具链C库重定向printf的关键函数printf内部调用路径
Keil MDK + ARM Compilermicrolibfputcprintf -> fputc -> 硬件驱动
CLion + arm-none-eabi-gccnewlib / newlib-nano_writeprintf -> vfprintf -> _write -> 硬件驱动

我在实际项目里同时维护过两套工程(一套给Keil的老客户,一套给CLion的新项目),最深的体会是:不要试图在代码里用条件编译同时兼容两套重定向方式,很容易导致逻辑混乱。更好的做法是在底层抽象一层类似uart_write_buffer()的函数,然后分别在fputc和_write里调用它,这样无论哪个环境都能正常工作。

2.3 既然路径不同,那么如何快速判断自己该改谁

如果你不确定自己的项目里printf最终会调用谁,最直接的办法不是猜,而是看map文件。在CLion编译完成后,去build目录里找到.map文件,搜索fputc和_write,看这两个符号是否被引用。如果fputc被调用了,说明你的库走的是fputc路径;如果出现了_write或_sbrk等系统调用符号,说明你用的是newlib全家桶,应该重写_write。

还有一种更快的验证方法:在代码里同时定义fputc和_write,各自往不同的串口发一段固定字符。然后调用printf,看哪个串口收到了数据。这虽然是一个比较粗暴的测试方法,但结果非常直观,能让你在五分钟内弄清楚当前工具链的实际行为。

3. 在CLion里重定向printf的正确姿势

3.1 先确保串口驱动已经能正常工作

说了这么多原理,接下来进入实操部分。在CLion里重定向printf,第一步不是写_write函数,而是先确保串口本身能收发数据。

我在实际开发中,会先写一个最简单的测试:不调用printf,直接调用串口发送函数,往串口发一个固定的字符串,例如uart_send_string("UART OK\r\n")。如果这一步在串口助手里能看到数据,说明串口的GPIO配置、时钟使能、波特率设置都是对的,可以直接进行下一步。

很多人在这个环节出错:先写了_write,然后调用printf,发现没有输出,就开始怀疑_write写错了。结果排查了半天,最后发现是串口的TX引脚配置错了,或者GPIO复用功能没开。这个顺序问题很关键,先把基础的串口发送验证通过,再谈printf重定向。

3.2 一个可以直接抄的_write实现

串口验证通过之后,下面是我在CLion + STM32环境中常用的_write实现,可以直接抄:

#include <errno.h> #include <sys/unistd.h> // STM32使用USART1,寄存器级别的发送函数 // 注意:这里假设你已经初始化了USART1,并使能了TX static void uart_send_char(char c) { // 等待发送数据寄存器为空 while (!(USART1->ISR & USART_ISR_TXE_TXFNF)); // 发送一个字节 USART1->TDR = (uint8_t)c; } int _write(int file, char *ptr, int len) { // newlib调用_write时,file通常为1(stdout)或2(stderr) // 这里不做区分,统一从串口输出 (void)file; for (int i = 0; i < len; i++) { uart_send_char(ptr[i]); } // 返回实际发送的字节数,newlib会根据返回值判断是否发送成功 return len; }

这段代码实现的功能是:newlib的vfprintf格式好一段文本后,会调用_write,把缓冲区的地址和长度传进来。我用一个循环,把缓冲区里的每个字符逐个通过USART1发送出去。注意返回值必须返回len,表示所有字符都发送成功了。如果返回值不等于len,newlib可能会认为发送出错,甚至会触发错误处理逻辑。

3.3 需要留意的小细节与排查边界

上面这段代码虽然简单,但也有几个需要注意的细节。

第一个细节是寄存器名字。STM32F4系列和G0系列的寄存器命名有差异,有的系列是ISR+TDR,有的老系列是SR+DR。我上面写的是较新系列的命名,如果你的芯片是老型号,需要换成对应的寄存器。此外,HAL库用户可以直接调用HAL_UART_Transmit函数,但要注意这个函数有超时机制,传递一个较大的超时值会更稳妥。

第二个细节是,如果你的_write会被多个地方调用,例如printf和fprintf(stderr)都可能触发它,那么你的_write实现里最好加一个简单的临界区保护,防止并发访问串口导致的字符交错。在裸机环境下,最简单的方式是关中断或使用一个互斥的标志位。当然,如果你只是在一个主循环里调用printf,没有多线程,也不开中断发送,那么不加保护问题也不大。

第三个细节是在使用newlib-nano时,如果你调用printf输出浮点数,默认情况可能什么都打不出来。这是因为newlib-nano为了减小体积,把浮点格式化支持裁剪掉了。解决方式是在CMakeLists.txt里加上:

add_compile_options(-u _printf_float)

这个选项会强制链接浮点打印支持。我遇到过好几次这个坑,所以特意提一下,否则你重写完_write,发现整数能输出,一到浮点数就空白,很容易怀疑是_write的问题。

3.4 为什么说CLion里重写fputc不生效是有原因的

综合上面的分析可以看到,CLion里printf输出不经过fputc,本质上是C库的实现路径差异导致的。但这里还要补充一种特殊情况:如果你的代码里显式调用了fputc函数,比如执行fputc('A', stdout),那么你重写的fputc是会生效的。问题只在于printf不会调用fputc,而不是fputc本身不工作。

这就解释了为什么你会看到一种现象:重写fputc后,先调用printf没有输出,再直接调用fputc测试,能看到字符发出来,于是百思不得其解。这个现象其实说明你的fputc重写是对的,串口初始化也是对的,只是printf根本没走上这条路。

4. 从printf重定向延伸到C库的系统调用依赖

4.1 底层钩子不止_write一个

在嵌入式C项目中,一旦你使用了newlib,标准库的很多功能都会依赖一些“底层系统调用”。这些函数原本是为有操作系统的环境设计的,在裸机上没有现成实现,需要开发者自己补齐或者让库使用精简模式。

除了_write,常见的还有_sbrk、_read、_close、_lseek、_fstat、_isatty等。其中_sbrk负责堆内存管理,如果你的程序用了malloc或者newlib内部需要申请堆空间,就会用到它。在STM32的启动文件里,如果没有正确设置堆指针,malloc和printf的某些内部操作可能崩溃。

我觉得对只做串口输出的小项目来说,你可以不实现所有系统调用,只实现_write就够用了。但如果你的程序涉及文件操作、标准输入,或者使用了一些依赖文件描述符的库函数,就要补上对应的函数。一个常见的做法是写一个syscall.c文件,把这些钩子统一放在一起管理。

4.2 使用Retargeting需要注意的编译告警

在CLion的CMake工程里,你可能需要告诉编译器“这些函数是我自己实现的,不要再从库里面拉取默认版本”。否则某些工具链版本会报duplicate symbol错误,或者出现链接警告。

比较常用的做法有几种:最简单的是直接用--specs=nano.specs,这样newlib-nano会自动允许用户改写这些底层钩子。还可以利用链接脚本和编译器选项配合处理,让自定义的_write覆盖库里的弱定义。

我个人的习惯是,在CMakeLists.txt里明确加入:

set(COMMON_FLAGS "--specs=nano.specs") set(COMMON_FLAGS "${COMMON_FLAGS} -u _printf_float") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${COMMON_FLAGS}") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${COMMON_FLAGS}")

这样就少了很多重复的链接告警,printf浮点支持也能正常使用。如果你的芯片内存非常小,还可以关闭浮点打印来省空间,代价是调试时不能直接用printf输出浮点数据。

4.3 为什么不建议用HAL库的HAL_UART_Transmit直接顶替

在我看过的一些项目里,有人在_write里直接调用HAL_UART_Transmit,这个方法本身可以用,但要注意两个问题。第一个问题是HAL_UART_Transmit的阻塞式发送在调试阶段问题不大,如果以后要改成中断或DMA发送,_write里再等待发送完成,会影响系统实时性。第二个问题是HAL_UART_Transmit内部有超时循环,它依赖一个系统时钟源(tick),如果调试器暂停在断点时间过长,启动时的tick没有及时更新,在特定情况下可能在发送大字符串时触发超时退出,导致丢数据。

所以我的建议是:测试阶段可以用HAL库函数快速跑通流程,但正式产品代码里,_write内部尽量操作寄存器,或者单独封装一层中断/DMA发送接口。这样不依赖HAL的阻塞逻辑,也不会因为tick被调试器冻结而产生不确定行为。

5. 我的一个实际调试经历与建议

5.1 排查日志里出现的bad file descriptor

有段时间我用CLion调试一个stderr重定向时,程序运行后串口没有任何输出,但控制台里出现了类似fatal: write failure on 'stdout': bad file descriptor的提示。看到这个提示,第一反应是不是_write写错了?检查了好几遍,函数逻辑没问题。

后来深入查了一下,发现问题出在newlib的stderr默认关联的文件描述符与系统环境的差异上。在桌面系统里,stderr通常也是有效的终端文件描述符;但在裸机程序里,如果你没有初始化标准流,调用fprintf(stderr, ...)会触发内部错误处理,进而走进默认的_write,但这个默认版本可能没有正确实现,于是返回一个错误值。

实际上,这种情况通常是因为我在newlib的配置里使用了NOSYS(no system calls)模式,导致默认的_write被设置为一个永远返回错误的版本,而我又没有正确覆盖它。后来我在项目里统一注释掉了NOSYS相关的宏,并且在_write实现里对file参数做了忽略处理,问题就解决了。

5.2 最后的小建议:从Keil迁移过来先确认环境再照搬

我在写了这么多样例之后,最想强调的一点是:嵌入式开发里printf重定向不是一个“万能统一”的问题,它跟你用什么IDE、什么编译器、什么C库强相关。

如果你正在用CLion做STM32开发,请记住最核心的一句话:当前环境默认的gcc工具链走的是newlib体系,正确做法是重写_write而不是fputc。至于那些让你重写fputc的教程,要先看清它对应的编译环境,再考虑是否适用于你的项目。判断环境的开销很低——打开map文件看一眼符号表就够了,但照搬错误方案的排查成本却高得多。

从我个人经验来说,当你怀疑“为什么我重写fputc不起作用”的时候,先别急着怀疑编译器、怀疑启动文件、怀疑调试器,先去看一眼printf的底层调用链。C库的源码是开放的,如果手头不方便看源码,直接在_write和fputc里各放一个断点,让程序跑一次,哪个断了,说明printf走的哪个出口。这比任何理论分析都来得直截了当。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询