资讯动态

STM32在CLion中printf串口重定向:为什么必须重写_write而不是fputc

发布时间:2026/9/7 13:36:59 来源:尧图企业网站定制
搞嵌入式开发的朋友尤其是从STM32CubeIDE、Keil转到CLion之后大概率都遇到过这么一个情况想用printf通过串口打印调试信息网上搜了一堆教程照着别人写的fputc重定向代码复制进工程结果串口助手什么都收不到或者程序一跑起来就卡死。这个现象几乎每个用CLion做STM32开发的人都撞见过。问题的根源就在于在CLion默认搭配的ARM GCC工具链和Newlib C库这套体系里printf最终不是通过fputc输出字符的而是通过一个叫_write的底层函数。你不重写_write光折腾fputc等于在给一扇不存在的门配钥匙。这篇文章就把这件事彻底讲透为什么是_write而不是fputc两者的调用关系到底什么样以及CLion工程里究竟该怎么改代码才能让printf稳定输出到串口。不管你是刚转CLion的新手还是被这个问题折磨过的老手这篇文章应该能帮你省下不少排查时间。1. 一条printf背后的调用链为什么你改的fputc没起作用1.1 标准库也是分层设计的很多人写嵌入式C的时候默认把printf当成一个“最终输出函数”以为printf内部就是逐个字符调用fputc把字打出去的。这个理解在部分IDE里成立但在GCC工具链里完全是另一回事。C标准库其实是一个分层结构printf属于高层格式化函数它只负责把参数格式化成一个一个的字符然后把字符交给“流”这个抽象层真正把字符送到硬件外设的是更靠近底层的系统调用函数。我打一个比方你就明白了。printf就像是一家快递公司的客服中心它把你的包裹格式化好的字符串收进来贴上单号然后交给运输车队。车队把货送到你所在城市的配送站配送站再安排快递员送到门口。fputc在这里只是个“包装工”它负责处理单个小件包裹的交接但不负责干线运输。而_write才是那个真正把包裹塞进运输卡车的人也就是实际把数据写入硬件设备的动作发生处。所以在GCC Newlib的环境下printf调用链大致是这样的printf - vfprintf - __sfvwrite - _write。你看这条链路上根本没有fputc的位置。fputc当然也存在于标准库里但它是一条独立的分支主要服务于fputc、fputs、putchar这类按字符流操作的函数。printf的底层输出汇聚点是_write而不是fputc。1.2 fputc与_write两个完全不同的角色为了让你彻底搞清楚我把这两个函数的定位差异拆开说。fputc是ANSI C标准定义的函数它的原型是int fputc(int ch, FILE *stream)。它接收一个字符和一个文件流指针把字符写入这个流。在桌面程序里这个流可以对应到屏幕、文件、网络连接等。在嵌入式环境里标准库内部会维护stdin、stdout、stderr三个默认流。如果你重写fputc你改变的是“往stdout流里写一个字符时的行为”。而_write是POSIX系统调用层的底层接口原型是int _write(int file, char *ptr, int len)。它不关心字符的格式化只负责把ptr指向的len个字节一次性交出去。在桌面Linux上_write最终对应到内核的write系统调用数据会被写入文件描述符对应的设备。在嵌入式Newlib库中_write是标准库与“宿主环境”之间的桥标准库把所有格式化之后的数据统一丢给_write由它决定这些字节到底发到串口、LCD还是SD卡。关键点就在这里printf内部做了格式化之后最终是按数据块调用的_write而不是逐个字符调fputc。所以你重写fputc后printf的输出根本不走你重写的那个函数当然没有效果。只有当代码里明确调用了fputc自己时你写的那份fputc才会被执行。1.3 不同工具链的“埋伏”MDK和GCC的路径不一样这里必须说一个特别容易让人混淆的事情很多老教程说重定向printf要重写fputc而且确实在Keil MDK环境下是可行的。这是怎么回事Keil MDK默认使用的C库是ARM自家的microlib在microlib的实现里printf的底层字符输出确实会经过fputc或者fwrite这类流函数所以你在Keil里重写fputcprintf就会乖乖走你的串口发送代码。这个方法在Keil的生态里流传太广以至于很多人形成了思维定式以为所有工具链都是这样。但你用CLion配合ARM GCC工具链时默认连接的是Newlib或Newlib-nano。这套C库的底层I/O抽象是系统调用层printf最终汇聚到_write而fputc只是流函数层的一个普通成员。这就是为什么网上同一个“重写fputc”的教程在Keil里能跑通换到CLion里就石沉大海。不是你的代码写错了而是你改错了层级。我在实际开发中经常遇到有同事从Keil项目迁移到CLion改了一下午fputc没反应最后我帮他改成重写_write立刻就好了。这篇文章第一篇的核心就是让你建立这个概念先看工具链再看标准库最后才决定改哪个函数。2. 必须重写_write的三个真正理由2.1 半主机模式会让你“死机”如果只是在CLion里新建一个空的STM32工程不写任何重定向代码直接调用printf你会发现程序往往跑不到主循环就进了HardFault或者卡死在某处。这个现象的幕后黑手是Newlib的默认_write实现。Newlib在libgloss里提供了一套默认的syscall实现这套实现依赖ARM的半主机模式Semihosting。半主机是ARM调试架构里的一种机制它允许目标板上的代码通过一个特殊的软件中断BKPT指令向调试主机请求服务比如往主机的控制台打印字符、读写主机文件等。它不是真正的硬件外设驱动而是调试用的辅助通道。当你在IDE里点“下载并运行”调试器会接管这个软件中断把数据转发到IDE的控制台。看起来好像printf真的工作了一样。但只要你断开调试器让芯片独立运行或者用非调试模式启动BKPT指令就没人响应了CPU会直接卡死或者触发异常。这个现象表现得很诡异你的代码明明没有死循环但程序就是停在那里不动。重写_write之后你完全绕开了半主机模式数据不再走BKPT指令而是被你自己的代码直接搬运到UART寄存器里。程序不依赖调试器也能正确输出这是重写_write最实际的价值。2.2 缓冲区刷新机制只有_write能兜住还有一个很容易被忽略的问题缓冲。标准库的stdout流默认是有缓冲的printf的字符先进入缓冲区缓冲区满了、或者调用fflush、或者程序正常结束时缓冲区里的数据才会一次性刷出去。在嵌入式中这个缓冲问题很容易导致“printf没输出”的假象。很多人在主循环里printf一条日志然后在串口助手里什么都没看到就是因为数据还躺在缓冲区里没发出去。如果你只重写了fputc因为fputc本身不参与printf的写缓冲逻辑你依然会遇到这类问题。而重写_write是从缓冲区刷出的最终出口只要缓冲区需要落盘最终都会调用_write把整块数据送出去所以你能在一个统一的出口截获全部数据。当然你也可以通过setvbuf把stdout改成无缓冲模式但那就牵涉到更多标准库配置。最干净的做法还是重写_write从源头上掌控数据的最终去向缓冲的刷新策略也能自己控制。2.3 一次重写一劳永逸重写_write还有一个隐藏优势它覆盖的不只是printf。在你的代码里fprintf、vfprintf、puts、putchar、fputs等一堆函数最终都会汇聚到底层的_write。你只需要在一个地方实现_write这些函数就全部都被重定向了。相比之下如果你去重写fputc你只能覆盖显式调用fputc或者依赖fputc的少数函数。像fprintf这种可以直接写到其他流上的函数你甚至没法只用一个fputc来统一处理。实际项目里各种第三方库很可能偷偷调用了不同的输出函数你不可能把它们全部重写一遍。在_write这一个点上做文章成本最低覆盖面最广这是我在多个项目里验证过的经验。3. CLion下完整实操从代码到验证3.1 先确认你的工程走哪套C库在动手改代码之前先花两分钟确认自己的工程配置。CLion本身不提供编译工具链你需要借助ARM GCC工具链和CMake来构建STM32工程。最常见的是使用STM32CubeMX生成CMakeLists.txt然后用CLion打开。打开CMakeLists.txt重点看编译选项里有没有这两个参数add_compile_options(-specsnano.specs) add_compile_options(-specsnosys.specs)-nano.specs表示使用Newlib-nano这个精简版C库体积更小但功能也少一些不过printf重定向完全没问题。-nosys.specs则表示不链接libgloss里那套半主机syscall实现避免BKPT指令导致的卡死。这两个参数强烈建议都加上。如果你用的是标准Newlib而不是nano版本-nosys.specs同样适用。还有一点值得注意CubeMX生成的代码里通常自带一个syscalls.c文件里面提供了_write、_sbrk、_close、_lseek等一系列系统调用的弱定义。这些弱定义的函数默认就是半主机实现。你要做的就是提供自己的强符号定义来覆盖它们最常见的做法就是在main.c或者专门建一个retarget.c实现自己的_write。3.2 重写_write的标准代码直接上代码。下面这段是经过多个项目验证的UART1重定向实现用的是直接操作寄存器的方式不依赖HAL库的阻塞发送性能和稳定性都比较好。#include stdint.h #include stddef.h #include sys/unistd.h // 这里的USART1寄存器定义来自芯片头文件 // 以STM32F4系列为例只需包含stm32f4xx.h即可 int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { for (int i 0; i len; i) { // 等待发送数据寄存器为空 while (!(USART1-SR USART_SR_TXE)); // 写入一个字节到发送数据寄存器 USART1-DR (uint8_t)ptr[i]; } } return len; }这段代码的逻辑很简单file是文件描述符STDOUT_FILENO对应标准输出STDERR_FILENO对应标准错误。printf默认走的是STDOUT_FILENO把这两者都处理了能避免一些意外情况。循环里先判断USART1的SR寄存器TXE位是否为1为1表示发送数据寄存器空了可以写入下一个字节然后通过DR寄存器把字符发出去。最后返回len告知标准库“我成功发送了len个字节”。如果你是HAL库用户也可以把核心发送部分换成HAL_UART_Transmitint _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); } return len; }用HAL_UART_Transmit的好处是代码可读性好跨芯片型号迁移方便坏处是阻塞等待超时会产生额外的延迟而且依赖HAL库初始化。我个人的习惯是调试阶段用HAL_UART_Transmit省事正式产品里改成寄存器操作减少对库函数的依赖。还需要注意一个细节_write的重定义可能和CubeMX生成的syscalls.c冲突。syscalls.c里已经有一个弱符号的_write你只要在别的文件中定义一个同名强符号链接器会优先使用你的版本这不叫冲突是正常的覆盖。但如果syscalls.c里的_write不是弱符号定义就会导致重复定义错误。较新版本的CubeMX生成的代码已经是弱符号了基本不用操心。真遇到重复定义可以直接把syscalls.c从工程里排除或者注释掉里面所有syscall函数因为新库的-nosys.specs本来就不需要它们。3.3 什么时候仍然需要重写fputc说清楚了_write的重要性再来平衡一下fputc在有些场景下还是有必要重写的。比如你引入了一些直接调用fputc的第三方组件像某些精简的shell组件、协议栈的调试接口它们内部不是通过printf输出而是直接调fputc写字符。这种情况下你重写fputc才有意义。所以正确的做法不是二选一而是分清楚调用方是谁。你自己的日志代码、调试代码统一走printf重写_write就够了如果你的中间件库明确调用了fputc那就在_write之外再补一个fputcint fputc(int ch, FILE *stream) { // 同样可以调用HAL_UART_Transmit或者寄存器发送 while (!(USART1-SR USART_SR_TXE)); USART1-DR (uint8_t)ch; return ch; }这两个函数可以共存互不干扰。我在FreeRTOS的流缓冲区调试、CLI组件接入时就同时用了两份实现一个给printf用一个给组件库用。4. 高频问题与CLion特有坑位排查4.1 常见问题速查表下面这张表总结了我在CLion上做串口重定向时遇到过的典型问题以及对应的解决方法你可以直接对照排查。现象根本原因解决方案printf完全无输出重写了fputc但没重写_write按上文示例重写_write程序启动后卡死、不进主循环默认半主机实现触发BKPT无人响应重写_write或加-nosys.specs串口输出乱码波特率不匹配或发送寄存器判断位错误核对USART初始化波特率检查SR标志位输出只出现一次之后不再打印stdout缓冲区未刷新或代码后续修改了优先级/中断调用fflush(stdout)或重写_write时理解缓冲机制编译报_write重复定义syscalls.c与你的文件都有强符号定义删除syscalls.c或确认弱符号覆盖CLion控制台里看不到输出半主机输出在CLion里不是默认打开用串口助手而非IDE控制台或配置SWO Viewer加入重定向后程序变慢逐字节阻塞发送没有DMA使用DMA空闲中断或在_write里用环形缓冲这里特别说一下乱码问题。乱码有两个来源一个是串口助手的波特率和芯片不一致另一个是字符编码不一致。CLion的控制台默认按UTF-8解码而串口助手五花八门有的默认GBK。如果代码里输出的是中文很容易在串口侧看到乱码。和UART没有关系是两端的文本编码约定不同。4.2 三个CLion配置里的关键细节第一个细节是CMakeLists.txt里的链接选项。很多人在CLion从STM32CubeMX生成的工程里发现编译链接时少了一些必要的选项导致重写_write后还是报错。建议在CMakeLists.txt里显式加上target_link_options(${PROJECT_NAME} PRIVATE -specsnosys.specs -specsnano.specs)同时把编译选项也加上确保头文件搜索路径和宏定义正确。如果不加-nosys.specs即使重写了_write链接器也可能把libgloss里其他的syscall实现拉进最终的elf里这些实现同样有半主机问题。所以确保链接阶段真的没有把半主机实现带进来。第二个细节是CLion的OpenOCD配置。STM32CubeMX生成的工程里通常有OpenOCD的配置文件你需要确认调试器接口、目标芯片型号和复位方式都正确。重写_write后如果程序还是不进主循环先在OpenOCD控制台看有没有异常比如读取寄存器失败、复位向量不对。很多“printf没输出”的问题其实是程序压根没跑起来跟_write一点关系都没有。第三个细节是SWO和ITM。如果你的调试器支持SWO可以不用占串口直接把调试信息通过ITM通道发出来。这时_write的实现可以改成int _write(int file, char *ptr, int len) { for (int i 0; i len; i) { ITM_SendChar((uint8_t)ptr[i]); } return len; }ITM_SendChar是Cortex-M内核调试单元提供的函数它会把字符通过SWO引脚输出给调试器。在CLion里可以打开SWO ITM窗口实时看到printf输出速度比串口快而且完全复用调试线。如果你手上有支持SWO的调试器比如J-Link、ST-Link V3我强烈建议试一下这个方案。4.3 一个小技巧如何确认自己的_write被调用最后分享一个特别实用的排查技巧。有时候你改了代码不确定编译器到底链接了哪一份_write可以在_write函数第一行加一个硬件断点或者GPIO翻转用示波器或者逻辑分析仪看一下。我经常这么做把某个引脚在进入_write时拉高返回时拉低然后跑一次printf看这个引脚有没有波形。如果引脚完全没有反应说明你的_write根本没被调用。这时候需要检查两个点第一链接器是否把你的目标文件排除掉了可以用readelf或者CMake构建日志里查找你的函数符号第二printf是不是真的被执行了有些优化级别下未使用的printf调用会被编译器优化掉。确认这两点基本就能定位问题。还有一个小经验在_write里加个计数器变量在调试器里看它的值变化比GPIO翻转更隐蔽也不占引脚。我用这个办法在排查多路输出重定向时特别管用哪一路输出是走的哪个函数一目了然。这个技巧算是我从无数次调试里总结出来的不算什么高大上的东西但真的效率很高。我在实际项目中摸索下来的体会是重写_write不仅是个技术动作更是一种思路上的转变——你得先搞清楚你的工具链到底依赖什么再决定改哪里。很多人一上来就套用Keil时代的经验结果浪费了大量时间。现在多花十分钟把这一层关系理清楚后面整个调试体验都会顺畅很多。

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

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

免费获取报价