资讯动态

CLion STM32 printf重定向:为什么必须重写_write而非fputc

发布时间:2026/9/6 10:53:34 来源:尧图企业网站定制
用 CLion 做 STM32 开发十有八九会卡在 printf 重定向这一步。明明在 Keil 里重写一个 fputc 就能输出到了 CLion 里同样代码就是不出东西。这问题我踩过而且当时把论坛翻了个底朝天最后才搞明白CLion 只是个壳真正干活的是 arm-none-eabi-gcc这个工具链的 C 库是 newlibnewlib 的 printf 只会走 _write根本不碰 fputc。今天我把这条链路完整写出来给还在和 _write、fputc 搏斗的同行一个参考。1. 为什么会有这个问题CLion 典型开发链路与 printf 重定向需求1.1 从 Keil 迁移到 CLion 后最熟悉的套路失效了如果你和我一样是从 Keil MDK 转过来的那么对这个代码片段一定不陌生int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }然后在 Keil 工程设置里勾上 MicroLIB串口助手就能收到 printf 的内容。这套组合拳在 Keil 时代非常统一相关的教程、例程、CSDN 博客遍地都是以至于很多人像我一样潜意识里直接认定printf 内部就是一个字符一个字符地调 fputc。等到把工程搬到 CLion问题就来了。同样的文件、同样的代码、同样的串口printf 的输出在串口助手里一点动静都没有。刚开始我以为是 CLion 配置问题检查了调试器、烧录设置、串口参数全部正常。后来试着在代码里直接调 HAL_UART_Transmit发现串口是通的问题就出在 printf 到串口之间的那段隐形路径上。关键在于CLion 只是集成开发环境它本身不负责编译。STM32 项目在 CLion 里默认走的是 arm-none-eabi-gcc 工具链和 CMake 构建系统。arm-none-eabi-gcc 的 C 标准库是 newlib或者更常见的精简版 newlib-nano。这套标准库的内部实现和 Keil 的 ARMCC 标准库完全不是一回事printf 的底层输出路径自然也不同。所以你在 Keil 里总结出的printf 走 fputc这个经验在 GCC 环境下并不成立。1.2 printf 重定向到底在重定什么先说清楚一个基本概念嵌入式环境里根本没有屏幕printf 的输出目标是标准输出流 stdout。标准库在实现 printf 的时候必须有一个最终把数据写到某个地方的动作这个动作在不同的标准库里长得不一样名字也不一样。拿三类主流工具链来对比工具链标准库printf 底层出口常见重定向钩子Keil MDK (ARMCC)ARM 标准库使用 MicroLIBfputcint fputc(int ch, FILE *f)arm-none-eabi-gccnewlib / newlib-nano_write / _write_rint _write(int file, char *ptr, int len)IAR EWARMDLib__writesize_t __write(int handle, const unsigned char *buf, size_t len)重定向的本质就是把标准库输出链路的最后一段替换成自己的实现。快递行业有个类似的逻辑一个包裹从发件人到收件人中间要经过多个转运中心每个转运中心末端负责投递的分拣员名字不一样。Keil 的分拣员叫 fputcGCC newlib 的分拣员叫 _write。你把自己的快递员换成 fputc 的样子对 Keil 有用但 GCC 的快递单上写的是 _write不把 _write 换掉包裹就永远卡在中心站。这也是我后来给新人解释时最喜欢用的比喻别管网上教程写的是什么先弄清楚你当前工程用的标准库是谁再去找它规定的底层出口。2. 从 printf 到串口newlib 里到底发生了什么2.1 printf 不是 fputc 的上层封装C 标准只规定了 printf 和 fputc 的行为并没有规定它们之间的实现关系。printf 可以内部调 fputc也可以完全不理会 fputc。这不是标准规定的接口契约而是各个标准库的自由实现。newlib 的实现方式是printf 只是 vfprintf 的一个薄封装它把 stdout 这个 FILE 流指针和可变参数打包传进去int printf(const char *fmt, ...) { // 内部拿到 stdout 的 FILE 结构 // 调用 vfprintf(stdout, fmt, arg) }vfprintf 负责两件事解析格式串以及把生成好的字符写入 stdout 对应的缓冲区。它并不直接操作硬件也不逐字符回调你写的 fputc。真正负责把缓冲区的数据发出去的函数是 vfprintf 内部经过多级调用之后触发的 __swrite 和 __swbuf这些内部函数最终会调用到 _write。很多人想不通的一点就在这里既然 newlib 也有 fputc为什么 printf 不用它因为 newlib 的设计思路是 POSIX 风格的文件描述符模型printf 这类 stdio 函数全部建立在流的概念之上流的底层统一走 _write 这个系统调用接口。fputc 只是另一个流操作函数它和 printf 的关系不是父子而是兄弟最终都要向 _write 汇合。2.2 缓冲层是怎么影响输出的newlib 的 stdio 会为 stdout 维护一块缓冲区vfprintf 解析出来的字符先写进这块缓冲区而不是立刻发送。什么时候真正把缓冲区里的数据发给 _write大致有几种情况缓冲区满了默认 BUFSIZ通常 1024 字节遇到换行符 \n且当前流是行缓冲模式程序主动调用 fflush(stdout)流被关闭或程序正常退出这个机制解释了为什么很多教程都建议在 printf 字符串末尾加 \n否则输出可能憋着不出来。裸机调试时主循环一直在跑不做任何 flush如果你 printf(hello) 不加换行数据就一直躺在缓冲区里。解决思路有两种一是每条 printf 都加 \n二是在初始化阶段调用setvbuf(stdout, NULL, _IONBF, 0);将 stdout 设为无缓冲模式。无缓冲模式下每次 printf 都会立刻触发 _write非常适合调试阶段用。代价是每个 printf 都会发起一次完整的 UART 发送事务效率略低但裸机调试完全无感。2.3 一条完整的调用链把整个过程串起来大致是这样printf(abc\n); └→ vfprintf(stdout, abc\n, ...) └→ 字符写入 stdout 的 FILE 缓冲区 └→ __sfvwrite(...) // 换行触发缓冲刷新 └→ _write_r(...) └→ _write(1, abc\n, 4) ← 你重定向的钩子 └→ HAL_UART_Transmit(huart1, abc\n, 4)注意这条链路里fputc 从头到尾没有出现。这就是为什么你只重写 fputcprintf 依旧没有反应。你的 fputc 确实存在但 newlib 的 printf 根本没走那条路。3. 手写 _write最简实现与进阶细节3.1 一个能用的 _write 长什么样在裸机 STM32 工程里最直接、最省事的 _write 实现如下#include stdio.h #include unistd.h #include main.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY) HAL_OK) { return len; } return -1; }代码逻辑很直白把 newlib 交过来的一整块数据通过 HAL_UART_Transmit 一次性发出去。发送成功就把 len 原样返回失败返回 -1。这就是整个重定向的核心其他都是在这个基础上的优化和补充。需要提醒的是这个函数不要随便加 static也不要加 inline因为 newlib 在链接期要找到这个全局符号来替换库里的弱实现。放在任何 .c 文件里都行工程里只允许出现一次强定义。3.2 file、ptr、len 的语义与陷阱三个参数的含义不算复杂但有几个细节容易出问题file文件描述符。0 是 stdin1 是 stdout2 是 stderr。裸机环境下通常不分全走同一个串口但如果你有多个调试串口可以靠它区分。ptr指向待发送数据缓冲区的指针不是单个字符。这段内存由 newlib 内部管理只读即可不要尝试修改它的内容。len要发送的字节数单位是字节。注意它的类型是 int不是 unsigned int理论上可能为负实际使用中概率极低但严谨一点的实现可以加个判断。一个常见的低级错误是强转类型时用错指针HAL_UART_Transmit的第二个参数类型是uint8_t *而_write收到的是char *C 语言里直接强转没有问题。真正需要警惕的是把ptr误解成指向单个字符于是只发一个字节就返回结果通信中断一半数据。进阶一点的用法是用 file 参数把 stdout 和 stderr 分流到不同串口int _write(int file, char *ptr, int len) { switch (file) { case STDOUT_FILENO: HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); break; case STDERR_FILENO: HAL_UART_Transmit(huart2, (uint8_t *)ptr, len, HAL_MAX_DELAY); break; default: break; } return len; }这种写法在调试多模块系统时很实用正常日志走串口 1错误日志走串口 2两条日志互不干扰。需要注意stdout 默认是行缓冲stderr 默认无缓冲两者 flush 时机不同同时输出时可能出现顺序错乱。想要严格有序可以统一 setvbuf 的策略。3.3 配上 _read 让串口输入也能用重定向不能只做输出输入侧也有对应的钩子函数 _read。想让 scanf、getchar 从串口接收数据就实现它int _read(int file, char *ptr, int len) { HAL_StatusTypeDef status HAL_UART_Receive(huart1, (uint8_t *)ptr, 1, HAL_MAX_DELAY); if (status HAL_OK) { return 1; } return -1; }newlib 的底层逻辑会循环调用 _read 直到凑够需要的字节数所以一次只接收一个字节完全可行。这里我用的是阻塞接收没有串口数据进来时程序会卡在这一行这对裸机程序来说有时候是优点等待输入有时候是致命问题阻塞了其他任务。在 RTOS 里更稳妥的做法是让 _read 从队列里取数据没数据就返回 0把调度权让出去。另外提一句裸机工程如果用了 mallocnewlib 还会要求提供 _sbrk 这个堆指针调整函数否则链接或运行时可能出问题。它和 _write 同样属于 newlib 的系统调用桩在 STM32 工程里经常和重定向一起被补全。4. 那 fputc 到底能不能用Keil 和 GCC 的库实现差异4.1 Keil ARMCC 的 microlib 为什么重写 fputcKeil 里 fputc 重定向能够生效核心前提是启用了 MicroLIB。MicroLIB 是 ARM 为嵌入式环境裁剪的精简库它的 stdio 实现采用逐字符输出模型printf 最终会对每个字符调用 fputc所以重写 fputc 就可以让 printf 输出到任何外设。如果用的是标准 ARM 标准库而不勾 MicroLIBfputc 重定向不一定生效Keil 官方文档里也专门提到过两种模式的区别。很多老教程直接默认勾选 MicroLIB导致大家只知道重写 fputc这个结论看不到它背后的前提。MicroLIB 的代价是功能阉割比如 C 支持不完整、部分标准函数缺失、浮点格式化支持也受限。如果项目有 C 需求MicroLIB 往往撑不住。这也是我在新项目里优先用 GCC newlib 的原因之一虽然要重新理解 _write但整体灵活度高很多。4.2 newlib 中 fputc 的最终归宿也是 _write那么问题来了在 newlib 环境下重写 fputc 真的完全没用吗也不是。newlib 确实定义了 fputc你可以在工程里写一个同名函数去覆盖它但你要清楚覆盖之后影响的是谁。newlib 的 fputc 实现本身也是通过流缓冲机制工作的最终也会落到 _write。就算你用自定义的 fputc 把标准库的 fputc 替换掉printf 不知道也不会用因为 printf 的代码路径压根不经过 fputc 这个符号。你辛苦写的 fputc 只有在代码里显式调用fputc(c, stdout)时才起作用。换句话说在 GCC newlib 环境下重写 fputc 属于修了一条支路主线毫不相干。真正一劳永逸的是把主线出口 _write 换掉。只要 _write 通了printf、puts、fprintf、fputc、fwrite 全部都能工作因为它们最终都在同一个出口汇合。有个小规律可以分享看到教程让你改 fputc基本可以判断它面向的是 Keil/ARMCC看到让你改 _write基本面向的是 GCC/newlib看到让你改 __write那是 IAR。把它们对应到自己的工具链再操作能少走很多弯路。4.3 两种实现对比总结维度Keil ARMCC (MicroLIB)GCC newlib / newlib-nano标准库类型ARM 精简库newlibprintf 底层出口fputc_write重定向入口int fputc(int ch, FILE *f)int _write(int file, char *ptr, int len)输入重定向入口int fgetc(FILE *f)int _read(int file, char *ptr, int len)缓冲策略相对简单逐字符明显FILE 缓冲需注意刷新时机浮点支持MicroLIB 下受限newlib 完整nano 需加 -u _printf_float这张表后来成了我判断新工程该改哪个函数的依据先确认工具链再查它的标准库文档而不是盲抄上一份工程的模板代码。很多人反复踩坑就是因为把不同工具链的代码混着用。5. 实操中绕不开的坑中文乱码、多目标构建与 HardFault5.1 中文乱码先查编码别瞎改函数printf 重定向做好了紧接着容易遇到中文乱码。网上搜索clion中文输出乱码的频率非常高我排查过不少次绝大部分都不是 _write 或 fputc 的问题而是编码不统一。三个环节逐一排除源码文件编码。CLion 默认使用 UTF-8但如果你从别的工程拷来一个 GBK/GB2312 编码的 .c 文件字符串字面量在编译时会按源文件编码存进 rodata。输出到串口后按 UTF-8 解释就会出现乱码。解决办法是在 CLion 右下角把文件编码统一转为 UTF-8或者重新输入中文内容。串口终端解码。SecureCRT、MobaXterm、PuTTY 这些工具都有字符集设置。Windows 自带串口助手很多默认 GBK而你的代码是 UTF-8两边对不上自然乱码。把终端字符集切到 UTF-8 通常能解决。波特率与数据位。波特率错乱产生的乱码是一整片随机符号和编码乱码的表现有差别。如果只有中文乱码、英文正常优先怀疑编码如果英文也乱检查波特率。还有一个容易被忽视的细节UTF-8 的中文是 3 字节GBK 是 2 字节。如果发送过程中某次缓存刷新恰好把一个汉字切成了两段终端也会显示乱码。好在我们重写 _write 之后newlib 刷新缓冲区时会把一整段连续的字节交给 HAL_UART_Transmit串口底层按顺序发送不会在同一汉字中间插入其他数据所以这类截断问题在输出方向很少出现。5.2 一个工程放多个 main 的正确姿势围绕clion写多个main的搜索热度也很高。很多教程默认 STM32 工程只有一个 main.c但实际开发中经常要写多个测试程序比如一个 usart 测试、一个 i2c 测试。在 CLion 里正确做法不是把多个 main 函数硬塞进同一个可执行文件而是为它们分别建 target。CMakeLists.txt 里的示意add_executable(demo_uart demo_uart.c Core/Src/main.c Core/Src/usart.c Core/Src/gpio.c # ... ) add_executable(demo_i2c demo_i2c.c Core/Src/main.c Core/Src/i2c.c Core/Src/gpio.c # ... )然后在 CLion 右上角运行配置下拉框里选择对应的 target再点烧录调试即可。这样两个 demo 互不干扰还能共享底层的 HAL 驱动文件。这个方式同样适用于保留多个实验代码的场景。不过有两点要提醒一是 CubeMX 重新生成代码时要小心它可能清掉你手动往 CMakeLists 里追加的内容二是如果多个 target 里对同一个弱符号有不同强定义比如某个 HAL 回调函数链接器会报告冲突此时需要用 CMake 的 target_compile_definitions 区分宏。5.3 重定向后的 HardFault 与非法写内存搜索热词里还有两条典型的报错信息在调试 printf 重定向时也经常见到write to location 0000000000000020 caused an access violation.和write access to const memory has been detected, the output may be wrong!第一条常见原因是 _write 收到的 len 异常比如一个巨大的或负的长度传入 HAL_UART_Transmit导致外设读取了非法内存地址。遇到这个问题先别急着查硬件把断点打在 _write 入口看 len 的值是否正常。如果正常再检查 ptr 指向的地址是否在有效 SRAM 区域。第二条典型操作是把字符串字面量当普通数组修改char *p hello; // hello 存在 .rodata 段 p[0] H; // 试图写只读内存在 GCC 链接器脚本里.rodata 段通常放在只读区域写入会触发总线错误。正确写法是char p[] hello;这样字符串会复制到可读写的 .data 段。这类问题用 CLion 内置的 GDB OpenOCD 调试链路定位非常方便在 HardFault_Handler 中断处停下查看调用栈和 PC 寄存器基本都能锁定到具体函数。6. 如果重新配一遍我会注意什么上面这些坑踩过一轮之后我现在新建一个带串口重定向的工程基本按照固定的流程来先确认工具链用的标准库是什么。在 CMake 构建日志里看编译命令找--specsnano.specs、--specsrdimon.specs这类参数确认是 newlib 还是 newlib-nano。这一步决定了我该写 _write 还是 _write_r以及浮点支持是否需要额外加-u _printf_float。直接实现 _write 和 _read不再纠结 fputc。注册后立即用 printf(test\r\n) 验证确保串口物理链路没问题。初始化阶段加一行setvbuf(stdout, NULL, _IONBF, 0);避免换行才出数据造成的假死错觉。串口助手和源码编码统一用 UTF-8中英文混输不乱码。如果项目需要 C 或者复杂堆操作认真补全 _sbrk 等系统调用桩而不是等到 malloc 崩了再查。这样配置下来printf 重定向基本不会再成为项目的绊脚石。回到文章标题的问题CLion 中为什么要重写 _write而不是 fputc说到底就一句话CLion 不代表任何标准库它背后是 arm-none-eabi-gcc 和 newlib而 newlib 的 printf 链路认的是 _write。理解这条链路之后无论你换到 IAR、换到 RTOS还是换到别的芯片都能快速找到属于自己的最后一个分拣员。

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

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

免费获取报价