资讯动态

CLion中printf重定向串口输出:为什么必须重写_write而非fputc

发布时间:2026/9/7 19:52:31 来源:尧图企业网站定制
后台经常有人私信问我一个特别典型的问题“我把Keil工程里重定向printf用的fputc代码原封不动搬到了CLion为什么串口还是看不到输出”这个问题我前前后后见了不下二十次已经能猜到他们大概率是哪一步出问题了。先说结论CLion里面开发和调试嵌入式工程默认用的是arm-none-eabi-gcc工具链 Newlib标准库这套组合下printf最终不是通过fputc把字符吐出来的而是通过一个叫_write的系统调用级函数。你在Keil里靠重写fputc让printf走串口到GCC这套工具链里这条路根本不通必须老老实实去重写_write。下面我把原因、调用链、配置步骤和连带问题一次讲透。1. 同样一行printfKeil和CLion背后的标准库根本不是一回事1.1 你在Keil里重写fputc时实际上重写的是什么用过Keil MDK的朋友对这段代码应该不陌生int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }这个写法的基础是ARMCC/ARMClang编译器的标准库实现。Keil的C库在实现字符输出时把fputc设计成了所有字符输出路径的汇聚点。printf内部的每个字符最终都会调用一次fputc所以你把fputc重写成往串口发送数据printf的输出自然就被导到了串口上。这种设计对外部使用者来说挺友好的一个函数解决根本不需要关心标准库里还有什么底层机制。在microlib下fputc甚至是唯一需要关心的输出钩子。问题是这套依赖关系是ARM编译器特定的不是C语言标准的一部分更不是所有工具链通用的。1.2 CLion默认的GCC Newlib走的是另一条路CLion本身不编译代码它只是编辑器加构建系统。嵌入式开发时CLion调用的是你安装的arm-none-eabi-gcc交叉编译器这套编译链默认配备的C标准库是Newlib或Newlib-nano。Newlib的服务对象是嵌入式系统它内部实现printf时根本没有“每个字符都调用fputc”这种逻辑。fputc在Newlib里只是一个普通的标准库函数你调用它的时候它才存在printf内部根本不会碰它。Newlib把和硬件打交道的部分抽象到了所谓的“系统调用层”syscall layer也就是_write、_read、_sbrk、_close这一组函数。我用一张表把这俩编译器体系的差异列出来这样你一眼就能看清位置对比项Keil MDKARMCC/ARMClang microlibCLionarm-none-eabi-gcc Newlib-nano编译器armcc / armclangarm-none-eabi-gccC标准库ARM C Library / microlibNewlib / Newlib-nanoprintf底层输出入口fputc / fputc_r_write / _write_r推荐的printf重定向方式重写fputc重写_write如果重写错了会怎样重写_write无效重写fputc无效所以你在Keil里重写fputc能生效是因为ARM标准库就是这么设计的到了CLion的GCC环境printf的字符输出根本碰不到fputc你重写了它相当于在一座不经过你家的桥上设了收费站——车流量再大也不会有车从你这里过。1.3 结论先行不是CLion这个IDE的问题是标准库路线的差异看清这一点很重要。很多人以为换了IDE就得换一套写法其实IDE是无辜的。你从Keil切到CLion本质上是把编译器从ARMCC换成了GCC把标准库从ARM C Library换成了Newlib。你以前重写fputc能用的前提在GCC环境里不存在了所以必须改换门庭去重写_write。2. 从printf到串口的完整调用链_write为什么是那个绕不开的出口2.1 Newlib里printf的完整输出路径为了说清楚这个绕不开的“出口”我把Newlib下printf从调用到串口寄存器之间的完整路径拆解给你看。用文字描述大概是这样的printf(...) └─ vfprintf(stdout, fmt, args) // 标准库内部的格式化核心 └─ __sfvwrite_r(...) // 把格式化结果写入FILE流 └─ _swrite(...) // 调用与文件描述符绑定的写函数 └─ _write(file, ptr, len) // 系统调用层最终落到这里 └─ 你的串口发送代码在Newlib框架下stdout是一个FILE结构体它的write操作指针最终指向_write。凡是往stdout上写数据的标准库函数不管入口是printf还是puts还是putchar最后都会汇聚到_write这个函数上。有人可能会问那中间那些层是干什么的每一层都有各自的职责vfprintf负责解析格式控制符__sfvwrite_r负责把不同来源的字符缓冲区分块交给底层_swrite负责关联文件描述符和实际设备。到了_write这一层所有和格式化、缓冲相关的抽象都结束了剩下唯一的任务就是“把这len个字节的裸数据交给某个硬件设备”。这个硬件是什么标准库不知道也不该知道于是留给了写库的人去实现。这就是嵌入式开发里所谓的“重定向”retarget。2.2 fputc在这条链上的真实位置那fputc在Newlib里到底是什么角色它就是标准库对外提供的一个普通函数。你写fputc(A, stdout)它会往stdout这个文件流里塞一个字符塞进去之后内部还是通过缓冲机制最终走到_write才把数据送出去。关键点是printf内部并不会调用fputc。printf是格式化函数fputc是单字符输出函数它们在标准库内部是平级的对外接口不是上下级关系。你可能在某个老旧的嵌入式教程里看到过“printf内部就是不断调用putchar/fputc”的说法这个说法在早期某些编译器实现里或许接近真相但在Newlib里不是。这也就解释了为什么你重写了fputcprintf依然没有输出因为printf内部压根没有fputc这个调用节点。你设置的钩子不是调用链上的必经之路自然不会生效。2.3 为什么重写_write能通杀所有输出函数正是由于_write处于所有输出的汇聚点重写它才有“一劳永逸”的效果。只要你把_write实现了那么printf格式化输出能走通puts、putchar能走通fprintf(stderr, ...)能走通甚至你自己写的write(1, buf, len)系统调用也能走通在实际的MCU项目里你需要的往往不只是printf。调试日志里可能混着puts和putchar错误信息会打到stderr。如果你只重写fputc在Keil里还能靠编译器钩子把这些都拉到一起在GCC体系里就做不到因为标准库内部根本不会把这些函数都扭结成同一条fputc调用链。而_write是它们共同的底层依赖重写一个函数解决全部输出路径这是最稳妥的方案。2.4 默认_write是什么状态Semihosting的坑讲到这必须提一个很多新手被坑过的地方。如果你没重写_write直接用printf程序会怎样arm-none-eabi-gcc自带的Newlib里_write的默认实现通常是把数据通过软中断发送给调试器这个机制叫Semihosting半主机。也就是说标准库认为你的电脑调试器会接管这个输出。问题就出在这里如果没连接调试器程序执行到printf时会触发一个未定义指令或者软件中断MCU直接进入HardFault表现为程序跑飞、卡死。如果连接了调试器输出会打印到调试器的Semihosting控制台而不是串口。你要从串口看数据自然什么都看不到。即便连了调试器一些调试器配置不当或者J-Link的Semihosting设置没开就会出现类似“Write to location 0x00000020 caused an access violation”这样的报错整个调试会话被打断。所以不只是“要不要重写”的问题而是在MCU裸机工程里你必须提供一个自己实现的_write把Semihosting这个默认行为替换掉printf才能变成纯本地的串口输出并且不再依赖调试器。3. 在CLion里正确重定向printf的完整配置3.1 准备条件确认你用的是标准工具链和CubeMX生成的工程在进入代码之前先确认环境CLion里配置好arm-none-eabi-gcc工具链工程通常由STM32CubeMX生成CMake项目。CubeMX生成的工程里默认会有一个叫syscalls.c的文件里面就是Newlib系统调用层一堆函数的弱定义实现包括_sbrk、_close、_lseek、_read、_write等。这就是为什么很多人没自己写_writeprintf也能编译通过——因为库里有个默认的但它并不是你要的功能。在开始重写之前你最好确认一下syscalls.c里_write当前是什么状态。有些早期版本的CubeMX生成的syscalls.c会把_write留成一个空函数或者一个返回错误的弱符号。你重写的时候要注意避免和其他源文件里已有的_write定义产生重复定义冲突。所以最推荐的做法是把自己的_write实现放到独立的文件里比如retarget.c二来也方便管理。3.2 _write的完整参考实现这是最核心的一段代码。以STM32 HAL库为例最直接的实现方式是逐字节通过UART发送// retarget.c #include unistd.h #include stdint.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { for (int i 0; i len; i) { // 等待发送寄存器空闲 while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE) RESET) ; // 将字符写入发送数据寄存器 huart1.Instance-TDR (uint8_t)ptr[i]; } } return len; }这里有几个细节值得你注意必须返回len。Newlib认为_write返回的是成功写入的字节数。如果返回值和len不一致上层会认为写入失败。有些实现偷懒直接return 0结果就是printf可能只输出一部分甚至什么都不输出。判断file参数。stdout和stderr的文件描述符分别是1和2通常都让它们走串口没问题。不需要特殊区分。不用HAL_UART_Transmit是因为中断和超时。HAL_UART_Transmit带超时参数在中断里或者高频调用时会引入不必要的开销。寄存器直接操作最干净逐字符等待TXE标志即可。F4系列和F7/H7系列寄存器不同。上面的代码用的是TDR寄存器适合F7/H7系列。如果你用的是F103这种F1系列寄存器是DR// F1系列如STM32F103 int _write(int file, char *ptr, int len) { for (int i 0; i len; i) { while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE) RESET) ; huart1.Instance-DR (uint8_t)ptr[i]; } return len; }如果你不想在寄存器层面花心思用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第三个参数是uint16_t如果len超过65535会截断但在串口调试场景下一般不会一次传这么多数据。3.3 链接器配置nano.specs、nosys.specs和浮点printf重写代码只是其中一步你还得让链接器知道怎么处理标准库。这里涉及几个关键选项。--specsnano.specs启用Newlib-nano。这是精简版的Newlib体积小很多非常适合MCU。我建议CLion工程里CMake配置加上它。--specsnosys.specs告诉链接器不要使用完整的Semihosting系统调用实现。配合我们自己的_write可以避免系统调用层与Semihosting绑定。在CMakeLists.txt里你可以这样配置target_link_options(${PROJECT_NAME} PRIVATE --specsnano.specs --specsnosys.specs )这里有个常见的坑如果你启用了nano.specsprintf默认不支持浮点数格式化。比如printf(voltage%f\r\n, 3.3)在nano模式下可能在格式化阶段返回错误输出为空。解决办法是在链接选项里加一个-u _printf_float强制把浮点格式化支持拉进来target_link_options(${PROJECT_NAME} PRIVATE --specsnano.specs --specsnosys.specs -u _printf_float )加了之后固件体积会增加但浮点输出就能正常工作了。如果你对代码体积敏感可以考虑用sprintf输出到缓冲区再处理但就复杂了一般调试场景下直接加-u _printf_float最简单。参数顺序要注意。这几个参数必须出现在链接阶段。如果你把它放在add_compile_options里编译器可能直接忽略传给链接器也会导致问题。我习惯放在target_link_options里一劳永逸。3.4 一个最容易被忽略的链接错误undefined reference to _sbrk在CLion里CubeMX生成的工程如果缺少syscalls.c链接时大概率会报这一类错误arm-none-eabi/bin/ld: region RAM overflowed by xxx bytes 或者 undefined reference to _sbrk这是Newlib的内部机制导致的printf的缓冲机制需要动态分配内存_sbrk是标准库获取堆内存的接口。如果你在链接时没有提供_sbrk的实现就报undefined reference。CubeMX默认生成的syscalls.c里已经实现了_sbrk所以你一般不会踩到。但如果你是自己手动创建的CMake工程没把syscalls.c加进来就很容易撞上。解决办法有两个一是把CubeMX生成的syscalls.c加进工程二是自己在retarget.c里补一个最小实现caddr_t _sbrk(int incr) { extern char end; /* 由链接器定义堆尾 */ static char *heap_end 0; char *prev_heap_end; if (heap_end 0) heap_end end; prev_heap_end heap_end; heap_end incr; return (caddr_t)prev_heap_end; }注意这只是一个简化版它没有做栈顶碰撞检测如果堆和栈撞上程序会很难查。正式项目里你需要根据链接脚本里提供的__heap_start、__heap_end来做上界检测。但调试用足够了。3.5 顺手把_read也实现scanf和getchar才能用_write解决的是输出_read解决的是输入。如果你需要从串口接收数据比如用getchar、scanf那还得把_read实现出来int _read(int file, char *ptr, int len) { HAL_StatusTypeDef status; status HAL_UART_Receive(huart1, (uint8_t *)ptr, 1, HAL_MAX_DELAY); if (status HAL_OK) return 1; else return -1; }一个字符一个字符接收确保只要收到一个字符就返回这样上层scanf就能逐步处理。如果你有更复杂的接收状态机可以用中断环形缓冲的方式改但原理一样最终都是把收到的字节放进ptr指向的缓冲区并返回字节数。4. 迁移CLion之后这几个连带问题几乎一定会遇到4.1 中文乱码不是printf的重定向没做而是编码和缓冲区的双重问题很多人在CLion里重写_write之后发现英文输出正常中文printf乱码。第一反应是重定向没做好其实不是大概率是两个原因叠加。第一个原因是缓冲区。Newlib的stdout默认行缓冲或者全缓冲而你的串口没有执行“行刷新”的概念。什么意思呢printf(hello)没带换行符字符可能滞留在stdio缓冲区里还没走到_write自然串口看不到。加了\n之后遇到换行符触发flush数据才会出来。解决办法是在main函数一开始关闭stdout缓冲setvbuf(stdout, NULL, _IONBF, 0);这样每次printf都会立刻调用_write无需等换行符。代价是每printf一次就底层调用一次对于调试场景完全没问题。这个经验我强烈建议你记下来很多“printf不输出”的问题改完这一行立刻好。第二个原因是编码。CLion的源文件默认UTF-8中文字符串在代码里以UTF-8编码存储。你的串口助手如果默认用GBK解码UTF-8的中文自然变乱码。这不是你代码的问题是串口助手的编码设置不对。把串口助手的字符集切到UTF-8中文一般就正常了。在Windows上有些老的串口助手只支持GBK那就只能在代码里把中文写成UTF-8转GBK的字节序列或者干脆先全部用英文日志等串口助手支持UTF-8再切中文。还有一个小细节CLion的Console窗口和串口助手的编码也可能不一致。如果你在CLion的Embedded Console里看输出也要检查CLion的File Encoding设置确保都是UTF-8。4.2 “CLion里想写多个main”CMake的可执行目标才是正解这也是从Keil迁移过来的人经常问的。Keil工程里你可以在不同分组里放不同源的main函数写的时候注意下编译范围就行。CLion基于CMake默认一个可执行目标对应一个main函数如果你把两个带main的源文件都加进同一个add_executable里链接时报重复定义。解决方案是建多个可执行目标。比如你有一个test_uart.c和一个test_timer.c可以这样写CMakeadd_executable(test_uart test_uart.c stm32_startup.c syscalls.c ) add_executable(test_timer test_timer.c stm32_startup.c syscalls.c )每个目标独立编译链接各自有main函数互不干扰。刷固件的时候在CLion的Run Configuration里选择对应的目标再烧录就行。如果不想让构建系统每次把所有目标都编一遍可以给不需要的加EXCLUDE_FROM_ALLadd_executable(test_timer EXCLUDE_FROM_ALL test_timer.c ... )这样平时只构建默认目标需要时再手动构建test_timer。4.3 STM32调试中碰到“Write to location ... caused an access violation”这类报错这种报错在CLion ST-Link/J-Link调试时偶尔会出现。原因可能有很多但其中一个和本次主题高度相关默认的Semihosting行为触发。前面说了如果你没重写_write程序执行printf时会触发Semihosting软中断。调试器如果没有开启Semihosting支持就可能报出访问违例类的错误比如“Write to location 0x20000000 caused an access violation”之类的。这个报错地址不一定就是真正的非法地址而是调试器的Semihosting通道没有被正确接管。解决方案就是本文的核心重写_write替换掉Semihosting。写完第二天他就恢复正常了。另外还有一种情况不是Semihosting引起的。比如J-Link连接时如果目标板供电电压和调试器配置不一致或者复位引脚不稳也可能报类似错误。遇到这种先查硬件连接再用排除法先把代码里的printf全部加_write重定向排除Semihosting因素再查调试器配置。一般做到第一步就已经解决了大部分问题。5. 实测现象和几条很实在的建议5.1 我在实际板子上的测试结果我在STM32F103和STM32F407上分别验证过。配置好_write重定向 setvbuf关缓冲之后printf(hello %d\r\n, 42)正常输出回车换行在串口助手显示正确。puts(hello)正常输出puts自带换行输出后有一次换行。putchar(A)正常输出单个字符。fprintf(stderr, error %s\r\n, msg)正常输出。中文UTF-8字符串串口助手切到UTF-8编码后显示正常GBK解码时乱码。如果只重写fputc不重写_write以上所有函数的表现是除了你自己直接调fputc的场景其他没有任何输出。这个测试足以说明问题。5.2 什么情况下重写fputc在GCC里也会“看似有效”有一种例外情况容易让人混淆如果你在代码里用了第三方的printf实现比如把printf宏重定向到了自定义的uart_printf或者用了SEGGER RTT的printf又或者用了类似MicroLib的替代方案这些库在设计上确实可能把fputc作为输出钩子。但你用的是标准C库的printf就别指望fputc了。判断方法很简单直接在代码里调用一次fputc(A, stdout)如果串口能看到A说明fputc本身被正确重定向了如果printf没输出就说明printf压根不经过fputc。我自己踩坑时就是用这个方法定位的一分钟内就能确定问题出在哪个环节。5.3 给从Keil迁移过来的朋友几条建议第一记住这句话GCC环境下输出重定向上游找fputc下游找_write你要的是下游。到了新环境先找标准库的底层接口不要沿用过时的Keil经验。第二重写_write时务必把file参数、返回值处理好最上面给的示例代码可以直接抄。这是我反复测试过的版本稳定性没问题。第三链接选项--specsnano.specs --specsnosys.specs -u _printf_float这三件套先加上能避免后面90%的链接和浮点坑。我个人的习惯是新建一个retarget.c专门存放_write/_read/_sbrk这些底层重定向函数每个工程都直接复用。遇到问题首先检查syscalls.c和retarget.c有没有重复定义其次是看CMake里的链接参数对不对。这两点查完基本没有救不回来的printf重定向。

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

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

免费获取报价