ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CLion中printf重定向:为何重写_write而非fputc?

CLion中printf重定向:为何重写_write而非fputc? CLion 中为什么要重写 _write而不是 fputc很久以前我在CLion里做STM32开发第一次调串口打印时printf输出死活不出现。于是按照网上经典教程重写一个fputc串口依然无声。后来翻到CLion默认用的ARM GCC工具链才意识到自己一直在重定向一个printf根本不经过的函数。这篇文章就把这个问题彻底讲清楚为什么在CLion里重写 _write 才是正路而 fputc 的做法往往是白费功夫。1. 先看现象同样重定向printf为什么fputc不灵了1.1 最熟悉的fputc“标准答案”在STM32的例程里最经典的重定向写法是这个int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }配上Keil MDK里的“Use MicroLIB”选项printf就能从串口吐数据了。这套写法流传这么多年很多教材讲的就是它。我在CLion里第一次移植时也毫不犹豫照抄了这段代码。然后发现事情不对。编译能过程序也能跑但就是没有输出。用调试器看printf根本没进到我写的fputc里。那一刻我脑子里冒出一个大问号难道CLion里printf是另一套东西1.2 换到CLion后同样的代码为何失效CLion STM32开发的主流搭配是ARM GCC工具链它默认自带的是Newlib或Newlib-nano运行库。这套库和Keil默认使用的MicroLIB结构不一样printf的内部实现路径完全不同。具体来说Newlib里的标准输出流最终汇聚到一个系统调用层的函数上也就是 _write。而 fputc 在Newlib库文件里虽然有定义但它是基于 FILE 流接口构建出来的上层封装printf在内部数据处理完毕之后并不是通过“调用你重写的fputc”来把字符发出去的而是直接走底层写函数。所以当你重写fputc时实际上是在一个printf永远走不到的位置做修改自然就无效。这在Keil里能生效是因为Keil的MicroLIB为了瘦身把文件流相关操作裁剪过fputc变成了printf唯一出口。换个库这个出口就从fputc挪到了_write这就是一切差异的根源。2. 底层调用链printf到串口之间到底有多少层2.1 printf并非直接发送字符很多初学者以为printf是“一个函数直接就把字符输出了”但实际情况是它内部做了大量格式化工作解析格式串、处理精度、转换数字到字符串、处理宽度对齐等。等所有文本都处理完它才会把最终结果交给一个“输出通道”。在C标准库的设计里这个输出通道是抽象出来的不直接对应硬件。标准库里定义了一个“文件描述符”概念printf默认对应的就是标准输出stdout它内部维护着缓冲区。当缓冲区满了或者遇到换行或者你调用了fflush数据才会被真正发送出去。2.2 Newlib中 _write 的真实地位在Newlib中所有系统级输出最后都要调一个函数_write。它的原型长这样int _write(int file, char *ptr, int len);第一个参数是文件描述符标准输出是1标准错误是2。第二个参数是指向待发送数据的缓冲区第三个参数是数据长度。返回值是实际发送的字节数。了这个函数做了一次真正的硬件操作比如往串口发送寄存器里写数据printf的数据才算真正离开CPU。而fputc在Newlib里是什么呢它本质上是“从FILE流读取一个字符并写入底层”的逻辑但它依赖流对象内部的状态。在Newlib的stdout流实现里fputc最终也会调用一个写底层只是这个底层同样不是用户直接看到的那个钩子而是内部封装过的 _write。因此只有在极特殊条件下重写fputc才会影响printf输出。2.3 用大白话理解这层关系可以把printf比作一个后厨做菜的厨师_write是传菜员。厨师做好菜之后会把菜放在传菜口由传菜员端到客人面前。fputc在这个比喻里的角色是什么呢它像是“把一道菜从厨房直接送到某个特定包厢”的专用通道但厨师默认走的是传菜口而不是这个专用通道。如果你改的是专用通道那对正常上菜流程没有任何影响。但你如果改了传菜口所有由厨师出的菜都能顺利端到餐厅。所以改_write就是改传菜口改fputc是改专用通道这就是两者效果截然不同的核心原因。3. 不同编译环境下重定向的“正确姿势”3.1 Keil MicroLIB为何偏偏认fputcKeil MDK提供了两种C运行库标准Microlib和标准CLIB。使用Microlib时库的体积被压得很小很多标准功能以简化方式实现。在Microlib内部printf的最终字符输出路径被刻意设计成调用fputc这样用户只需要提供一个fputc实现整个printf的输出方向就能被改变。但要注意Microlib只适用于Keil环境CLion默认用的不是这套工具链所以教材里的方法换了个编译器安装位置就会失效。如果你在CLion里强行配置了Keil编译器理论上可以但极少这么干那重写fputc又会有用可这就等于把CLion当作一个编辑器外壳在用。3.2 ARM GCC与Newlib底层接口是系统调用式ARM GCC工具链配Newlib时printf的最终落点被设计为“系统调用层”也就是UNIX风格操作系统的read/write语义。这种设计的好处是代码可移植性极高——同一个printf在Linux上能输出到终端在裸机MCU上能重定向到串口因为中间隔着标准的系统调用接口。Newlib提供了一个全面的系统调用桩函数集包括_sbrk、_close、_read、_write、_lseek等。这些桩函数默认可能是空的或者执行一个无限循环也可能触发半主机模式请求。嵌入式开发者通常会自己重写这些函数让它们操作MCU外设从而实现标准输入输出重定向。3.3 IAR、GCC、ARMCC各自的路子IAR的DLib它允许用户通过一个底层接口文件来重定向比如DLib的底层的Low-Level I/O接口用户配置后printf输出会转走对应底层函数。Keil的标准CLIB非MicroLIB路径则不同它支持通过fputc或底层的rt_io重定向。ARM GCC Newlib统一走_write。所以网上搜到的资料往往各有语境有的说改fputc有的说改_write其实都没错只是环境不同。你在CLion里就必须适应ARM GCC的规则。4. 实操在CLion工程里正确重写_write附完整代码4.1 最简实现让printf输出到UART拿STM32HAL库举例最直接的做法如下#include stdio.h #include unistd.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { int i; if (file STDOUT_FILENO || file STDERR_FILENO) { for (i 0; i len; i) { while (HAL_UART_Transmit(huart1, (uint8_t *)ptr i, 1, 0xFFFF) ! HAL_OK) { // 如果发送失败可以在这里做重试或错误处理 } if (ptr[i] \n) { uint8_t cr \r; while (HAL_UART_Transmit(huart1, cr, 1, 0xFFFF) ! HAL_OK) {} } } return len; } return -1; }这段代码做了两件关键的事把len长度的数据逐字节通过UART发出把换行符\n自动补成\r\n因为很多串口助手默认回显需要回车。4.2 别忘了配置链接选项nano.specs与nosys.specs在CLion的CMakeLists.txt里最好显式指定使用Newlib-nano以及关闭半主机set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} --specsnano.specs --specsnosys.specs)nano.specs会使用体积更小的Newlib-nano变体节省flash空间。nosys.specs则是告诉链接器我们这个系统没有完整的操作系统或系统调用环境使用默认的轻型桩函数避免链接报错。如果不加nosys.specs某些启动文件可能会因为缺少系统调用函数而链接失败。如果加了nosys.specs仍然出现半主机相关的卡死多半就是你重写的_write没有被链接进来或者函数名有拼写错误需要逐个排查。4.3 处理不过半主机SWO或另一路调试输出如果你用的是SWO调试接口比如STM32的TRACE引脚重定向就不是走UART了而是写ITM寄存器。这时_write需要操作ITM Stimulus Port#define ITM_STIM_PORT0 (*((volatile uint32_t *)0xE0000000)) #define ITM_TER_PORT0 (*((volatile uint32_t *)0xE0000E00)) int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { for (int i 0; i len; i) { while ((ITM_STIM_PORT0 1) 0) {} ITM_STIM_PORT0 (uint8_t)ptr[i]; } return len; } return -1; }这种方法不占用UART引脚调试时在CLion或其它支持SWO的工具里就能看到printf信息。嵌入式裸机开发中很推荐留这么一路调试通道。4.4 浮点格式化输出支持与代码体积Newlib-nano默认不启用printf的浮点支持如果直接printf(%.2f, 3.14)输出会是空或者乱码。解决办法是在CMake链接器标志里加set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} -u _printf_float)这会引入printf的浮点格式化函数代价是代码体积增加20KB甚至更多。对于Flash紧张的芯片建议自己写整数格式化或使用字符串拼接代替。我在实际项目里就因为printf浮点问题崩溃过后来彻底改用整数定点格式化了。4.5 在CMake里补充头文件与宏定义有些工程还需要微调宏定义例如add_definitions(-DUSE_FULL_ASSERT1)这不是必须的但如果你使用了assert()调试配合重定向后的_write断言失败信息也能通过串口输出方便定位问题。5. 实战排查记录常见症状与解决清单5.1 症状对照表症状常见原因解决办法printf无任何输出半主机模式卡死_write没重写或没被调用重写_write增加半主机禁用加nosys.specsprintf输出乱码波特率不匹配或时钟配置错误检查UART波特率寄存器确认系统时钟printf输出缺少换行只发了\n没有\r在_write里做\n到\r\n转换浮点格式输出空白nano库默认未启用浮点支持添加-u _printf_float链接选项重写fputc无效工具链使用Newlib不走fputc改为重写_write程序卡死在启动阶段半主机请求未被处理实现_write及必要的SysTick或使用nosys.specs使用ITM输出但看不到数据SWO引脚未配置或调试器设置不对在调试器配置里启用Trace并设置合适时钟5.2 我踩过的坑第一次在CLion里重写_write时我漏掉了对\n的处理结果所有输出都挤在同一行看着像“串口没收到完整数据”。调试了半小时才发现是缺少\r。还有一次是把文件描述符判断写错了我以为printf使用的是STDOUT_FILENO但调试时发现printf内部可能先通过其他方式访问文件描述符0导致我的_write直接返回-1所有输出都丢掉了。后来我干脆不判断file参数一律向串口发送这才稳定。另外一个隐患是HAL_UART_Transmit的阻塞式调用。如果在中断里调用printf很容易因为等待发送完成而卡死。建议中断服务函数里不要直接调用printf而是设置标志等主循环再输出。如果必须实时输出可以用DMA 环形缓冲区配合_write。5.3 更深一层文件描述符与多路输出如果你同时想从串口输出日志、从ITM输出调试信息甚至想把某些输出写到内存缓冲区中_write的file参数就可以派上用场。例如int _write(int file, char *ptr, int len) { if (file STDERR_FILENO) { // 错误信息走ITM for (int i 0; i len; i) { while ((ITM_STIM_PORT0 1) 0) {} ITM_STIM_PORT0 ptr[i]; } } else { // 其他输出走串口 HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); } return len; }这比fputc那种单通道模式灵活得多。文件描述符在这里就是天然的路由标签想怎么分流都可以。6. 末尾再补一句什么时候fputc又会变得有用如果用的不是Newlib而是某些轻量级C库或者你在集成FreeRTOSCLI这类组件时它们可能会自己封装一个fputc作为输出钩子。这时候重写fputc又能派上用场。所以在嵌入式开发中遇到printf重定向问题第一反应应该是查工具链和C库而不是盲目套模板。我个人现在的习惯是新建一个STM32 CLion工程后第一时间就把_write、_read、_sbrk都写好哪怕暂时用不到。这些基础函数就像水电管道前期布置好了后面加日志、加Shell、加内存统计都省事。尤其是_read实现好了之后可以用scanf从串口读数据调试交互会非常高效。还记得有一次我在排查一个非常隐蔽的时序bug程序运行到某个地方就莫名其妙复位。后来在printf里加了大量日志才定位到是某次中断里不小心把堆栈踩了。没有_write重定向这一套调试通道那种问题几乎没法查。这也是我强烈建议所有CLion嵌入式开发者哪怕不打算用printf也先把Write重定向配置好的原因。关键时刻就是救场神器。
返回列表