ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CLion下STM32的printf重定向:为何要重写_write而不是fputc

2026/9/6 10:05:48 拓冰建站 浏览量
CLion下STM32的printf重定向:为何要重写_write而不是fputc 在 CLion 里用 ARM GCC 工具链调试 STM32 时很多人第一件事就是把 printf 重定向到串口。如果你照着 Keil 时代的习惯去重写 fputc大概率会发现串口一个字符都不出。这不是你板子坏了也不是串口助手设置不对而是你选错了重写对象。真正需要重写的是_write不是fputc。这个问题我在刚切换到 CLion 时也踩过折腾了好几天一度以为是 CMake 配置有问题。后来把 C 标准库的调用链理清楚才明白 GCC 工具链和 Keil 的 ARMCC 库在 IO 底层实现上完全是两套逻辑。这篇文章就把这条调用链讲透顺便把 CLion 下嵌入式工程的 printf 重定向、半主机输出、链接参数这些实操细节一并整理出来给还在坑里的朋友一个参考。1. 先搞清楚 printf 到最终输出的调用链1.1 C 标准库的分层printf、fputc、_write 各在哪一层很多嵌入式开发者对 printf 的认知停留在调用它就能打印但对它内部怎么把字符送到外设其实没有完整概念。我拿一套常见的 ARM GCC 工具链来说标准库用的是 newlib它把 IO 分成三个层次格式化层printf、sprintf、snprintf 这类函数负责把 %d、%f 这些占位符解析成字符串。标准 IO 层fputc、fputs、fwrite 这类函数它们操作 FILE 结构体处理缓冲和文件状态。系统调用层_write、_read、_open、_close这些带下划线的函数它们是标准库和操作系统/硬件之间的桥梁。这三层是递进关系printf 格式化完数据后会写入 stdout 对应的 FILE 缓冲区缓冲区刷新时调用_write_write才真正把数据交给串口、调试器或者其他底层出口。用生活里的例子类比printf 是后厨的大厨负责把菜做好fputc 是传菜员负责把菜从后厨端到出菜口_write才是那个把菜端到客人桌上的服务员。你要改的是服务员的工作方式而不是在旁边重新招聘一个传菜员。1.2 为什么 fputc 在 GCC 环境下经常根本不会被走到这是整篇文章最关键的一点。在 Keil 的 ARMCC 环境下printf 内部会调用 fputc所以很多 STM32 教程教你重写 fputc 重定向 printf这在 Keil 里是可行的。但到了 GCC newlib 环境情况完全变了。newlib 的 printf 最终会调用vfprintfvfprintf内部对字符的处理并不是像 ARMCC 那样逐个调用 fputc而是把格式化后的结果写入 FILE 结构体自带的缓冲区。只有缓冲区满了、遇到换行符触发行缓冲刷新、或者程序显式调用 fflush、exit 时缓冲区数据才会通过_write_r和_write送出去。也就是说fputc 在 newlib 的 printf 调用链中并不是必经之路。你重写 fputcprintf 内部根本不鸟它字符被存在缓冲区里缓冲区满了之后又调用系统自带的_write最终还是回到了标准库的默认实现你的串口自然什么都收不到。我画过一张调用链图核心路径就是printf - vfprintf - 写入 FILE 缓冲区 - fflush/缓冲区满 - _write_r/_write - 串口/SWO/半主机理解这条链路你就明白为什么网上搜CLion printf 重定向得到的答案是重写_write而不是fputc。这不是谁对谁错的问题是标准库实现机制决定的。2. 嵌入式环境下三种常见的 printf 落地方案2.1 重写 _write 接管串口发送标准做法最常用的方案就是自己实现一个_write把数据通过串口发出去。在 newlib 中_write的签名是固定的int _write(int file, char *ptr, int len);其中file是文件描述符stdout 是 1stderr 是 2。ptr指向要发送的数据缓冲区len是要发送的字节数。函数返回值是实际发送的字节数。一个典型的 STM32 串口重定向实现长这样#include stdint.h #include stddef.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file 1 || file 2) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); } return len; }这段代码把 stdout 和 stderr 的数据都通过 USART1 发送出去HAL_MAX_DELAY表示阻塞发送直到数据全部发完。有一点需要注意newlib 内部实际上调用的是_write_r它带一个struct _reent *参数用于多线程环境的线程安全。在单线程裸机环境下newlib 默认的_write_r会转调_write所以你直接重写_write是没问题的。如果工程里用了 RTOS且标准库配置了多线程支持那需要一并处理重入问题不过那是另一个话题了。HAL_UART_Transmit 这种方式简单粗暴实际测试下来稳定性没有问题。唯一要留意的是一次 printf 的数据可能很长串口速度又只有 115200 波特率如果大量打印程序会阻塞在等待发送完成的循环里。这在调试阶段问题不大但在正式运行时要考虑是否需要非阻塞发送。2.2 用半主机Semihosting从调试器窗口拿输出除了串口CLion 的嵌入式调试还支持半主机模式。半主机是 ARM 定义的一套机制让开发板上的程序通过调试器比如 ST-Link 内部的调试通道直接和宿主机通信。这样不用接串口线printf 的内容直接显示在调试器的控制台里。使用半主机时_write的实现要调用半主机系统调用 SYS_WRITE通过 BKPT 指令触发调试器接管。CLion 配合 OpenOCD 调试时可以在 OpenOCD 配置里开启 semihostingarm semihosting enable然后在代码里实现_writeint _write(int file, char *ptr, int len) { if (file 1 || file 2) { __asm volatile ( mov r0, #0x05\n /* SYS_WRITE */ mov r1, %0\n mov r2, %1\n mov r3, %2\n bkpt #0xAB\n :: r(1), r(ptr), r(len) : r0, r1, r2, r3, memory); } return len; }不过这里有一个大坑如果程序在未连接调试器的状态下独立运行半主机调用会触发 BKPT 指令CPU 会直接卡死在异常里。我遇到过的情况是拔掉调试器后板子完全没反应重新插上调试器查看 PC 指针就停在 BKPT 处。所以用半主机方案时一定要谨慎评估你的使用场景。如果板子永远在调试器连接下运行那没问题如果要从调试器断开独立运行就必须加判断或者干脆用串口方案。2.3 重写 fputc 到底有没有用什么场景下它才是对的说了这么多重写 fputc 在某些场景下依然是正确做法所以不能一棍子打死。Keil 的 ARMCC 环境printf 内部确实会走到 fputc重写 fputc 是官方支持的惯用方式。IAR 环境IAR 的 EWARM 也支持重写 fputc 重定向不过它更推荐重写__write。使用微型 printf 库很多嵌入式项目会引入第三方轻量级 printf比如 mpaland/printf 或者 e-printf这类库为了减小体积通常直接定义_putchar或putchar_作为底层输出接口这时候要重写的是它们规定的接口和 fputc、_write 都没关系。你只是用 fputc 直接输出如果代码里本身就是 fputc(A, stdout) 这样直接调用那重写 fputc 当然有效因为它就是直接入口。所以关键结论是重写哪个函数取决于你所使用的标准库内部到底调用谁。盲目照搬教程往往就是掉进教程环境和实际环境不一致的坑。3. 在 CLion 里具体怎么配置 printf 重定向3.1 CLion 的嵌入式工程与 CMake 配置CLion 做 STM32 开发一般要装 Embedded Development 插件。这个插件提供 STM32CubeMX 工程导入、OpenOCD 调试支持但底层的构建系统还是 CMake。也就是说你需要在 CMakeLists.txt 里把交叉编译工具链、链接参数、烧录配置都处理好。一个典型的 CMakeLists.txt 关键部分set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(TOOLCHAIN_PREFIX arm-none-eabi-) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g) set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc) add_executable(project.elf Core/Src/main.c Core/Src/retarget.c Core/Src/stm32f1xx_hal_msp.c ... ) target_link_options(project.elf PRIVATE -T ${CMAKE_SOURCE_DIR}/STM32F103C8Tx_FLASH.ld -specsnano.specs -specsnosys.specs -u _printf_float )retarget.c就是你用来放_write实现的文件。我习惯把重定向代码单独放一个文件而不是堆在 main.c 里这样多个工程复用起来方便也不会污染主逻辑。3.2 链接选项里 nano.specs 和 nosys.specs 到底起什么作用这两个参数是 ARM GCC 工具链里最容易被忽略、也最能坑人的地方。-specsnano.specs表示使用精简版 newlib也就是 newlib-nano。它比完整版 newlib 小很多代价是 printf 默认不支持浮点格式化。如果你代码里写了printf(%f, 3.14)输出会显示成 0.000 或者直接乱码。要支持浮点必须加上-u _printf_float强制拉入浮点格式化代码。-specsnosys.specs表示使用无系统调用的标准库变体。它提供了一组空的 syscall stub比如_sbrk、_close、_read、_write等都是一些默认的弱定义实现。链接器遇到标准的_write定义时会用你强定义的版本覆盖它这样 printf 输出就能被你接管。反过来如果去掉-specsnosys.specs链接器可能会从 libgloss 里拉入 semihosting 版本的 syscall 实现那一套是调 RKPT 的。如果没有配合调试器启用 semihosting程序照样卡死。所以我在 CMakeLists.txt 里的固定组合是target_link_options(project.elf PRIVATE -specsnano.specs -specsnosys.specs -u _printf_float -u _scanf_float )这里_scanf_float也是为了支持scanf读浮点如果不用 scanf 可以去掉。还有一个关联的参数是--specsrdimon.specs。这个链接的是 ARM 的半主机监控库它直接为 semihosting 提供全套 syscall 实现不需要你自己写_write但需要在启动代码里初始化监控环境。我们项目里没有用这种方式因为它引入的依赖比较重除非你明确就是要用半主机输出否则不建议碰。3.3 多个 main 目标下的重定向符号冲突CLion 一个工程里可以定义多个可执行目标这是它比 Keil、IAR 方便的地方。比如我在一个工程里同时放了 bootloader 和 app 两个目标或者放了一个app_uart.elf、一个app_swo.elf分别用不同外设输出调试信息。这时候最容易出问题的是符号冲突。如果两个 target 都链接了同一个 retarget.c而这个文件里又定义了_write那链接时两个 target 都引用了同一个强符号如果它们各自内部还有一份_write定义就会报 multiple definition 错误。我的做法是给每个 target 单独指定 retarget 文件add_executable(app_uart.elf Core/Src/main.c Retarget/retarget_uart.c ... ) target_link_options(app_uart.elf PRIVATE ...) add_executable(app_swo.elf Core/Src/main.c Retarget/retarget_swo.c ... ) target_link_options(app_swo.elf PRIVATE ...)如果你看网上教程很多人会把 printf 重定向直接写在 main.c 里CLion 里一旦多目标这种写法就特别容易翻车。单独拆文件各个目标引用各自的实现符号归属清楚排查也方便。4. 常见问题与排查技巧实录4.1 printf 中文输出乱码是串口工具的问题还是代码的问题很多人在 CLion 里调试发现中文打出来是乱码第一反应是代码里编码不对。其实这个问题主要在两端编译环境的编码和串口工具的编码。CLion 默认文件编码是 UTF-8字符串字面量在编译后也是 UTF-8 字节串。而 Windows 上常用的串口助手默认按 GBK/ANSI 解码UTF-8 的中文字节序列按 GBK 解读自然就是乱码。解决办法有两种一是串口工具切换到 UTF-8 编码现在比较新的串口助手都支持二是代码里用中文前先通过转码函数转成 GBK。但从工程可维护性角度看我建议调试信息尽量用英文中文只在正式产品的人机界面里用。还有一类乱码是波特率不对导致的比如代码里配置 115200串口工具选成 9600字符全是乱码。这种情况一般稍微排查就能发现但如果你用的是外部晶振而内部 PLL 配置错误实际波特率会偏差很大显示乱码就非常隐蔽。4.2 OpenOCD 调试时报 write to location caused an access violation这个报错我在 CLion 的调试控制台里见过好几次完整的表述是类似write to location 0000000000000020 caused an access violation。大部分时候和 printf 重定向没有直接关系而是程序里出现了野指针或者访问了未映射的地址。调试办法是看 OpenOCD 报错时的 PC 指针和 LR 寄存器跳到出问题的函数。如果是在_write内部访问外设寄存器时触发那要检查外设时钟是否开启。比如你在_write里直接操作 USART1-TDR但 USART1 的时钟没使能硬件层面对这个地址的访问可能就是不合理的从而触发错误。还有一次比较特殊报错地址是在 Flash 控制器附近最后查出来是 HAL_Delay 依赖的 SysTick 没有初始化在 printf 调用链中某个处触发了对无效地址的访问。所以看到这个错误时先把断点设到_write入口确认调用关系再说。4.3 半主机卡死为什么接调试器好好的拔掉就死机前面已经提过半主机调用的底层指令是 BKPT这是用于调试目的的指令。当调试器连接的 OpenOCD 配置了arm semihosting enableBKPT 会被调试器接管执行 SYS_WRITE 返回结果。一旦拔掉调试器BKPT 触发后没有调试器响应CPU 就会进入 halted 状态。解决方案有两种思路。一是在_write里判断是否处于调试状态代码像这样int _write(int file, char *ptr, int len) { if (*((volatile uint32_t *)0xE000EDF0) 0x00000001) { // 调试器连接中走半主机 __asm volatile(bkpt #0xAB); } else { // 正常运行走串口 for (int i 0; i len; i) { // 发送到 UART } } return len; }0xE000EDF0 是 SCB-DHCSR 寄存器它的 bit0 是 C_DEBUGEN表示调试使能。这个方法实测有效但需要确认你的芯片支持这个寄存器位Cortex-M 系列基本都支持。更稳妥的思路是干脆不用半主机只用串口。串口输出不依赖调试器接不接调试器都能跑这在实际项目里更贴近真实需求。4.4 CLion 主机侧编译报 stdout: bad file descriptor这个报错严格来说不是嵌入式目标上的问题而是 CLion 在宿主机上运行某些工具时出现的。常见于 CMake 构建阶段的外部命令或者你用 CLion 的终端跑了一些输出重定向的命令。bad file descriptor指的是一个已经关闭的文件描述符被继续使用。比如你执行command /dev/null 21时shell 把 stdout 重定向到别的 fd结果程序内部还对原始 stdout 操作就可能出现这个错误。遇到它时别往嵌入式代码上想先看具体哪条命令触发的。如果是 CMake 的 try_compile 或者自定义命令可以试着清理构建目录重建大部分情况下就能解决。要是和某个软件版本有关检查 CLion 的终端设置切换一下cmd.exe和PowerShell有时也能绕过。我把这几个高频问题整理成一个速查表方便你定位现象可能原因解决思路printf 无输出重写了 fputc 但没重写 _write确认工具链类型GCC 下重写 _writeprintf 输出乱码串口波特率不一致或编码不匹配检查波特率和串口工具编码串口卡死无响应半主机模式未连接调试器改用串口输出或加调试状态判断OpenOCD 报 access violation外设时钟未使能或访问非法地址检查 PC 寄存器定位异常指令链接报 multiple definition多个源文件定义了同一个 _write把重定向代码独立成文件按目标引用stdout bad file descriptor主机侧命令输出重定向异常清理 CMake 缓存或调整终端5. 从 fputc 到 _write我的选型心得回到标题的问题CLion 里为什么要重写_write而不是fputc最根本的原因是 CLion 默认使用的 GCC 工具链它的标准库实现和 Keil 的 ARMCC 不一样。printf 在 newlib 中不依赖 fputc而是依赖底层的_write。你重写了 fputc就相当于替换了一个 printf 根本不会调用的函数自然没有效果。我在实际项目里的固定做法是工程里单独放一个 retarget 目录里面放retarget.c长这样#include stdint.h #include stddef.h #include usart.h int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); } return len; }需要注意huart1的声明。我见过有人直接在 retarget.c 里写extern UART_HandleTypeDef huart1;但如果 main.c 里的句柄是huart2就又要改。更好的做法是从 CubeMX 生成的usart.h里引入声明谁初始化了串口谁就在对应的头文件里暴露句柄。还有一个细节_write的ptr不是以 \0 结尾的字符串而是一段长度确定的字节数组。所以一定不能写HAL_UART_Transmit(huart1, ptr, strlen(ptr), ...)而要直接用len。这个我踩过一次printf 输出很多字符时数据少了后半截排查了很久才发现是 strlen 截断了。总之printf 重定向绕这么一圈本质上就是三个字跟对底层。CLion 的好处是工程可视化、调试集成度高但它的默认构建链路和传统 IDE 差别不小。把标准库这些底层机制搞明白你才会真正理解为什么教程里给出的方案各不相同。希望这篇能把这条链上的弯弯绕绕说清楚让你少走几步弯路。