我刚开始从 Keil 转到 CLion 做 STM32 开发时,也踩过这个坑:照着网上教程认认真真重写了fputc,代码编译通过、下载正常,串口却死活不输出任何东西,甚至程序还直接卡死。后来排查半天才发现,CLion 默认的 GCC + Newlib 工具链下,printf的“最后一公里”根本就不是fputc,而是_write。如果你也遇到同样的问题,这篇文章就是为你准备的。
这个问题的本质,是不同 C 运行库对标准输出重定向的约定不一样。Keil MDK 的 ARMCC 库把钩子放在fputc,而 GCC 系工具链配合的 Newlib 库,底层走的是_write系统调用接口。CLion 作为一套以 GCC 为默认工具链的 IDE,自然沿用了 Newlib 的规则。搞懂这个差异之后,你不仅能解决串口输出问题,也能真正理解嵌入式开发里“重定向”这三个字意味着什么,以后遇到类似的调试输出需求,都能一次配好。
这篇文章适合正在使用或者打算使用 CLion 进行 STM32/嵌入式开发的工程师、学生和爱好者,尤其是遇到printf重定向不生效、串口输出卡死、调试程序跑飞等场景的人。我会从“为什么重写 fputc 没有用”讲起,把 C 库调用链、半主机模式、_write的底层逻辑理清楚,再给出我在 CLion 里实际验证过的完整实现步骤,最后分享一些常见的排查技巧。
1. 为什么你重写了 fputc,串口还是“沉默不语”
1.1 网上教程和 CLion 工程的“世界观”差异
如果你之前是在 Keil MDK 上开发的,一定对下面的代码非常熟悉:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }这段代码在 Keil 里确实能让printf重定向到串口输出,但放到 CLion + STM32CubeMX 生成的 GCC 工程里,效果就很微妙了。很多时候是:编译没问题、下载没问题、程序跑起来也没报错,但串口助手就是收不到数据。
为什么会这样?因为 Keil 和 CLion 默认使用的 C 标准库实现完全不是一回事。Keil 默认使用 ARM 官方的 ARMCC/AC6 库,这套库把标准输出重定向的钩子设计在了fputc上,只要你在工程里实现了fputc,printf内部的字符输出就会走你这个函数。而 CLion 默认工具链是 arm-none-eabi-gcc,配合的是 Newlib 或 Newlib-nano,这套库的底层抽象走的是 POSIX 风格的_write接口,printf最终几乎不会经过fputc。
这就是“世界观”的差异。你要在 CLion 里让printf正常输出,首先就要放下 Keil 时代的习惯,从 GCC 的角度去理解重定向这件事。换句话说,不是fputc错了,而是在 CLion 的编译环境下,你的fputc根本没有被printf的内部调用链经过,写了也白写。
1.2 printf 的“最后一公里”到底有多长
为了弄清楚printf到底调用了什么,我做过一次很笨但有效的实验:在fputc里加一个断点,然后在代码里调用printf("Hello\r\n"),运行后断点压根没触发。接着我又在_write里加断点,程序立刻停住了。这个简单的操作,比任何文档都直观。
简单梳理一下 Newlib 下printf的调用路径,大致是这样的:
printf -> vfprintf -> __sfvwrite_r -> _swrite -> _write其中_write是面向操作系统/裸机环境提供的“系统调用层”接口。在裸机环境下,没有操作系统可以替你做输出,Newlib 就默认假设存在一个半主机调试环境,用BKPT指令触发调试器接管输出。如果你没有重写_write,也没有连接调试器的半主机支持,程序就会卡在调试异常里,表现出来就是串口无输出、程序像死了一样。
而fputc在 Newlib 中是一个标准流内部函数,它属于更高层的库函数,printf的默认实现路径并不会强制经过它。所以你在 CLion 里重写fputc,本质上是在重写一个和printf没有直接关联的函数,自然无效。
1.3 半主机模式:嵌入式开发里最容易被忽略的“隐形杀手”
半主机模式(Semihosting)是 ARM 调试体系里的一种机制,简单说,就是目标板通过调试接口借用 PC 主机上的标准输入输出设备。比如你的代码执行printf时,不是通过真实串口输出,而是通过调试器把字符传送到 PC 上的 IDE 控制台。
听起来很美好,但代价是:目标板必须等待调试器响应。如果你下载程序后拔掉调试器,或者 IDE 没有开启半主机支持,BKPT指令就会触发 HardFault 或者让 CPU 进入异常等待,程序卡死。
CLion 配合 OpenOCD 调试时,默认是不会去解释半主机请求的(除非你专门开启相关配置)。所以 GCC 工程如果不重写_write,printf一执行就是一个死局。这也是为什么“重写 fputc”在很多 GCC 工程里不仅串口没输出,程序本身还会跑飞的原因。
2. 真正的主角:_write 函数到底做了什么
2.1 Newlib 给嵌入式世界留下的“系统调用钩子”
在 Newlib 的世界里,_write是一个必须由用户提供的底层接口,除非你链接了默认的半主机实现。它的签名是:
int _write(int file, char *ptr, int len)三个参数的意思很好理解:
file:文件描述符,标准输出一般是 1,标准错误是 2;ptr:要输出数据的缓冲区指针;len:要输出的字节长度。
函数返回值应当是实际输出的字节数,正常情况下等于len。
这个函数之所以重要,是因为 Newlib 内建的printf、puts、fwrite等高层接口,最终输出时都会汇聚到_write这里。你只要实现好它,整个输出家族都会被“打通”,而不只是printf。
这也是我后续实现重定向时的核心思路:不要在高层函数身上打补丁,去最底层的系统调用接口上解决问题,一次解决所有标准输出问题。
2.2 我写给 STM32 的 _write 实现
在 CLion + STM32CubeMX 工程里,我写的_write重定向函数是这样的:
#include <unistd.h> #include "main.h" extern UART_HandleTypeDef huart1; int _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; }注意几个关键点:
- 需要包含
<unistd.h>,否则STDOUT_FILENO、STDERR_FILENO这两个宏没有定义; - 用
HAL_UART_Transmit的阻塞模式发送,超时时间我习惯给0xFFFF,对于调试日志足够; - 返回值直接返回
len,告知库函数数据已全部处理完毕。
有人可能会问,为什么不用HAL_UART_Transmit逐字节发送,而要像上面这样整包发送?其实直接整包发送是最高效的做法。_write拿到的是一个缓冲区和长度,正好符合串口发送接口的参数形式,一次性把整段数据交给串口外设,比在fputc里逐字节发送不知道快了多少倍。
2.3 为什么不建议只重写 fputc 就够了
我在前面已经说了 Newlib 下printf不走fputc,但这里还想多聊一句:即便在某些配置下你的fputc被调用了,只重写它也会留下隐患。
比如,有些旧式工程会通过-fno-builtin或者特定编译选项让printf更“朴素”一些,这时fputc可能确实会被以putc或putchar的形式触及。但你的日志里如果用到fprintf(stderr, ...)、puts,或者某些库函数内部调用了fwrite,一个单独的fputc是覆盖不到的。
而_write是整个标准输出链路的汇聚点,把这里实现好,printf、puts、putchar、fprintf都能正常工作。这也是我强烈建议你在 CLion 中使用_write而不是fputc的核心原因:一次重写,全家通畅。
3. 在 CLion 中完整配置 printf 串口输出的实操流程
3.1 从 CubeMX 到 CLion:工程基础准备
这一节针对还没有在 CLion 里建立工程的读者,如果已经有工程了,可以直接跳到 3.2 节。
我习惯先用 STM32CubeMX 生成基础工程,再导入 CLion。CubeMX 里的配置重点有两个:
- 选择串口外设(比如 USART1),配置好波特率、数据位、停止位;
- 在 Project Manager 里把 Toolchain 选为
STM32CubeIDE或Makefile,方便 CLion 识别和编译。
生成之后,用 CLion 打开工程目录,CLion 会自动识别 CMakeLists.txt 或者 Makefile,加载编译配置。由于 CubeMX 生成的代码里已经包含了串口初始化、时钟配置等基础内容,接下来的重定向工作就非常简单了。
3.2 手把手添加 _write 重定向代码
第一步,在工程里找到放置用户代码的位置。CubeMX 生成的main.c里有两处适合放置自定义函数的区域:一处是/* USER CODE BEGIN 0 */附近的全局区,另一处是/* USER CODE BEGIN 4 */附近的函数定义区。
我建议把_write实现放在/* USER CODE BEGIN 4 */之后,这样后续重新生成代码不会被覆盖。示例如下:
/* USER CODE BEGIN 4 */ int _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; } /* USER CODE END 4 */同时,记得在文件头部包含<unistd.h>。CubeMX 生成的main.c默认不会包含这个头文件,你需要手动添加在/* USER CODE BEGIN Includes */区域:
/* USER CODE BEGIN Includes */ #include <unistd.h> /* USER CODE END Includes */第二步,确认huart1是全局可访问的。在 CubeMX 生成的代码里,huart1定义在main.c的全局区,默认就是全局变量,所以直接extern引用或者直接使用都没问题。如果你用的是其他串口,比如huart2,记得改名字。
第三步,编译下载并测试。
我在main函数的while(1)循环里加过这样一段测试代码:
/* USER CODE BEGIN 3 */ printf("Hello from CLion, UART output OK!\r\n"); HAL_Delay(1000); /* USER CODE END 3 */烧录后打开串口助手,波特率 115200,很快就能看到正常输出。
3.3 关于 Newlib-nano 和浮点打印的特殊处理
很多 STM32 工程为了节省 Flash 空间,会使用 Newlib-nano。CLion 工程里通常可以在 CMakeLists.txt 中看到类似这样的设置:
add_compile_options(-specs=nano.specs) add_link_options(-specs=nano.specs)用上 nano 之后,printf的体积会大幅下降,但它默认不支持浮点格式化输出。如果你试图用printf("%.2f", 3.14),会得到一串令人困惑的输出,甚至可能是“%f”这种原文。
解决方法是在链接选项里补上:
add_link_options(-u _printf_float)-u的作用是强制链接器把_printf_float这个符号拉进来,从而启用浮点输出支持。代价是 Flash 占用会增加几十 KB,但对调试阶段来说完全值得。实际项目中,如果你严格控制日志格式,只输出整数,也可以不启用浮点支持,省下来的空间很可观。
3.4 重定向函数放哪个文件其实有讲究
我见过有人把_write随便塞到某个.c文件里,结果也编译通过了。这样做虽然大多数情况下没问题,但工程结构会变得混乱。尤其是当你使用多个串口、或者需要在不同模式下切换输出通道时,把_write单独放在一个类似retarget.c的文件里,反而是更好的选择。
我自己的做法是新建retarget.c和retarget.h,专门存放重定向相关代码。这样每次 CubeMX 重新生成代码时,用户区代码不会被覆盖,同时依赖关系也非常明确。文件内容大致如下:
// retarget.c #include <unistd.h> #include "main.h" extern UART_HandleTypeDef huart1; int _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; }注意,如果retarget.c不在 CubeMX 生成代码的自动添加列表里,你需要在 CMakeLists.txt 中确保该源文件被编译。CLion 通常会自动扫描目录下的源文件,但如果你的 CMakeLists.txt 是手写的,记得加一句:
add_executable(${PROJECT_NAME}.elf Core/Src/main.c Core/Src/retarget.c ... )这种模块化的好处在后期的项目迭代中非常明显,你不需要在 CubeMX 每次重新生成后重新粘贴一遍代码。
4. 常见问题与排查技巧实录
4.1 printf 没有输出:先从这三个方向查
代码写对了,串口还是静悄悄的,这种问题通常出在三个方向。
第一是串口配置问题。检查 CubeMX 里是否正确开启了 UART 外设,引脚是否被复用为串口功能,波特率是否和上位机匹配。排查方法很简单:手动调用HAL_UART_Transmit(&huart1, "test", 4, 100),如果能看到test,说明串口链路没问题。
第二是链接脚本或启动文件问题。有些精简工程没有链接syscalls.c,而 Newlib 的_write默认定义就在syscalls.c里。如果你自己实现了_write,要确保没有和默认实现产生多重定义冲突。GCC 对于强符号和弱符号的处理有时候比较隐蔽,报错时仔细观察链接日志,不要只看最后几行。
第三是缓冲问题。Newlib 的stdout默认是行缓冲还是全缓冲,取决于目标环境。在裸机上,printf的缓冲区行为有时候会让你觉得数据“丢失”了。最简单的解决办法是在_write里直接整包发送,因为_write拿到的就是库函数内部已经准备好的缓冲块,这个层级已经绕过了最外层的高频字符级缓冲。这也是为什么我们不建议回到fputc的原因之一,fputc这种逐字符接口更容易被缓冲策略影响。
4.2 一调用 printf 程序就 HardFault:半主机模式背锅
这是 GCC 工程里非常经典的问题。默认情况下,Newlib 的_write实现会触发半主机指令,如果你的调试器或者运行环境不支持半主机,程序就会异常。
遇到这种情况,第一件事就是检查你的工程里是否真的重写了_write。很多人在网上copy了代码,但文件名不对、或者放在了#if 0之类的条件编译段里,导致实际链接的还是默认实现。
如果你链接了nosys.specs,情况又会不同。
add_link_options(-specs=nosys.specs)nosys.specs会把 Newlib 的半主机相关实现替换为一个“什么都不做”的版本,程序不会 HardFault,但printf也不会有实际输出。这种配置适合你暂时不想实现_write,又不想让程序崩掉的场景。但在开发调试阶段,我更推荐老老实实重写_write,而不是依赖nosys.specs这个“沉默开关”。
4.3 输出乱码:这些细节别忽略
串口有输出,但内容是乱码,一般原因是波特率不一致,或者发送的字符串编码格式与上位机预期不一致。嵌入式开发里常用的输出字符串,我建议使用\r\n做换行,而不是只用一个\n。有些串口助手或者终端程序对\n的处理不够智能,就会出现“阶梯状”输出,看起来很乱。
类似这种:
printf("Line 1\r\n"); printf("Line 2\r\n");如果看到Line 1Line 2没有换行,可以优先检查是不是只写了\n。
另外,_write里发送len个字节时,确保传入ptr的缓冲区尾部有明确的终止符。虽然len已经告诉了你长度,但有些旧代码会把printf和strlen的结果混用,造成越界发送。一个简单的习惯是:在调试期多开编译器的-Wall -Werror选项,很多坑可以在编译阶段就暴露。
4.4 为什么有的工程重写 fputc 也能生效
这个问题很多人问过。结论是:如果工具链是 ARMCC,用fputc没问题;如果工具链是 GCC + Newlib,用_write才是正确的。
更进一步说,在 Keil 里如果你勾选了 MicroLib,重定向钩子通常也是基于fputc的;如果使用标准 ARMCC 库,有的版本也支持通过$Sub$$和$Super$$这类方式做底层替换。这就造成了一个很有趣的现象:同一个fputc重定向代码,在 Keil 里有效,换到其他 IDE 就不一定了。
我的经验是,拿到任何一份工程代码,先看三样东西:工具链是什么、C 库是什么、启动文件和链接脚本有没有特殊处理。这三样了解清楚,很多“为什么我的没效果”的问题其实都能自己回答。CLion 里默认是 GCC,那你就要以_write为主,不要被 Keil 时代的老经验束缚。
4.5 调试输出也能“软件分路”:ITM/SWO 的另类用法
最后分享一个我在实际项目中经常用的辅助技巧。当你把_write指向串口后,调试阶段如果想同时保留串口给业务数据用,又不想丢掉日志输出,可以用 ITM/SWO 通道。
具体来说,STM32 的 SWO 引脚可以通过调试器把 ITM 数据传回 PC,CLion 配合 OpenOCD 时也可以做相关配置。你可以把_write改成:当某个全局标志为真时输出到 ITM,否则输出到串口。这种“软件分路”在调试复杂通信协议时非常有用,相当于白白多了一个调试通道,而不需要额外占用一个 UART 外设。
当然,ITM/SWO 依赖调试器连接,脱机运行时就派不上用场了。所以我通常只在联调阶段使用,最终交付的固件会把_write固定指向真实串口。这种灵活切换的能力,也是把重定向代码独立成模块带来的好处。
最后再分享一点我的个人体会
刚开始从 Keil 切换到 CLion 时,我也挺烦这种“明明在其他 IDE 里能用的代码,到这里就失效”的情况。但后来想明白了,这不是 IDE 的问题,而是整个工具链的底层设计逻辑变了。理解fputc和_write的区别,也不只是为了在 CLion 里输出一串日志,而是帮你建立了一个更本质的认知:标准库的输出,最终要落到某一个系统调用层接口上,你在裸机上要做的不是魔法,而是把这个接口接到你的硬件上去。
我个人的建议是,哪怕你用 Keil,也值得抽时间了解一下_write这套机制。它和操作系统的接口非常接近,将来你要是跑 RT-Thread、FreeRTOS + POSIX 层,或者把代码往 Linux 环境移植,会发现自己写过的_write经验完全能平移过去。相反,如果只记住了fputc重定向这个“万能公式”,遇到新的软件生态就容易手足无措。
希望这篇文章能帮你少走一些弯路。如果你在 CLion 里配好了_write,串口也顺利输出了第一个Hello,那恭喜你,你的嵌入式开发工具箱里又多了一个值得信赖的伙伴。