ARTICLE DETAIL

资讯详情

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

CLion下printf重定向到串口:为什么必须重写_write而非fputc

CLion下printf重定向到串口:为什么必须重写_write而非fputc 记得第一次把 STM32 工程从 Keil 迁移到 CLion 的时候绕不开的一件事就是把 printf 重定向到串口。跟着网上的教程一顿操作发现有的文章说要在fputc里写USART_SendData有的说必须重写_write。当时我也很懵到底该听谁的折腾几天、把 stdout、FILE 结构体、重定向这些概念翻来覆去捋清楚之后我才明白为什么 CLion 这套链路上必须动_write而不是fputc。这篇文章就把这段经验掰开揉碎讲清楚。先说结论在 CLion ARM GCCnewlib / newlib-nano这套工具链下printf 最终并不是经过fputc把字符吐出去而是通过 C 库的底层 syscall 接口_write把一串字节交给操作系统这里其实就是你的串口外设。fputc在 newlib 的默认实现里确实存在但它本身不是 printf 的输出终点而且默认实现还要依赖底层的_write所以你在fputc里做串口发送等于是在一个默认没有启用的分支上做文章方向就错了。这个内容适合谁正在用 CLion 做 STM32/嵌入式开发、想把 printf 输出到串口调试的人以及那些在 Keil、IAR、GCC 之间反复横跳、被各种重定向写法搞晕的同学。看完你不仅能解决“printf 没输出”的问题还能理解整条输出链路的来龙去脉以后再遇到类似重定向需求不用再猜。1. 内容整体设计与思路拆解1.1 为什么需要重定向 printf 到串口嵌入式开发的调试手段到现在还是逃不过串口。仿真器断点能看变量但打印日志依然是快速定位问题的最直接手段。printf 是标准 C 库函数把格式化的文本输出到“标准输出 stdout”在 PC 上这个 stdout 默认就是控制台但在 MCU 上根本没有“控制台”这种东西所以 C 库不知道要把字符往哪里送只能给你打一个空转的底桩。这时候需要做的事就是告诉 C 库你把字符交给我我来往串口外设里丢。这个过程就是“重定向 printf 到串口”。看起来简单但重定向的对象写得对不对直接决定了 printf 能不能在你的板子上吐出字来。1.2 不同工具链背后的实现差异为什么网上教程会有fputc和_write两种写法因为不同 IDE 背后用的 C 库不一样输出链路的终点也不一样。Keil 的 MDK-ARM 默认使用的是 ARM Compiler 自带的 microlib / 标准 C 库这套库的 stdout 输出链路里fputc被设计成一个可以被用户覆盖的钩子所以大家在 Keil 里写int fputc(int ch, FILE *f)就能实现 printf 重定向。IAR 也有类似的机制重写int putchar(int ch)或者用__write都有人用。而 CLion 搭配的 ARM GCC 工具链用的是 newlib 或 newlib-nano 这套开源 C 库。newlib 是为嵌入式系统设计的精简 C 库它的 stdio 实现遵循类 POSIX 的层次printf→fputc/fwrite→ 底层_writesyscall。真正的字符出口是_write这个系统调用桩子默认实现是个空函数什么都不干只返回一个错误码。所以你在 CLion 里只改fputc等于改了一个根本没有被调用到的地方。还有一点特别容易踩坑CLion 里链接的编译器可能是 arm-none-eabi-gcc这个工具链默认使用 newlib-nano而 newlib-nano 对某些 stdio 特性做了裁剪浮点打印默认是不开的后面会专门讲。这也是为什么很多人在 CLion 里 printf 输出整数正常、一打印小数就是空白的直接原因。1.3 从“能用”到“为什么能用”的进阶路径如果你只在 Keil 里写过一个fputc你可能一直以为 printf 就是靠 fputc 输出的。直到换成 CLion代码复制过去发现 printf 完全没反应才会有动力去搞清楚内核里面发生了什么。我的建议是不要停留在“改哪个函数能跑通”的层面而是把这条链路的每个节点都梳理一遍。这样换工具链、换 C 库、换 RTOS 的时候你都能快速定位问题。下面这一节我就把 newlib 的输出调用链从头到尾拆开讲。2. 核心细节解析与实操要点2.1 newlib 的 printf 输出调用链先看一条完整的调用链。在 ARM GCC 环境下你调用printf(hello\r\n)的时候newlib 内部大致经历了这样的过程printf(hello\r\n); → vfprintf(stdout, hello\r\n, args); → __sfvwrite / fputc / fwrite 等输出函数 → __sputc 或直接写 FILE 的缓冲区 → stdout 缓冲区满或遇到 \n → _write(1, buf, len); → 你的自定义实现往串口寄存器里写数据注意中间其实有可能经历缓冲区。newlib 的 stdout 默认是行缓冲或全缓冲取决于是否检测到终端。在 MCU 环境下缓冲区策略经常造成“printf 打了但串口没收到”的假象因为数据还闷在缓冲区里没被刷出来。fputc本身在 newlib 里有默认实现它是操作FILE *结构体、往缓冲区里写数据的接口。但它最终把缓冲区刷出去的时候还是要调用_write。所以你在fputc里做串口发送只是处理了缓冲区里的单字节而真正的批量落地动作在_write。2.2_write的本质syscall stubnewlib 里有一批“系统调用桩子”比如_write、_read、_sbrk、_close、_lseek、_fstat、_isatty、_exit等。它们的存在是为了让 C 库脱离操作系统只保留标准的 API 表面。当有真实操作系统的时候比如跑在 Linux 上这些函数由内核提供当没有操作系统的时候裸机 MCU这些函数由你来实现。_write的原型是int _write(int fd, const void *buf, size_t count);fd文件描述符。stdin 是 0stdout 是 1stderr 是 2。重定向 printf 到串口本质上就是处理 fd 1 的情况。buf要输出的数据缓冲指针。count期望输出的字节数。返回值是实际输出的字节数。如果实现得不对返回 0 或负数printf 可能认为输出失败表现就是数据丢了或者输出停滞。这个接口为什么设计成“给一段缓冲区”而不是“给一个字符”因为底层设备UART、USB CDC、网络 socket更适合批量发送。与其一个字节一个字节地进中断/查询发送不如攒一批一起发效率高得多。这就是为什么_write被设计成块写接口。2.3fputc与_write的分工用一个做饭的类比fputc相当于“把做好的菜夹到盘子里摆盘”。_write相当于“把整盘菜端出去上桌”。printf 在 newlib 里是负责“做菜”的把格式化结果放到内存缓冲区里“上桌”这步到底由谁执行取决于库的设计。Keil 的 microlib 设计得比较直接printf→fputc一个字符一个字符地往外送。所以你在fputc里拦截是有效的。newlib 设计得则更接近 POSIXprintf先把字符填入FILE的缓冲区最后统一通过_write把缓冲区“倒”出去。你只改fputc很少会被调用到。2.4 为什么直接操作fputc在 newlib 下不稳定有人可能会说“我在 CLIon 里写fputc也成功过啊” 这种情况确实存在但有条件。newlib 里有一个宏叫__sputc在某些情况下fputc会被内部优化路径调用到尤其是当你直接调用fputc(a, stdout)而不是printf的时候。但如果你的代码是printf(hello %d, i)内部走的是vfprintf的批量写入路径很大几率根本不碰你自定义的fputc。另外newlib 的FILE结构体里有一个函数指针表_read、_write、_seek、_close这是新标准 C 库stdio对底层 I/O 的抽象。fputc只是一个普通库函数并不是虚拟钩子。所以要重定向最稳妥的路径就是直接实现_write。还有一个更隐蔽的点即使你实现了自己的fputcnewlib 内部可能还会在_write层面做缓冲处理。比如你往串口丢了一个字符缓冲没满时它可能根本不会触发_write于是看起来就是“fputc 被调用了但串口没反应”。这种问题最容易让人怀疑人生。2.5 在 CLion 中重写_write的最小实现下面直接给出一个在 STM32 上可以用的最小实现。假设你的串口发送函数是HAL_UART_Transmit#include errno.h #include sys/unistd.h // STM32 的 HAL 库姐妹头文件实际使用 STM32CubeMX 生成的 stm32xx_hal.h int _write(int fd, const void *buf, size_t count) { if (fd STDOUT_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)buf, count, 1000); return count; } if (fd STDERR_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)buf, count, 1000); return count; } errno EBADF; return -1; }几个关键点fd STDOUT_FILENO判断是不是标准输出。因为_write不仅要处理 stdout也可能被stderr调用。都重定向到同一个串口没问题。返回值必须是count否则 C 库认为写入失败。超时时间给个 1000ms 足够用调试打印速度不会太快。如果你的串口发送函数是非阻塞的比如 DMA 中断那_write里要加等待发送完成的信号量或标志位否则数据可能被截断。2.6 要不要同时保留fputc的实现有些教程会建议你把fputc也写了理由是这样在fputc被调用的时候也能正确发送。我的建议是主要实现_write这是 newlib 的“正路”。如果兼容性焦虑可以额外把fputc也实现内容就是调用_write发送一个字符。这样无论是在哪个路径被调用都能正确输出。int fputc(int ch, FILE *f) { char c (char)ch; _write(STDOUT_FILENO, c, 1); return ch; }这样并不冲突。本质上fputc变成了_write的一个薄封装双向保险。不会影响正常 profram 逻辑。不过要提醒一句fputc里调用_write属于投机取巧别把它当成理解模型。更扎实的理解是知道_write是唯一的关键出口。3. 实操过程与核心环节实现3.1 CLion 工程里配置 printf 重定向的完整流程下面以 STM32CubeMX CLion 为例走一遍完整流程。第一步STM32CubeMX 里使能一个串口比如 USART1配置好波特率、引脚生成代码。在 CLion 里打开工程确认 toolchain 是 arm-none-eabi-gcc。第二步在main.c的 user code 区或者单独建一个retarget.c文件加入上面的_write实现。注意要包含对应头文件#include stddef.h #include errno.h #include sys/unistd.h #include stm32f1xx_hal.h // 根据你的芯片型号调整第三步确认huart1这个句柄在_write里能访问到。因为huart1默认是定义在main.c里的全局变量你如果在另一个文件里写_write需要extern UART_HandleTypeDef huart1;或者从main.h引入。第四步编译下载打开串口助手看效果。如果一切正常串口助手应该能收到 printf 的内容。这是一个调试阶段非常典型的效果输出逐行打印字节肉眼可见地蹦出来。3.2 缓冲区与刷新策略的影响newlib 的 stdio 默认行为里stdout 如果是终端设备isatty 返回 1就是行缓冲否则是全缓冲。裸机环境下_isatty的实现如果没有特殊处理通常返回 0也就是默认全缓冲。全缓冲意味着比如你把 100 字节的数据攒在缓冲区里一直到缓冲区满了通常 1024 字节或者你调用fflush(stdout)它才会真正调用_write把数据送出去。避免这个坑有几种处理办法在 printf 末尾加\n。如果是行缓冲\n会触发 flush。调用setvbuf(stdout, NULL, _IONBF, 0)把 stdout 设为无缓冲。每个 printf 之后手动fflush(stdout)。在_write实现里直接让操作系统视为终端设备——修改_isatty返回 1。最省事的是修改_isatty告诉 newlib “我的 stdout 是终端设备”这样默认就是行缓冲遇到换行就会刷数据。这个方法在调试阶段特别好用。int _isatty(int fd) { if (fd STDOUT_FILENO || fd STDERR_FILENO) return 1; return 0; }3.3 浮点 printf 输出为空怎么解决这是 CLion 环境下的高频问题printf 打印整数正常一旦 printf(%f, 3.14) 就什么都不出。原因是 newlib-nano 为了裁剪体积默认关闭了浮点格式化支持。链接的时候需要加-u _printf_float选项强制把浮点格式化代码拉进来。在 CLion 里打开 CMakeLists.txt修改 target_link_optionsadd_executable(${PROJECT_NAME}.elf ${sources}) target_link_options(${PROJECT_NAME}.elf PRIVATE -Wl,--gc-sections,--no-warn-rwx-segments -u _printf_float -u _scanf_float )添加-u _printf_float后编译生成的固件体积会明显增加这是正常的为了打印浮点数就得付出这点空间代价。类似的还有%f的输入scanf需要-u _scanf_float不过调试打印一般只用到输出。3.4 中文输出乱码怎么办CLion 中文输出乱码是很多人迁移之后立刻撞上的第二个坑。这个问题的根源在于字符编码不一致C 源文件里保存的中文字符串是什么编码串口助手解析时用的又是什么编码两者对不上就乱码。比较省心的组合是源文件使用 UTF-8 编码保存。CLion 默认就是 UTF-8这个一般没问题。串口助手按 UTF-8 解析字符。单片机发送的原始字节就是 UTF-8 编码的字符串字节不经转换直接发出。如果你在 Keil 里用 GB2312 保存的源文件搬到 CLion 后显示会乱因为 CLion 默认按 UTF-8 打开。你需要先统一为 UTF-8再用串口助手按 UTF-8 看或者是反过来源文件保持 GBK串口助手按 GBK 看。乱码的本质就是编码不统一。还有一类乱码是波特率不对比如代码里 115200串口助手却开了 9600那肯定全是乱码。优先排查波特率再排查编码。3.5 STDIN、STDOUT、STDERR 的标准表达在 newlib 里文件描述符 0、1、2 有专门的宏STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO定义在sys/unistd.h里。写_write的时候用宏可读性更好。如果你希望不仅 printf 走串口scanf也从串口来还需要重写_readint _read(int fd, void *buf, size_t count) { if (fd STDIN_FILENO) { // 从串口接收一个字符存到 buf HAL_UART_Receive(huart1, (uint8_t *)buf, 1, HAL_MAX_DELAY); return 1; } errno EBADF; return -1; }这样就能实现“printf 输出到串口scanf 从串口输入”一台 PC 串口助手就能交互调程序手感接近控制台。3.6 实际工程中的 UART 句柄传递细节_write里用到huart1如果你把_write写在单独的文件里别忘extern。而且要注意一个隐晦问题如果工程里存在多个串口你的 printf 到底该送到哪个串口这没有统一答案取决于你的硬件接线。通常有三种做法硬编码一个UART_HandleTypeDef *debug_uart huart1;用一个全局指针变量初始化时指定。直接用stdio提供的 write 钩子在运行时切换目标串口。简单场景直接硬编码即可代码量少逻辑清晰。等你真遇到多个串口需求再设计成一个可切换的模块也不迟。4. 常见问题与排查技巧实录这里列几个我在 CLion 里做 printf 重定向时遇到并排查过的典型问题你可以直接对号入座。4.1 printf 完全没有输出可能性有多层按顺序排查串口硬件通不通用最简单的方式直接调用HAL_UART_Transmit发一段固定字符串看串口助手能不能收到。收不到就是硬件/初始化问题先解决这个基础问题。_write有没有被链接进来可以在_write里加个断点。如果 printf 执行但没进_write多半是缓冲区没刷。缓冲区问题检查是不是全缓冲把数据闷住了。临时调用fflush(stdout)验证。符号被优化掉链接选项是不是加了--gc-sections而_write没被引用到被垃圾回收了这个情况比较少见但确实可能。可以在链接选项里加-Wl,--undefined_write强制保留。4.2 只输出部分内容或者最后一段总是不出来这种情况常见于最后一批数据还在缓冲区里程序就复位/跑飞了。比如你在 main 函数最后打印 Exit但for(;;)之前没调用fflush(stdout)缓冲区没满数据一直没发出去看起来就是“最后一段打印消失了”。解决思路在关键路径加fflush(stdout)或者干脆把 stdout 设成无缓冲或者调试时确保每个 printf 都以\r\n结尾并且_isatty返回 1。这样每行都会触发 flush基本不会丢尾。4.3 在中断或 RTOS 任务里调用 printf 死机这是进阶问题。printf本身不是线程安全的newlib 虽然在某些配置下有重入设计但裸机上多个任务同时 printf还是可能造成互斥问题。加上 UART 发送如果又是阻塞等待中断里调用HAL_UART_Transmit延时等待很容易出问题。我的建议是中断里尽量别直接调 printf。攒到标志位在主循环里统一输出。如果实在要在 RTOS 里多任务打印给_write加一个互斥锁。如果打印数据量大改用 DMA 双缓冲_write里只负责拷贝数据、启动 DMA、等待完成标志。4.4 调试器显示 access violation 或程序跑飞热词里有 “write to location 0000000000000020 caused an access violation” 这种错误。这类问题通常与_write无关而是你往非法地址写了数据。比如未初始化串口句柄、huart1是空指针、或者 DMA 缓冲区越界。如果在加完_write之后出现这个错误重点检查huart1是否在MX_USART1_UART_Init()里正确初始化。是不是在初始化完成之前就调用了 printf。如果是你等于用了一个没初始化的外设句柄问题紧接着就来了。中断优先级配置是否合理UART 中断是否导致栈溢出。另外printf本身也会消耗栈空间特别是浮点打印启用后栈占用会明显增加。MCU 链接脚本里的栈设得过小某个深层任务里调一次 printf 就直接越界。排查办法是把栈加大一档试试。4.5 表格常见问题速查症状可能原因排查/解决办法printf 完全无输出_write 没实现缓冲区未刷串口初始化失败_write 加断点fflush验证 HAL_UART_Transmit 单独能发数据最后一段输出丢失stdout 全缓冲数据滞留未发送换行结尾 _isatty 返回 1显式 fflush整数正常小数不打印newlib-nano 默认关闭浮点格式化链接参数加-u _printf_float打印出来中文乱码源文件编码与串口助手解析编码不一致统一 UTF-8 与串口助手 UTF-8 模式核对波特率中断/多任务打印死机不可重入阻塞发送卡死中断里不打印加锁DMA 发送加 printf 后程序跑飞栈不足UART 句柄未初始化加大栈确认初始化顺序检查中断优先级4.6 一个容易被忽略的细节CRLFWindows 上的串口助手换行一般要用\r\n不然输出会整行叠在一起。嵌入式开发习惯在字符串里写\r\n但如果你源码又刚好是跨平台版本注意别只写\n。很多调试输出格式不齐就是因为\n被当成换行但缺少回车光标回到行首但没换行下一个字符直接覆盖了当前行。这个小问题看着不大排查起来闹心。我的习惯是统一在调试宏的末尾加\r\n。如果你用printf的封装函数比如#define DBG(fmt, ...) printf([DBG] fmt \r\n, ##__VA_ARGS__)这样每个日志自动带\r\n一劳永逸。5. 个人实操心得CLion 重定向的最终建议CLion ARM GCC 这套组合用熟了之后其实比 Keil 舒服很多关键在于理解它对标准 C 库的使用方式和 Keil 不一样。_write和fputc之争说到底是 newlib 与 microlib 的设计理念不同一个是 POSIX 风格的系统调用层抽象一个是为 MCU 量身定制的微库接口。我个人在实际操作中的体会是在 CLion 里做 printf 重定向最稳定的组合是实现_write覆盖 stdout 和 stderr。实现_isatty返回 1让 stdout 走行缓冲。在 CMakeLists.txt 里加上-u _printf_float避免浮点打印失灵。源文件统一 UTF-8串口助手按 UTF-8 解析。调试宏统一追加\r\n。照这套组合来基本不会踩到莫名其妙的问题。后期如果你需要从串口输入数据、实现 shell 交互再补一个_read整个调试体验会非常接近 Linux 终端。到那时候你不但解决了“为什么重写_write”的疑惑还能顺手搭出一个属于自己板子的交互环境。
返回列表