后台经常有人私信问我一个特别典型的问题:“我把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 / armclang | arm-none-eabi-gcc |
| C标准库 | ARM C Library / microlib | Newlib / 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(半主机)。也就是说,标准库认为你的电脑调试器会接管这个输出。问题就出在这里:
- 如果没连接调试器,程序执行到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等。这就是为什么很多人没自己写_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; }这里有几个细节,值得你注意:
- 必须返回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
重写代码只是其中一步,你还得让链接器知道怎么处理标准库。这里涉及几个关键选项。
--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重定向。