ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CLion嵌入式开发:printf串口重定向为何要重写_write而非fputc

CLion嵌入式开发:printf串口重定向为何要重写_write而非fputc 1. 问题背后的真正逻辑CLion 与 Keil 的底层差异之争如果你在 CLion 里写过 STM32 或者其它嵌入式 C 工程那你大概率在某个调试深夜遇到过这个困惑printf 死活不输出串口助手一片空白。网上一搜十篇教程里有八篇会让你重写 fputc再配合一句勾选 Use MicroLIB之类的操作。你照着做了结果在 Keil 里跑得飞起一换到 CLion 就原形毕露——编译能过链接能过下载能过就是不说话。问题出在哪不是你的代码写错了而是你被Keil 的思维定式绑架了。CLion 的嵌入式开发通常不走 Keil/ARMCC 那套工具链而是用 GCC ARM Embeddedarm-none-eabi-gcc配合 CMake 构建系统链接的 C 运行库是 Newlib 或者 Newlib-nano而不是 Keil 的 MicroLIB。这两套运行库对 printf 底层的重定向接口定义完全不同。Keil 的 MicroLIB 和 ARMCC 标准库走的是 fputc 这个应用层钩子而 GCC 的 Newlib 库则是用 _write 这个系统调用层钩子。你死磕 fputc等于在一个根本不存在的岗位上投简历——编译器压根不认识你要重定向的那个入口。从实际开发角度看这个问题看着小却是 CLion 嵌入式开发中第一个真正卡住新手的暗礁。它背后牵扯到对 C 运行库层次的理解对 printf 调用链的认知以及对不同 IDE/编译器生态的区分。搞懂了它你就搞懂了嵌入式串口调试的基本盘后面不管换什么芯片、什么工具链都能在一分钟内定位问题。2. fputc 和 _write名字一字之差层级天差地别要搞懂为什么要重写 _write得先认清这两个函数在 C 运行库里的编制完全不同。这是一个典型的应用层与系统调用层的关系问题。2.1 函数签名差异一个面向字符一个面向文件描述符先看两个函数的原型int fputc(int ch, FILE *f);fputc 的语义很明确往某个 FILE 流里写一个字符。它属于 C 标准库的流 I/O层处理的是 FILE 指针这种上层抽象干的是把一个字符塞进缓冲区这种细活。再看 _writeint _write(int file, char *ptr, int len);注意这是旧版签名以 arm-none-eabi-gcc 的 Newlib 为例实际定义一般是int _write(int file, char *ptr, int len); // 或者较新工具链中可能是 int _write(void *file, char *ptr, int len);它属于系统调用层syscall layer处理的是文件描述符 fd0 是标准输入1 是标准输出2 是标准错误干的是把一段缓冲区整体交给底层设备这种粗活。我们可以把 fputc 理解成单个送货上门而 _write 是直接整车发货。在 ARMCC 的 MicroLIB 环境里printf 最终会调用 fputc 来逐字符输出。而在 Newlib 环境里printf 经过一系列缓冲处理后最终会调用 _write 来把整段文本一次性刷到设备上。两者的调用链层级完全不同。2.2 调用链对比谁在背后支撑 printf以 Keil MDK ARMCC 为例printf(Hello\r\n) 的走向大致是这样printf() - fputc(H, stdout) - fputc(e, stdout) ... - fputc(\n, stdout)每个字符独立走一次 fputc实现简单但效率不高。ARMCC 标准库本身也有缓冲机制但 MicroLIB 模式下为了省资源路径相当直白。再看 CLion arm-none-eabi-gcc Newlib 环境printf(Hello\r\n) - vfprintf(stdout, Hello\r\n) - 内部缓冲处理 - _write(1, Hello\r\n, 7) - 底层设备驱动这里体现了一个关键差异printf 把格式化好的字符串交给 vfprintfvfprintf 根据 stdout 的类型决定是否缓冲最终当缓冲区满了、遇到换行符或者主动 fflush 时会把整段数据交给 _write由 _write 负责把数据真正发到串口或其他设备。之所以要有 _write 这一层是因为操作系统环境下printf 是标准库函数它不知道屏幕是什么只知道文件描述符 1。_write 就是标准库和底层硬件之间的翻译官——标准库说给我写 7 个字节到 fd 1_write 就负责把这 7 个字节真真切切地交给某个能接收的东西比如串口的发送寄存器。2.3 为什么 Keil 里的 fputc 经验一换到 CLion 就失灵原因说到底就一句话CLion 默认的 GCC 工具链压根就不调用你重写的 fputc。Newlib 的 printf 实现里确实有 fputc 这个概念但那只是 itoa/格式化过程中的内部函数不是给你重定向用的钩子。你重写了 fputc链接器可能都感知不到——没有人调用它或者调用了但你的实现和 Newlib 内部符号冲突导致链接错误。我在第一次从 Keil 迁移到 CLion 时就犯了同样的错。在 CLion 里按老经验重写 fputc编译顺利通过串口却一个字都不吐。翻遍了论坛帖子后才意识到 Newlib 根本不理睬 fputc它在等待我提供 _write。这个平台切换的认知鸿沟是无数嵌入式新手在 CLion 上栽跟头的最常见原因。3. CLion GCC 环境下的正确重定向姿势既然明确了要重写 _write接下来就是实操环节。我以 STM32F103 STM32CubeMX CLion 的典型工程为例从零演示完整的 printf 重定向流程。3.1 底层串口发送函数先打好地基别一上来就写 _write。先确认你的串口发送函数是出门可用的。我这里用 HAL 库的阻塞发送函数HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);如果你用的是 LL 库或标准外设库对应换成 LL_UART_TransmitData8 或者自己操作 DR 寄存器。核心要求是有一个能一次发一个字节或一段字节的函数。无论哪种方式底层发送函数的可靠性直接决定重定向是否成功。我在调试中发现如果底层发送函数没有正确等待发送完成标志数据会丢失表现为printf输出乱码或随机丢字符。3.2 完整重定向代码与关键注释在 main.c 或者单独新建的 retarget.c 里加入以下代码#include stdio.h #include stdarg.h #include string.h #include stm32f1xx_hal.h // 声明你实际使用的串口句柄以 UART1 为例 extern UART_HandleTypeDef huart1; // 新版 Newlib 的 _write 签名注意第二参数类型是 char * int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }这里有几处坑必须说明第一函数名必须叫_write且前面不要加 static。Newlib 在链接阶段需要一个全局可见的_write符号。如果你写成了 static 函数链接器找不到会保留 Newlib 的默认弱实现导致你的代码根本没被调用。第二签名必须严格匹配。旧版工具链里_write的第二个参数可能是char *ptr新版 Newlib 在某些配置下可能是void *ptr。如果你在编译时报conflicting types for _write错误优先检查签名是否和工具链头文件 sys/unistd.h 里的声明一致。第三返回值的坑。_write必须返回实际写入的字节数。我见过有人返回 0结果 printf 的循环认为写入没成功不断重试或干脆跳过后续字符输出就千奇百怪。最安全的写法就是return len;。第四关于__io_putchar的旧写法。网上很多教程会让你重写int __io_putchar(int ch) { ... }这是旧版 Newlib 的接口。新版工具链已经不再调用它而是直接调用_write。如果你的工具链版本较新写了__io_putchar也不会报错——因为没人调用它它就是个死代码。这就是很多教程看起来对了但没效果的根源。3.3 禁用以太网/文件系统所需的其它系统调用_retarget 不只是 _write 一个函数。Newlib 的某些功能比如 malloc、printf 的某些路径等在无操作系统环境下会尝试调用 _sbrk、_close、_lseek、_read、_fstat、_isatty 等系统调用。如果你不做任何处理链接时可能遇到 undefined reference 的报错或者在运行时因为系统调用缺失而 crash。最简单粗暴的方案把这些野指针全部堵上int _close(int file) { return -1; } int _fstat(int file, void *st) { return 0; } int _isatty(int file) { return 1; } int _lseek(int file, int ptr, int dir) { return 0; } int _read(int file, char *ptr, int len) { return 0; } caddr_t _sbrk(int incr) { return (caddr_t)0; }注意_sbrk 的返回值类型在不同工具链里可能是void*或者caddr_t按报错信息调整即可。如果你嫌麻烦还有一种极简方案在编译选项里加上-specsnano.specs -specsnosys.specs用 nosys.specs 提供一组什么都干不了但不会报错的默认系统调用实现这样你只需要重写 _write 就行了。这个方案在 CLion 的 CMakeLists.txt 里特别实用。3.4 在 CLion 里配置链接选项打开项目的 CMakeLists.txt在 target_link_options 或 set(CMAKE_EXE_LINKER_FLAGS) 中加上 nano.specs 与 nosys.specsset(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} -specsnano.specs -specsnosys.specs)加 nano.specs 的意义是使用 Newlib-nano它比完整版 Newlib 更省 flash同时 printf 的浮点支持默认是关闭的。如果你需要打印浮点数有两个选择加-u _printf_float或者调用printf前在 CMake 里链接-Wl,-u,_printf_float。这里我建议用-u _printf_float不然 printf(%.2f) 会输出f之类的乱码。这个坑我踩过调了半天才发现是浮点打印没有链接进去。4. _write 与 fputc 的三不同性能、兼容性与场景选择很多人会问一个问题既然 fputc 也能用在某些 IDE 里为什么非要用 _write这背后还有性能层面的考量。4.1 单字符 vs 批量缓冲机制的巨大差异前面说过Keil MicroLIB 环境下 printf 的每个字符都可能直接走一次底层发送。假设你要打印 Hello, World!\r\n 这 15 个字符意味着要调用 15 次串口发送函数每次发送函数都有可能经历等待上次发送完成→写入数据寄存器→再次等待完成的完整流程。这中间的开销不容忽视尤其是在低主频单片机上打印 100 个字符的耗时可能会让实时性需求崩溃。Newlib 的 printf 则不一样。vfprintf 先把格式化结果放进一个缓冲区缓冲区满或遇到换行时才调用一次 _write。比如同一句话可能只调用 1 次 _write把 15 个字符一次性交给底层驱动。底层驱动可以连续写入 15 个字节显著减少等待时间。这就是为什么在无操作系统环境下Newlib 的 printf 重定向更适合高频打印的原因。当然这里有一个例外如果你在重定向代码里不使能缓冲也就是 stdout 是行缓冲或无缓冲模式那么 _write 的调用次数会变多。Newlib 内部默认情况下 stdout 可能是全缓冲或行缓冲取决于你是否有 tty 标志。我通常用setvbuf(stdout, NULL, _IONBF, 0)把 stdout 设为无缓冲这样每次 printf 内容立刻通过 _write 输出适合调试场景的实时性要求。代价是性能下降但换来的所见即所得体验值得。4.2 重定向的兼容性差异fputc 只能活在自家生态里fputc 的接口是 C 标准定义的理论上任何 C 标准库都提供。但接口存在不等于重定向有效。Keil ARMCC 标准库内部确实把 printf 的输出导向了 fputc所以重写 fputc 就能劫持输出。但 GCC Newlib 的设计更贴近类 Unix的操作系统抽象应用程序通过 printf 输出libc 调用 write 系统调用在这里就是 _write。所以 Newlib 的钩子天然是 _write。换个角度说你在 Keil 里重写 fputc 是被 ARMCC 库照顾的便利在 CLion/GCC 里重写 _write 是 Newlib 遵循的标准协议。这种差异还有一个更隐蔽的坑当你把代码从 Keil 工程复制到 CLion 工程时如果保留重写的 fputc 但不重写 _writeprintf 很可能依然输出不了。我在实际项目里遇到过这种情况当时是把 Keil 的串口驱动源码直接拖到 CLion 工程里折腾了一下午才发现 fputc 是罪魁祸首。所以代码迁移时一定要检查重定向的接口是否匹配目标工具链。4.3 什么情况下用 fputc 也可以什么情况下必须用 _write既然 _write 是 Newlib 的正道是否意味着 fputc 就毫无用处也不尽然。如果你用的是回调式串口驱动比如某些 SDK 提供了int USER_UART_PUTCHAR(int ch)这种自定义弱函数那就没必要纠结 _write——直接改这个 SDK 的回调即可。又或者你只用了 GCC 但禁止了全部库函数完全自己实现putchar通过正则表达式替换来实现输出也能绕开。但这些都属于特例。绝大多数情况下在 CLion arm-none-eabi-gcc 的标准路径里正确的做法只有一个重写_write。这样不管是 printf、puts 还是 putchar最终都汇聚到 _write 一个入口代码干净行为统一。5. 实战踩坑CLion 串口重定向高频问题排查手册这部分分享几个我在实际项目里反复遇到、且特别有代表性的问题整理成速查表供各位参考。现象最可能原因排查与解决printf 无输出串口完全空白没有重写 _write或重写了但未被链接用 nm 或 map 文件确认_write符号是否被你定义确认函数非 static编译报 undefined reference to _write没有提供 _write且没有链接 nosys.specs添加 -specsnosys.specs或自己实现 _write编译报 conflicting types for _write签名与工具链头文件不一致打开工具链的 sys/unistd.h按声明修改签名输出乱码或丢字符底层串口发送函数未等待发送完成标志HAL_UART_Transmit 的 Timeout 加大或改为阻塞等待 TC/ TXE 标志printf 打印浮点显示为 f/vNewlib-nano 裁剪了浮点打印支持链接选项加 -u _printf_float程序一调用 printf 就死机_write 调用了未初始化的串口句柄或者 _sbrk 未实现导致堆分配失败确认串口初始化在 printf 之前补全 _sbrk 实现输出有几百毫秒延迟才出现stdout 是全缓冲模式缓冲区满才输出调用 setvbuf(stdout, NULL, _IONBF, 0) 设为无缓冲输出一段后卡死使能了中断优先级冲突或中断嵌套问题检查串口中断是否和 printf 发送路径冲突或用轮询发送UTF-8 中文显示乱码串口助手终端编码设置不正确在 CLion 的串口终端或外部串口助手里设置 UTF-8 编码5.1 最容易忽视的一点串口句柄的可见性上面表格里 程序一调用 printf 就死机 是最常见的隐蔽问题。很多人把 _write 放在 retarget.c 里但这个文件访问不到 main.c 中定义的huart1于是在 _write 里直接写了一个不存在的句柄或者文档中标记的句柄名比实际定义的少了个字母。这类问题编译时通常不会报错extern 声明是你自己写的但运行时就崩。我的建议是_write 里不要直接依赖某个全局句柄可以抽象成你自己的发送函数比如在 retarget.c 中留一个弱函数__attribute__((weak)) int uart_write_buf(uint8_t *buf, uint16_t len) { // 默认实现空 return -1; }然后在 main.c 里实现这个函数内部调用 HAL_UART_Transmit。这样你换板子、换串口时不用改 retarget.c只改 main.c 的 uart_write_buf 实现就行。5.2 不要忽略链接顺序和 map 文件CLion 里使用 CMake 时链接顺序偶尔会冒出来捣乱。比如你重写了 _write但某个库文件比如 libc.a的顺序排在你的目标文件之前链接器可能先看到库里的弱 _write 符号于是阴影覆盖了你实现的强符号。最终行为要么是链接报错要么是你实现的 _write 不生效。排查方法不靠猜打开构建目录里的 .map 文件搜索_write的符号解析路径看看最终使用的是哪个地址、哪个文件的定义。如果发现链接到了 libc 内部实现检查你的目标文件是否真的参与了链接以及 target_link_libraries 的顺序。让用户代码的 .o 尽量排在库文件之前即可。5.3 HAL_UART_Transmit 超时参数别用 0可能有人图省事把 Timeout 设成 0。在阻塞发送中超时 0 意味着立刻超时如果串口正忙可能还没发完就返回错误。这会导致数据截断或乱码。我一般用 0xFFFF简单够用。如果你在中断环境下调用 printf比如在定时器中断或 DMA 中断里要注意阻塞发送可能引发死锁。这种情况下建议换非阻塞方案或者加临界区保护。当然这块已经超出重定向本身属于printf 安全调用范围的问题了。6. 在 CLion 中配置 JNI/交叉语言调试的相近启示这个话题可能看起来跳脱但理解 _write 重定向的逻辑对你做 JNIJava Native Interface调试也有帮助。原因在于JNI 环境下的日志输出同样存在标准输出被重定向到哪的问题。很多 Java 开发者调 JNI 时在 C/C 代码里写 printf发现 Android 的 logcat 里看不到。原因和 Newlib 的 _write 场景类似Android 的 C 运行库把 stdout/stderr 重定向到 /dev/null 或者 logcat 的某种机制printf 的输出没有走到你期望的终端上。你需要在 JNI 层调用__android_log_print()这类专门的日志接口而不能依赖 printf。这和嵌入式里printf 要重定向到串口是同一个思想标准输出函数printf/fputc本身不知道设备在哪你必须给它指定一条实际的通路。所以当你理解了 _write 的作用你就理解了一切printf 不输出问题的通用解法找到标准库与实际硬件之间的那层接口然后实现它。无论这个接口叫 _write、fputc、__android_log_print还是 CDC_Transmit_FS本质都是一样的。7. 少走弯路的个人经验总结我从 Keil 迁到 CLion 做嵌入式开发这几年关于 printf 重定向这条路由实实在在踩了不少坑分享几条个人体会。第一条拿到新工程的第一件事先跑通串口打印。不要先写业务逻辑先把板子的声带打通。唇亡齿寒没有 printf 的嵌入式调试就像蒙眼开车效率低十倍。只要串口打印通了后续所有问题都能靠打印大法定位。第二条CLion 的 CMake 工程一定要学会看构建输出。遇到 undefined reference不要慌复制完整错误信息去搜索而不是只看undefined reference几个字。80% 的问题在完整的错误上下文里已经给出了答案。第三条重定向代码尽量独立成文件不要和业务代码混在一起。我习惯建一个uart_retarget.c专门放 _write、_read 以及其它系统调用的桩实现。这样当工程跨平台复用比如从 STM32 换到 GD32时只需改这一个文件里的串口句柄其它地方不用动。第四条调串口打印时务必保留至少一个硬开关。比如在 main.c 中定义一个宏DEBUG_UART_ENABLE关掉它就能全局禁用所有调试打印。产品量产时把宏关掉代码里的 printf 自动变为空操作既不用删代码又能避免串口打印拖慢系统。回到最初的问题CLion 里为什么要重写 _write而不是 fputc本质上是工具链和 C 运行库的生态差异决定的。GCC Newlib 的标准输出路径就是printf - vfprintf - _write你选对了入口一切自然通畅。希望这篇从原理到坑位的记录能给正在 CLion 串口重定向泥潭里挣扎的你一个明确的攀登路径。
返回列表