资讯动态

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

发布时间:2026/9/9 6:14:39 来源:尧图企业网站定制
上周有个做嵌入式开发的朋友问我在CLion里调STM32的printf输出网上的教程都让重写fputc为什么你写的方案却是重写_write这个问题其实很典型尤其是从Keil转到CLion的开发者十有八九都会撞上。CLion的嵌入式开发环境默认的工具链是arm-none-eabi-gcc加上newlib而不是Keil里那套ARM Compiler加microlibprintf的内部调用路径完全不一样。如果不搞清楚这一层照搬网上的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-nanoprintf的路径是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通常为1stdout或2stderr // 这里不做区分统一从串口输出 (void)file; for (int i 0; i len; i) { uart_send_char(ptr[i]); } // 返回实际发送的字节数newlib会根据返回值判断是否发送成功 return len; }这段代码实现的功能是newlib的vfprintf格式好一段文本后会调用_write把缓冲区的地址和长度传进来。我用一个循环把缓冲区里的每个字符逐个通过USART1发送出去。注意返回值必须返回len表示所有字符都发送成功了。如果返回值不等于lennewlib可能会认为发送出错甚至会触发错误处理逻辑。3.3 需要留意的小细节与排查边界上面这段代码虽然简单但也有几个需要注意的细节。第一个细节是寄存器名字。STM32F4系列和G0系列的寄存器命名有差异有的系列是ISRTDR有的老系列是SRDR。我上面写的是较新系列的命名如果你的芯片是老型号需要换成对应的寄存器。此外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错误或者出现链接警告。比较常用的做法有几种最简单的是直接用--specsnano.specs这样newlib-nano会自动允许用户改写这些底层钩子。还可以利用链接脚本和编译器选项配合处理让自定义的_write覆盖库里的弱定义。我个人的习惯是在CMakeLists.txt里明确加入set(COMMON_FLAGS --specsnano.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的配置里使用了NOSYSno 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走的哪个出口。这比任何理论分析都来得直截了当。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价