资讯动态

CLion STM32 printf重定向:用_write替代fputc解决串口无输出

发布时间:2026/9/8 17:44:50 来源:尧图企业网站定制
我刚开始从 Keil 转到 CLion 做 STM32 开发时也踩过这个坑照着网上教程认认真真重写了fputc代码编译通过、下载正常串口却死活不输出任何东西甚至程序还直接卡死。后来排查半天才发现CLion 默认的 GCC Newlib 工具链下printf的“最后一公里”根本就不是fputc而是_write。如果你也遇到同样的问题这篇文章就是为你准备的。这个问题的本质是不同 C 运行库对标准输出重定向的约定不一样。Keil MDK 的 ARMCC 库把钩子放在fputc而 GCC 系工具链配合的 Newlib 库底层走的是_write系统调用接口。CLion 作为一套以 GCC 为默认工具链的 IDE自然沿用了 Newlib 的规则。搞懂这个差异之后你不仅能解决串口输出问题也能真正理解嵌入式开发里“重定向”这三个字意味着什么以后遇到类似的调试输出需求都能一次配好。这篇文章适合正在使用或者打算使用 CLion 进行 STM32/嵌入式开发的工程师、学生和爱好者尤其是遇到printf重定向不生效、串口输出卡死、调试程序跑飞等场景的人。我会从“为什么重写 fputc 没有用”讲起把 C 库调用链、半主机模式、_write的底层逻辑理清楚再给出我在 CLion 里实际验证过的完整实现步骤最后分享一些常见的排查技巧。1. 为什么你重写了 fputc串口还是“沉默不语”1.1 网上教程和 CLion 工程的“世界观”差异如果你之前是在 Keil MDK 上开发的一定对下面的代码非常熟悉int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }这段代码在 Keil 里确实能让printf重定向到串口输出但放到 CLion STM32CubeMX 生成的 GCC 工程里效果就很微妙了。很多时候是编译没问题、下载没问题、程序跑起来也没报错但串口助手就是收不到数据。为什么会这样因为 Keil 和 CLion 默认使用的 C 标准库实现完全不是一回事。Keil 默认使用 ARM 官方的 ARMCC/AC6 库这套库把标准输出重定向的钩子设计在了fputc上只要你在工程里实现了fputcprintf内部的字符输出就会走你这个函数。而 CLion 默认工具链是 arm-none-eabi-gcc配合的是 Newlib 或 Newlib-nano这套库的底层抽象走的是 POSIX 风格的_write接口printf最终几乎不会经过fputc。这就是“世界观”的差异。你要在 CLion 里让printf正常输出首先就要放下 Keil 时代的习惯从 GCC 的角度去理解重定向这件事。换句话说不是fputc错了而是在 CLion 的编译环境下你的fputc根本没有被printf的内部调用链经过写了也白写。1.2 printf 的“最后一公里”到底有多长为了弄清楚printf到底调用了什么我做过一次很笨但有效的实验在fputc里加一个断点然后在代码里调用printf(Hello\r\n)运行后断点压根没触发。接着我又在_write里加断点程序立刻停住了。这个简单的操作比任何文档都直观。简单梳理一下 Newlib 下printf的调用路径大致是这样的printf - vfprintf - __sfvwrite_r - _swrite - _write其中_write是面向操作系统/裸机环境提供的“系统调用层”接口。在裸机环境下没有操作系统可以替你做输出Newlib 就默认假设存在一个半主机调试环境用BKPT指令触发调试器接管输出。如果你没有重写_write也没有连接调试器的半主机支持程序就会卡在调试异常里表现出来就是串口无输出、程序像死了一样。而fputc在 Newlib 中是一个标准流内部函数它属于更高层的库函数printf的默认实现路径并不会强制经过它。所以你在 CLion 里重写fputc本质上是在重写一个和printf没有直接关联的函数自然无效。1.3 半主机模式嵌入式开发里最容易被忽略的“隐形杀手”半主机模式Semihosting是 ARM 调试体系里的一种机制简单说就是目标板通过调试接口借用 PC 主机上的标准输入输出设备。比如你的代码执行printf时不是通过真实串口输出而是通过调试器把字符传送到 PC 上的 IDE 控制台。听起来很美好但代价是目标板必须等待调试器响应。如果你下载程序后拔掉调试器或者 IDE 没有开启半主机支持BKPT指令就会触发 HardFault 或者让 CPU 进入异常等待程序卡死。CLion 配合 OpenOCD 调试时默认是不会去解释半主机请求的除非你专门开启相关配置。所以 GCC 工程如果不重写_writeprintf一执行就是一个死局。这也是为什么“重写 fputc”在很多 GCC 工程里不仅串口没输出程序本身还会跑飞的原因。2. 真正的主角_write 函数到底做了什么2.1 Newlib 给嵌入式世界留下的“系统调用钩子”在 Newlib 的世界里_write是一个必须由用户提供的底层接口除非你链接了默认的半主机实现。它的签名是int _write(int file, char *ptr, int len)三个参数的意思很好理解file文件描述符标准输出一般是 1标准错误是 2ptr要输出数据的缓冲区指针len要输出的字节长度。函数返回值应当是实际输出的字节数正常情况下等于len。这个函数之所以重要是因为 Newlib 内建的printf、puts、fwrite等高层接口最终输出时都会汇聚到_write这里。你只要实现好它整个输出家族都会被“打通”而不只是printf。这也是我后续实现重定向时的核心思路不要在高层函数身上打补丁去最底层的系统调用接口上解决问题一次解决所有标准输出问题。2.2 我写给 STM32 的 _write 实现在 CLion STM32CubeMX 工程里我写的_write重定向函数是这样的#include unistd.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, 0xFFFF); } return len; }注意几个关键点需要包含unistd.h否则STDOUT_FILENO、STDERR_FILENO这两个宏没有定义用HAL_UART_Transmit的阻塞模式发送超时时间我习惯给0xFFFF对于调试日志足够返回值直接返回len告知库函数数据已全部处理完毕。有人可能会问为什么不用HAL_UART_Transmit逐字节发送而要像上面这样整包发送其实直接整包发送是最高效的做法。_write拿到的是一个缓冲区和长度正好符合串口发送接口的参数形式一次性把整段数据交给串口外设比在fputc里逐字节发送不知道快了多少倍。2.3 为什么不建议只重写 fputc 就够了我在前面已经说了 Newlib 下printf不走fputc但这里还想多聊一句即便在某些配置下你的fputc被调用了只重写它也会留下隐患。比如有些旧式工程会通过-fno-builtin或者特定编译选项让printf更“朴素”一些这时fputc可能确实会被以putc或putchar的形式触及。但你的日志里如果用到fprintf(stderr, ...)、puts或者某些库函数内部调用了fwrite一个单独的fputc是覆盖不到的。而_write是整个标准输出链路的汇聚点把这里实现好printf、puts、putchar、fprintf都能正常工作。这也是我强烈建议你在 CLion 中使用_write而不是fputc的核心原因一次重写全家通畅。3. 在 CLion 中完整配置 printf 串口输出的实操流程3.1 从 CubeMX 到 CLion工程基础准备这一节针对还没有在 CLion 里建立工程的读者如果已经有工程了可以直接跳到 3.2 节。我习惯先用 STM32CubeMX 生成基础工程再导入 CLion。CubeMX 里的配置重点有两个选择串口外设比如 USART1配置好波特率、数据位、停止位在 Project Manager 里把 Toolchain 选为STM32CubeIDE或Makefile方便 CLion 识别和编译。生成之后用 CLion 打开工程目录CLion 会自动识别 CMakeLists.txt 或者 Makefile加载编译配置。由于 CubeMX 生成的代码里已经包含了串口初始化、时钟配置等基础内容接下来的重定向工作就非常简单了。3.2 手把手添加 _write 重定向代码第一步在工程里找到放置用户代码的位置。CubeMX 生成的main.c里有两处适合放置自定义函数的区域一处是/* USER CODE BEGIN 0 */附近的全局区另一处是/* USER CODE BEGIN 4 */附近的函数定义区。我建议把_write实现放在/* USER CODE BEGIN 4 */之后这样后续重新生成代码不会被覆盖。示例如下/* USER CODE BEGIN 4 */ 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; } /* USER CODE END 4 */同时记得在文件头部包含unistd.h。CubeMX 生成的main.c默认不会包含这个头文件你需要手动添加在/* USER CODE BEGIN Includes */区域/* USER CODE BEGIN Includes */ #include unistd.h /* USER CODE END Includes */第二步确认huart1是全局可访问的。在 CubeMX 生成的代码里huart1定义在main.c的全局区默认就是全局变量所以直接extern引用或者直接使用都没问题。如果你用的是其他串口比如huart2记得改名字。第三步编译下载并测试。我在main函数的while(1)循环里加过这样一段测试代码/* USER CODE BEGIN 3 */ printf(Hello from CLion, UART output OK!\r\n); HAL_Delay(1000); /* USER CODE END 3 */烧录后打开串口助手波特率 115200很快就能看到正常输出。3.3 关于 Newlib-nano 和浮点打印的特殊处理很多 STM32 工程为了节省 Flash 空间会使用 Newlib-nano。CLion 工程里通常可以在 CMakeLists.txt 中看到类似这样的设置add_compile_options(-specsnano.specs) add_link_options(-specsnano.specs)用上 nano 之后printf的体积会大幅下降但它默认不支持浮点格式化输出。如果你试图用printf(%.2f, 3.14)会得到一串令人困惑的输出甚至可能是“%f”这种原文。解决方法是在链接选项里补上add_link_options(-u _printf_float)-u的作用是强制链接器把_printf_float这个符号拉进来从而启用浮点输出支持。代价是 Flash 占用会增加几十 KB但对调试阶段来说完全值得。实际项目中如果你严格控制日志格式只输出整数也可以不启用浮点支持省下来的空间很可观。3.4 重定向函数放哪个文件其实有讲究我见过有人把_write随便塞到某个.c文件里结果也编译通过了。这样做虽然大多数情况下没问题但工程结构会变得混乱。尤其是当你使用多个串口、或者需要在不同模式下切换输出通道时把_write单独放在一个类似retarget.c的文件里反而是更好的选择。我自己的做法是新建retarget.c和retarget.h专门存放重定向相关代码。这样每次 CubeMX 重新生成代码时用户区代码不会被覆盖同时依赖关系也非常明确。文件内容大致如下// retarget.c #include unistd.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, 0xFFFF); } return len; }注意如果retarget.c不在 CubeMX 生成代码的自动添加列表里你需要在 CMakeLists.txt 中确保该源文件被编译。CLion 通常会自动扫描目录下的源文件但如果你的 CMakeLists.txt 是手写的记得加一句add_executable(${PROJECT_NAME}.elf Core/Src/main.c Core/Src/retarget.c ... )这种模块化的好处在后期的项目迭代中非常明显你不需要在 CubeMX 每次重新生成后重新粘贴一遍代码。4. 常见问题与排查技巧实录4.1 printf 没有输出先从这三个方向查代码写对了串口还是静悄悄的这种问题通常出在三个方向。第一是串口配置问题。检查 CubeMX 里是否正确开启了 UART 外设引脚是否被复用为串口功能波特率是否和上位机匹配。排查方法很简单手动调用HAL_UART_Transmit(huart1, test, 4, 100)如果能看到test说明串口链路没问题。第二是链接脚本或启动文件问题。有些精简工程没有链接syscalls.c而 Newlib 的_write默认定义就在syscalls.c里。如果你自己实现了_write要确保没有和默认实现产生多重定义冲突。GCC 对于强符号和弱符号的处理有时候比较隐蔽报错时仔细观察链接日志不要只看最后几行。第三是缓冲问题。Newlib 的stdout默认是行缓冲还是全缓冲取决于目标环境。在裸机上printf的缓冲区行为有时候会让你觉得数据“丢失”了。最简单的解决办法是在_write里直接整包发送因为_write拿到的就是库函数内部已经准备好的缓冲块这个层级已经绕过了最外层的高频字符级缓冲。这也是为什么我们不建议回到fputc的原因之一fputc这种逐字符接口更容易被缓冲策略影响。4.2 一调用 printf 程序就 HardFault半主机模式背锅这是 GCC 工程里非常经典的问题。默认情况下Newlib 的_write实现会触发半主机指令如果你的调试器或者运行环境不支持半主机程序就会异常。遇到这种情况第一件事就是检查你的工程里是否真的重写了_write。很多人在网上copy了代码但文件名不对、或者放在了#if 0之类的条件编译段里导致实际链接的还是默认实现。如果你链接了nosys.specs情况又会不同。add_link_options(-specsnosys.specs)nosys.specs会把 Newlib 的半主机相关实现替换为一个“什么都不做”的版本程序不会 HardFault但printf也不会有实际输出。这种配置适合你暂时不想实现_write又不想让程序崩掉的场景。但在开发调试阶段我更推荐老老实实重写_write而不是依赖nosys.specs这个“沉默开关”。4.3 输出乱码这些细节别忽略串口有输出但内容是乱码一般原因是波特率不一致或者发送的字符串编码格式与上位机预期不一致。嵌入式开发里常用的输出字符串我建议使用\r\n做换行而不是只用一个\n。有些串口助手或者终端程序对\n的处理不够智能就会出现“阶梯状”输出看起来很乱。类似这种printf(Line 1\r\n); printf(Line 2\r\n);如果看到Line 1Line 2没有换行可以优先检查是不是只写了\n。另外_write里发送len个字节时确保传入ptr的缓冲区尾部有明确的终止符。虽然len已经告诉了你长度但有些旧代码会把printf和strlen的结果混用造成越界发送。一个简单的习惯是在调试期多开编译器的-Wall -Werror选项很多坑可以在编译阶段就暴露。4.4 为什么有的工程重写 fputc 也能生效这个问题很多人问过。结论是如果工具链是 ARMCC用fputc没问题如果工具链是 GCC Newlib用_write才是正确的。更进一步说在 Keil 里如果你勾选了 MicroLib重定向钩子通常也是基于fputc的如果使用标准 ARMCC 库有的版本也支持通过$Sub$$和$Super$$这类方式做底层替换。这就造成了一个很有趣的现象同一个fputc重定向代码在 Keil 里有效换到其他 IDE 就不一定了。我的经验是拿到任何一份工程代码先看三样东西工具链是什么、C 库是什么、启动文件和链接脚本有没有特殊处理。这三样了解清楚很多“为什么我的没效果”的问题其实都能自己回答。CLion 里默认是 GCC那你就要以_write为主不要被 Keil 时代的老经验束缚。4.5 调试输出也能“软件分路”ITM/SWO 的另类用法最后分享一个我在实际项目中经常用的辅助技巧。当你把_write指向串口后调试阶段如果想同时保留串口给业务数据用又不想丢掉日志输出可以用 ITM/SWO 通道。具体来说STM32 的 SWO 引脚可以通过调试器把 ITM 数据传回 PCCLion 配合 OpenOCD 时也可以做相关配置。你可以把_write改成当某个全局标志为真时输出到 ITM否则输出到串口。这种“软件分路”在调试复杂通信协议时非常有用相当于白白多了一个调试通道而不需要额外占用一个 UART 外设。当然ITM/SWO 依赖调试器连接脱机运行时就派不上用场了。所以我通常只在联调阶段使用最终交付的固件会把_write固定指向真实串口。这种灵活切换的能力也是把重定向代码独立成模块带来的好处。最后再分享一点我的个人体会刚开始从 Keil 切换到 CLion 时我也挺烦这种“明明在其他 IDE 里能用的代码到这里就失效”的情况。但后来想明白了这不是 IDE 的问题而是整个工具链的底层设计逻辑变了。理解fputc和_write的区别也不只是为了在 CLion 里输出一串日志而是帮你建立了一个更本质的认知标准库的输出最终要落到某一个系统调用层接口上你在裸机上要做的不是魔法而是把这个接口接到你的硬件上去。我个人的建议是哪怕你用 Keil也值得抽时间了解一下_write这套机制。它和操作系统的接口非常接近将来你要是跑 RT-Thread、FreeRTOS POSIX 层或者把代码往 Linux 环境移植会发现自己写过的_write经验完全能平移过去。相反如果只记住了fputc重定向这个“万能公式”遇到新的软件生态就容易手足无措。希望这篇文章能帮你少走一些弯路。如果你在 CLion 里配好了_write串口也顺利输出了第一个Hello那恭喜你你的嵌入式开发工具箱里又多了一个值得信赖的伙伴。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价