ARTICLE DETAIL

资讯详情

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

CLion下STM32 printf重定向:彻底搞懂_write与fputc的底层区别

CLion下STM32 printf重定向:彻底搞懂_write与fputc的底层区别 在CLion里调STM32串口printf死活不吐字这是很多刚接触嵌入式开发的兄弟最容易卡住的一关。网上搜一圈老教程全在教“重写fputc”抄过来发现编译能过程序跑起来却什么都没有。后来翻了newlib的实现才明白在arm-none-eabi-gcc这套工具链下真正该动手的地方是_write()。这篇文章就把这个“为什么”彻底讲透顺带把CLion里配置、编码、JNI这些实际坑一并排掉。1. 先理清printf的底层链路fputc和_write到底谁说了算1.1 printf不是直接怼硬件的很多人对printf的理解停留在“调用它就能输出”但嵌入式环境没有终端、没有操作系统printf本身只是个格式化函数它干的事情是把%d、%f、%s这些占位符替换成实际内容然后往一个抽象的“标准输出”里塞字符。至于塞进去之后谁来接收、发到哪里C标准库根本不关心。在PC上这个“标准输出”由操作系统接管最终跑到终端窗口。在单片机上没有操作系统标准库就只能依赖开发者自己提供的底层输出接口。问题就出在这里不同工具链、不同C库这个“底层接口”的名字不一样有的叫fputc有的叫_write还有的叫__io_putchar。1.2 标准库、系统调用、设备驱动的三级结构要把这个问题说得透得引入一个三级结构的概念我习惯叫它“应用层—库函数层—系统调用层”。应用层你代码里的printf、puts、fprintf。库函数层C标准库的实现比如newlib、newlib-nano、glibc。printf在这里完成格式化然后调用更底层的写函数。系统调用层真正和硬件或者宿主环境打交道的那一层在newlib里对应的就是_write()。fputc属于库函数层的回调接口它操作的是FILE流。_write()属于系统调用层它操作的是文件描述符。很多老教程把fputc当成“底层接口”这在老的Keil、IAR或者某些特定配置下没问题但换成GCC工具链printf内部压根不走fputc这条线。1.3 为什么“但是我的fputc能输出啊”每次说到这一定有人跳出来说“我明明重写fputc就能输出”。这种情况确实存在但多半是工具链内部做了兼容。比如STM32CubeIDE生成的工程里syscalls.c或者某个兼容层会提供一个weak类型的_write()实现它内部会调用__io_putchar而__io_putchar默认实现又是空的。这时候你重写fputc实际上是把printf的输出通过流缓冲最终转发到了_write()等于间接生效了。链路绕了一大圈最后干活的还是_write()。CLion里如果你用Embedded Development插件配合arm-none-eabi-gcc它不会自动帮你生成这层兼容代码所以你照着网上fputc的教程搞大概率失败。这就是标题那句话的核心CLion场景下_write才是那个真正绕不开的入口。2. 为什么CLionARM GCC场景下写fputc容易翻车2.1 CLion默认工具链的C库实现CLion本身不直接编译嵌入式工程它通过插件调用你配置的工具链。最常见的组合是arm-none-eabi-gcc newlib-nano。这个组合下printf的实现来自newlib。newlib对输出函数的分层非常明确printf内部调用vfprintfvfprintf最终调用_write_r()这是一个可重入版本的系统调用封装它再调用你提供的_write()。整个过程和fputc的关系很小除非你强行把stdout的缓冲模式设置成特定的流回调。换句话说在newlib的体系里_write()是printf这条链路上的“最后一公里”。你不修这最后一公里前面printf格式化得再漂亮也没用。2.2 fputc失效的几种典型现象我总结了下fputc方案在CLion工程里翻车通常有这几种表现编译通过程序运行正常但串口什么也不输出。有时输出有时不输出加个延时又能出来几个字符。输出全乱了或者程序直接卡死在打印语句上。第一种现象最普遍原因是printf的输出被newlib内部缓冲了缓冲区没满、没换行、不主动flush数据根本不会到达fputc。第二种现象通常是缓冲区撞上了偶尔触发flush才挤出来一点。第三种则可能是走了半主机模式程序卡死在等待调试器响应的状态里。2.3 半主机模式是个隐藏大坑ARM工具链的newlib库里默认带了一份syscalls实现这个默认实现很多接口依赖于半主机模式。半主机是什么意思简单说它让单片机通过调试器把输入输出转发到PC主机上相当于在开发环境下“借用”电脑的终端。听起来很方便但实际上极其坑。如果你不重写_write()printf的数据流会尝试进入半主机逻辑调试器没开启半主机支持程序就会卡住甚至触发HardFault。在CLion里配合OpenOCD调试时这种卡死现象特别容易遇到。所以重写_write()的第一个目的是把输出从默认的半主机路径“夺”回来改道到你的串口外设上。这比重写fputc更根本。3. 实操在CLion中重写_write的正确姿势3.1 准备工作CLion嵌入式工程与UART初始化在动手写_write()之前得先保证UART能正常工作。我默认用的是STM32 HAL库以下代码基于STM32CubeMX生成的工程。假设你已经通过USART1来输出初始化代码通常在uart.c里生成好huart1这个句柄就是我们的输出通道。调试阶段建议先把波特率固定到1152008N1关掉流控减少变量。/* main.c 中需要包含头文件 */ #include stdio.h #include uart.h3.2 最简单的_write实现阻塞发送这是推荐新手用的版本原理简单、不出错int _write(int file, char *ptr, int len) { /* 逐字节发送等待TXE标志位 */ for (int i 0; i len; i) { while (!(huart1.Instance-ISR USART_ISR_TXE)); huart1.Instance-TDR (uint8_t)ptr[i]; } return len; }这段代码干了什么从ptr指针指向的缓冲区里一个字节一个字节地往UART发送寄存器里写。USART_ISR_TXE是发送寄存器空的标志只有寄存器空了才能写下一个字节否则会丢数据。最后返回len告诉C库“这些数据我都处理掉了”。HAL库版本可以写成int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }注意HAL_UART_Transmit的参数类型是uint8_t *所以从char *转过来要强转一下。我实测在115200波特率下这个版本对一个几十字节的printf输出完全够用。3.3 为什么这个版本比fputc版本稳定对比一下常见的fputc版本int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }fputc版本每次只发一个字节每发一个字节都会调用一次HAL_UART_Transmit进入一次库函数封装。性能差还在其次关键是newlib的printf不会直接调用fputc它是把格式化后的内容放进一个缓冲区然后调用_write()一次性提交。你重写fputc等于别人在终点等你你却在半路另一条道上等永远等不到人。_write()版本是直接接管“一次性提交”的入口一个printf调用对应一次_write()调用整段字符串一次处理链路短、逻辑直、不会有缓冲区滞留问题。3.4 关闭缓冲与浮点支持默认情况下newlib可能对stdout做缓冲这会导致明明调用了printf串口却迟迟没有输出。推荐在main函数里加上setvbuf(stdout, NULL, _IONBF, 0);_IONBF表示无缓冲让printf的数据直接提交给_write()对调试输出特别友好。如果你的printf里用了%f浮点格式还需要在CMake链接选项里加上set(CMAKE_C_FLAGS ${CMAKE_C_FLAGS} -u _printf_float) set(CMAKE_C_FLAGS ${CMAKE_C_FLAGS} -u _scanf_float)如果不加newlib-nano为了省空间默认不链接浮点转换%f输出会是空的。4. 进阶场景JNI、多任务与访问冲突排雷4.1 在CLion中配置JNI时_write也有用武之地热词里有“在clion中配置jni环境”这里顺带说一个经验。JNI场景下你写的是本地C/C库Java通过JNI调用C代码里的printf输出到标准输出。但在Windows上Java进程的标准输出未必直接显示在IDE的控制台里尤其当你从IntelliJ或Eclipse里启动Java程序时本地库的printf经常“隐身”。解决方法之一就是也重写_write()把输出重定向到Windows调试输出或者写入日志文件#ifdef _WIN32 int _write(int file, char *ptr, int len) { if (file 1 || file 2) { char buf[512]; int n len 511 ? len : 511; memcpy(buf, ptr, n); buf[n] \0; OutputDebugStringA(buf); } return len; } #endif这段代码判断文件描述符1是stdout2是stderr然后把内容转发给OutputDebugStringA在Visual Studio的调试输出窗口里能看到。实测在CLion配MinGW的JNI开发里这套方案比纠结printf为什么消失要省心得多。4.2 FreeRTOS多任务下_write配合互斥锁跑FreeRTOS或者RT-Thread这类RTOS时多个任务同时printf字符串会交错看起来就像乱码。fputc方案一次只有一个字符反而交错得更厉害。_write()方案优势在于你可以在函数入口加锁一次性把整段字符串发完再解锁。核心实现逻辑#include FreeRTOS.h #include semphr.h extern SemaphoreHandle_t uartMutex; extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { xSemaphoreTake(uartMutex, portMAX_DELAY); HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); xSemaphoreGive(uartMutex); return len; }互斥锁保证了同一时间只有一个任务能往里写字符串不会交错。这是fputc版本很难做到的因为fputc每次只提交一个字符锁粒度太小效率低下且仍可能在格式化阶段被其他任务打断。4.3 访问冲突类报错的排查思路热词里有一条“write to location 0000000000000020 caused an access violation.”如果你在CLion调试时撞上这类错误十有八九是_write()里引用了未初始化的外设句柄或者UART外设时钟没开启就调用printf。还有一条“write access to const memory has been detected”这个通常是往只读区域写数据导致的。比如把一个字符串常量当缓冲区然后往里写内容。写_write()时只读拷贝ptr指向的内容不要试图修改它就不会触发这类问题。排查这类错误我建议按三步走第一步确认printf是在UART初始化之后才调用的。第二步确认_write()内部引用的句柄是全局可见的最简单的方式是直接在main.c里定义huart1别跨文件extern一堆。第三步在_write()第一行设置断点看程序是否进入半主机路径。如果在断点处停住说明_write()被调到了问题在前半段如果断点压根没触发说明输出缓冲还没被推送。5. 排查速查表与我的几点经验笔记5.1 常见问题速查表我整理了一份自用速查表遇到printf不输出直接对照着查现象可能原因处理方案printf无任何输出newlib缓冲未刷新使用setvbuf(stdout, NULL, _IONBF, 0)printf无任何输出未重写_write走了半主机按上文方法实现_write()偶尔输出几个字符缓冲未满未触发提交关闭缓冲或输出末尾加fflush(stdout)中文全变成乱码源文件编码与串口工具编码不一致统一为UTF-8串口助手选择UTF-8解码中文显示部分正常部分乱波特率过高导致丢数据降低波特率或优化发送逻辑%f输出空白newlib-nano未链接浮点转换添加-u _printf_float程序卡死半主机等待调试器响应确保_write被正确重写调试器报access violation外设句柄未初始化在printf前完成外设初始化5.2 中文乱码的完整解决方案CLion默认源文件编码是UTF-8Windows上很多串口助手默认按GBK解码两边对不上自然乱码。解决方式有两种。第一种串口助手设置里把解码方式改成UTF-8一劳永逸。第二种把源文件编码改成GBK。在CLion里通过File File Encoding选择GBK但要注意改完后所有中文字符串常量都会变成GBK编码的字节流发送到串口的就是GBK格式。这时候串口助手按GBK或中文解码就正常了。我个人建议用第一种保持所有工程文件都是UTF-8跨平台协作时不闹心。另外要注意\n换行问题很多串口工具对\n支持不好显示会错行。可以在_write()里把\n替换成\r\n或者在printf里直接用\r\n。5.3 写_write时的几个终极提醒第一不要在_write()内部调用printf或者任何标准库输出函数递归调用直接卡死这是新手最容易踩的坑。第二_write()返回的必须是实际发送的字节数。漏写返回值C库会认为输出失败后续printf可能被中断。第三中断优先级的问题。如果你的UART使用中断发送且你在中断里也调用printf会形成重入。调试阶段请一律用阻塞发送稳定压倒一切。第四链接时如果报_write符号重复定义说明你的工程里已经有默认syscalls提供了weak实现你直接定义强符号覆盖它即可不用删除任何文件。写到最后的一些心里话CLion做嵌入式开发printf重定向这个坎绕不过去。我在解决这个问题时最大的体会是先搞清楚自己的工具链里C库是怎么实现的再动手写代码。fputc和_write不是谁替换谁的关系而是不同层级的东西。你选择了arm-none-eabi-gcc就相当于选择了newlib这套规则遵守规则比硬套老教程更省时间。现在我自己写调试代码不管在STM32、树莓派Pico还是自己的小玩具板上一律优先重写_write()把串口输出、日志缓冲、并发锁统一放在这一层处理。后面如果再遇到printf不输出你要做的第一件事是打开反汇编或者库源码看看printf最后到底调了谁。搞懂那个“谁”你的问题就解决一半了。
返回列表