一个很常见的场景:你在 CLion 里配好了一个 STM32 裸机工程,想用 printf 把调试信息从串口打出来,结果串口助手上一片空白;网上教程翻了一堆,有人说改 fputc,有人说改__io_putchar,还有人让你去折腾链接脚本。你先把最常见的 fputc 版本抄了一遍,编译不报错,烧进去,依旧没有任何输出。再加一个__io_putchar,还是静悄悄。这篇文章就把这个“CLion 下 printf 到底该重写谁”的问题彻底讲透。核心结论先说:在你用arm-none-eabi-gcc+ newlib 的标准路径下,printf 最终会汇入底层函数_write,而不是 fputc。所以你在 CLion 里照着 Keil 那套经验改 fputc,等于改了一个 printf 根本不调用的函数。下面我会从输出链路、实操代码、CMake 配置到坑位排查,一条条拆开说。
1. 为什么这个问题会冒出来
1.1 从一句“printf 打不出来”说起
先还原一下我见过很多次的排查现场。大家在 CLion 里新建嵌入式工程,通常都是拿 STM32CubeMX 生成初始化代码,再通过 CMake 导入 CLion。工具链一般就是 arm-none-eabi-gcc,C 库默认是 newlib 或者 newlib-nano。这个组合本身没问题,但问题出在“网络上的重定向教程来源太杂”。很多老帖子、旧博客的示例来自 Keil MDK 或者 IAR,它们把 printf 的输出重定向描述成“重写 fputc 就行”。新手照着一抄,函数是写了,可 printf 的输出流程从头到尾就没走到 fputc 那一步。
更隐蔽的情况是:程序在串口上偶尔会蹦出一个字节,或者第一次调用 printf 之后整个程序直接卡死。这通常是_write默认指向了 semihosting 相关实现,串口没接调试器,或者调试器没有处理 semihosting 请求,程序就在一条断点指令上等死。从这个现象也能看出来,真正影响 printf 的底层函数是_write,你改上层 fputc 当然治标都治不到。
1.2 fputc 方案在哪些场合能 work
不是说 fputc 重定向方案是错的,只是它有严格的前提。Keil MDK 的 MicroLIB 就是一个典型例子。MicroLIB 为了裁剪体积,把 stdio 的输出实现做成按字符回调:printf 内部每输出一个字符,就调用一次 fputc。所以在 Keil + MicroLIB 环境下,重写 fputc 确实能改变 printf 的走向。很多老教程就是从这个环境里总结出来的。
但换到 GCC 工具链,情况完全不同。arm-none-eabi-gcc 配套的 newlib 实现里,printf 是先把格式化结果写进 stdout 的缓冲区,等到缓冲区满、遇到换行、或者显式调用 fflush 时,再整批交给底层系统调用。这个底层系统调用就是_write。它并不是围绕 fputc 设计的。因此同样是“重定向 printf 到串口”,不同工具链要改的函数可能完全不同。把这几个环境放在一起看就不容易踩坑了。
| 工具链 / 运行库 | 重定向目标 | 原因 |
|---|---|---|
| Keil MDK + MicroLIB | fputc | MicroLIB 的 stdio 按字符回调 |
| arm-none-eabi-gcc + newlib | _write | stdio 缓冲刷新最终走底层 syscall |
| IAR EWARM | __write / __write_buffered | IAR 专用 retarget 层 |
| 桌面包环境 | 不一定需要 | stdout 已被操作系统接管 |
1.3 先搞清你的工具链层级:newlib 与 _write
newlib 是面向嵌入式系统的一套 C 运行库。它和桌面 Linux 的 glibc 不一样,很多底层能力需要使用者自己提供,比如从硬件读一个字节、向硬件写一段字节、调整堆指针等。这些底层函数在 newlib 里有统一的名字,_write就是“向某个文件描述符写入”的那一个。在裸机环境里,fd=1 的 stdout 本质上就是个逻辑概念,你想让它走 UART 还是走别的外设,就自己去实现_write。
很多人在这一步会犯迷糊:write和_write有什么关系?简单说,POSIX 标准里叫write,而 newlib 为了在嵌入式这么小的空间里做适配,把这类底层函数用下划线开头的名字暴露出来。你的业务代码不需要直接调用它,但 printf 的缓冲机制在背后会经过它。所以“重定向 printf”这个需求,准确点说就是“接管底层_write的走向”,而不是去上层 fputc 上缝缝补补。
2. 链路拆解:printf 到串口到底走的是哪条路
2.1 newlib 的底层 I/O 设计
想真正理解为什么是_write,得把这一条调用链看清楚。在 newlib 里,printf 本质上是一个格式化函数,它负责把“要输出的内容”按格式转成字节,但这些字节并不是立刻、逐个送到硬件去。它们会先被塞进 stdout 对应的一个缓存区。等到缓存区被填满,或者碰到换行符触发行缓冲刷新,或者程序主动调用 fflush 时,缓存里的整块内容才会走一次真正的“输出动作”。
这个“输出动作”在 newlib 内部最终会落到_write这个函数上。你可以把流程理解为:
printf → 格式化 → stdout 缓冲区 → 缓冲刷新 → _write → 硬件外设注意,这里面的关键点是:_write拿到的是一整块待输出的字节,而不是单个字符。它一次能拿到几十个甚至上百个字节,能不能把这些字节都交给串口,由你的实现决定。fputc 则不同,它天然就是单字符接口,把它放在这条链路里,效率和组织方式都对不上。
顺带提一个非常容易踩的问题:因为 stdout 是带缓冲的,很多新手以为 printf 执行完,串口就应该立刻有数据。但在嵌入式环境里,如果没有把 stdout 设置成无缓冲,或者_isatty没有告诉运行库“这是一个终端设备”,printf 可能攒了一大片才往外吐,看起来就像“偶尔有输出、偶尔没输出”。这种时候不一定是_write没写好,而是缓冲策略在捣乱。
2.2 fputc 和 _write 的本质区别
fputc 是 C 标准里的流式输出函数,它面向的是 FILE 结构。你可以用 fputc 往 stdout 或者任意一个文件里写一个字符,它本身并不关心底层硬件长什么样。它是“流”这个抽象层的成员。_write 则更底层,它把自己定位成“文件描述符”的写操作,一次写一段字节,返回实际写入的字节数。
这两者的差异在实际工程里非常明显。用 fputc 做重定向时,printf 每次输出几个字符,可能要频繁进出串口发送函数,性能很差,而且很难利用 DMA。用_write做重定向时,一次调用就能拿到一整包待输出数据,你可以选择阻塞发送、中断发送,甚至直接把缓冲区地址丢给 DMA。这对嵌入式调试体验的提升是肉眼可见的。
还有一点值得说:_write的返回值有明确语义。如果串口发送超时,或者缓冲区还没准备好,你的实现要返回实际成功发送的字节数,甚至返回 -1 并设置 errno。这种和底层硬件打交道的设计,就是为了让上层库能感知到异常。而 fputc 的返回只是单个字符或者 EOF,信息量完全不同。
2.3 一张表看懂“哪个函数该被谁重写”
我用一张表把不同情况下该改谁列清楚,你可以直接对号入座。
| 你的环境 | 正确的重定向函数 | 常见误导 |
|---|---|---|
| CLion + arm-none-eabi-gcc + newlib | _write | 网上教程说改 fputc |
| Keil + MicroLIB | fputc | 有人让你改底层半主机函数 |
| IAR | __write | 有人让你改 fputc |
| CubeIDE + GCC | 同样是 _write | CubeIDE 本质也是 GCC 工具链 |
| 纯 C++ 工程 | 注意 extern "C" 包住 _write | 忘了 C++ name mangling |
表格背后其实就一个原则:先搞清楚你这条工具链里 printf 最终汇入哪个底层函数,再决定重定向谁。不要拿着 A 环境经验硬套 B 环境。很多人在 CLion 下折腾半天没结果,就是被环境错位给带偏了。
3. CLion 里重写 _write 的完整实操
3.1 最小可用版本:UART1 输出
在 CLion 里,最朴素也最常用的方案是这样。新建一个retarget.c,放在工程源码目录里,然后写这段代码。我这里假设你已经用 CubeMX 初始化好了 UART1,句柄叫huart1。
#include <errno.h> #include <sys/unistd.h> #include "main.h" extern UART_HandleTypeDef huart1; int _write(int fd, char *pBuffer, int size) { if (fd == STDOUT_FILENO || fd == STDERR_FILENO) { HAL_UART_Transmit(&huart1, (uint8_t *)pBuffer, size, HAL_MAX_DELAY); return size; } errno = EBADF; return -1; }这段代码干了三件事:判断 fd 是不是标准输出或标准错误;把整段字节交给 UART 发送;返回实际发送的字节数。注意_write函数把pBuffer直接当成一块连续内存传给 HAL 库,所以它和“逐字符发送”是两种思路。
如果你还想支持scanf,可以顺便实现_read。逻辑差不多,只是串口接收往往一次只能收一个字节,所以实现里通常按单字节处理。
int _read(int fd, char *pBuffer, int size) { if (fd == STDIN_FILENO) { HAL_UART_Receive(&huart1, (uint8_t *)pBuffer, 1, HAL_MAX_DELAY); return 1; } errno = EBADF; return -1; }在 main 函数最开始,我建议再加一句setvbuf(stdout, NULL, _IONBF, 0);。它的意思是把 stdout 设置成无缓冲模式,让每一次 printf 都即时触发_write。调试阶段这样最直观,否则你可能碰见“printf 之后没反应,程序跑了好半天才突然冒出一串数据”的怪现象。
3.2 进阶:多路输出、超时与 DMA
有的工程不只有一个串口,可能调试串口用 USART1,某个外设日志走 USART2。_write的签名里只有 fd、缓冲区指针和长度,没有“我是谁”这种参数。所以你不能简单说“printf 同时往两个串口打”。更常见的做法是把硬件句柄定义成一个可切换的宏,或者干脆在_write里做一个全局输出通道。
#define DEBUG_UART_HANDLE huart1 int _write(int fd, char *pBuffer, int size) { if (fd == STDOUT_FILENO) { while (HAL_UART_Transmit(&DEBUG_UART_HANDLE, (uint8_t *)pBuffer, size, 1000) != HAL_OK) { /* 超时或 USART 忙,简单重试 */ } return size; } errno = EBADF; return -1; }这里我把 HAL_UART_Transmit 的返回值单独拿出来检查,是因为实际中真有可能遇到串口忙或者发送超时。如果 HAL 返回 HAL_BUSY,而你直接忽略,HAL 库并不会帮你重发,最后串口上就会丢数据。调试时看不出来,一旦系统里中断变多、任务切换频繁,这个问题就会暴露。
用 DMA 发送是另一个进阶方向。_write一次能拿到一整块缓冲,天然适合 DMA。但要注意缓冲区生命周期问题:如果 DMA 还没发送完,_write就返回了,而上层 printf 紧接着又修改了这块缓冲,就会出现“发出去的数据是乱码、缺字、错位”。稳妥做法是用 DMA + 发送完成中断或者信号量,等发完再返回。
3.3 CMake 链接配置:nano.specs 与浮点 printf
很多人重写了_write,编译正常,但链接时一片红色报错,尤其容易出现undefined reference to _sbrk、undefined reference to _lseek这类符号。这是因为 GCC 的 newlib 库里,除了_write,还预留着其他系统调用。如果你想偷懒,就让链接器使用nosys.specs,它会把一组空的系统调用实现塞进来,保证链接通过。
在 CLion 的 CMakeLists.txt 里,比较典型的写法是这样:
target_link_options(${PROJECT_NAME} PRIVATE --specs=nano.specs --specs=nosys.specs -u _printf_float )如果你用的 CMake 版本比较老,不支持target_link_options,也可以用传统方式:
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} --specs=nano.specs --specs=nosys.specs -u _printf_float")nano.specs选用精简版 C 库,体积小很多,适合 Flash 资源紧张的 MCU。但精简版默认不带 printf 的浮点输出,如果你要printf("%.2f", 3.14)这种格式,链接器会给出一个明确提示。加上-u _printf_float就是强制把浮点格式化函数拉进来。代价是代码体积会明显增加,这一点要在意,别等到 Flash 溢出了才一头雾水。
4. 高频翻车现场与排查实录
4.1 中文乱码:编码、波特率与时钟三项检查
CLion 下中文输出乱码,是一个热度极高的问题,而且很多时候它和_write无关,是环境没对齐。先说最核心的一点:_write收到的是一串字节,它不认“中文”这个概念。如果源码文件是 UTF-8 编码,那么"你好"这两个字在内存里就是 6 个 UTF-8 字节;如果你的串口助手用 GBK 解析,自然显示成一堆乱码。
所以排查要分三层。第一层是源码和 IDE 编码。CLion 里建议把 File Encodings 统一设置为 UTF-8,并且可以让 CMake 编译时明确字符集。第二层是串口助手的显示编码,换成 UTF-8 再看。第三层是波特率和时钟。这个最隐蔽:如果 MCU 的串口波特率实际值是 9600,而串口助手开的是 115200,那所有内容都会是花屏一样的乱码,和编码设置一点关系都没有。
我给一个很笨但很有效的定位方法:先别打中文,只打printf("ABCD");,然后把串口助手切到十六进制显示。如果能看到41 42 43 44,说明_write链路已经通了,乱码纯属编码或波特率问题。如果连这 4 个字节都不对,才需要回头查_write本身。
4.2 “write access to const memory”与指针乱写
CLion 配 OpenOCD 调试时,偶尔会弹出一句类似 “write access to const memory has been detected” 的提示,意思是代码正在往只读区域写数据。遇到这个,第一反应不是去怪调试器,而是查自己哪里越界或者指针类型搞错了。最常见的情况就是把一个 const 限定符强转去掉,然后往里面写值,这种操作在 C 语言里属于未定义行为,MCU 的 MPU 或调试器一旦发现就会报警。
还有一种常见原因和_sbrk缺失有关。newlib 的 malloc 依赖_sbrk来扩展堆空间。如果_sbrk被 nosys.specs 里的空实现覆盖,或者实现里返回的堆顶地址不对,malloc 出来的指针可能指向内存映射外的区域,一写就触发硬件异常。调试器会把它描述成“write to location 0000000000000020 caused an access violation”之类的地址错误。这时要检查链接脚本里堆栈区域的符号是不是被正确使用,尤其不要自己随便魔改.ld文件。
排查顺序我建议是:先看有没有明显的指针类型转换,再看数组有没有越界,最后才考虑堆分配问题。这三个里面,前两个占了绝大多数。
4.3 链接阶段找不到 syscall 符号
除了前面讲的_sbrk,我这几年还常收到这样的报错:
undefined reference to `_write' undefined reference to `_lseek' undefined reference to `_close' undefined reference to `_fstat' undefined reference to `_isatty' undefined reference to `_getpid' undefined reference to `_kill'看到这一串符号,第一反应不是去一个个实现,而是检查链接 flags 里有没有--specs=nosys.specs。这个 options 会把你没实现的那批系统调用空实现自动链接进来,属于“够用但很简陋”的类型。如果你想要真正的串口输入输出,就自己实现_write和_read,它们是强符号,优先级高于 nosys 提供的弱符号。
如果工程里文件扩展名是 .cpp,或者你把_write实现在了 C++ 文件里,记得用extern "C"包一层。否则 C++ 编译器会对函数名做 name mangling,链接器找的_write和实际产出的符号对不上,报错还特别难查。防御性写法是专门建一个retarget.c,用纯 C 实现,省得纠结。
4.4 多个 main 与 CMake 文件收集
CLion 里“工程里放了多个 main.c”也是个常见事故。不少人的项目目录长这样:根目录下有一个main.c作为正式入口,后来又建了个test_main.c想临时测点东西。结果 CMake 里用了aux_source_directory(. SRC)之类的方式,把当前目录所有 .c 文件全收进来,两个源文件都定义了 main 函数,链接器直接报重复定义。
这个问题和_write无关,但它会卡住整个开发流程。标准做法是把不同入口做成不同的可执行目标,或者手动维护源文件列表。比如:
add_executable(project_app Core/Src/main.c Core/Src/usart.c Drivers/... )如果只是临时调试,也可以先注释掉某一个 main,或者用set(SOURCE_FILES main.c ...)显式指定。CLion 里最忌讳的就是靠搜索 *.c 自动收集,因为它会把所有历史残留源文件都拖进来。
5. 实测经验与后续扩展
5.1 为什么同一段代码在别的 IDE 里改 fputc 又能跑
很多人在 CLion 里踩完坑,回头会想:明明我在别的 IDE 里改 fputc 就能跑,怎么到这就失灵了?这不是玄学,是运行库实现差异。Keil MicroLIB 选择把 printf 的单字符输出渠道固定在 fputc,方便用户在小工程里快速重定向。而 arm-none-eabi-gcc 配套的 newlib 更偏向于类 POSIX 的底层 syscall 设计,它天然认为“输出一坨数据”比“输出一个字符”更合理。
理解了这一点,你就能写出跨环境代码。比如用宏区分编译器:
#if defined(__GNUC__) int _write(int fd, char *pBuffer, int size) { /* GCC 工具链:实现 _write */ } #elif defined(__CC_ARM) int fputc(int ch, FILE *f) { /* Keil 工具链:实现 fputc */ } #endif这种写法在代码移植时很实用,也是很多商用 SDK 内部处理 retarget 的套路。
5.2 向 RTOS 与 C++ 工程扩展
如果你的工程里用了 FreeRTOS,_write里直接调用阻塞式的HAL_UART_Transmit要特别小心。任务上下文里调用没问题,但如果某个中断服务函数里间接调用了 printf,你又在_write里做长时间的阻塞等待,就可能把整个中断阻塞住,轻则任务卡顿,重则系统跑飞。更合理的方式是加一个互斥量,把串口发送保护起来。
int _write(int fd, char *pBuffer, int size) { if (fd == STDOUT_FILENO) { osMutexAcquire(uart_mutex, osWaitForever); HAL_UART_Transmit(&huart1, (uint8_t *)pBuffer, size, HAL_MAX_DELAY); osMutexRelease(uart_mutex); return size; } errno = EBADF; return -1; }这个例子里用 CMSIS-RTOS 的接口,换成其他 RTOS 也同理。加了互斥量之后,多个任务同时 printf 就不容易把数据打断成乱码。
C++ 工程我也提一句:只要你保持_write是 C 链接方式,std::cout最终也会走到 stdout,所以_write对 C++ 的流式输出同样有效。重点还是那个extern "C",不要被 C++ 的名字修饰坑到。
最后再分享一个我自己常用的验证习惯:拿到一块新板子,别急着调中文输出,先用printf("M");这种单字符,同时把串口助手切到 hex 显示。看到4D,说明_write链路和波特率都是通的;这时候再逐步加长输出、加中文、加编解码切换。这招帮我省了不知道多少排查时间,尤其适合在 CLion 这种“IDE 环境、编译器、调试器、串口工具”多重变量叠加的场景里,快速锁定真正的问题出在哪一层。