“在 CLion 里用 arm-none-eabi-gcc 开发 STM32,串口重定向是绕不开的一道坎。很多人第一次都会按 Keil 教程重写 fputc,结果发现 printf 完全没反应,程序还动不动卡死;换成网上说的重写 _write,烧进去一秒钟就通。CLion 里为什么要重写 _write,而不是 fputc?这背后不是编辑器偏好,而是 C 运行库的调用链差异。完整理解这条链,以后换工具链、换芯片都不会再被这个问题卡住。”
1. 先复现一次现场:fputc 改了没反应,问题出在哪
1.1 代码照 Keil 教程写的,串口却纹丝不动
我之前在一个 CLion + STM32F103 的项目里做调试输出,第一个动作就是找熟悉的套路:重写 fputc,把字符丢给串口。代码大概是这样的:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY); return ch; }然后 main 里写:
printf("hello\r\n");烧进去,打开串口助手,什么都没有。程序也没有跑飞,LED 正常闪烁,中断正常执行,只有串口像死了一样。我重新检查 HAL_UART_Transmit 的参数,波特率、引脚复用,全都没问题。单独把HAL_UART_Transmit拎出来发一个字节,又能正常发送。
也就是说,重定向本身没生效,而不是串口外设有问题。
1.2 真相浮出水面:链接的是 newlib,不是 MicroLIB
在 Keil MDK 里,多数教程默认启用 MicroLIB。MicroLIB 的 printf 实现比较“朴素”,打印字符时最终会回调 fputc,所以重写 fputc 就能接管输出。而 CLion 里最常见的 STM32 工具链是 arm-none-eabi-gcc,默认的 C 库是 newlib 或 newlib-nano。newlib 的 stdio 并不依赖 fputc 作为最终落点,它有一套更接近 POSIX 的底层接口,其中最关键的是这组函数:
- _write
- _read
- _lseek
- _close
- _fstat
- _isatty
- _sbrk
printf 的数据最终会走_write,而不是 fputc。所以你在 CLion 里重写 fputc,本质上是在改一个 newlib 内部根本不走的旁路函数。旁路修得再漂亮,主路不通就是不通。
1.3 这里其实还藏了一个调用层次的问题
把 fputc 和 _write 放在一起对比,能看出两个完全不同的抽象层次。
| 对比项 | fputc | _write |
|---|---|---|
| 所属层次 | C 标准库的字符输出函数,操作 FILE 流 | 系统调用层,通过文件描述符写数据 |
| 典型参数 | 单个字符 + FILE 指针 | 文件描述符 + 缓冲区指针 + 字节数 |
| 工作方式 | 处理了缓冲、出错标志等 stdio 细节 | 只负责把缓冲区里的数据“交出去” |
| printf 是否经过 | newlib 下不经过 | newlib 下最终必经 |
| MicroLIB 下是否有效 | 有效 | 不适用或不经由 |
这也是很多从 Keil 转过来的开发者最困惑的地方:明明“写一个字符”这个动作是 fputc 干的,为什么 newlib 不认?因为 newlib 把问题分成了两层:上层 stdio 负责格式化、缓冲;下层 syscall 负责真正把数据送到哪里。printf 属于上层,_write 属于下层。fputc 虽然也叫“字符输出”,但它本质上是上层对单个字符的封装,不属于这个分层体系里的必经点。
2. 顺着调用链往下看:newlib 里的 printf 为什么不认 fputc
2.1 printf 并不是“一个字符一个字符往外吐”
很多嵌入式教程会给你一种感觉:printf 内部就是个循环,把格式化后的每个字符依次扔给 fputc。这个模型在 MicroLIB 里大致成立,在 newlib 里却不成立。
newlib 的 printf 最终会走进 vfprintf,vfprintf 负责解析格式串,然后把结果字符写到 stdout 对应的 FILE 结构里。写的过程并不是直接调用外部符号 fputc,而是把字符放进 stdio 的缓冲区,或者通过内部的刷新路径把缓冲区内容交给底层。这个底层刷新动作最终会调到 _write。
换句话说,newlib 里 printf 的完整路径大致是这样的:
- printf 解析格式串,生成输出内容
- 内容写入 stdout 的用户态缓冲区
- 遇到换行、缓冲区满,或者调用 fflush 时触发刷新
- 刷新函数把缓冲区指针、要写的长度交给 _write
- _write 拿到文件描述符和字节块,真正往串口发送
这条链上确实会出现“把单个字符写进某个地方”的动作,但那个动作在 newlib 内部用宏和函数指针完成,不会回头去调用你重写的 fputc。你在 fputc 里下的断点,根本不会被触发。
2.2 从 printf 到串口 TX 的完整数据流
为了把路径说得更实在,我用一个具体场景描述:执行printf("hello\r\n")。
第一步,编译器生成调用 printf 的代码,printf 再调用 vfprintf。这里传入的流指针是 stdout。
第二步,vfprintf 处理字符串。对于普通字符,它会调用内部的 putc 逻辑。putc 宏先检查 stdout 缓冲区有没有位置,有就直接放进去;如果没有,就触发缓冲区刷新。
第三步,刷新函数拿到 FILE 结构里的写函数指针。在 newlib 中,stdout 的底层写函数并不是 fputc,而是一个从文件描述符出发的写操作,它最终走到_write_r,再转到你实现的_write。
第四步,_write 收到三个参数:文件描述符 file(stdout 通常是 1,即 STDOUT_FILENO),缓冲区指针 ptr,以及需要发送的长度 len。你的实现要把 ptr 指向的 len 个字节一个不剩地交给串口,然后返回实际发送的字节数。
所以你会看到一种现象:在 _write 里打断点,printf 执行时会命中;在 fputc 里打断点,永远不命中。这不是优化把你代码吃了,是调用路径根本没经过它。
2.3 newlib 为什么把“底”留在 _write
这个问题背后是 newlib 的设计原则:它希望标准库代码与具体硬件完全解耦。printf、vfprintf、fputc 这些都要符合 C 标准,代码逻辑可以是通用的;但“往串口写数据”这种操作,不同开发板、不同操作系统、不同场景完全不同。newlib 把这类平台相关的操作集中到一组 syscall stub 上。
这组 stub 的作用有点像“操作系统调用接口”。在 Linux 上,printf 最终会通过 write 系统调用写到文件描述符;在 MCU 上没有操作系统,newlib 就把这个“系统调用”留给开发者自己实现。你实现 _write,就相当于告诉库:当程序往 stdout / stderr 写数据时,把这些字节送到我这里。
fputc 之所以不适合当这个“底”,是因为它一次只处理一个字符,而且必须绑定 FILE 流。底层设备驱动更自然的接口是“给我一块数据,帮我发出去”,而不是“一个一个字符地挤牙膏”。一次传一整块,驱动可以做循环发送、DMA、中断控制;一次传一个字符,效率和灵活性都会大打折扣。
2.4 什么时候重写 fputc 才是对的
并不是说 fputc 重写永远没用。当你的工具链和 C 库是下面这些情况时,重写 fputc 是标准做法:
- Keil MDK 里勾选了 MicroLIB
- 使用 ARM Compiler 的某些 stdio 库配置
- 使用 IAR 的默认 printf 重定向方案
- 某些第三方轻量级 printf 实现,比如把 putchar 作为唯一底层接口的 printf 库
但在 CLion 默认使用的 arm-none-eabi-gcc + newlib/newlib-nano 环境里,重写 fputc 大概率没有效果。这也是为什么网上一搜,答案全是“重写 _write”——因为提问的人几乎都在用 GCC 工具链。
你真正需要判断的是:自己项目链接的是哪个 C 库。查看 CMake 或 Makefile 里的编译选项,如果看到--specs=nano.specs或--specs=nosys.specs,那基本就是 newlib 体系,直接重写 _write 就好,不用再碰 fputc。
3. CLion + STM32 工程里真正可用的 _write 实现
3.1 环境假设:ARM GCC + newlib + CMake
我下面的实现基于最常见的 CLion 嵌入式开发环境:arm-none-eabi-gcc 工具链,工程由 STM32CubeMX 生成或手动搭建,C 库使用 newlib-nano,调试器用 ST-Link 或 J-Link 配合 OpenOCD。
这种工程通常会有一个 syscalls.c 文件,里面已经放了 _write、_read、_sbrk 等函数的弱实现或桩实现。你在自己的文件里再写一个强符号的 _write,链接时就能覆盖掉原来的版本。但要注意,如果 CubeMX 生成的 syscalls.c 里 _write 是强符号而不是弱符号,链接会报重复定义。
我遇到过好几个人在这个地方卡住。解决方案很简单:直接打开你的 syscalls.c,把里面 _write 函数体改成自己的实现;或者注释掉原函数,在自己的文件里写。哪个文件方便维护就改哪个,问题是要保证链接后只有一份 _write。
3.2 _write 的标准实现模板与返回值逻辑
这是最常用、最稳的一个实现,直接用寄存器操作串口:
#include <errno.h> #include <stdint.h> #include <unistd.h> extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { errno = EBADF; return -1; } for (int i = 0; i < len; i++) { while ((huart1.Instance->ISR & USART_FLAG_TXE) == 0) { } huart1.Instance->TDR = (uint8_t)ptr[i]; } while ((huart1.Instance->ISR & USART_FLAG_TC) == 0) { } return len; }有几个细节想强调。第一个是返回值。printf 会根据 _write 的返回值判断输出是否成功。你要返回实际发送成功的字节数,这里发送了 len 个字节,就返回 len。如果你在循环中因为某个错误提前退出,返回一个小于 len 的值,printf 会认为写入不完整;如果返回 0,在有些实现下会反复重试,这看起来就像程序卡在 printf 里。
第二个细节是文件描述符判断。常规情况下 stdout 是 1,stderr 是 2。很多人的 _write 实现完全不判断 file 参数,直接往串口发,这样也能跑,但不够严谨。我习惯把 STDOUT_FILENO 和 STDERR_FILENO 都接收下来,这样任何时候用 printf、perror 输出错误信息,都能从串口看到。
第三个细节是最后等待 TC 标志。TXE 置位只表示数据从数据寄存器搬到了移位寄存器,并不代表发送完成。如果 _write 返回后紧接着执行了低功耗指令或者关闭串口时钟,最后一个字节可能丢掉。等一下 TC 能保证数据真正从 TX 引脚发完,代价是阻塞几个微秒,对调试输出完全不影响。
3.3 用 HAL 还是寄存器操作,怎么选
如果不想碰寄存器,直接用 HAL_UART_Transmit 也可以:
int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { errno = EBADF; return -1; } if (HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY) != HAL_OK) { return -1; } return len; }用 HAL 的优点是代码可读性好,不用关心具体寄存器差异;缺点是 HAL_UART_Transmit 内部会做状态检查、上锁、超时判断,开销比直接操作寄存器大。对于调试输出这种低频场景,其实无所谓。
但有一个很隐蔽的问题:HAL_UART_Transmit 的第三个参数是 uint16_t 类型,len 如果超过 65535 会被截断。printf 单次输出一般不会这么长,但如果你用 _write 做比较底层的数据导出,可能踩到。我自己的做法是,能用寄存器就直接寄存器,少一层封装,少一个坑。
另外要注意,HAL_UART_Transmit 是阻塞发送,在调用期间如果来一个更高优先级的中断,而那个中断里也调用 printf,会出现字节交错或者死锁。后面第 4 章会专门说这个。
3.4 顺带处理 isatty 与输出缓冲
_write 重写完,很多人还会碰到一种诡异情况:单独写printf("hello")没有输出,加个\n或者手动fflush(stdout)就正常了。这个锅不在 _write,而在 stdio 缓冲策略。
newlib 判断一个文件描述符是不是“终端设备”,会去调用 _isatty。如果返回值是 0,库就认为你是普通文件,可能启用全缓冲;如果是非 0,就按行缓冲或交互设备处理。CubeMX 生成的 syscalls.c 里 _isatty 通常返回 1,但如果你用的是其他模板,有可能返回 0 或者报错。
简单粗暴的解决办法是在 main 开头加一行:
setvbuf(stdout, NULL, _IONBF, 0);把 stdout 设置成无缓冲。这样只要 printf 被执行,数据会立刻进 _write,不依赖换行符。代价是单字节发送模式效率变低,但在 MCU 调试场景里无所谓。
如果你希望在 stderr 上也生效,也可以顺手处理一下:
setvbuf(stderr, NULL, _IONBF, 0);3.5 编码问题:中文乱码别让 _write 背锅
热词里有个“CLion 中文输出乱码”,这里单独说一句。CLion 的编辑器默认是 UTF-8 编码,而很多串口助手软件在 Windows 下默认用 GBK 解码。你在 CLion 源码里写中文,编译后字符串在 flash 里是 UTF-8 字节序列;信号到串口助手里,如果 PC 端用 GBK 解,就会显示成乱码。
这不是 _write 的错,你把同样的字符串用 HAL_UART_Transmit 直接发,一样乱码。解决方式有两种:
- 把串口助手的字符集改成 UTF-8
- 把源码编码转成 GBK,同时告诉编译器字符串用 GBK 编码
我个人建议第一种,因为源码用 UTF-8 是趋势,CLion 里处理 UTF-8 最方便,串口助手选 UTF-8 解码即可。不要在 _write 里做编码转换,那是在错误的地方解决问题。
4. 四个几乎每个人都会踩的坑:半主机、缓冲、乱码与中断重入
4.1 半主机模式:为什么 printf 会触发 HardFault
有一种更令人崩溃的现象:重写 _write 之前,printf 不仅没输出,程序还会进 HardFault。很多人以为是自己时钟配置错了,其实根因往往是半主机模式。
arm-none-eabi-gcc 的 newlib 在缺少用户提供的系统调用时,部分 stub 会走 semihosting,也就是半主机模式。它的实现方式是在代码中触发一个断点指令,然后让调试器来处理,比如把字符交给开发主机的调试终端。如果调试器没有开启半主机支持,或者现场没有连接调试器,这个断点指令就会触发一个异常,最终进 HardFault。
CubeMX 生成的 syscalls.c 里其实已经规避了大部分半主机依赖,_write、_sbrk 这些都有独立实现。但如果你用的是自定义启动文件、自定义链接脚本,或者不小心链接了 libnosys.a,半主机问题就会冒出来。
一个额外需要注意的警告:不要试图在 _write 里调用 printf 之类的函数。你已经在输出路径里了,再进入 printf 相当于无限递归,很快栈就爆了。调试输出函数里只做最底层的发送动作。
4.2 断点验证:确认程序真的进了我的 _write
写完了 _write,下一步别急着烧录,先在 CLion 的调试模式里验证一下调用链。
在 _write 函数的第一行设一个断点,然后运行,在某个地方触发 printf。如果断点命中,说明整条链路是通的,剩下的只是数据内容和格式问题。如果断点一直不命中,先检查三件事:
- _write 的定义是否被编译进工程,确认源文件在 CMakeLists 的 add_executable 列表里
- 链接时是否真的链接到了你的 _write,可以用
nm 你的elf文件 | grep _write查看符号地址 - 是不是从来没有执行到 printf,这个可以用普通行断点验证
还有一个技巧是看反汇编。在 CLion 的调试界面里打开 Disassembly 窗口,找到 printf 的调用点,看它跳转到哪个函数,就能确认库里的 printf 是不是链接了标准实现。大多数情况是你自己代码根本没走到 printf,或者 printf 被某个宏替换掉了。
如果断点命中但串口还是没数据,再看 _write 参数里的 len 是不是 0,以及 ptr 指向的内容是否正常。有时候 printf 传入的字符串本身就是因为各种原因变成空串了。
4.3 打印顺序错乱与缓冲延迟
用 _write 做输出后,另一个常见问题:多个 printf 输出的顺序不对,或者某条日志迟迟不出来,过一会儿突然一下子全出来。
这通常是缓冲造成的,不是 _write 的问题。newlib 的 stdout 如果被判定为块设备,就会启用缓冲区,printf 的内容先攒在内存里,等缓冲区满或者遇到显式刷新才发出来。由于 _write 可能一次收到一大块数据,发送顺序是连续的,但多条日志之间没有明显的界限,看起来就好像“挤在一起”。
我建议在开发阶段直接设置无缓冲,也就是前面那行:
setvbuf(stdout, NULL, _IONBF, 0);这样每条 printf 执行时会立刻进入 _write,日志的时序更接近真实执行顺序。代价是频繁的写操作会拖慢执行速度,但对定位 bug 来说,这个损失完全值得。
4.4 中断上下文里调用 printf 的互斥问题
最后这个坑比较隐蔽。如果你的串口输出不仅在主循环里用,还会在中断服务函数里用,那 _write 就必须考虑重入问题。
考虑一个场景:主循环里正在执行printf("main"),_write 循环发送到一半,定时器中断触发,中断服务函数里又调用了printf("irq")。这个新的 printf 会再次进入 _write,两个发送流程同时操作同一个 UART,会造成字节交错,打印出来就是乱码。
更糟的是如果 _write 里的等待逻辑没有超时,而且发送接口用的 HAL 锁被占用,可能直接卡死。
我的经验是,在 _write 里加一个简单的临界区保护:
int _write(int file, char *ptr, int len) { if (file != STDOUT_FILENO && file != STDERR_FILENO) { errno = EBADF; return -1; } uint32_t primask = __get_PRIMASK(); __disable_irq(); for (int i = 0; i < len; i++) { while ((huart1.Instance->ISR & USART_FLAG_TXE) == 0) { } huart1.Instance->TDR = (uint8_t)ptr[i]; } while ((huart1.Instance->ISR & USART_FLAG_TC) == 0) { } __set_PRIMASK(primask); return len; }这样可以保证一次 _write 的发送过程不会被中断打断。但要注意,关中断时间不能太长。如果 len 很大,或者波特率很低,发送一次可能吃掉好几毫秒,这对实时性要求高的系统是不能接受的。更好的方案是用 DMA 加环形缓冲区,把发送动作放到后台,但这个复杂度就高很多,不适合作为默认调试方案。
如果你只是普通调试,我建议主循环用 printf,中断里用不带锁的简化发送函数,或者干脆在中断里只置标志位,把 printf 留到主循环。
5. 验证手段与经验清单
5.1 快速检查清单:从“没反应”到“正常输出”
如果你现在正被 printf 重定向折磨,按照下面这个顺序排查会很快:
| 检查项 | 判断方法 | 常见问题 |
|---|---|---|
| 串口本身是否可用 | 直接用 HAL_UART_Transmit 发固定数据 | 引脚复用、波特率、时钟没配置好 |
| 调用链是否走到 _write | 在 _write 打断点 | 源文件没编译、链接到旧库 |
| 半主机是否干扰 | 看是否 HardFault,反汇编看 BKPT | 缺少系统调用 stub |
| 缓冲是否吞输出 | 加 \n 或 setvbuf 无缓冲 | stdout 被当成块设备 |
| 中文乱码 | 换串口助手编码为 UTF-8 | 编辑器与串口助手编码不一致 |
| 中断交错 | 关中断保护发送过程 | 多上下文同时调用 printf |
这套清单我每次换开发板、换工程模板时都会过一遍,能省掉大量查资料时间。
5.2 在 CLion 中管理多个带 main 的实验工程
顺带说一个和 CLion 使用相关的经验。初学阶段经常想把多个教程代码放在一个项目里,每个文件都写一个 main,结果链接直接报重复定义。这不是 printf 的问题,但你排查代码时容易分心。
我的做法是一个工程只保留一个 main,其他实验代码做成独立源文件,配合 CMake 的预处理宏来切换。比如在 CMakeLists 里定义:
add_definitions(-DTEST_UART_PRINTF)然后在 main 里用条件编译选择实验入口,或者用多个独立的 CMake 配置目录。如果想偷懒,也可以为每个实验单独建一个 CMake 工程,反正 CLion 打开工程切换不费什么时间。保持一个可运行的任务边界,调试起来思路清晰很多。
5.3 最后分享几个串口调试习惯
既然在做串口重定向,顺手分享几个我用下来很顺手的习惯。
第一个是单独留一个调试专用串口。如果硬件条件允许,把调试输出和其他通信分到不同串口。调试串口只连 PC,不会被业务报文干扰,日志看起来干净得多。
第二个是给 _write 加上可配置开关。不同编译阶段对输出的需求不一样:开发阶段需要详细日志,发布阶段可能需要完全关掉。可以用宏包一层:
int _write(int file, char *ptr, int len) { /* 日常调试输出 */ return len; }平时不需要大改代码,只配置宏就能切换输出级别。如果你用了日志分级库,类似思路会更明确。
第三个是不要嫌轮询发送“低级”。在调试场景里,稳定可靠比性能重要。DMA 发送虽然省 CPU,但一旦没处理好半字对齐和回调,反而浪费你排查时间。先把轮询版跑通,再考虑优化。
我的体会是,串口重定向这问题,归根到底就是“你的 C 库把 printf 的出口放在哪”。搞清楚 newlib 的 syscall 分层,重写 _write 就是顺理成章的事;死记“必须重写 _write”这个结论,下次换到 MicroLIB 环境反而会一脸懵。工具链会变,芯片会变,但“printf 数据最终要经过某个底层写函数”这件事是不变的,找到那个函数,问题就结束了一半。