news 2026/9/7 19:52:28

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CLion中printf重定向串口输出:为什么必须重写_write而非fputc

后台经常有人私信问我一个特别典型的问题:“我把Keil工程里重定向printf用的fputc代码,原封不动搬到了CLion,为什么串口还是看不到输出?”这个问题我前前后后见了不下二十次,已经能猜到他们大概率是哪一步出问题了。

先说结论:CLion里面开发和调试嵌入式工程,默认用的是arm-none-eabi-gcc工具链 + Newlib标准库,这套组合下,printf最终不是通过fputc把字符吐出来的,而是通过一个叫_write的系统调用级函数。你在Keil里靠重写fputc让printf走串口,到GCC这套工具链里,这条路根本不通,必须老老实实去重写_write。

下面我把原因、调用链、配置步骤和连带问题一次讲透。

1. 同样一行printf,Keil和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 MDK(ARMCC/ARMClang + microlib)CLion(arm-none-eabi-gcc + Newlib-nano)
编译器armcc / armclangarm-none-eabi-gcc
C标准库ARM C Library / microlibNewlib / Newlib-nano
printf底层输出入口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里不是。

这也就解释了为什么你重写了fputc,printf依然没有输出:因为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(半主机)。也就是说,标准库认为你的电脑调试器会接管这个输出。问题就出在这里:

  1. 如果没连接调试器,程序执行到printf时会触发一个未定义指令或者软件中断,MCU直接进入HardFault,表现为程序跑飞、卡死。
  2. 如果连接了调试器,输出会打印到调试器的Semihosting控制台,而不是串口。你要从串口看数据,自然什么都看不到。
  3. 即便连了调试器,一些调试器配置不当或者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等。这就是为什么很多人没自己写_write,printf也能编译通过——因为库里有个默认的,但它并不是你要的功能。

在开始重写之前,你最好确认一下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; }

这里有几个细节,值得你注意:

  1. 必须返回len。Newlib认为_write返回的是成功写入的字节数。如果返回值和len不一致,上层会认为写入失败。有些实现偷懒直接return 0,结果就是printf可能只输出一部分甚至什么都不输出。
  2. 判断file参数。stdout和stderr的文件描述符分别是1和2,通常都让它们走串口没问题。不需要特殊区分。
  3. 不用HAL_UART_Transmit是因为中断和超时。HAL_UART_Transmit带超时参数,在中断里或者高频调用时会引入不必要的开销。寄存器直接操作最干净,逐字符等待TXE标志即可。
  4. 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

重写代码只是其中一步,你还得让链接器知道怎么处理标准库。这里涉及几个关键选项。

--specs=nano.specs:启用Newlib-nano。这是精简版的Newlib,体积小很多,非常适合MCU。我建议CLion工程里CMake配置加上它。

--specs=nosys.specs:告诉链接器不要使用完整的Semihosting系统调用实现。配合我们自己的_write,可以避免系统调用层与Semihosting绑定。

在CMakeLists.txt里,你可以这样配置:

target_link_options(${PROJECT_NAME} PRIVATE --specs=nano.specs --specs=nosys.specs )

这里有个常见的坑:如果你启用了nano.specs,printf默认不支持浮点数格式化。比如printf("voltage=%f\r\n", 3.3),在nano模式下可能在格式化阶段返回错误,输出为空。解决办法是在链接选项里加一个-u _printf_float,强制把浮点格式化支持拉进来:

target_link_options(${PROJECT_NAME} PRIVATE --specs=nano.specs --specs=nosys.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,可以这样写CMake:

add_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_ALL

add_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参数、返回值处理好,最上面给的示例代码可以直接抄。这是我反复测试过的版本,稳定性没问题。第三,链接选项--specs=nano.specs --specs=nosys.specs -u _printf_float这三件套先加上,能避免后面90%的链接和浮点坑。

我个人的习惯是新建一个retarget.c,专门存放_write/_read/_sbrk这些底层重定向函数,每个工程都直接复用。遇到问题首先检查syscalls.c和retarget.c有没有重复定义,其次是看CMake里的链接参数对不对。这两点查完,基本没有救不回来的printf重定向。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 19:52:00

用Docker Desktop运行Redis:从安装到避坑全指南

新电脑到手&#xff0c;我装的第一批软件里&#xff0c;Docker Desktop 一定排在最前面&#xff0c;紧接着就是它容器里的 Redis。早年我在 Windows 上跑 Redis&#xff0c;用的还是编译好的 exe 版本&#xff0c;虽然双击能用&#xff0c;但版本切换、数据清理、多项目隔离这些…

作者头像 李华
网站建设 2026/9/7 19:51:39

驾照证件照尺寸与照片压缩全攻略:从规格到实操一次搞定

开车的人绕不过去的一个环节就是驾驶证照片。不管是初次申领、期满换证、遗失补证&#xff0c;还是线上APP申请电子驾驶证&#xff0c;最后都得过"照片"这一关。我身边不少朋友自己拍好照片、兴冲冲上传&#xff0c;结果被系统提示"照片尺寸不符合要求"&qu…

作者头像 李华
网站建设 2026/9/7 19:49:38

RWEQ计算——C因子

1. 核心结论 根据 RWEQ 原始技术文档《Revised Wind Erosion Equation (RWEQ)》对植被模块的定义&#xff0c;如果研究目标是开展中国国家尺度防风固沙服务评估&#xff0c;建议将国内文献中常写的单一“C 因子”理解为 RWEQ 的综合植被因子&#xff0c;并至少采用逐月计算&am…

作者头像 李华
网站建设 2026/9/7 19:48:03

自定义内存检测工具实战:从malloc拦截到业务链路定位

内存检测工具这个东西&#xff0c;做后端和客户端的人应该都不陌生。线上服务内存持续上涨、嵌入式设备跑到一半内存耗尽、或者某个接口一调用就吃掉几百兆内存&#xff0c;这类问题排查起来最折磨人。用现成的 Valgrind 跑一遍&#xff0c;编译速度慢得让人怀疑人生&#xff1…

作者头像 李华