CLion环境STM32串口重定向:一文搞懂printf到_write的完整链路
最近在嵌入式开发群里经常能看到这样一条提问CLion 里做 STM32 串口重定向网上清一色让重写_write可我在 Keil 里面明明重写fputc就能让 printf 输出到串口怎么换个工具链就完全换了一套玩法如果你也有同样的疑惑说明你被工具链的“标准库实现细节”卡住了。这篇内容我不打算只给代码而是把printf从用户态到串口外设的完整链路拆开说清楚 CLion 搭配的 ARM GCC 工具链里为什么_write才是真正需要动手的出口顺带把我在实际工程里遇到的各种重定向坑一并整理出来。适合正在从 Keil/IAR 转向 CLion 的嵌入式开发者也适合那些被 printf 输出问题折磨得焦头烂额的 STM32 玩家。1. 从 Keil 到 CLion重定向问题为什么会出现1.1 工具链不同标准库实现就不同很多人刚用 CLion 开发 STM32 时第一反应是把工程建好、串口初始化写好然后习惯性地写下int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }这是 Keil ARMCCARM Compiler环境下最常见的重定向写法。Keil 的微库MicroLIB在实现printf的时候底层输出函数走的是fputc所以你只需要把fputc覆盖掉就能让所有printf的字符最终流到串口。但 CLion 默认使用 arm-none-eabi-gcc 工具链也就是 GCC 的 ARM 版本配合的 C 库通常是 newlib 或 newlib-nano。newlib 虽然是嵌入式场景里非常轻量的 C 库但它的设计思路跟 Keil 的 MicroLIB 并不一样printf在 newlib 内部并不直接调用fputc而是通过一个统一的系统调用层去写文件描述符。这个系统调用层里最关键的就是_write。所以你在 CLion 里重写fputc实际上是在一个 newlib 根本不打算走的路口设置关卡printf 的输出自然到不了串口。道理就是这么简单但如果不理解背后的链路换一个环境就容易懵。1.2 printf 重定向的本质让标准输出指向外设理清楚这个问题先要明确“重定向”到底是什么意思。printf的全名是 formatted print to standard output它的默认输出目标不是屏幕而是文件描述符 1也就是 stdout。在我们的嵌入式裸机环境里根本不存在“屏幕”和“文件系统”stdout 只是一个抽象概念需要由开发者告诉底层“stdout 的数据请你发给串口 1”。这个过程在 Keil 里被设计成重写fputc在 GCC/Newlib 里被设计成重写_write。本质上都是把“抽象输出”和“具体硬件”之间搭一座桥只是桥的位置不同。这是一种典型的“移植层”思想。芯片和工具链只负责提供标准接口至于输出到哪个串口、用什么方式发送、要不要等待发送完成全部由使用者自己决定。理解这一点后你再看 CLion 里那些重写_write的教程就会发现那不是一个神秘的魔法而是一次标准库层面的“接管”。1.3 一句话回答printf 最终调用的不是 fputc如果只记结论那就是这句话在 ARM GCC 工具链 newlib/newlib-nano 环境下printf的字符输出链路是printf - vfprintf - _write其中_write是从应用层到硬件层的最后一个可覆盖入口。fputc在这条链路上根本没有位置所以重写它不起作用。但这里有一个容易引发争论的地方有些资料说用__io_putchar也行有些又说可以直接定义_write还有人遇到过重定向后 printf 能用但fwrite不稳定的情况。这些其实并不矛盾区别在于构建系统选用的具体库实现和编译选项。后面我会专门讲不同写法之间的异同以及在 CLion 工程里怎么选才最稳。2. 底层原理ARM GCC 工具链下 printf 到底怎么走到硬件2.1 newlib 的输出链路是一个分层结构要真正理解_write的作用建议把 newlib 的源码结构想像成一个管道最上层是应用程序调用的printf它负责解析格式字符串、处理参数、把整数转换成字符序列中间层是标准 I/O 的缓冲区管理负责把小块数据聚合成批次最底层才是文件描述符级别的读写操作也就是_read、_write、_open、_close这些系统调用。printf本质上是vfprintf(stdout, ...)的封装而 stdout 是一个FILE结构体指针。newlib 在初始化标准 I/O 时会默认打开三个流stdin、stdout、stderr它们分别对应文件描述符 0、1、2。所有输出到这个流的数据最终都会被送到底层的_write(int fd, const void *buf, size_t count)函数。在完全裸机的环境下这些系统调用没有任何硬件支撑newlib 给了默认的 stub 实现返回-1或者直接进入死循环。所以如果不重写_writeprintf会“假装”输出成功实际上数据全被丢弃。重写它就是把系统调用层和真实外设对接起来。2.2 _write 为什么是真正的“最后一道出口”_write的函数签名非常清晰int _write(int file, char *ptr, int len)其中file是文件描述符ptr是数据缓冲区指针len是需要写入的字节数返回值表示实际写入的字节数。当你在代码里调用printf(hello)newlib 最终会把这段字符串打包成一次或多次_write(1, hello, 5)调用。这个设计的牛逼之处在于它具备极强的通用性。你可以根据file参数区分 stdout 和 stderr让正常打印走串口 1、错误信息走串口 2也可以把len当作一次传输的总长度自主决定是做单字节阻塞发送还是一整包 DMA 发送。这些都是重写fputc做不到的。在 CLion 的 STM32 工程里重定向的核心工作就是补全_write的实现。最常见的方式是遍历缓冲区逐字节调用 HAL 库的串口发送函数int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }这里直接一次发送一整段比逐字节调用效率高很多。返回len表示所有数据均已提交给硬件符合底层契约。2.3 常见误区fputc、__io_putchar、PUTCHAR_PROTOTYPE 到底有什么关系我见过不少人在网上复制代码时一会儿贴fputc一会儿贴__io_putchar还有 STM32CubeMX 生成的代码里带有PUTCHAR_PROTOTYPE宏的东西。这里顺便把它们的区别一次性讲透。__io_putchar是 STM32CubeMX 生成代码时内部使用的一个 Hetian 函数名本质也是给新添加的输出钩子用的。在 STM32CubeMX 生成的retarget.c文件里经常能看到这样的写法int __io_putchar(int ch) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }它的地位和fputc类似是给printf使用的一个底层字符输出函数。但在 ARM GCC newlib 环境下真正被调用的是_write而不是__io_putchar。那为什么有些人说__io_putchar也能用因为 STM32CubeMX 的新版模板在_write内部调用了__io_putchar。也就是说你只需要在_write里实现对__io_putchar的循环调用就能把单片字符逐个送出去或者更简单直接重写_write。PUTCHAR_PROTOTYPE是 STM32CubeMX 在 Keil 环境下为fputc生成的宏定义跟 GCC 环境关系不大。所以当你看到网上一个教程用fputc、一个教程用__io_putchar、还有一个直接用_write别急着困惑。只需要记住判断标准你的工具链用的什么标准库底层最终调用的哪个函数你就重写哪个函数。对 ARM GCC newlib 来说重写_write永远是最直接、最不依赖模板版本的做法。3. CLion 中完整的配置与重定向实操3.1 CLion STM32 工程前置准备说回 CLion 本身。要在 CLion 里搞好 STM32 开发第一步自然是把环境配置好。CLion 本身不直接识别 STM32 启动文件、链接脚本这些东西它依赖一个插件来识别 STM32CubeMX 生成的工程。最常用的插件是 STM32CubeMX 插件安装好之后可以在 IDE 里直接导入 .ioc 文件或者打开一个由 CubeMX 生成的 Makefile 工程。这里有个小建议如果是从零开始先让 STM32CubeMX 生成一个基于 Makefile 的工程再用 CLion 直接打开这个目录。CLion 会把 Makefile 识别成构建方案并把输出文件解析成可调试的执行程序。C 环境问题也顺带能解决——CLion 对 C/C 的索引能力很强只要在 CMakeLists 或者 Makefile 里正确指定了头文件路径代码补全和跳转都很舒服。网上很多人卡在“CLion 配置 C 环境”这一步其实多半不是 IDE 的问题而是头文件路径没加全导致 #include 都标红。工程能编译之后下一步就是串口重定向。这个操作跟具体用哪颗芯片关系不大核心步骤都是串口初始化 重写底层输出函数 确保 Standard Library 选择正确。3.2 串口初始化重定向的前提设备串口重定向前必须先把 UART 外设初始化好。以最常用的 STM32F103 系列为例在 CubeMX 里配置 USART1模式选 Asynchronous波特率设为 1152008 位数据、无校验、1 位停止位。然后在生成代码里你会得到MX_USART1_UART_Init()函数以及全局变量huart1。如果你用的是更常用的 HAL 库初始化代码基本长这样UART_HandleTypeDef huart1; void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1); }这里提醒一点重定向之前一定要确保HAL_UART_Init被正确执行而且 GPIO 的复用功能配置正确。很多人折腾半天 printf 没输出最后发现是串口 TX 引脚根本没配置成复用推挽输出。这个我在排查章节会再次强调。3.3 重写 _write 的推荐写法在 CLion 的 GCC 环境下我推荐直接用_write作为重定向入口并且把整段数据的发送交给 HAL 函数一次处理。考虑到有些读者可能还需要兼容多种开发环境比如同一份代码既能在 CLion 里编译也能在 Keil 里跑我建议用条件编译做区分。下面是一段可以直接移植的参考实现#include stdio.h #include stdarg.h #include stm32f1xx_hal.h extern UART_HandleTypeDef huart1; #ifdef __GNUC__ /* 在 GCC 工具链下重写 _write使 printf 重定向到串口1 */ int _write(int file, char *ptr, int len) { (void)file; HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); return len; } #else /* 在 ARMCC/Keil 环境下重写 fputc */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; } #endif这段代码里最核心的地方有三个第一__GNUC__宏用来识别 GCC 工具链arm-none-eabi-gcc 会定义这个宏第二_write的第三参数是int len很多老版本的模板写的是size_t len这里保持了实际工具的签名编译时如果报类型冲突改成size_t或intptr_t即可第三发送超时时间给了0xFFFF这是阻塞发送的兜底时间不是越高越好而是够用就行。另外如果你希望输出到串口时不用一直占用 CPU 死等可以把阻塞发送换成中断发送或者 DMA 发送。但要注意中断发送是异步的如果直接返回len下一次 printf 可能覆盖上次的缓冲区。这里有几种常见策略后面我专门讲。3.4 使用 newlib-nano 时要注意什么CLion 的 STM32 CMake 工程默认可能会使用-specsnosys.specs和-specsnano.specs这两个选项。nosys.specs会提供默认的系统调用 stubnano.specs会使用精简版的 newlib-nano。问题就出在这里当你重写了_write后如果链接器使用nosys.specs提供的_writestub你的自定义_write是否会被链接器采用答案是肯定的前提是你的_write定义在链接时可见且符号冲突没有把它覆盖掉。一般情况下只要避免重复定义链接器会优先使用你定义的符号而不是库中的默认 stub。但也有一种情况容易踩坑nosys.specs的_write可能被编译成一个弱符号weak symbol而你的定义是强符号这是正常的会走你的强符号。如果哪天发现 printf 完全没有输出可以先用arm-none-eabi-nm查看生成的.elf文件里_write符号是不是指向你的函数地址方法我放在后面验证环节。3.5 如何验证你的 printf 确实走了 UART写完重定向代码后不要急着接串口助手看数据还有更稳妥的验证顺序。第一步编译通过后检查 map 文件或者用nm工具查看符号表确认_write没有被链接成默认 stub。在 CLion 的终端里执行arm-none-eabi-nm build/你的工程名.elf | grep _write正常情况下输出会显示类似08002a35 T _write这样的地址前缀T表示这是一个文本段代码段中的全局符号。如果你看到地址后面是U那说明它还没有被解析这种情况下要么是你的_write没编译进去要么是链接脚本和库配置有问题。第二步在_write函数第一行加一个临时断点用 ST-Link 调试器跑起来然后在串口助手里输入数据或者直接再代码里手动调用printf(test)看看断点有没有命中。如果断点命中了说明整个链路已经打通。如果没命中优先检查是不是有多个_write定义以及printf是否因为未初始化 stdout 而崩溃。4. 实战中踩过的坑和排查方法4.1 重写了 fputc 却没有任何输出这是从 Keil 迁移到 CLion 最常见的问题原因前面已经说清楚了GCC 工具链的 newlib 库根本不调用fputc。但这里有个细节值得注意有些人重写fputc后居然也能输出这种现象是怎么出现的排查下来一般是两个原因。第一个原因是用了armcc的兼容层比如把__stdout或者FILE流重新绑定到了自定义函数但这在纯 GCC 环境里很少见。第二个原因是工程里其实还参加了别的库比如有的第三方库内部自己调用了某个putchar钩子无意间实现了输出。所以正确的排查顺序是先确认工具链再看标准库选项最后验证符号表。不要一上来就怀疑代码写错了。CLion 的构建输出窗口里能看到完整的编译命令重点看一下命令行里有没有-specsnano.specs、-specsnosys.specs这能帮你判断 C 库是哪种形态。4.2 串口输出乱码时钟和波特率到底谁的问题如果_write重定向成功但串口助手里显示出来是乱码先别急着怀疑重定向代码。STM32 的串口乱码九成是波特率不匹配再深一层可能是时钟树配置错误。比如 STM32F103 系列内部 PLL 配置不对导致 APB2 总线时钟不是 72MHzUART 的波特率自然就不准。有些人用 HSI 内部时钟跑误差比较大也会在高速波特率下出现乱码。调试方法是把波特率改低比如 9600看看是否还乱码。如果 9600 正常而 115200 乱码大概率是时钟误差偏大需要检查时钟树。另外一类“伪乱码”需要特别注意如果_write里用HAL_UART_Transmit发送整段数据发送频率过高时串口助手那边会因为接收缓冲区处理不及时丢数据显示出来像是乱码。这种属于节奏问题不是代码错。4.3 打印几次后程序死机printf 重定向后程序死机是另一个高频坑。最常见的原因是在_write里使用了同一个串口的发送但 printf 本身可能在中断或调度器里被多次重入。如果串口发送是阻塞的那基本没有重入问题但如果用了中断方式发送并且在中断回调里又调用了 printf就会形成递归或互相等待。另一个常见原因是内存不足。newlib 的printf会动态申请内存特别是使用%f浮点格式化时如果堆太小malloc失败就会导致程序异常。在 STM32 这种小内存芯片上我一般建议在链接脚本里把堆容量调大同时避免在中断里调用 printf。可以试试在_write函数开头添加一个全局标志位用互斥量或者关中断的方式避免重入再观察是否还死机。如果死机现象消失说明就是重入问题。4.4 中文输出乱码与编码问题在 CLion 里代码源文件的中文注释和字符串很容易出现编码混乱。C 源文件默认可能是 UTF-8而串口助手显示时按照 GBK 或者 ASCII 解析中文自然变成乱码。这个跟_write没有直接关系而是开发环境、源文件编码和串口显示工具之间的编码不一致。我的习惯是除非软件流程里确实需要向用户输出中文字符串否则串口调试信息一律使用英文。如果业务要求必须有中文那就要保证源文件保存为 UTF-8并且串口助手也设置为 UTF-8 显示同时字符串内部的编码要能被终端正确识别。很多开源调试工具对这个支持得比较好但一些老牌串口助手还是默认 GBK调整显示编码就好。在 CLion 里可以在 Settings - Editor - File Encodings 里设置全局编码为 UTF-8避免文件被转成其他编码后出现编译警告甚至乱码。4.5 CLion 工程中多个 main 文件的问题用了 CLion 之后很多人喜欢在一个工程里同时放多个测试代码比如main1.c、main2.c或者把每个例子的main放在不同目录。结果编译时总是报重复的 main 函数定义。这是因为 CLion 基于 CMake 会把所有源文件一起编译多个main自然冲突。解决办法有几种。最简单的是把不需要用到的文件排除出编译列表在 CMakeLists.txt 里用注释掉源文件的方式临时屏蔽。或者在文件头部加一个宏开关不同测试代码之间用预编译条件切换。还有一种思路是拆分多个可执行目标每个目标包含不同的源文件子集这样就能在一个工程里保留多个 main。但要注意CLion 的调试器一次只能加载一个目标切换调试目标的时候需要在 Run Configuration 里设置。多个 main 文件的问题本身不难解决但容易跟重定向问题混在一起。如果你在一个工程里放了两个测试文件其中一个重写了_write另一个没有重写调试时链接器可能把两个符号混在一起最终出现奇怪的行为。建议是同一时间保持一个 main 入口一个_write入口。4.6 一些不太容易察觉的 _write 相关报错CLion 开发 STM32 时还可能在编译或运行阶段遇到一些跟_write看似无关、实际相关的报错。比如编译时提示fatal: write failure on stdout: bad file descriptor这种一般不是代码逻辑问题而是构建工具或终端环境在重定向标准输出时出错了。在 CLion 里最常见于构建控制台被某些插件污染或者 Makefile/CMake 脚本里混入了对 stdout 的特殊处理。先清理构建目录关掉多余的日志插件再重新编译大概率能解决。运行阶段如果遇到类似write to location 000000... caused an access violation的调试器报错这就是典型的非法内存访问。在_write重定向后数据缓冲区指针可能是非法的或者传进来的 len 过大把不存在的地址也当作输出缓冲区。遇到这种情况优先检查printf的格式化参数是否跟实际参数类型匹配比如用%d打印了一个 64 位整数格式不对会在运行时产生不可预知的数据进而污染接收缓冲区。这一点在嵌入式调试中非常隐蔽建议全程开启编译器的-Wformat警告。5. 从串口重定向延伸出去的经验5.1 让 _write 同时服务多个串口_write的签名里带有file参数这个参数的价值在于你可以根据它区分不同的输出设备。比如项目里有两路串口串口 1 做调试日志串口 2 做数据交互可以在_write里做分流int _write(int file, char *ptr, int len) { if (file 1) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); } else if (file 2) { HAL_UART_Transmit(huart2, (uint8_t *)ptr, len, 0xFFFF); } return len; }这样你在应用层只要选择 printf 是写到 stdout 还是 stderr就能控制走哪条物理链路。虽然裸机开发中用到的场景不多但一旦引入 RTOS 或者日志系统这种分流的收益立刻能体现出来。5.2 重定向不能顺便解决一切问题_write重定向只是把 printf 的数据送进了外设但 printf 本身还有一些天生的限制。比如浮点格式化会显著增加代码体积因为 newlib-nano 默认不包含浮点格式化的完整实现。如果你用%f却发现输出成了空字符串需要检查编译选项是否启用了 float printf 支持比如-u _printf_float。还有一点每次调用HAL_UART_Transmit的阻塞超时时间设置过短比如给 10ms而一帧数据在低波特率下发送需要更久就会导致函数超时返回数据被截断。这种问题通常表现为输出“前半段正常、后半段丢失”。排查时把超时时间增大或者干脆用HAL_MAX_DELAY也就是0xFFFFFFFF不过要小心别让 bug 被永远卡住。5.3 后续可以扩展的方向如果你觉得只做 printf 重定向还不够过瘾可以考虑在此基础上做一个简易的日志系统把_write收到的数据同时发送到串口和存进 RAM 缓冲区再通过调试器读取。或者给_write加一个过滤规则某些等级的日志直接丢弃减少串口干扰。在 CLion 的调试环境里还可以把_write和 SEGGER RTT 结合起来让 printf 同时输出到串口和 J-Link RTT Viewer这样就算没有串口线也能看到日志。思路是在_write里轮询调用 RTT 的发送函数。这个玩法在板子调试口被占用时会非常方便。根据我个人的经验重定向这件事最值得花时间的不是找到那个函数名而是理解工具链和库的关系。CLion 只是暴露了这种关系的冰山一角一旦你能在_write、fputc、__io_putchar之间自如切换以后换任何开发环境都不会慌。最后再分享一个小习惯每次在新工具链里做串口打印我先不写任何重定向函数直接编译一次再用nm看看默认库里有哪些钩子可用。这样能避免很多“照着网上的代码改了半天还是不工作”的弯路。希望这篇东西能帮你在 CLion 的串口重定向上少折腾几个晚上。