CLion开发STM32:printf串口重定向要写_write而非fputc
如果你跟我一样是从 Keil 或者 IAR 那套“直接改例程”的路子转去用 CLion 写 STM32 的大概率会在串口重定向这一步卡上一整天。我当时的代码是这样的抄一个fputc实现给它配上HAL_UART_Transmit信心满满地点击 Build下载打开串口终端——然后屏幕上一片空白。反复确认了波特率、引脚、接线都没问题之后我才开始怀疑是不是fputc在这个环境里根本不是那个入口。后来把工具链翻了个底朝天才明白 CLion 背后那套 GNU 工具链根本不走fputc这条路由它要的是_write。这个问题其实一句话就能说清不是fputc错了而是你用的 C 库不认识fputc这个“后门”。但为了以后不再被类似问题坑第二次也为了让刚入坑的人少走弯路我觉得值得把这背后的分层逻辑、工具链差异、以及完整可用的重写方案都摊开讲一遍。1. 先还原现场同样的 printf换个工具链就不灵了1.1 Keil 里那套“抄过来就能用”的 fputc 方案以前在 Keil MDK 里做串口重定向绝大多数教程会让你拿fputc开刀。最典型的写法长这样#include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }然后你就不管了随手一个printf(hello\r\n)串口助手里面立刻就跳出来。这个方案在 ARM Compiler 5 及其配套的 C 库里行之有效因为那套库在设计上把printf最终要“写出去的字符”通过fputc这个接口交给你。你重写了fputc就等于把终端输出重定向到了 UART 上简单粗暴而且几乎不用关心什么底层缓冲。我当时在正点原子裸机例程里看到的就是这套代码抄到自己的工程里确实也能跑。所以当我切换到 CLion 时第一反应就是照搬。这大概是所有从 MDK 转过来的嵌入式开发老兵都跳不过去的坑你以为“重定向”是标准 C 语言的通用机制结果它只是一家运行时库的约定。1.2 到了 CLionfputc 突然失效的诡异时刻CLion 本身不是一个编译器它只是通过 CMake 去调动工具链。我当时的组合是CLion ARM GNU Toolchainarm-none-eabi-gcc STM32CubeMX 生成的 Makefile/CMake 工程。把 Keil 那份fputc代码放进工程编译链接毫无问题函数也写进 ELF 文件了但运行起来就是没有任何串口输出。更诡异的是我用 ST-Link 调试器打断点明明在printf下一行能够停住断点放到fputc里面却永远不会命中。这就说明printf的执行路径根本不会碰fputc。那它跑哪去了我当时的第一个猜测是编译器优化把printf优化掉了。于是我把优化等级改成-O0断电重跑依旧没有反应。后来用反汇编看了启动代码和 main 之间的流程又在 map 文件里一通乱翻才在 newlib 的符号表里看到一个熟悉又陌生的名字_write。1.3 两种方案背后的工具链差异fputc和_write的差异本质上不是“推荐哪个”的问题而是“你的 C 运行时库最终把数据交给谁”的问题。以老 MDK 的 ARM Compiler 5 为例它的标准库实现里printf是建立在fputc之上的因此你改fputc就能截获全流程。而arm-none-eabi-gcc默认用的 newlib 或者 newlib-nano 里printf生成的 stdio 流最终通过一个更底层的系统调用函数_write把数据送出去fputc反倒是高层的流接口两者不在一个层级上。理解这一点之后官方文档里那些“用 GCC 时请实现_write不要动fputc”的建议就一点不奇怪了。CLion 之所以给你带来这种困惑只是因为它把 GCC 工具链和 CMake 包装成了一个好用的 IDE却在文档层面没有任何“补课”。2. printf 的内幕它凭什么把一个字符送进串口2.1 标准库的三层结构用户接口、流缓冲、底层设备很多人学 C 语言时把printf当成了一个自带神仙法术的函数其实它背后是一个分层结构。最外层是你熟悉的printf、fprintf、putchar这些标准库函数中间层是 stdio 的缓冲管理主要涉及FILE结构与缓冲区最内层则是真正把字节写到设备上的动作。打个不严谨但容易记的比方printf像是一家公司的销售部它受理订单、生成单据FILE流是仓库订单先压到仓库里不是立刻发货_write才是货运司机接到指令后真的把货物拉走。fputc则是销售部里的一台小打印机可以把某个字符打印到仓库的台账本上但订单并不是每张都必须走那台打印机。printf之所以你可以随意使用是因为标准库已经帮你把这三层接好了。它默认的stdout是一个带缓冲的FILE流收到你要打印的字符串之后先往这块缓冲区里塞。当缓冲区满、显式调用fflush、遇换行取决于是否行缓冲或者程序正常退出时缓冲区里的内容才会被“冲”到底层设备驱动去。问题来了底层设备驱动是什么在 PC 上它是终端、文件系统在嵌入式裸机上它什么都不是。所以你必须告诉运行时库“你把数据交给哪个寄存器、哪根串口线”这个“告诉”的动作就是重定向。2.2 newlib 的调用链vfprintf - _swrite - _writearm-none-eabi-gcc自带的 C 库叫 newlib其中还有一套针对嵌入式精简过的 newlib-nano。它的printf不直接使用fputc而是走一条更长的链printf └─ vfprintf(stdout, ...) // 格式化字符串写入 FILE 流缓冲区 └─ __swrite / _swrite // 流对象内部关联的写方法 └─ _write // 底层系统调用钩子由用户实现在 newlib 源码里FILE结构体保存了一个函数指针表_fns__swrite会从系统调用的语义出发最终调用_write(fd, buffer, length)。这个fd是文件描述符stdout对应的通常是1。这也是为什么你在实现的时候会看到类似这样的形式int _write(int fd, const char *buf, size_t len);_write这个名字来自 POSIX 系统调用规范newlib 特意把 stdin/stdout/stderr 的系统调用接口留给了用户。默认状态下newlib 自带的_write是个弱符号或者由其它启动文件提供它要么什么都不做要么直接走 semihosting 把输出丢到调试器终端。如果你在裸机工程里没有重写它printf做的一切格式化工作都白费数据到一个空洞中就结束了。顺便纠正一个网上流传的说法有些人说“newlib 的 printf 最终调用putc或fputc”这其实只是在特定的库实现或特定的编译选项下才成立。至少在我用arm-none-eabi-gcc 10.3配合--specsnano.specs的实测过程中fputc根本不会被printf路径调用到不了就是我前面说的那种断点不命中的现象。2.3 fputc 的定位流接口不是设备接口那fputc到底有什么用它是标准 C 规定的“写入一个字符到流”的函数。你完全可以在自己的业务代码里调用fputc(A, stdout)它确实会写字符到流的缓冲区里。但是缓冲区最终还是要通过底层_write才能发到设备上。换句话说fputc只是“存货到仓库”的动作_write才是“发车出货”的动作。如果你重写fputc你相当于把仓库里某台设备换成了你的 UART 驱动但仓库的大门还是原来那道。printf写入的内容可能根本不经由那台设备而是直接从另一端大门出库了。这也是为什么在 newlib 环境中改fputc是隔靴搔痒真正的施工点必须放在_write上。3. 为什么在 CLion 里偏偏要碰 _write3.1 CLion 嵌入式开发默认拥抱 arm-none-eabi-gccCLion 自己不带嵌入式交叉编译器它的嵌入式插件只是调用 CMake而绝大多数 STM32 工程模板使用的编译器都是 ARM 官方提供的arm-none-eabi-gcc。这个组合就决定了你的标准库是 newlib 或 newlib-nano。因此不管你是不是第一次在 CLion 里写串口重定向只要你没有刻意替换成 ARMCC 或者 IAR你遇到的问题一定是 newlib 的规则。我在新项目中已经习惯让 CubeMX 生成 CMake 工程其中 toolchain 文件里写明了set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g)然后在链接选项里还能看到-specsnano.specs。nano.specs会强制链接器使用 newlib-nano 的库而 newlib-nano 依旧采用_write作为底层钩子所以即便你为了省空间用 nano 版重定向的思路也完全一致。3.2 newlib 的 _write 与 ARMCC 的 fputc 是两种哲学ARM Compiler也就是 Keil MDK 内置的 armcc/armclang在提供标准库时做了一个面向嵌入式玩家的设计用fputc作为“重定向点”让初学者不需要了解 POSIX 接口也能控制输出。这是好事但也导致了大量网上教程只教fputc完全没有 VC/Linux 背景的读者会被带到沟里。newlib 则更像标准 UNIX 世界程序最终通过write系统调用与设备交互。虽然嵌入式环境没有操作系统但 newlib 保留了那层 syscall 作为扩展点。它甚至配套了nosys.specs用来提供一组默认的空实现包括_write、_read、_sbrk等。当你配合--specsnosys.specs时这些符号就已经存在了你再去_write等于覆盖了一个弱符号比直接报“undefined reference to _write”要温柔得多。所以同样是“让 printf 往串口发数据”ARMCC 是“给我一个架子你只需要挂上 fputc”newlib 则是“这里有一个系统调用缺口你来填_write”。你在 CLion 里遇到的是后者自然得用_write。3.3 除了 STM32Windows 和 Linux 桌面环境也是这个逻辑不要以为只有嵌入式才碰_write。你在 Windows 上用 MinGW GCC 编译一个控制台程序底层其实是 MSVCRT 的_write在 Linux 上使用 glibc底层则是write系统调用。CLion 在 Windows 上调试本地程序时如果你写了一个自定义终端控件想去拦截控制台输出一样会想到钩住_write而不是fputc。从这一点回头想题目里的问题就非常合理了CLion 这个 IDE 横跨多个平台但它骨子里相信的仍是 CMake 标准工具链那套规则。_write才是所有 ANSI C 外部设备交接的核心。fputc只是特定库为了友善而开的侧门不是通用的门。4. 在 CLion 中重写 _write 的完整实操4.1 先准备一个可用的 UART 发送函数无论你重写_write还是fputc第一步都是让 MCU 能把一个字节从 UART 发出去。我用 STM32 HAL 库最基础的方法HAL_UART_Transmit(huart1, (uint8_t *)pData, len, HAL_MAX_DELAY);huart1是你初始化好的串口句柄pData是要发送的缓冲区len是长度HAL_MAX_DELAY表示阻塞等待发送完成。这一步没什么花头只需要保证代码里能访问到那个句柄就行。如果你用的是 LL 库或者直接填寄存器也完全没问题核心是把这个函数写好并验证它自己能够工作。4.2 实现 _write 函数核心代码与逐行讲解接下来重写_write。在 newlib 里原型如下int _write(int fd, const char *buf, size_t len);参数含义很直白fd是文件描述符buf是发来的数据地址len是数据长度。返回值应该是实际发送的字节数。一份可以直接用的代码#include sys/unistd.h // 提供 STDOUT_FILENO、STDERR_FILENO 等宏 #include errno.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; int _write(int fd, const char *buf, size_t len) { size_t sent 0; if (fd ! STDOUT_FILENO fd ! STDERR_FILENO) { errno EBADF; return -1; } HAL_UART_Transmit(huart1, (uint8_t *)buf, len, HAL_MAX_DELAY); sent len; return sent; }我把它拆开解释一下fd判断printf默认写的是STDOUT_FILENO日志输出也可能到STDERR_FILENO。如果你只想管标准输出那么判断fd STDOUT_FILENO就够了。对不认识的句柄返回“坏文件描述符”并设置errno是符合 POSIX 习惯的做法。HAL_UART_Transmit的第三个参数是长度printf可能一次性给你几十甚至上百个字节所以直接用len整段发送不要一个字符一个字符地调用 HAL效率高也稳定。返回len而不是1很容易踩坑。有人写成return 1结果printf以为一次只写了一个字节某些实现会重复调用你的_write或者干脆触发缓冲管理的长度判定错误。实测下来printf返回的字符数会变得乱七八糟。阻塞到底HAL_UART_Transmit配合HAL_MAX_DELAY会一直等到所有字节发完。这保证了你不会丢数据代价是阻塞 CPU。调试场景通常可以接受。如果你的板子串口不止一个或者你想通过一个全局开关切换输出设备可以先加一个转发函数再在_write里调度。我喜欢写成这样static void debug_send(const char *buf, size_t len) { HAL_UART_Transmit(huart1, (uint8_t *)buf, len, HAL_MAX_DELAY); }然后在_write里直接调用debug_send。以后要改成 RTT、以太网或文件只需要换一个函数体不用动重定向的胶水逻辑。4.3 在 CMake 里配置 newlib-nano 和 nosys有了代码还不行你得让链接器知道库里的_write默认实现不要和我冲突或者至少允许我用自定义的符号覆盖它。我推荐在 CMake 里把链接命令加上两个规格文件set(COMMON_FLAGS --specsnano.specs --specsnosys.specs) set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} ${COMMON_FLAGS}) add_compile_options(${COMMON_FLAGS})--specsnano.specs启用 newlib-nano减小代码体积同时把printf变成精简的vfprintf实现功能足够日常调试。--specsnosys.specs告诉链接器使用nosys库这个库提供了所有默认的底层 stub包括一个什么都不做的_write弱实现。我们自己写了一个强符号_write链接时就会覆盖它。如果不加--specsnosys.specs某些版本的 GCC 会因为没有提供_write以外的其它系统调用符号而报出一堆undefined reference to _sbrk之类的错误。加上之后这些符号都给你“兜底”了内存堆管理用的是内部默认值不影响基本重定向。实测下来这个组合在 STM32F103 和 STM32F407 上均能稳定运行。4.4 验证怎么确认你的 _write 真的接管了 stdout写完代码编译烧写之后如果串口没反应别急着改波特率。先确认你的_write是否进到了最终的 ELF 文件里以及是否取代了库的同名符号。两个命令最有用arm-none-eabi-nm build/xxx.elf | grep _write如果输出类似08002150 T _writeT表示这个符号位于代码段text是你自定义的强符号。如果我们用的是库里的弱符号会显示为W或者w而且地址未必在你的 app 区域内。也可以反汇编一下main附近的调用关系或者直接在_write函数里打断点。当你从调试器上看到断点命中时说明这条路径已经通了。如果你发现断点不命中但程序已经在跑优先去检查链接顺序是不是你的源文件没参与链接是不是有个旧工程文件仍占用了符号。5. 实战中踩过的坑与排查技巧5.1 重写 _write 但串口依然没反应的五个原因我在这个环节踩过不少坑有些非常隐蔽整理成一个速查表供你对照现象可能原因排查/解决printf后串口无输出stdout 是有缓冲的没有换行或没有fflush用printf(...\r\n)或在一开始调用setvbuf(stdout, NULL, _IONBF, 0)关闭缓冲断点能在_write命中但串口没字符HAL 库的 UART 句柄没有正确初始化或串口外设时钟没开检查HAL_UART_Transmit返回值确认huart1是全局的那个句柄编译报undefined reference to _write没有加--specsnosys.specs或者源文件没参与链接在 CMake 链接选项里加nosys.specs或确保实现_write的文件在 target 的源文件列表里有输出但内容乱码波特率不匹配或者发送的是 UTF-8 多字节中文核对串口助手波特率调试期优先用纯 ASCII发中文时确认终端编码程序运行非常慢或卡死HAL_UART_Transmit阻塞等待导致死锁或引脚配置冲突降低波特率或改用中断/ DMA 发送但注意_write返回时要保证数据已进入发送队列printf的缓冲问题尤其值得强调。Keil 环境下很多例程默认是行缓冲甚至无缓冲所以你看到的现象是“一句 printf 立刻输出”。而在 newlib 里stdout 可能是全缓冲的只有在缓冲区满、显式fflush、或者程序退出时才真正调用_write。所以我一般会在初始化代码中加上setvbuf(stdout, NULL, _IONBF, 0);它把 stdout 设为无缓冲让每次printf都立刻触发_write非常符合裸机调试的直觉。代价是性能变差但对调试信息来说完全可以接受。5.2 重写 fputc 的代码要不要删掉在 newlib 环境下fputc代码保留着不会报错但也不会被printf调用除了让你多一个永远打不中的断点之外没有实际作用。我自己会把多余的fputc注释掉以免后面的维护者误解“只要动了 fputc 就能重定向”。如果项目里既有fputc又有_write不会造成编译错误因为它们是不同的符号。但必须提醒一句如果未来有人把工具链换回 ARMCC 而代码里还留着_write也可能出现奇怪的现象。所以比较稳妥的做法是写一个底层的“平台适配层”工具链切换时只换这个文件。5.3 从焦头烂额到根因诊断一份速查表下面这张表来自我自己的调试过程。它能帮你快速判断问题出在“重定向没生效”还是“UART 本身没工作”先用一个无参的HAL_UART_Transmit在main里直接发固定字节确认硬件链路通。再调用printf(A\n)但不要开任何优化断点放在_write里确认链接符号。如果断点命中把HAL_UART_Transmit的第一个参数地址打出来和初始化代码里的huart1地址对比。如果断点没命中检查 map 文件确认_write符号最终落在哪个地址。实在不行就新建一个最小工程只包含一个printf和一个_write逐层排查。这套流程能解决九成以上的“串口没有输出”问题。很多时候不是 C 库的锅而是工程配置里某个文件被排除了。6. 最后一次收紧的心得把“底层输出”和“上层业务”解耦6.1 一个可跨 Keil/CLion/IAR 移植的小框架既然fputc和_write都是运行时库的“约定出口”聪明的做法是写一个转发层让上层代码完全不知道底层是哪个函数。我当前项目的做法是// debug_io.h void DebugIO_Init(void); void DebugIO_Output(const char *data, size_t len);然后在不同工具链的适配文件里// 适配 newlib / GCC int _write(int fd, const char *buf, size_t len) { DebugIO_Output(buf, len); return len; } // 适配 ARMCC / Keil int fputc(int ch, FILE *f) { DebugIO_Output((const char *)ch, 1); return ch; }这样无论你在 CLion 里用 GCC还是切回 Keil或者后续换 IAR都不需要动应用代码。真正实现“一个业务层多套出口适配”。6.2 建议关闭 stdout 的缓冲或者习惯用 \r\n嵌入式模块一般内存不大直接关掉 stdout 缓冲是最省心的。不过要注意一旦关闭缓冲每条日志都会立刻产生一次_write调用如果使用的是HAL_UART_Transmit的阻塞模式高频日志可能拖垮实时性。比较优雅的方案是用环形队列加中断发送_write里把数据放入队列就立刻返回UART 中断里慢慢地发。返回的字节数仍然等于len上层不必等待。这个方法适合日志量大的项目只是实现复杂度会高一些。我自己的习惯是调试期用阻塞模式程序趋于稳定之后改成 DMA 模式。在_write里发送之前先检查上一次 DMA 是否完成如果没完成就等一下否则可能出现数据覆盖。这样既能保证调试实时性又不至于把 CPU 锁死在串口上。6.3 如果还想再深入学习下一步应该看什么这次弄懂_write之后你会对嵌入式软件的整体运行产生新的敏感度。我建议接着去翻两样东西一是 newlib 的源码看看_write下面还挂了哪些系统调用比如_read和_sbrk它们分别对应输入、堆内存管理二是你的工程链接 map 文件了解标准库那一大坨符号都是从哪来的。老实说重定向printf是每个嵌入式工程师迟早都要迈过的一道坎。CLion 并不是故意要跟你作对它只是把你扔进了一个更接近 POSIX 的世界。你在这里重写_write学到的远不止一个串口函数而是看穿了标准库输出路径的全貌。以后不管换到什么工具链、什么库你至少能一眼看出“该在哪个环节动手”而不是只能背下某种环境的模板代码。这就是这个坑给我的最大价值。
