
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_rnewlib 中带有 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里把这个文件描述符映射到串口寄存器或者你想要的任何设备上。这种设计的好处是上层逻辑完全与硬件解耦。以后如果你加了 RTOSFreeRTOS、RT-Thread 等这套系统调用接口依然保持不变只不过真正实现的函数会由操作系统的设备驱动来接管你的应用代码几乎不需要改动。3.2 newlib 重定向时的消息单位字节流而非单字符另一个值得深入理解的点是_write的参数设计。fputc的参数是单个字符int ch但本质是一个字节返回的是这个字符本身或者 EOF 表示错误。这个接口设计天然偏向“逐字节输出”。_write的参数则完全不一样int _write(int file, char *ptr, int len);其中file文件描述符。0 表示标准输入stdin1 表示标准输出stdout2 表示标准错误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 库或者有遗留的半主机相关代码。解决办法是重新检查链接配置确保--specsnano.specs和--specsnosys.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 接收解析。那么你还需要补充_readint _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 的。在链接阶段会有一坨编译选项其中两个和本文的问题直接相关--specsnano.specs和--specsnosys.specs。--specsnano.specs使用 newlib-nano 替代完整版 newlib。newlib-nano 对格式化输出做了精简比如不支持浮点数打印除非你开启-u _printf_float。代价是很多高级功能被裁剪但体积显著缩小。--specsnosys.specs提供一个最简化的系统调用桩避免链接器报未定义符号错误。它给你的_write、_sbrk等函数提供默认实现但默认实现都是空壳或直接返回错误。我在实践中通常两个都用--specsnano.specs --specsnosys.specs然后在自己写的retarget.c中重新定义_write和_read把默认的弱符号覆盖掉。这样既能享受 nano 库的体积优势又不会有链接错误。如果你在链接时遇到类似undefined reference to _write或者_sbrk、_close等一堆奇怪符号缺失多半是没有加--specsnosys.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 时尤其容易遇到。旧工程可能启用了半主机模式在调试器连接时一切正常但断电重新上电、脱离调试器独立运行后程序一旦执行到 printfMCU 就会触发 HardFault 或者死循环。原因前面说过半主机模式依赖调试主机响应脱离了调试器相关指令无法执行。排查方法检查启动文件和链接脚本看看是否有semihost相关配置检查编译选项里是否隐含了 rdimon 库。建议统一使用--specsnosys.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。