news 2026/9/5 5:49:24

CLion中printf重定向:为什么是_write而不是fputc?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CLion中printf重定向:为什么是_write而不是fputc?

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 都能一眼看穿原理,不再靠复制粘贴碰运气。

文章会从调用链分析开始,逐步解读_writefputc的本质差异,然后给出 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 标准库内部函数、重定向目标、设备驱动三者的边界

很多教程在讲重定向时,喜欢把“重定向”说得很玄,其实本质就是“找到那个最终调用硬件、把字节发出去的函数,然后把它替换掉”。关键是要分清边界:

  • printfvfprintf__sfvwrite这些是标准库内部实现,属于标准化、与硬件无关的部分。你改它们既没必要也不现实。
  • fputcfwrite这些是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_FILENOSTDERR_FILENO<unistd.h>中定义,分别是 1 和 2。如果你不想包含额外的头文件,也可以直接写file == 1 || file == 2,但可读性差一些。
  • HAL_UART_Transmit的最后一个参数是超时时间,我一般用HAL_MAX_DELAY,也就是无限等待。在裸机轮询场景下这是最省心的,不会出现“发送还没结束就被打断”的问题。
  • 注意HAL_UART_Transmit的第二个参数类型是uint8_t *,而ptrchar *,需要强转。有些编译器会在类型不匹配时报 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 没输出,但编译下载都正常

这是最常见的问题,占到整个重定向失败案例的七成以上。排查顺序建议如下:

  1. 确认是否真的调到了你的_write。在_write函数里加一个调试标志位,或者用一个 GPIO 翻转来看函数是否被调用。如果函数根本没被调到,说明链接时可能没有把你的retarget.c编译进去,或者你的_write被链接器丢掉了(section GC)。
  2. 确认printf确实被执行。有时候是程序逻辑根本没走到 printf 那行,比如在某个 while 循环里卡死了。可以先用一个临时变量在调试器中看程序运行位置。
  3. 确认串口硬件接线和参数。串口助手的波特率、数据位、停止位、校验位要和 CubeMX 配置一致。用逻辑分析仪或示波器看 TX 引脚有没有波形,是判断硬件问题的最快方法。
  4. 确认没有别的 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 时程序卡死

前面已经提过,scanfgetchar依赖_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 的发送寄存器被反复覆盖,最后串口助手收到的内容错乱。

解决思路有三类:

  1. 串口发送互斥锁。在_write内部加一个全局互斥信号量(FreeRTOS 的SemaphoreHandle_t或裸机的临界区保护),确保同一时刻只有一个任务在执行HAL_UART_Transmit
  2. 把所有 printf 输出集中到一个任务。其他任务需要打印时,把数据扔到一个消息队列里,由专门的任务负责从队列取出并调用 printf。这种方式最为灵活,也是大型项目的推荐做法。
  3. 增加发送完成的等待机制。确保上一次发送完全结束后,下一次才允许启动发送。裸机下就是轮询判断串口的 TC 标志位。

对于有 RTOS 的工程,我建议从一开始就把调试输出模块单独抽象出来,不要直接在各个任务里裸调用 printf。否则后期调试问题会非常痛苦。

5.6 现象六:拔掉调试器后程序卡在 printf

这个问题如果你是从其他 IDE 迁移到 CLion 时尤其容易遇到。旧工程可能启用了半主机模式,在调试器连接时一切正常,但断电重新上电、脱离调试器独立运行后,程序一旦执行到 printf,MCU 就会触发 HardFault 或者死循环。

原因前面说过:半主机模式依赖调试主机响应,脱离了调试器,相关指令无法执行。

排查方法:检查启动文件和链接脚本,看看是否有semihost相关配置;检查编译选项里是否隐含了 rdimon 库。建议统一使用--specs=nosys.specs替代,彻底切断半主机依赖。还有一种情况是使用了printf之外的fopenfclose等文件操作函数,这些函数依赖_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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 5:45:23

MBTI四个字母为什么不能换顺序?从四组偏好读懂类型代码

MBTI结果由四个字母组成&#xff0c;但这些字母不是随意排列的缩写。有人测出INFP后&#xff0c;会问能不能写成NIFP&#xff1b;也有人看到ENFJ和INFJ只差一个字母&#xff0c;就把每个位置当作独立分数。理解固定顺序&#xff0c;才能读懂类型代码表达的四组偏好。 第一个位置…

作者头像 李华
网站建设 2026/9/5 5:40:32

LLVM代码混淆实战:从原理到实现控制流扁平化Pass

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 5:36:51

Unity生存建造游戏开发:从物理系统到建造模块的完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 5:34:57

Burp Suite社区版实战:从抓包到Intruder自动化测试的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/5 5:33:47

芯片烧录质量控制与夜班异常处理实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华