ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CLion下STM32的printf重定向:为何必须重写_write而非fputc

CLion下STM32的printf重定向:为何必须重写_write而非fputc 最近总有人在嵌入式群里问一个问题用CLion开发STM32时想把printf重定向到串口网上一搜全是教重写fputc的代码抄过来却发现完全不生效。折腾半天最后换成了_write又不知道为什么要这样改。这个问题的本质是工具链变了。CLion里通常搭配的是arm-none-eabi-gcc交叉编译链底层用的C库是Newlib而过去绝大多数STM32教程基于Keil MDK用的是ARMCC编译器加MicroLIB。两套环境里printf走到硬件之间的链路不一样所以重定向的入口也不同。这篇就掰开揉碎讲清楚_write和fputc到底是什么关系为什么换到CLion之后必须重写_write以及具体怎么写、踩过哪些坑。1. 先搞清楚重定向到底在解决什么问题1.1 printf的输出链路到底长什么样很多人对printf的理解停留在“标准库函数往屏幕打印字符”。这句话在PC上没问题但在单片机上要打一个问号开发板上没有显示器printf往哪里打印其实C语言标准库里的printf并不知道“屏幕”是什么它只负责把格式化后的字符交给一个抽象的“输出通道”。这个通道在PC上是stdout在单片机上默认指向调试器。如果没有做任何特殊处理printf最终会把字符送到调试器那里进入所谓的semihosting半主机模式——程序跑着跑着就卡住或者干脆死机。所以重定向要干的事很明确把printf最终要写入的那个抽象通道从调试器改成我们自己的串口外设。要做到这一点就得知道在当前的编译环境下printf输出字符时最后一步调用了哪个函数。fputc和_write的分歧就出在这里。1.2 fputc和_write各自处在链路的什么位置先说结论在ARMCC MicroLIB环境里printf一层层往下调最终会走到fputc而在GCC Newlib环境里printf一层层往下调最终会走到_write。这里不能用“最终都一样”来糊弄自己因为这两条链路的中间环节是完全不同的实现。ARMCC的MicroLIB为了减小代码体积把标准I/O做得很薄printf内部直接以字符为单位反复调用fputc每个字符通过fputc交给硬件。Keil工程里最常见的那段代码int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }就是利用了这个调用关系把每个字符拦下来送到串口。这个模式下fputc就是printf输出字符的最后一棒。GCC的Newlib就不一样了。Newlib是面向嵌入式系统重写的C库它的I/O分层更接近Linux的“文件描述符”模型。printf格式化好的字符串会作为一个整体、按字节流的形式传给底层。底层跟硬件对接的入口是_write函数它的原型是int _write(int fd, const char *ptr, int len);fd是文件描述符0、1、2分别对应标准输入、标准输出、标准错误ptr指向要发送的字符缓冲区len是字节数。在这个模型里fputc只是标准库暴露给用户程序的一个普通函数printf并不会去调用它。所以这就引出了一个问题为什么网上那么多人说重写fputc有效因为在Keil环境下确实有效。但放到CLion GCC工具链里printf不走fputc你重写它printf自然理都不理。2. 工具链变了重定向的入口就变了2.1 Keil/ARMCC的fputc时代过去几年STM32教学的绝对主流是Keil MDK。ARM编译器的C库提供了一种便捷的retarget机制只要用户重定义fputcprintf的字符输出就被接管了。这里有个细节要注意Keil只有勾选了“Use MicroLIB”之后重写fputc才能正常生效。如果不勾选printf也不会走fputc而是会走底层的_sys_write之类的函数直接调用semihosting通道程序同样会卡死。所以老玩家应该都有印象每次建工程第一件事就是去Options里的Target选项卡勾上MicroLIB。MicroLIB本质上是一个为了Cortex-M单片机压缩过的C库。它省去了复杂的文件系统、多任务I/O管理把I/O的工作尽量简化。这种简化有一个副产品printf的字符最终要经过fputc一个字符一个字符地送出去。你重写了fputcprintf就按你的要求把字符送到串口。在这个时代fputc就是出口。2.2 CLionGCC的_write时代CLion在嵌入式领域推荐的方案跟Keil完全不同。你在CLion里写STM32通常有两条主流路线一条是STM32CubeMX生成CMake或Makefile工程再用arm-none-eabi-gcc交叉编译另一条是通过PlatformIO插件管理GCC工具链。两条路线的编译器都是arm-none-eabi-gcc。这个编译器自带的C库是Newlib。Newlib在嵌入式界的地位相当于“Linux的libc移植到单片机上”它保留了比较完整的POSIX风格I/O抽象。printf打印一串字符时内部会做格式化、缓冲然后调用底层的系统调用函数。在Newlib里这个函数就是_write。如果你不重写_writeNewlib会使用它自带的弱定义这个弱定义默认就是走semihosting。在CLion里用OpenOCD调试时程序一旦执行到printf就会触发semihosting陷阱在调试器没有额外配置的情况下直接卡死在异常里。所以很多人从Keil搬到CLion后遇到的第一个问题就是代码下进去串口什么都没有或者程序跑飞。2.3 为什么照搬fputc代码会在CLion里失效把Keil的fputc代码原封不动放进CLion工程你会发现编译能通过但printf就是没输出。原因就是之前梳理的调用链对不上。GCC Newlib环境下你重写fputc时确实修改了标准库中fputc函数的行为。如果用户代码里显式调用fputc(stdout, A)它会被送到串口。但printf内部根本没调用fputc而是直接操作FILE结构体的缓冲区最后通过__sputn这类内部接口把整块数据传给_write。所以你改fputc等于把大门旁边的侧门修好了printf这辆主车根本不从侧门走。如果把整个调用链往下挖一层还会发现一个更隐蔽的问题。Newlib在fputc这个字符输出函数内部实际上也是通过对FILE结构的写操作完成的也就是说fputc最终也会调用_write。在某些情况下你重写fputc只是覆盖了用户层的字符输出函数但底层从FILE缓冲区刷新到设备的路径还是走_write如果不重写_write最终还是落到默认的semihosting实现。理解了这一层就能明白为什么在CLion里写重定向代码正确写法就是int _write(int fd, const char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }这段代码把stdio要发送的整块数据直接从串口发出去简洁利落是CLion GCC组合下最标准的做法。3. CLion STM32 下正确的_write重写姿势3.1 最小可用实现直接上代码下面给一份可以直接抄的代码。适用于STM32 HAL库串口初始化由CubeMX完成句柄名假设是huart1实际使用时改成你自己的#include stdio.h #include stdint.h #include main.h extern UART_HandleTypeDef huart1; int _write(int fd, char *ptr, int len) { if (HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY) ! HAL_OK) { return -1; } return len; }把这段代码放在任意一个C文件里保存重新编译printf就能往串口输出内容了。核心接口就这三个参数fd是文件描述符ptr是要发送的缓冲区指针len是要发送的长度。注意一点一定要return len而不是return 0。C库通过返回值判断这次底层写入是否成功、写了多少字节。如果你返回0C库会认为写入失败in stdio层可能触发错误标志导致后续printf行为异常。3.2 处理多字节与超时细节上面是最简版实际使用中还需考虑几个细节。第一HAL_UART_Transmit的最后一个参数是超时时间。HAL_MAX_DELAY表示无限等待。这在调试阶段最省心因为只要能发出去慢一点没关系。但如果在定时器中断或回调函数里调用printf就不建议用HAL_MAX_DELAY因为阻塞等待会卡住整个中断流程。可以考虑改用HAL_UART_Transmit_IT异步发送或者把超时设成一个具体的时间比如100。第二多字节数据的发送连续性。HAL的阻塞发送函数在一次调用中是连续发送的但底层是按字节一个个写入发送数据寄存器再等待发送完成标志。如果发送的数据量比较大在高波特率下通常没问题示波器看波形每个字节之间会有极小的间隔但不会丢数据。第三如果串口外设同时开启了DMA或IT模式发送函数在忙时会返回HAL_BUSY。这时候简单粗暴的同步写法可能会失败。很多人在重定向里加一个等待上一次发送完成的循环int _write(int fd, char *ptr, int len) { for (int i 0; i len; i) { while (HAL_UART_GetState(huart1) ! HAL_UART_STATE_READY); HAL_UART_Transmit(huart1, (uint8_t *)ptr[i], 1, HAL_MAX_DELAY); } return len; }这个版本的可靠性更高但速度会慢一些。对调试输出来说完全够用。3.3 浮点printf支持与连接器选项GitHub上常见的CLion STM32工程的CMakeLists.txt里都会有一行target_link_options(${PROJECT_NAME} PRIVATE -u _printf_float)这行代码的作用是强制链接浮点格式化支持。嵌入式GCC默认使用的是nano.specs配置文件它把printf的实现替换成了小型化版本以省代码空间。小型版printf不支持浮点数所以你直接printf(%.2f, 3.14)输出是空的或者显示乱码。加上 -u _printf_float 之后链接器会把完整的浮点格式化函数拉进来。代价是代码体积增加几KB对STM32F103这种Flash只有64KB的芯片来说还能接受但要注意。还有一个配置是newlib的规格选择。如果你的CMake里没有指定任何specs默认会使用标准Newlib。标准Newlib的printf本身支持浮点不需要额外配置但体积比nano版本大不少。如果你用了nano.specs就必须加 -u _printf_float。CMake配置里常见的完整写法是这样set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} --specsnano.specs -u _printf_float)或者用target_link_options也行。总之记一句话CLion里printf要显示浮点数必须检查你有没有开nano库开了就一定要加 -u _printf_float。4. 常见问题与排查实录4.1 重写_write后中文乱码这个问题的根源不在_write代码本身而在文件编码和终端解析。CLion默认源码文件是UTF-8编码。你在代码里写 printf(温度: %.1f, temp)编译器把这段中文字符串按UTF-8编码存放在Flash里每个汉字占3个字节。串口助手如果不认识UTF-8按GBK或ASCII解码就会看到乱码。实测下来最省事的方案是串口助手编码选择UTF-8。市面上的串口助手有些支持手动切换编码有些默认GBK需要自己找。另外发送区不要选“字符转义发送”那只会给数据制造麻烦。如果不想改串口助手配置也可以把中文字符串全部改成英文或者用转义序列拼接UTF-8字节。但说实话调试阶段用英文最省心。4.2 浮点数输出空白或输出为零症状是printf(%d, a)正常printf(%.2f, b)什么都没有或者输出0.00。很多人在CLion里用默认配置生成工程链接器参数里没有加 -u _printf_float。浮点格式化函数没有被链接进来printf遇到格式符%f时直接返回错误或者跳过输出。排查方式很直接打开编译日志看链接器命令行确认里面到底有没有nano.specs和 _printf_float。还有一种情况是用了fputc重定向方案心想fputc都写了加上_write吧然后两个一起定义编译直接报重复定义错误。这个后面专门讲。4.3 程序死在printf里semihosting的坑很多人在CLion下第一次用printf程序直接跑飞或者调试器停在HardFault_Handler。这时候第一反应是怀疑自己的_write写错了其实更常见的原因是编译器根本没有把你定义的_write链接进去。怎么确认可以看编译产物里能不能搜到_write符号。在构建目录下执行arm-none-eabi-nm build/xxx.elf | grep write如果只看到 fputc说明你的_write没有被编译进工程printf走的还是默认semihosting实现。这类问题多发生在文件没有加入CMakeLists.txt的源文件列表里。CubeMX生成工程后自动添加了所有.c文件但如果手动创建了新文件CMake可能没有重新扫描需要手动add_executable或重新加载CMake工程。如果确认已经链接进去了再看一下OpenOCD配置。正常情况下Newlib调用semihosting会触发BKPT指令而你在普通调试模式下没有配置semihosting服务端所以死机。如果你重写了_write这条路径就被切断了printf不会再触发semihosting。4.4 fputc和_write混在一起编译报错有些同学代码写了一半网上抄了个fputc又加上_write编译时报重复定义。这是因为Newlib的标准库里同时有fputc和_write的弱定义。你重写任何一个都会覆盖库里的弱符号但如果你两个都自己写就等于定义了两次fputc吗不是而是你自己定义fputc和库里的fputc冲突了。Newlib里fputc不是一个弱符号而是一个正式符号。正常编译时它存在于libc.a里。你重写fputc时因为你的目标文件里定义的符号优先级高于库里的同名符号链接器会优先用你写的。但如果你同时把fputc和_write都写在一个文件里不会报重复定义因为库里那个fputc被你的版本覆盖了_write也是同理。真正会报错的场景是你把Keil工程里的system_stm32xx.c或者其他库里自带的一个fputc实现也一起编译进来了产生多个强符号冲突。排查方法还是用nm命令看符号来源两个都定位到后会非常清晰。5. 一套代码兼容Keil和CLion的条件编译实战5.1 用宏判断当前编译环境实际产品开发中有人用Keil有人用CLion代码需要同时维护两套环境。这种情况下最好是写一个retarget.c文件在文件里做条件编译#if defined(__ARMCC_VERSION) (__ARMCC_VERSION 6010050) // ARM Compiler 6 走 _write int _write(int fd, char *ptr, int len) { for (int i 0; i len; i) { while ((__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET)); HAL_UART_Transmit(huart1, (uint8_t *)ptr[i], 1, HAL_MAX_DELAY); } return len; } #elif defined(__CC_ARM) // ARM Compiler 5 / Keil 走 fputc int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; } #else // GCC / CLion 走 _write int _write(int fd, char *ptr, int len) { for (int i 0; i len; i) { while ((__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET)); HAL_UART_Transmit(huart1, (uint8_t *)ptr[i], 1, HAL_MAX_DELAY); } return len; } #endif__ARMCC_VERSION是ARMCC编译器预定义的宏__CC_ARM是老版本ARMCC 5的宏__GNUC__是GCC的宏。这样一份代码在Keil里编译时fputc生效在CLion里编译时_write生效互不干扰。这个方案有一点要注意ARM Compiler 6AC6虽然也是ARM官方编译器但它的C库也换了更接近Newlib模型所以重定向入口也是_write。很多人以为AC6和AC5一样写fputc结果新工程默认AC6就直接踩坑。5.2 为什么有时“看起来有效”的fputc也能跑起来网上某些案例里GCC环境下重写fputc后printf确实输出了这让人混淆。原因是Newlib内部fputc最终也是调用_write如果你的fputc实现里调用了HAL_UART_Transmit那么确实能覆盖字符输出。但printf并不经过fputc所以严格来说你重写fputc对printf不起作用。那“看起来有效”是怎么发生的有两种可能第一种是你同时定义了fputc和_writefputc里的代码被printf间接调用到了但真正让printf工作的是_write。第二种是你用的printf是某个第三方库的自定义实现那它的调用链可能真的经过fputc。只要不是这两种情况光重写fputc在GCC下必然无效。所以判断标准很简单如果你在GCC环境下只写了fputcprintf能正常输出那大概率你的工程里某个地方还有一份_write实现或者你用的printf根本不是Newlib的printf。6. 实测CLion重定向过程中的几个小技巧最后分享几个CLion实操中容易忽略的细节都是我自己调试时一点点试出来的。第一_write函数里的fd参数可以不用但最好不要忽略它。在某些调试场景中你可能会同时重定向日志到串口和文件通过fd判断当前是哪个流在写入留着以后扩展会方便很多。第二重定向写好后建议在初始化最后加一行setvbuf(stdout, NULL, _IONBF, 0)。这个调用会把stdout设置成无缓冲模式。否则Newlib默认可能启用缓冲printf的内容会先攒在内存里等缓冲区满或遇到换行符才真正调用_write。调试时总感觉输出慢半拍加这一行能解决。第三串口波特率与重定向代码没关系。很多人误认为改_write会影响串口通信速率其实重定向只是把数据交给USART外设波特率完全由CubeMX初始化决定。第四如果你的代码里同时用了freertos就要特别注意在中断里别调用printf。即使重写了_writeprintf内部也可能有关锁操作在中断上下文调用会导致死锁。我的做法是建一个调试队列中断里只往里丢数据由后台任务统一printf。第五CLion自带的串口监视器在调试时很好用但它的历史版本对UTF-8中文支持不稳定。串口乱码时先别怀疑编码用第三方串口助手指着同一串数据再看一次很多“乱码”都是工具解析的问题。从我自己的经历来说从Keil迁移到CLion最难受的并不是IDE本身而是很多固有的认知需要刷新。fputc和_write的差异就是最典型的一个。理解清楚工具链和C库的关系之后这类问题基本不会再困住你。如果哪天换了新的IDE、新的编译器你也能顺着这个思路自己找到正确的重定向入口。
返回列表