1. 为什么这个问题会出现在CLion里
先说个实际场景。很多朋友第一次在CLion里做STM32或者其他嵌入式开发时,都会遇到同一个困惑:明明照着别人的教程写了fputc重定向,串口助手就是没有任何输出;换成_write之后,printf 立刻就能正常打印了。还有人反过来,在 Keil 里一直用的fputc,换到 CLion 这套工具链就翻车,折腾半天不知道问题出在哪。
先说结论:CLion 默认使用的嵌入式工具链是 arm-none-eabi-gcc,配合全新的 newlib 库,printf 的底层输出接口走的是_write系统调用,而不是标准库内部的fputc。如果你把老一套的 IAR/Keil 思路直接搬过来,自然就踩坑了。
这个问题本质上是“工具链差异”造成的,不怪 CLion,也不怪你。真正要理解的是 C 标准库从printf到硬件外设之间那一条完整的调用链。搞清楚了这条链,你在任何工具链里重定向 printf 都能一眼看穿原理,不再靠复制粘贴碰运气。
文章会从调用链分析开始,逐步解读_write与fputc的本质差异,然后给出 CLion + STM32 环境下完整的重定向实现步骤,最后把常见的问题和排查思路整理成速查表。没有太多玄学,全是能落地的经验。
2. printf 的完整调用链到底长什么样
2.1 从 printf 到字符输出的路径分解
大多数人在 MCU 上使用 printf,其实根本不关心它背后经历了什么。但你要想解决“到底该重写谁”这个问题,就必须把这条链路拉出来看一遍。
C 标准库中的printf是一个格式化输出函数,它接收格式化字符串和可变参数,然后将格式化后的字符逐个写入到标准输出流stdout。这个过程内部非常复杂,紧随其后的是一系列缓冲与写入机制。大概的调用关系是这样的:
printf接收格式化参数,解析格式说明符(%d、%x、%s等),然后调用vfprintf或_vfprintf_r(newlib 中带有 reentrant 版本)。vfprintf内部会把格式化后的字符写入一个缓冲(stdio buffer),这个过程会通过函数指针调用到__sputc或__sfvwrite之类的内部函数。- 如果缓冲区满了,或者遇到换行符触发了 flush,最终会调用底层流写入函数。
- 在 ANSI C 标准库中,
fputc是一个公开的、用于向指定流写入单个字符的库函数。但在很多嵌入式工具链的 stdio 实现里,这个函数只是一个薄封装,它最终会调用底层设备写入钩子。 - 这个底层写入钩子在 newlib 中就是
_write(或者说_write_r),在 ARM Compiler 的 microlib 中通常是fputc,而在 IAR 的 DLIB 中则是__write。
我们再简化成一张路径图:
printf └─> vfprintf └─> __sfvwrite / __sputc └─> fputc (stdio 层,可选) └─> _write (系统调用层,真正落到底层设备) └─> 串口发送寄存器 / HAL_UART_Transmit / 自定义输出函数这就不难理解为什么很多人在 Keil 里重写fputc有效。ARM Compiler 的 microlib 在实现 stdio 时,把fputc设计成了最终落到底层设备的那一层,而 newlib 则把这一层定义成了_write。
2.2 标准库内部函数、重定向目标、设备驱动三者的边界
很多教程在讲重定向时,喜欢把“重定向”说得很玄,其实本质就是“找到那个最终调用硬件、把字节发出去的函数,然后把它替换掉”。关键是要分清边界:
printf、vfprintf、__sfvwrite这些是标准库内部实现,属于标准化、与硬件无关的部分。你改它们既没必要也不现实。fputc、fwrite这些是C 标准库提供给用户程序使用的库函数,它们本身是可调的。你可以在自己的代码里调用fputc把单个字符发送到某个流,但在很多库实现里,fputc本身还会继续往下调用更低级别的出口。_write、_read这些是系统调用层的桩函数(stub),在裸机环境下没有操作系统,这些函数默认是空的或者返回错误。newlib 把它们作为“最终的设备无关层出口”,你要做的就是重新实现它,让它把你的字节真正发到串口、LCD、调试通道等任意目标设备。
换句话说,重写_write是在替换一个“系统调用”级别的出口,重写fputc则是在替换标准库中间层的一个函数。这个区别决定了在不同工具链里,你该重写谁的答案不同。
我用一个生活类比来帮新手理解。想象你把一份文件打印出来,流程是你把电子稿提交给打印店前台(printf),前台把内容排版格式化,然后把排好的稿子交给打印机(fputc),打印机本身还需要一个接口去控制纸张进出和喷墨(_write)。不同打印店(不同工具链)的流程设计不同:有的店前台就直接把稿子塞进打印机,你只要把前台替换掉;有的店前台只管排版,真正控制打印机的是后面那个接口,你换前台根本没意义。
_write就是这个“真正控制打印机”的接口。CLion 里的 newlib 把最终设备出口设计在了这里,所以你必须在这里动手。
2.3 有新库 vs 旧库:为什么有人照抄代码依然失败
照抄代码失败的场景特别典型,因为网上 80% 的串口重定向教程都是基于 Keil MDK + ARMCC 或者 IAR 环境写的。这些教程告诉你“重写 fputc 即可”,你跟着做了,在 Keil 里确实跑得通,因为 ARMCC 的 microlib 内部就是那样设计的。
但到了 CLion + arm-none-eabi-gcc + newlib 环境,库的底层实现机制完全不同。newlib 里fputc实现是这样的(简化描述):
int fputc(int ch, FILE *f) { return __sputc(ch, f); }__sputc会尝试写入缓冲区,最终调用__sfvwrite,再一步步走到_write。注意,在这个过程里,fputc并不是最终出口,它只是中间的一环。所以你在 CLion 里重写fputc,相当于换了一个中转站,但最终那个送货司机(_write)还是原来那个默认的空实现,没人把货送到串口。printf 当然没有任何输出。
而 Keil 的 microlib 为了精简代码体积,没有这么复杂的缓冲机制,fputc直接对接了底层 UART 输出函数。所以重写fputc在那个环境下有效。
这个差异给我们的教训是:嵌入式开发中,不要只背结论和代码,一定要看清楚你用的编译器、C 库和启动代码是什么。工具链不同,标准库内部结构就不同,同一个问题的解决方案可能完全不同。
3. 为什么标准库选择 _write 作为最终出口
3.1 _write 的语义本质:系统调用而非库函数
要理解为什么 newlib 把最终出口放在_write,需要了解 newlib 的历史定位。
newlib 最初是为了嵌入式系统(尤其是带 RTOS 的环境)设计的一套 C 标准库。它在设计上假设“下面可能有一个操作系统”,所以它把和硬件、文件系统、设备相关的操作,全部抽象成一组系统调用接口,比如:
_write:向文件描述符写入数据_read:从文件描述符读取数据_open:打开文件_close:关闭文件_lseek:移动文件指针_sbrk:调整堆内存_exit:退出程序
这些接口在没有任何操作系统的情况下,newlib 提供了默认的弱定义(通常只是空壳或者简单返回错误)。裸机开发者的任务,就是把这些“系统调用桩”自己实现掉,让标准库的函数(printf、fread、fwrite 等)能真正落到你的硬件上。
换句话说,在 newlib 的设计哲学里,串口、LCD、文件系统都属于“设备”,统一抽象为文件描述符(file descriptor)。printf写的是标准输出这个文件描述符,数值是 1,即stdout。当你调用printf时,最终执行的是系统调用_write(1, buf, len),向文件描述符 1 写数据。裸机上没有操作系统,你就在_write里把这个文件描述符映射到串口寄存器,或者你想要的任何设备上。
这种设计的好处是:上层逻辑完全与硬件解耦。以后如果你加了 RTOS(FreeRTOS、RT-Thread 等),这套系统调用接口依然保持不变,只不过真正实现的函数会由操作系统的设备驱动来接管,你的应用代码几乎不需要改动。
3.2 newlib 重定向时的消息单位:字节流而非单字符
另一个值得深入理解的点是_write的参数设计。
fputc的参数是单个字符(int ch,但本质是一个字节),返回的是这个字符本身,或者 EOF 表示错误。这个接口设计天然偏向“逐字节输出”。
_write的参数则完全不一样:
int _write(int file, char *ptr, int len);其中:
file:文件描述符。0 表示标准输入(stdin),1 表示标准输出(stdout),2 表示标准错误(stderr)。ptr:指向待写入数据的缓冲区地址。len:缓冲区长度(字节数)。
_write返回的值是实际写入的字节数。注意,不是写入成功返回 0,而是返回实际写入的字节数。这一点经常被初学者忽略,导致埋下隐患。
因为参数是“指针 + 长度”的形式,_write天然支持一次性写入一个缓冲区,而不是一次只处理一个字符。这在性能上具有明显优势:如果你用串口发送一大段日志,重写好的_write可以按缓冲区批量调用 UART 发送,而不是在 stdio 层一个字符一个字符地调用(虽然底层寄存器本质上还是一个字节一个字节发送,但你可以利用 HAL 的缓冲函数来减少函数调用开销,甚至配合 DMA)。
从语义上讲,_write更接近“write 系统调用”的真实含义,它面向的是字节流。而fputc是“字符输出函数”,它面向的是单个字符。所以即便在那些允许你重写fputc的工具链上,二者在抽象层级上也是不同的。
3.3 返回值的问题:为什么会有人发不完整
说到_write的返回值,很多教程压根儿没提。但恰恰是这个问题,在实际工程中坑了很多人。
来看一段常见的新手实现:
int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, 0xFFFF); return len; }从功能上讲,这段代码每次调用就能把len个字节全部发出去,然后返回len,表示“我把 len 个字节都写完了”。这种实现对于串口透传 printf 来说,已经够用了。
但如果你做的是软件仿真、调试器重定向、网络 Socket 虚拟串口,或者你的串口驱动本身支持部分发送(比如 DMA 发送一半出错了),那么_write的返回值就必须准确反映实际发送成功的字节数。因为标准库vfprintf在写入完缓冲区后,需要根据_write的返回值来判断是否发生了写入错误,如果返回值和len不一致,它可能会进入错误处理分支,甚至触发断言。
还有一种情况更隐蔽。当你用fwrite或者fprintf往一个文件流中写入大量数据时,标准库可能分多次调用_write,每次调用传入的len可能只是剩余数据的一部分。如果你的_write实现里有条件判断(比如“如果 len 大于某值我就只发前 N 个字节”),那么你返回的必须是实际发出去的 N,否则上层就不知道还有剩余字节没写。很多“串口日志打印一半就断了”的诡异问题,排查到最后发现是_write返回值写错了。
这里有一个标准实践:
int _write(int file, char *ptr, int len) { // 只处理 stdout 和 stderr if (file == STDIN_FILENO) { return -1; } // 实际发送,假设 HAL 发送是阻塞完整的 if (HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, 1000) != HAL_OK) { return -1; // 表示写入失败 } return len; // 表示全部发送成功 }在裸机环境下,大多数人的实现就是阻塞发送全部字节然后返回len,这是没问题的。但要建立起“_write 的返回值代表实际写入字节数”的意识。
3.4 重定向与半主机模式的关系
讲到_write就绕不开“半主机模式”(semihosting)。ARM 的调试系统在很早之前提供了一种机制,允许目标板上的代码通过一个特殊的软件中断(比如 SVC)来借用调试主机的设备进行输入输出。在这种情况下,printf 的输出并不是从串口出来的,而是直接在 IDE 的调试控制台里显示。
在早期,很多入门教程会让开发者配置半主机模式,然后用调试器看 printf 输出,不需要接任何串口线。听起来很方便,但在实际工程里,半主机模式有比较大的隐患:它必须依赖调试器连接才能运行,一旦拔掉调试器,程序执行到 printf 时会触发一个未定义指令异常,直接导致 MCU 卡死。所以做产品原型时,我一般直接禁用半主机,把 printf 的重定向写死到硬件串口上。
在 CLion + arm-none-eabi-gcc 环境下,newlib 默认并不开启半主机模式,除非你显式链接了相关的半主机库(rdimon)。所以大部分情况下你不用管这个,只需要实现你自己的_write就行。但如果你在链接时看到了类似_sys_open、_sys_write的未定义符号错误,那多半是链接了 rdimon 库或者有遗留的半主机相关代码。解决办法是重新检查链接配置,确保--specs=nano.specs和--specs=nosys.specs这类参数正确。
4. CLion 中重定向 printf 到串口的完整实战
4.1 工具链与硬件环境前提
这里我以一个最常见的组合为例:CLion + STM32CubeMX 生成的工程,arm-none-eabi-gcc 工具链,newlib-nano C 库,芯片是 STM32F103 系列(或其他 STM32 都适用)。这套流程在 AT32、GD32、APM32 等国产替代芯片上也完全通用,只要底层是 ARM Cortex-M 核心 + 正点原子/野火等开发板常用的 UART 外设即可。
在开始之前,请确认你已经能够在 CLion 中编译并下载一个空工程,led 闪烁或者点灯都没问题。如果还不能正常编译烧录,先去解决环境问题再继续。
另外,串口助手的波特率要和你在 CubeMX 中配置的 UART 保持一致。新手最容易忽略的就是 CubeMX 生成的时钟树配置,如果芯片主频不对,实际波特率就会出现偏差,串口助手收到的就是乱码。
4.2 写法一:仅支持 printf 的最简实现
很多人只需要 printf 输出日志,不需要 scanf 回传命令。这种情况下,只需要实现_write就够了。方法是在你的工程里新建一个文件,比如retarget.c,然后写:
#include <stdio.h> #include <stdarg.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, HAL_MAX_DELAY); } return len; }关键点说明:
STDOUT_FILENO和STDERR_FILENO在<unistd.h>中定义,分别是 1 和 2。如果你不想包含额外的头文件,也可以直接写file == 1 || file == 2,但可读性差一些。HAL_UART_Transmit的最后一个参数是超时时间,我一般用HAL_MAX_DELAY,也就是无限等待。在裸机轮询场景下这是最省心的,不会出现“发送还没结束就被打断”的问题。- 注意
HAL_UART_Transmit的第二个参数类型是uint8_t *,而ptr是char *,需要强转。有些编译器会在类型不匹配时报 warning,不影响功能,但建议转干净。
写完这个文件后,你在main.c里包含<stdio.h>,然后调用printf("Hello World\r\n"),就能从串口看到了。
4.3 写法二:printf + scanf 双向重定向
有些场景需要双向通信,比如上位机给 MCU 发指令,MCU 用 scanf 接收解析。那么你还需要补充_read:
int _read(int file, char *ptr, int len) { if (file == STDIN_FILENO) { HAL_UART_Receive(&huart1, (uint8_t*)ptr, 1, HAL_MAX_DELAY); return 1; } return 0; }注意事项:
- 这里实现的是每次接收 1 个字节,返回 1。对应 scanf 等函数从 stdin 读取数据时,底层一次拿一个字符。
HAL_UART_Receive用阻塞模式,意味着程序会卡在这一行直到收到数据。这在某些 RTOS 环境下可能导致任务挂死,需要注意使用场景。- 如果你需要非阻塞接收,可以通过查询串口 RXNE 标志位来实现,或者配合 DMA 中断,但那样代码复杂度会上升,需要单独处理。
新手最容易在这里犯的错误是:只实现了_write,但程序中使用了scanf或者getchar,结果程序编译能过,运行到该处直接卡死。原因是_read没有被实现,标准库默认的那个空实现返回了错误或者直接死循环。所以只要你的程序里有从 stdin 读数据的操作,就老老实实把_read一起写了。
4.4 编译配置:nano.specs 与 nosys.specs 的区别
CLion 的嵌入式工程,一般是通过 CMake 或 Makefile 来调用 arm-none-eabi-gcc 的。在链接阶段,会有一坨编译选项,其中两个和本文的问题直接相关:--specs=nano.specs和--specs=nosys.specs。
--specs=nano.specs:使用 newlib-nano 替代完整版 newlib。newlib-nano 对格式化输出做了精简(比如不支持浮点数打印,除非你开启-u _printf_float)。代价是很多高级功能被裁剪,但体积显著缩小。--specs=nosys.specs:提供一个最简化的系统调用桩,避免链接器报未定义符号错误。它给你的_write、_sbrk等函数提供默认实现,但默认实现都是空壳或直接返回错误。
我在实践中通常两个都用:
--specs=nano.specs --specs=nosys.specs然后在自己写的retarget.c中重新定义_write和_read,把默认的弱符号覆盖掉。这样既能享受 nano 库的体积优势,又不会有链接错误。
如果你在链接时遇到类似undefined reference to _write或者_sbrk、_close等一堆奇怪符号缺失,多半是没有加--specs=nosys.specs,或者你对_write的定义和系统库的某个符号冲突了。
4.5 多串口场景下如何灵活切换输出通道
在实际项目中,一个 MCU 可能同时接了调试串口、GPS 模块、蓝牙模块、触摸屏等多个 UART。有些场景希望 printf 默认走调试串口,但某个模块的逻辑需要能从另一个串口打印信息。这种情况下,直接把_write写死到huart1就不够灵活了。
一种常见的做法是引入一个可全局配置的句柄指针:
UART_HandleTypeDef *debug_uart; void debug_uart_set(UART_HandleTypeDef *uart) { debug_uart = uart; } int _write(int file, char *ptr, int len) { if ((file == STDOUT_FILENO || file == STDERR_FILENO) && debug_uart != NULL) { HAL_UART_Transmit(debug_uart, (uint8_t*)ptr, len, HAL_MAX_DELAY); } return len; }然后在初始化时调用debug_uart_set(&huart1);,后续如果某个函数需要临时切换输出通道,再调用debug_uart_set(&huart2);即可。
但要注意,这种方式在多线程或 RTOS 环境下并不是线程安全的。如果两个任务同时执行 printf,可能会出现交错输出。在 FreeRTOS 里可以配合互斥量,或者将调试功能集中到一个任务里统一输出。
4.6 与 DMA、中断方式发送的配合写法
有一种提高性能的思路是:在_write里不直接阻塞调HAL_UART_Transmit,而是借助 DMA 把数据发出去,然后立即返回。这样上层可以继续干活,异步地发送数据。
但这种写法有一个大坑:DMA 是异步的,而_write的返回值代表“已经写入”的字节数。如果你立即返回len,上层会认为数据已经全部写完,但实际上 DMA 可能还没把数据挪到串口移位寄存器。如果紧接着又调用下一次 printf,就可能造成缓冲区数据被覆盖、发送乱序。所以,除非你有一个环形缓冲区和完善的状态管理机制,否则在裸机上我并不建议用 DMA + 立即返回的方式实现 printf 重定向。
如果你的串口发送中断空闲,可以退一步:在_write中阻塞等待 DMA 传输完成再返回。HAL 库提供HAL_UART_Transmit_DMA和对应的回调函数,再加上一个信号量或者标志位:
volatile uint8_t uart_dma_tx_done = 1; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == huart1.Instance) { uart_dma_tx_done = 1; } } int _write(int file, char *ptr, int len) { while (uart_dma_tx_done == 0); // 等待上一次 DMA 完成 uart_dma_tx_done = 0; HAL_UART_Transmit_DMA(&huart1, (uint8_t*)ptr, len); // 如果需要完全同步,可以一直等到完成再返回 while (uart_dma_tx_done == 0); return len; }但从实际效果看,阻塞轮询发送和 DMA + 阻塞等待在裸机非频繁打印场景下性能差异并不大。真正的性能瓶颈通常在上层业务逻辑和串口波特率本身,_write内部那一点点函数调用开销可以忽略不计。所以我建议初学者不必一开始就追求 DMA 发送,先把阻塞模式跑通,再考虑优化。
5. 常见问题与排查思路实录
5.1 现象一:printf 没输出,但编译下载都正常
这是最常见的问题,占到整个重定向失败案例的七成以上。排查顺序建议如下:
- 确认是否真的调到了你的
_write。在_write函数里加一个调试标志位,或者用一个 GPIO 翻转来看函数是否被调用。如果函数根本没被调到,说明链接时可能没有把你的retarget.c编译进去,或者你的_write被链接器丢掉了(section GC)。 - 确认
printf确实被执行。有时候是程序逻辑根本没走到 printf 那行,比如在某个 while 循环里卡死了。可以先用一个临时变量在调试器中看程序运行位置。 - 确认串口硬件接线和参数。串口助手的波特率、数据位、停止位、校验位要和 CubeMX 配置一致。用逻辑分析仪或示波器看 TX 引脚有没有波形,是判断硬件问题的最快方法。
- 确认没有别的 printf 重定向实现和你的冲突。如果你还包含过正点原子、野火等开发板例程代码,里面很可能也定义过
fputc或_write,两个定义会导致多重定义链接错误,或者其中一个被链接器优先选中,但可能不是你想用的那个。
提示:使用 STM32CubeMX 生成的工程时,默认是不会自动生成 printf 重定向代码的。如果你在 HackerRank 之类的在线题目里练过 C 语言,可能会习惯性地以为 printf 本来就和 stdout 绑定,但在裸机上你必须亲手为它“铺路”。
5.2 现象二:换行符变成了“方块”或者乱码
这个现象非常典型,很多新手刚刚点亮串口时必遇到。原因多数是:Windows 平台下的串口工具将回车换行解析成了两个字符,但你的 printf 字符串里只写了\n。在 Windows 上标准的换行应该是\r\n,而 Unix/Linux 风格是\n。MCU 端输出的\n到 Windows 串口助手时,部分终端工具不会自动补上\r,于是光标只会往下走,不会回到行首,视觉效果就是“楼梯状”或者错位。
解决办法有两个:
- 在所有 printf 格式串中,手动使用
\r\n。比如printf("Hello World\r\n");。 - 在重定向层统一处理:在
_write内部检测到\n时,先补发一个\r。
第二种方式更一劳永逸:
int _write(int file, char *ptr, int len) { if (file == STDOUT_FILENO) { for (int i = 0; i < len; i++) { if (ptr[i] == '\n') { HAL_UART_Transmit(&huart1, (uint8_t*)"\r", 1, HAL_MAX_DELAY); } HAL_UART_Transmit(&huart1, (uint8_t*)&ptr[i], 1, HAL_MAX_DELAY); } return len; } return -1; }注意这里逐字节发送的效率较低。如果你对性能有要求,可以先判断缓冲区中是否需要补\r,再分段批量发送。
另外还有一类乱码与波特率偏差有关。如果你在串口助手里设置的是 115200,但 CubeMX 的时钟树配置导致 UART 实际波特率偏差超过 2%,就会出现规律性乱码。此时重新核对 HSE/LSI 频率以及 PLL 配置即可。
5.3 现象三:使用 scanf 时程序卡死
前面已经提过,scanf和getchar依赖_read。如果你只实现了_write没实现_read,程序运行到等待输入时大概率卡死或者直接跑飞。
另外还要注意:scanf 在 newlib-nano 中可能行为不正常,尤其是使用%s时,它可能不会像桌面环境那样自动跳过空白字符。这会让很多从桌面 C 语言环境切换过来的开发者极其困惑。我的建议是,在 MCU 的调试场景中,尽量减少对 scanf 的依赖,自己写一个简单的字符接收解析函数,可控性更强。如果一定要用,用getchar逐字符接收再做状态机解析,比 scanf 更稳定。
5.4 现象四:打印浮点数显示为 f 或者空白
这是一个经典问题:newlib-nano 默认不包含浮点数格式化支持。使用printf("%f", 3.14)时,不会输出 “3.14”,而是输出一个占位符(取决于版本,可能是空白或者f)。
解决方法是链接时加入-u _printf_float,强制拉入浮点数打印函数。在 CLion 的 CMake 配置中,找到链接选项,加上:
target_link_options(${PROJECT_NAME} PRIVATE -u _printf_float)加上之后,烧录再试,浮点数就能正常打印了。代价是编译体积增加几十 KB 左右,在 Flash 紧张的芯片上要权衡一下。
如果连-u _printf_float都无法解决,那就得确认你确实在使用 newlib-nano 而不是完整的 newlib。在完整版 newlib 里浮点数打印默认是支持的,不需要加这个参数。
5.5 现象五:使用 RTOS 后 printf 输出乱序或被截断
在 FreeRTOS 环境下,多个任务同时调用 printf 时,_write被并发进入,可能出现字符交错或者单次发送被另一个任务的发送打断。我踩过的坑是:任务 A 打印一个较长的调试字符串,发到一半时任务 B 也调用了 printf,结果 HALUART 的发送寄存器被反复覆盖,最后串口助手收到的内容错乱。
解决思路有三类:
- 串口发送互斥锁。在
_write内部加一个全局互斥信号量(FreeRTOS 的SemaphoreHandle_t或裸机的临界区保护),确保同一时刻只有一个任务在执行HAL_UART_Transmit。 - 把所有 printf 输出集中到一个任务。其他任务需要打印时,把数据扔到一个消息队列里,由专门的任务负责从队列取出并调用 printf。这种方式最为灵活,也是大型项目的推荐做法。
- 增加发送完成的等待机制。确保上一次发送完全结束后,下一次才允许启动发送。裸机下就是轮询判断串口的 TC 标志位。
对于有 RTOS 的工程,我建议从一开始就把调试输出模块单独抽象出来,不要直接在各个任务里裸调用 printf。否则后期调试问题会非常痛苦。
5.6 现象六:拔掉调试器后程序卡在 printf
这个问题如果你是从其他 IDE 迁移到 CLion 时尤其容易遇到。旧工程可能启用了半主机模式,在调试器连接时一切正常,但断电重新上电、脱离调试器独立运行后,程序一旦执行到 printf,MCU 就会触发 HardFault 或者死循环。
原因前面说过:半主机模式依赖调试主机响应,脱离了调试器,相关指令无法执行。
排查方法:检查启动文件和链接脚本,看看是否有semihost相关配置;检查编译选项里是否隐含了 rdimon 库。建议统一使用--specs=nosys.specs替代,彻底切断半主机依赖。还有一种情况是使用了printf之外的fopen、fclose等文件操作函数,这些函数依赖_open、_close、_lseek等系统调用,在裸机环境下你没有实现它们的话,同样会导致 HardFault。解决办法是把它们也补上(返回错误即可),或者避免在裸机上使用这些高层的文件 API。
6. 从 _write 到 _write_r:再往下挖一层
6.1 什么时候必须使用 reentrant 版本
在 newlib 中,大多数系统调用桩函数都有两个版本:普通版本和 reentrant 版本。比如_write和_write_r,_read和_read_r。
_write是简化版。不带_reent参数,newlib 内部会通过线程局部存储或全局变量来访问当前线程的 reentrancy 结构。_write_r是带 reentrant 参数版本。第一个参数是struct _reent *ptr。
在单线程裸机环境下,用_write就够了。但如果你的工程使用 RTOS,而且编译器/库配置为多线程安全的 reentrant 模式,那么 printf 内部可能调用的是_write_r,而不是_write。这时候你只实现了_write,会发现并没有生效。
不过对于大多数基于 arm-none-eabi-gcc + newlib-nano 的裸机工程,默认都使用_write。只有在显式开启-DREENTRANT_SYSCALLS_PROVIDED或者链接了某些特殊库时才会使用 reentrant 版本。如果你在 RTOS 场景下发现重定向无效,可以两个函数都实现:
int _write(int file, char *ptr, int len) { // 实现 } int _write_r(struct _reent *r, int file, char *ptr, int len) { return _write(file, ptr, len); }_read_r同理。这样不管你用的是哪个版本,都不会出现问题。
6.2 重定向的另一种思路:宏替换 vs 函数重写
除了在标准库底层重写_write,还有一种常见的做法是用宏把 printf 直接替换掉。比如:
#define printf(...) UART_Printf(__VA_ARGS__)这种方案看起来简单直接,但有很多隐患:
- 无法处理格式化参数类型(除非你自己实现了完整的格式化解析)。
- 在多文件工程中,必须在每个使用 printf 的文件里包含这个宏定义,否则你可能会用到了真正的库函数 printf,重定向失败。
- 调试器在解析 printf 调用时可能会困惑,影响单步调试体验。
所以我的建议是:新手老老实实学清_write重定向的原理,宏替换可以作为应急方案,但不适合作为长期工程策略。理解了标准库调用链后,你会明白函数重写才是与库协作的正确姿势。
7. 写在最后的一些个人体会
做了这么多年嵌入式,我越来越觉得,很多看似玄学的 bug,根源都是对工具链里库实现的理解不到位。网上太多教程告诉你要“照抄以下代码”,却很少解释为什么要这样写,于是形成了大量“在不同环境下抄不同代码”的经验主义工程师。
像重定向 printf 这种问题,只要理解了你用的 C 标准库是如何定义底层的设备输出入口的,你就不会在被问“为什么重写 _write 而不是 fputc”时愣住。
我在实际项目里的经验是:
- CLion 作为 IDE 和工具链组合,在嵌入式开发中的流行度越来越高。用它调试 STM32 的体验,确实比某些老旧的 IDE 要舒服很多,尤其是代码索引、重构和 Git 集成。
- STM32CubeMX 生成的代码框架越来越完善,但它不会替你解决 printf 重定向。你在任何工具链下,都需要自己做这一步。
- 重定向 printf 只是第一步。把它封装成一个稳定的调试输出模块,支持多串口切换、带时间戳、支持不同日志级别,这会让后续的开发调试效率提升很多。
- 永远不要在生产代码里依赖半主机模式输出日志。一套独立的串口日志方案,是嵌入式工程师的基本功。
如果这篇文章能帮你少走一些弯路,那就足够了。以后再看到类似“CLion 中 printf 不工作”的问题,希望你能直接判断出该重写_write而不是fputc。