
“在 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_sbrkprintf 的数据最终会走_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 为什么不认 fputc2.1 printf 并不是“一个字符一个字符往外吐”很多嵌入式教程会给你一种感觉printf 内部就是个循环把格式化后的每个字符依次扔给 fputc。这个模型在 MicroLIB 里大致成立在 newlib 里却不成立。newlib 的 printf 最终会走进 vfprintfvfprintf 负责解析格式串然后把结果字符写到 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 收到三个参数文件描述符 filestdout 通常是 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 里的编译选项如果看到--specsnano.specs或--specsnosys.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 是 1stderr 是 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 数据最终要经过某个底层写函数”这件事是不变的找到那个函数问题就结束了一半。