最近在嵌入式开发群里经常能看到这样一条提问:CLion 里做 STM32 串口重定向,网上清一色让重写_write,可我在 Keil 里面明明重写fputc就能让 printf 输出到串口,怎么换个工具链就完全换了一套玩法?如果你也有同样的疑惑,说明你被工具链的“标准库实现细节”卡住了。这篇内容我不打算只给代码,而是把printf从用户态到串口外设的完整链路拆开,说清楚 CLion 搭配的 ARM GCC 工具链里,为什么_write才是真正需要动手的出口,顺带把我在实际工程里遇到的各种重定向坑一并整理出来。适合正在从 Keil/IAR 转向 CLion 的嵌入式开发者,也适合那些被 printf 输出问题折磨得焦头烂额的 STM32 玩家。
1. 从 Keil 到 CLion:重定向问题为什么会出现
1.1 工具链不同,标准库实现就不同
很多人刚用 CLion 开发 STM32 时,第一反应是把工程建好、串口初始化写好,然后习惯性地写下:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }这是 Keil + ARMCC(ARM Compiler)环境下最常见的重定向写法。Keil 的微库(MicroLIB)在实现printf的时候,底层输出函数走的是fputc,所以你只需要把fputc覆盖掉,就能让所有printf的字符最终流到串口。
但 CLion 默认使用 arm-none-eabi-gcc 工具链,也就是 GCC 的 ARM 版本,配合的 C 库通常是 newlib 或 newlib-nano。newlib 虽然是嵌入式场景里非常轻量的 C 库,但它的设计思路跟 Keil 的 MicroLIB 并不一样:printf在 newlib 内部并不直接调用fputc,而是通过一个统一的系统调用层去写文件描述符。这个系统调用层里最关键的就是_write。
所以,你在 CLion 里重写fputc,实际上是在一个 newlib 根本不打算走的路口设置关卡,printf 的输出自然到不了串口。道理就是这么简单,但如果不理解背后的链路,换一个环境就容易懵。
1.2 printf 重定向的本质:让标准输出指向外设
理清楚这个问题,先要明确“重定向”到底是什么意思。printf的全名是 formatted print to standard output,它的默认输出目标不是屏幕,而是文件描述符 1,也就是 stdout。在我们的嵌入式裸机环境里,根本不存在“屏幕”和“文件系统”,stdout 只是一个抽象概念,需要由开发者告诉底层:“stdout 的数据,请你发给串口 1”。
这个过程在 Keil 里被设计成重写fputc,在 GCC/Newlib 里被设计成重写_write。本质上都是把“抽象输出”和“具体硬件”之间搭一座桥,只是桥的位置不同。
这是一种典型的“移植层”思想。芯片和工具链只负责提供标准接口,至于输出到哪个串口、用什么方式发送、要不要等待发送完成,全部由使用者自己决定。理解这一点后,你再看 CLion 里那些重写_write的教程,就会发现那不是一个神秘的魔法,而是一次标准库层面的“接管”。
1.3 一句话回答:printf 最终调用的不是 fputc
如果只记结论,那就是这句话:在 ARM GCC 工具链 + newlib/newlib-nano 环境下,printf的字符输出链路是printf -> vfprintf -> _write,其中_write是从应用层到硬件层的最后一个可覆盖入口。fputc在这条链路上根本没有位置,所以重写它不起作用。
但这里有一个容易引发争论的地方:有些资料说用__io_putchar也行,有些又说可以直接定义_write,还有人遇到过重定向后 printf 能用但fwrite不稳定的情况。这些其实并不矛盾,区别在于构建系统选用的具体库实现和编译选项。后面我会专门讲不同写法之间的异同,以及在 CLion 工程里怎么选才最稳。
2. 底层原理:ARM GCC 工具链下 printf 到底怎么走到硬件
2.1 newlib 的输出链路是一个分层结构
要真正理解_write的作用,建议把 newlib 的源码结构想像成一个管道:最上层是应用程序调用的printf,它负责解析格式字符串、处理参数、把整数转换成字符序列;中间层是标准 I/O 的缓冲区管理,负责把小块数据聚合成批次;最底层才是文件描述符级别的读写操作,也就是_read、_write、_open、_close这些系统调用。
printf本质上是vfprintf(stdout, ...)的封装,而 stdout 是一个FILE结构体指针。newlib 在初始化标准 I/O 时会默认打开三个流:stdin、stdout、stderr,它们分别对应文件描述符 0、1、2。所有输出到这个流的数据,最终都会被送到底层的_write(int fd, const void *buf, size_t count)函数。
在完全裸机的环境下,这些系统调用没有任何硬件支撑,newlib 给了默认的 stub 实现,返回-1或者直接进入死循环。所以,如果不重写_write,printf会“假装”输出成功,实际上数据全被丢弃。重写它,就是把系统调用层和真实外设对接起来。
2.2 _write 为什么是真正的“最后一道出口”
_write的函数签名非常清晰:
int _write(int file, char *ptr, int len)其中file是文件描述符,ptr是数据缓冲区指针,len是需要写入的字节数,返回值表示实际写入的字节数。当你在代码里调用printf("hello"),newlib 最终会把这段字符串打包成一次或多次_write(1, "hello", 5)调用。
这个设计的牛逼之处在于它具备极强的通用性。你可以根据file参数区分 stdout 和 stderr,让正常打印走串口 1、错误信息走串口 2,也可以把len当作一次传输的总长度,自主决定是做单字节阻塞发送还是一整包 DMA 发送。这些都是重写fputc做不到的。
在 CLion 的 STM32 工程里,重定向的核心工作就是补全_write的实现。最常见的方式是遍历缓冲区,逐字节调用 HAL 库的串口发送函数:
int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }这里直接一次发送一整段,比逐字节调用效率高很多。返回len表示所有数据均已提交给硬件,符合底层契约。
2.3 常见误区:fputc、__io_putchar、PUTCHAR_PROTOTYPE 到底有什么关系
我见过不少人在网上复制代码时,一会儿贴fputc,一会儿贴__io_putchar,还有 STM32CubeMX 生成的代码里带有PUTCHAR_PROTOTYPE宏的东西。这里顺便把它们的区别一次性讲透。
__io_putchar是 STM32CubeMX 生成代码时内部使用的一个 Hetian 函数名,本质也是给新添加的输出钩子用的。在 STM32CubeMX 生成的retarget.c文件里,经常能看到这样的写法:
int __io_putchar(int ch) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }它的地位和fputc类似,是给printf使用的一个底层字符输出函数。但在 ARM GCC + newlib 环境下,真正被调用的是_write,而不是__io_putchar。那为什么有些人说__io_putchar也能用?因为 STM32CubeMX 的新版模板在_write内部调用了__io_putchar。也就是说,你只需要在_write里实现对__io_putchar的循环调用,就能把单片字符逐个送出去;或者更简单,直接重写_write。
PUTCHAR_PROTOTYPE是 STM32CubeMX 在 Keil 环境下为fputc生成的宏定义,跟 GCC 环境关系不大。所以,当你看到网上一个教程用fputc、一个教程用__io_putchar、还有一个直接用_write,别急着困惑。只需要记住判断标准:你的工具链用的什么标准库,底层最终调用的哪个函数,你就重写哪个函数。对 ARM GCC + newlib 来说,重写_write永远是最直接、最不依赖模板版本的做法。
3. CLion 中完整的配置与重定向实操
3.1 CLion + STM32 工程前置准备
说回 CLion 本身。要在 CLion 里搞好 STM32 开发,第一步自然是把环境配置好。CLion 本身不直接识别 STM32 启动文件、链接脚本这些东西,它依赖一个插件来识别 STM32CubeMX 生成的工程。最常用的插件是 STM32CubeMX 插件,安装好之后,可以在 IDE 里直接导入 .ioc 文件,或者打开一个由 CubeMX 生成的 Makefile 工程。
这里有个小建议:如果是从零开始,先让 STM32CubeMX 生成一个基于 Makefile 的工程,再用 CLion 直接打开这个目录。CLion 会把 Makefile 识别成构建方案,并把输出文件解析成可调试的执行程序。C++ 环境问题也顺带能解决——CLion 对 C/C++ 的索引能力很强,只要在 CMakeLists 或者 Makefile 里正确指定了头文件路径,代码补全和跳转都很舒服。网上很多人卡在“CLion 配置 C++ 环境”这一步,其实多半不是 IDE 的问题,而是头文件路径没加全,导致 #include 都标红。
工程能编译之后,下一步就是串口重定向。这个操作跟具体用哪颗芯片关系不大,核心步骤都是串口初始化 + 重写底层输出函数 + 确保 Standard Library 选择正确。
3.2 串口初始化:重定向的前提设备
串口重定向前,必须先把 UART 外设初始化好。以最常用的 STM32F103 系列为例,在 CubeMX 里配置 USART1,模式选 Asynchronous,波特率设为 115200,8 位数据、无校验、1 位停止位。然后在生成代码里,你会得到MX_USART1_UART_Init()函数,以及全局变量huart1。
如果你用的是更常用的 HAL 库,初始化代码基本长这样:
UART_HandleTypeDef huart1; void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); }这里提醒一点:重定向之前,一定要确保HAL_UART_Init被正确执行,而且 GPIO 的复用功能配置正确。很多人折腾半天 printf 没输出,最后发现是串口 TX 引脚根本没配置成复用推挽输出。这个我在排查章节会再次强调。
3.3 重写 _write 的推荐写法
在 CLion 的 GCC 环境下,我推荐直接用_write作为重定向入口,并且把整段数据的发送交给 HAL 函数一次处理。考虑到有些读者可能还需要兼容多种开发环境,比如同一份代码既能在 CLion 里编译,也能在 Keil 里跑,我建议用条件编译做区分。
下面是一段可以直接移植的参考实现:
#include <stdio.h> #include <stdarg.h> #include "stm32f1xx_hal.h" extern UART_HandleTypeDef huart1; #ifdef __GNUC__ /* 在 GCC 工具链下重写 _write,使 printf 重定向到串口1 */ int _write(int file, char *ptr, int len) { (void)file; HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); return len; } #else /* 在 ARMCC/Keil 环境下重写 fputc */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; } #endif这段代码里最核心的地方有三个:第一,__GNUC__宏用来识别 GCC 工具链,arm-none-eabi-gcc 会定义这个宏;第二,_write的第三参数是int len,很多老版本的模板写的是size_t len,这里保持了实际工具的签名,编译时如果报类型冲突,改成size_t或intptr_t即可;第三,发送超时时间给了0xFFFF,这是阻塞发送的兜底时间,不是越高越好,而是够用就行。
另外,如果你希望输出到串口时不用一直占用 CPU 死等,可以把阻塞发送换成中断发送或者 DMA 发送。但要注意,中断发送是异步的,如果直接返回len,下一次 printf 可能覆盖上次的缓冲区。这里有几种常见策略,后面我专门讲。
3.4 使用 newlib-nano 时要注意什么
CLion 的 STM32 CMake 工程默认可能会使用-specs=nosys.specs和-specs=nano.specs这两个选项。nosys.specs会提供默认的系统调用 stub,nano.specs会使用精简版的 newlib-nano。
问题就出在这里:当你重写了_write后,如果链接器使用nosys.specs提供的_writestub,你的自定义_write是否会被链接器采用?答案是肯定的,前提是你的_write定义在链接时可见,且符号冲突没有把它覆盖掉。
一般情况下,只要避免重复定义,链接器会优先使用你定义的符号,而不是库中的默认 stub。但也有一种情况容易踩坑:nosys.specs的_write可能被编译成一个弱符号(weak symbol),而你的定义是强符号,这是正常的,会走你的强符号。如果哪天发现 printf 完全没有输出,可以先用arm-none-eabi-nm查看生成的.elf文件里_write符号是不是指向你的函数地址,方法我放在后面验证环节。
3.5 如何验证你的 printf 确实走了 UART
写完重定向代码后,不要急着接串口助手看数据,还有更稳妥的验证顺序。第一步,编译通过后检查 map 文件或者用nm工具查看符号表,确认_write没有被链接成默认 stub。
在 CLion 的终端里执行:
arm-none-eabi-nm build/你的工程名.elf | grep _write正常情况下,输出会显示类似08002a35 T _write这样的地址,前缀T表示这是一个文本段(代码段)中的全局符号。如果你看到地址后面是U,那说明它还没有被解析,这种情况下要么是你的_write没编译进去,要么是链接脚本和库配置有问题。
第二步,在_write函数第一行加一个临时断点,用 ST-Link 调试器跑起来,然后在串口助手里输入数据,或者直接再代码里手动调用printf("test"),看看断点有没有命中。如果断点命中了,说明整个链路已经打通。如果没命中,优先检查是不是有多个_write定义,以及printf是否因为未初始化 stdout 而崩溃。
4. 实战中踩过的坑和排查方法
4.1 重写了 fputc 却没有任何输出
这是从 Keil 迁移到 CLion 最常见的问题,原因前面已经说清楚了:GCC 工具链的 newlib 库根本不调用fputc。但这里有个细节值得注意,有些人重写fputc后居然也能输出,这种现象是怎么出现的?
排查下来,一般是两个原因。第一个原因是用了armcc的兼容层,比如把__stdout或者FILE流重新绑定到了自定义函数,但这在纯 GCC 环境里很少见。第二个原因是工程里其实还参加了别的库,比如有的第三方库内部自己调用了某个putchar钩子,无意间实现了输出。
所以,正确的排查顺序是:先确认工具链,再看标准库选项,最后验证符号表。不要一上来就怀疑代码写错了。CLion 的构建输出窗口里能看到完整的编译命令,重点看一下命令行里有没有-specs=nano.specs、-specs=nosys.specs,这能帮你判断 C 库是哪种形态。
4.2 串口输出乱码:时钟和波特率到底谁的问题
如果_write重定向成功,但串口助手里显示出来是乱码,先别急着怀疑重定向代码。STM32 的串口乱码九成是波特率不匹配,再深一层可能是时钟树配置错误。
比如 STM32F103 系列,内部 PLL 配置不对,导致 APB2 总线时钟不是 72MHz,UART 的波特率自然就不准。有些人用 HSI 内部时钟跑,误差比较大,也会在高速波特率下出现乱码。调试方法是把波特率改低,比如 9600,看看是否还乱码。如果 9600 正常而 115200 乱码,大概率是时钟误差偏大,需要检查时钟树。
另外一类“伪乱码”需要特别注意:如果_write里用HAL_UART_Transmit发送整段数据,发送频率过高时,串口助手那边会因为接收缓冲区处理不及时丢数据,显示出来像是乱码。这种属于节奏问题,不是代码错。
4.3 打印几次后程序死机
printf 重定向后,程序死机是另一个高频坑。最常见的原因是在_write里使用了同一个串口的发送,但 printf 本身可能在中断或调度器里被多次重入。如果串口发送是阻塞的,那基本没有重入问题;但如果用了中断方式发送,并且在中断回调里又调用了 printf,就会形成递归或互相等待。
另一个常见原因是内存不足。newlib 的printf会动态申请内存,特别是使用%f浮点格式化时,如果堆太小,malloc失败就会导致程序异常。在 STM32 这种小内存芯片上,我一般建议在链接脚本里把堆容量调大,同时避免在中断里调用 printf。
可以试试在_write函数开头添加一个全局标志位,用互斥量或者关中断的方式避免重入,再观察是否还死机。如果死机现象消失,说明就是重入问题。
4.4 中文输出乱码与编码问题
在 CLion 里,代码源文件的中文注释和字符串很容易出现编码混乱。C 源文件默认可能是 UTF-8,而串口助手显示时按照 GBK 或者 ASCII 解析,中文自然变成乱码。这个跟_write没有直接关系,而是开发环境、源文件编码和串口显示工具之间的编码不一致。
我的习惯是:除非软件流程里确实需要向用户输出中文字符串,否则串口调试信息一律使用英文。如果业务要求必须有中文,那就要保证源文件保存为 UTF-8,并且串口助手也设置为 UTF-8 显示,同时字符串内部的编码要能被终端正确识别。很多开源调试工具对这个支持得比较好,但一些老牌串口助手还是默认 GBK,调整显示编码就好。
在 CLion 里,可以在 Settings -> Editor -> File Encodings 里设置全局编码为 UTF-8,避免文件被转成其他编码后出现编译警告甚至乱码。
4.5 CLion 工程中多个 main 文件的问题
用了 CLion 之后,很多人喜欢在一个工程里同时放多个测试代码,比如main1.c、main2.c,或者把每个例子的main放在不同目录。结果编译时总是报重复的 main 函数定义。这是因为 CLion 基于 CMake 会把所有源文件一起编译,多个main自然冲突。
解决办法有几种。最简单的是把不需要用到的文件排除出编译列表,在 CMakeLists.txt 里用注释掉源文件的方式临时屏蔽。或者在文件头部加一个宏开关,不同测试代码之间用预编译条件切换。还有一种思路是拆分多个可执行目标,每个目标包含不同的源文件子集,这样就能在一个工程里保留多个 main。但要注意,CLion 的调试器一次只能加载一个目标,切换调试目标的时候需要在 Run Configuration 里设置。
多个 main 文件的问题本身不难解决,但容易跟重定向问题混在一起。如果你在一个工程里放了两个测试文件,其中一个重写了_write,另一个没有重写,调试时链接器可能把两个符号混在一起,最终出现奇怪的行为。建议是:同一时间保持一个 main 入口,一个_write入口。
4.6 一些不太容易察觉的 _write 相关报错
CLion 开发 STM32 时,还可能在编译或运行阶段遇到一些跟_write看似无关、实际相关的报错。比如编译时提示fatal: write failure on 'stdout': bad file descriptor,这种一般不是代码逻辑问题,而是构建工具或终端环境在重定向标准输出时出错了。在 CLion 里最常见于构建控制台被某些插件污染,或者 Makefile/CMake 脚本里混入了对 stdout 的特殊处理。先清理构建目录,关掉多余的日志插件,再重新编译,大概率能解决。
运行阶段如果遇到类似write to location 000000... caused an access violation的调试器报错,这就是典型的非法内存访问。在_write重定向后,数据缓冲区指针可能是非法的,或者传进来的 len 过大,把不存在的地址也当作输出缓冲区。遇到这种情况,优先检查printf的格式化参数是否跟实际参数类型匹配,比如用%d打印了一个 64 位整数,格式不对会在运行时产生不可预知的数据,进而污染接收缓冲区。这一点在嵌入式调试中非常隐蔽,建议全程开启编译器的-Wformat警告。
5. 从串口重定向延伸出去的经验
5.1 让 _write 同时服务多个串口
_write的签名里带有file参数,这个参数的价值在于你可以根据它区分不同的输出设备。比如项目里有两路串口,串口 1 做调试日志,串口 2 做数据交互,可以在_write里做分流:
int _write(int file, char *ptr, int len) { if (file == 1) { HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, 0xFFFF); } else if (file == 2) { HAL_UART_Transmit(&huart2, (uint8_t *)ptr, len, 0xFFFF); } return len; }这样你在应用层只要选择 printf 是写到 stdout 还是 stderr,就能控制走哪条物理链路。虽然裸机开发中用到的场景不多,但一旦引入 RTOS 或者日志系统,这种分流的收益立刻能体现出来。
5.2 重定向不能顺便解决一切问题
_write重定向只是把 printf 的数据送进了外设,但 printf 本身还有一些天生的限制。比如浮点格式化会显著增加代码体积,因为 newlib-nano 默认不包含浮点格式化的完整实现。如果你用%f却发现输出成了空字符串,需要检查编译选项是否启用了 float printf 支持,比如-u _printf_float。
还有一点,每次调用HAL_UART_Transmit的阻塞超时时间设置过短,比如给 10ms,而一帧数据在低波特率下发送需要更久,就会导致函数超时返回,数据被截断。这种问题通常表现为输出“前半段正常、后半段丢失”。排查时把超时时间增大,或者干脆用HAL_MAX_DELAY(也就是0xFFFFFFFF),不过要小心别让 bug 被永远卡住。
5.3 后续可以扩展的方向
如果你觉得只做 printf 重定向还不够过瘾,可以考虑在此基础上做一个简易的日志系统,把_write收到的数据同时发送到串口和存进 RAM 缓冲区,再通过调试器读取。或者给_write加一个过滤规则,某些等级的日志直接丢弃,减少串口干扰。
在 CLion 的调试环境里,还可以把_write和 SEGGER RTT 结合起来,让 printf 同时输出到串口和 J-Link RTT Viewer,这样就算没有串口线也能看到日志。思路是在_write里轮询调用 RTT 的发送函数。这个玩法在板子调试口被占用时会非常方便。
根据我个人的经验,重定向这件事,最值得花时间的不是找到那个函数名,而是理解工具链和库的关系。CLion 只是暴露了这种关系的冰山一角,一旦你能在_write、fputc、__io_putchar之间自如切换,以后换任何开发环境都不会慌。最后再分享一个小习惯:每次在新工具链里做串口打印,我先不写任何重定向函数,直接编译一次,再用nm看看默认库里有哪些钩子可用。这样能避免很多“照着网上的代码改了半天还是不工作”的弯路。希望这篇东西能帮你在 CLion 的串口重定向上少折腾几个晚上。