十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

CLion中printf重定向串口:为何应重写_write而非fputc?

CLion中printf重定向串口:为何应重写_write而非fputc? 1. 为什么在 CLion 里应该重写 _write 而不是 fputc写嵌入式 C 的人刚到 CLion 里折腾 printf 重定向时大多经历过一番搜索后对着两段代码发懵一段重写 fputc一段重写 _write都有教程声称“这样就能让 printf 走串口”。在 Keil 里用了很多年 fputc 的人到了 CLion 照抄结果要么编译不过要么板子跑起来串口一个字符都不吐。这篇文章就从工具链的底层差异入手把为什么在 CLion 里应该重写 _write、而不是 fputc 彻底讲清楚同时给出一份可以直接抄的 retarget.c并把重定向过程中常见的坑一并解决。1.1 两种写法背后的逻辑冲突先回到现象本身。你在搜索引擎里输入“CLion printf 串口重定向”会得到两类代码。第一类是 Keil 风格int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }第二类是 GCC/Newlib 风格int _write(int fd, const void *buf, size_t len) { HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 0xFFFF); return len; }表面上看只是函数名不同但背后是整套标准库实现路径的差异。Keil MDK 的 ARMCC/AC6 编译器在 MicroLib 模式下把 printf 内部的输出通道刻意收敛到了 fputc 这个“钩子”上你重写 fputcprintf 最终的字符就会从你指定的串口发出去。CLion 通常不用 ARMCC而是用 arm-none-eabi-gcc 这类交叉工具链配套的 C 库是新库Newlib或者 newlib-nano这两个库对底层字节输出的约定接口不是 fputc而是 _write。重定向的本质是去替换标准库调用链中最靠近硬件的那一环。如果你找错了环节代码能编译能下载但 printf 的数据根本到不了你的函数自然也就到不了串口。1.2 fputc 在 Newlib 里并不是必经之路很多人会问fputc 是 C 标准里定义的字符输出函数printf 最终难道不就应该调用它吗实际上标准只规定了 printf、putchar、fputc 这些函数的“外部行为”没有规定 printf 内部必须调用 fputc。在 Newlib 的实现里printf 初始化时会把 stdout 关联到一个FILE结构体这个结构体里保存了缓冲区和底层写回调。printf 首先把格式化后的内容写入缓冲区缓冲区满或者遇到换行时再通过__sfvwrite这类内部函数把数据批量写到系统调用层。它的结尾是 _write 系列函数而不是暴露给应用层的 fputc。换句话说fputc 是“标准库对外提供的接口”_write 是“标准库向操作系统要资源时使用的接口”。在裸机嵌入式环境里操作系统不存在所以取而代之的是开发者自己写的硬件驱动。Newlib 内部默认认为 _write 应该存在如果链接时找不到就直接报 undefined reference如果你强行只重写 fputc编译器根本不会去看它一眼因为库的实现里没有调用它。1.3 换工具链其实就是换“交接规矩”嵌入式领域经常有这种“换个编译器结果跑不通”的问题。根本原因不是代码逻辑错了而是不同工具链对“底层怎么写”的约定不一样。我们可以把 printf 想成快递公司你的串口硬件是收件人。Keil 的 MicroLib 是个“简化快递点”它要求你必须把包裹送到 fputc 这个固定柜台GCC Newlib 则是另一套系统它要求你把包裹送到 _write 这个转运站。你如果站在 fputc 柜台等发现包裹永远不到因为快递员走的是 _write 那条路。这也是为什么你在 CLion 里照抄 Keil 的 fputc 会有一种“看起来没问题实际完全没反应”的脱力感。不是 fputc 本身写错了而是你守着错误的路口。1.4 什么时候 fputc 又能用了我也见过一些 CLion 工程里重写 fputc 确实能跑通的情况。这多半是因为工程里用了特定的库配置、优化等级或者某个版本的 Newlib 实现它内部恰好有一条路径经过 fputc。比如当你显式调用fputc(a, stdout)时自然就会进入你写的 fputc又比如某些 stdio 实现里putchar会映射到fputc。但 printf 这种走缓冲的格式化输出没有保证路径。你用一个库版本测试成功换个新版本、换个--specsnano.specs参数可能就失效。真正稳定的做法是找到所有输出路径最后汇合的那个公共底层出口在裸机 GCC 工具链里这个出口就是 _write。2. printf 的输出调用链从 printf 到串口中间发生了什么要把这问题讲透光说“重写 _write”还不够得把 printf 从收到格式化参数到字节真正落到 UART 寄存器的完整链路理一遍。2.1 Newlib 里 printf 的实际行走路线当你调用printf(hello\r\n)时Newlib 内部大致做了这些事printf解析格式字符串把可变参数转换成字符串字节流。字节流先写入 stdout 对应的FILE结构体缓冲区。当缓冲区写满、遇到换行、或者程序主动fflush时缓冲区内容被交给底层写函数。底层写函数最终调用_write_r或_write把内存里的字节数组送往“操作系统”。在裸机上没有操作系统_write 指向的地址就是你自己的串口发送函数。如果断点打在 fputc 上前面两步就可能已经绕开了这个函数。如果断点打在 _write 上则第 4 步一定会命中因为它是整条链路里最后一个能由应用层接管的路口。2.2 _write 和 _write_r 的细节区别你翻 Newlib 头文件时可能还会遇到_write_r这个东西它是带 reent 参数的重入版本原型类似int _write_r(void *reent, int fd, const void *buf, size_t len);reent 参数是 Newlib 为了多线程安全引入的线程状态结构体。在裸机单线程场景下我们通常不需要区分多个线程上下文所以很多工程里直接实现_write并在链接时让它被解析到。不同版本的 GCC 工具链对这两个符号的依赖不一样有的要求必须实现_write_r有的实现了_write就能通过。最稳妥的做法是参考你本机工具链的 syscall 模板或者直接把两个都写了。不过大多数 STM32 工程里_write这一个函数就能解决因为它已经是 Newlib 简化重定向的约定接口。2.3 fd 参数到底是什么意思_write的第一个参数是文件描述符 file descriptor。在桌面 Linux 里fd 0 是标准输入1 是标准输出2 是标准错误。在裸机环境下没有真正的文件系统但库内部仍会按这套约定传入printf 输出时通常 fd 1fprintf(stderr, ...) 时 fd 2。所以严谨的 _write 实现会判断 fdint _write(int fd, const void *buf, size_t len) { if (fd 1 || fd 2) { // 重定向到串口 } return len; }实际开发中很多人把 fd 的检查省略掉直接全部发到串口这在单串口裸机调试里也能接受但如果你的硬件上有 LCD、网络等其它输出通道建议还是按 fd 分流。2.4 如果缺了 _write到底会发生什么你不实现 _write却用了 printf典型结果是链接报错undefined reference to _write因为 Newlib 里 printf 格式化完成后的最终发送符号没有被解析链接器找不到目标地址。和 _write 一起经常出现的还有_sbrk、_read、_exit、_close、_fstat、_isatty、_lseek等一串系统调用符号统称为 syscall 或 retarget 层。CLion 的嵌入式工程如果直接使用官方的工具链不加额外处理链接时通常会出现这些 undefined reference。很多人会用--specsnosys.specs参数来绕过这个参数会把一组空的系统调用函数编译进去让链接通过。但它有个坏处_write 变成了空函数printf 的输出直接被丢弃板子跑起来静悄悄的极容易误导新人。2.5 真正该做的事是“重定向”而不是“回避”把链接参数从 nosys 换成 nano.specs再自己实现 _write这是我在 CLion 工程里推荐的标准姿势。它的好处是你需要哪些系统调用就自己实现哪些printf、puts、fprintf 全部会走到 _write不再有例外路径。3. 实操在 CLion 里写一份能跑的 retarget.c理论部分到这里下面进入可以直接抄作业的环节。我以 STM32 HAL 库为例子但代码逻辑同样适用于其它 Cortex-M 芯片。3.1 准备一个不依赖 IDE 的嵌入式 CMake 工程在 CLion 里新建嵌入式工程时先确保 CMake 工具链指向你的交叉编译器比如arm-none-eabi-gcc arm-none-eabi-g arm-none-eabi-objcopy关键链接参数里要有add_compile_options(--specsnano.specs) add_link_options(--specsnano.specs)需要强调一下nano.specs会让标准库进入精简模式很多调试函数体积更小但 printf 的浮点格式化默认不启用。这个后面在常见问题里专门展开。链接脚本要用对应芯片的.ld文件并且启动文件里要定义好堆栈符号。3.2 一份可以直接套用的 retarget.c新建一个 retarget.c把下面代码放进去按照你自己的串口句柄修改huart1即可。#include stdint.h #include stddef.h #include errno.h #include sys/unistd.h #include main.h extern UART_HandleTypeDef huart1; int _write(int fd, const void *buf, size_t len) { const uint8_t *p (const uint8_t *)buf; size_t i; (void)fd; for (i 0; i len; i) { while (!(__HAL_UART_GET_FLAG(huart1, UART_FLAG_TXE))) { // 等待发送数据寄存器空 } huart1.Instance-TDR p[i]; } // 等待最后一字节发送完成避免后续数据覆盖 while (!(__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC))) { } return (int)len; } int _read(int fd, void *buf, size_t len) { uint8_t *p (uint8_t *)buf; size_t i 0; (void)fd; for (; i len; i) { while (!(__HAL_UART_GET_FLAG(huart1, UART_FLAG_RXNE))) { // 等待接收数据寄存器非空 } p[i] (uint8_t)(huart1.Instance-RDR 0xFFu); } return (int)i; } caddr_t _sbrk(int incr) { extern char end asm(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; }注意几个细节。第一_write的buf参数类型是const void *不要随手写成char *否则编译告警一堆。Newlib 的声明里通常喂进来的是一个字节数组printf 格式化后的所有内容都在这个 buffer 里。第二TDR和RDR寄存器是 STM32 某些系列如 F4、G0、L4的叫法更老一些的系列叫DR。如果你用的芯片没有这两个寄存器名直接换成huart1.Instance-DR p[i]和huart1.Instance-DR即可。第三_read里的循环会一直等到接收寄存器非空如果串口一直不来数据程序会死等在这里。这在调试阶段没问题但在产品上会拖住 CPU。更完善的写法是加超时判断比如基于 SysTick 的计数。3.3 HAL_UART_Transmit 能不能直接放在 _write 里很多教程偷懒直接在 _write 里调用 HAL 封装好的阻塞发送函数HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 0xFFFF);这样写最简单短消息测试完全没问题。但它有两个隐患一是HAL_UART_Transmit内部循环等待标志时同样可能死锁二是它默认最后会等待 TC 标志这个标志在某些芯片上如果串口没接外设或者波特率异常会一直置不起来。所以我更推荐直接操作寄存器层级的实现代码量不多可控性更强。如果你不想折腾寄存器可以在 _write 里做一个折中for (size_t i 0; i len; i 1) { HAL_UART_Transmit(huart1, (uint8_t *)((uint8_t *)buf)[i], 1, 100); }但这种方式效率低每个字节都要进出一次 HAL 层对于大规模日志输出不推荐。3.4 _sbrk 到底管什么用_sbrk 是 Newlib 里 malloc 函数族的底层依赖。如果你的代码里有用到 malloc、calloc、free或者间接用到一些依赖堆的标准库函数链接时就需要 _sbrk 存在。它的核心逻辑是“从堆区末尾分配内存”。上面的实现用一个静态指针维护当前堆的末尾每次调用 _sbrk 时把指针向后移动incr字节并返回旧地址。如果 _sbrk 里的堆顶指针越过了栈区应该返回(caddr_t)-1并设置errno ENOMEM。不过裸机工程里很多人不检查直接返回旧指针。这样做调试期可能没事但随着字符串拼接、格式化输出增多堆用尽后 malloc 会返回 NULL后续代码如果不做空指针判断轻则日志异常重则直接进入 HardFault。这也是我在常见问题里会把“访问违规”和堆栈关联起来的原因。3.5 在 CLion 里验证 _write 是否真的生效写完代码后按下面步骤验证能省掉很多猜测先用串口助手或者逻辑分析仪确认 UART 硬件初始化正确波特率、引脚、时钟都没问题。此时可以先不跑 printf直接用HAL_UART_Transmit发一个固定字节。编译并下载到开发板在 retarget.c 的_write函数第一行打断点。在 main 里调用printf(test\r\n)全速运行观察断点是否命中。断点命中后再查看串口助手确认数据内容正确。如果断点没命中多半是 retarget.c 没参与编译或者链接时用了 nosys.specs 把空的 _write 符号先占了。还可以用命令检查符号表arm-none-eabi-nm build/你的工程.elf | grep _write如果看到T _write说明实现已经进到最终镜像里如果没有说明链接有问题。4. 高频踩坑CLion printf 串口输出的现场实录这块内容是我在多个工程里反复踩过、也帮别人排查过多次之后整理出来的。按“现象、原因、方向”的速查方式来看比较直观。4.1 八类典型现象排查表现象大概率原因排查方向串口完全无输出retarget.c 没编译_write 没被调用UART 初始化失败检查断点命中、符号表、引脚时钟printf 输出一次后卡死_write 里的 TXE/TC 等待不满足串口线路断开检查波特率、串口线、开漏配置链接报 undefined reference to_write缺少 retarget 实现或 nosys.specs 没有兜底补 retarget.c链接报 undefined reference to_sbrk代码用了 malloc 相关功能补 _sbrk中文乱码源文件编码与串口助手编码不一致波特率不一致确认 UTF-8 与串口助手设置的编码浮点格式化显示异常newlib-nano 默认不启用 printf 浮点链接参数加 -u _printf_float程序跑飞进入 HardFault堆栈区重叠、指针越界、句柄为空检查链接脚本堆栈、_sbrk 返回、HAL 初始化顺序调试器提示某个 write 操作引发访问违规空指针、句柄未初始化、硬件外设无效回溯调用栈检查指针有效性4.2 中文乱码到底是怎么回事中文乱码在 CLion 里几乎是必经之路但真正原因往往不是代码而是编码不一致。CLion 环境下源代码默认 UTF-8你写的printf(你好)在内存里是一串 UTF-8 多字节序列。如果串口助手的解码方式是 GBK显示出来自然是乱码。解决办法有两个一是把串口助手的编码改为 UTF-8二是写字符串时直接用\x转义或者用英文调试信息。还有一个容易被忽略的地方某些精简版 Newlib 对单个字节的处理没问题但遇到多字节中文时你需要确保_write返回的字节数和传给它的len一致。如果中间有某个字节因为 UART 发送时序问题被丢弃比如上一条数据还没发完就开始发下一条整个字符串后续内容都会错位看起来就像乱码。排查顺序建议是先用固定的1234英文字符串测试确认链路正常再把字体切成 UTF-8 重新测试最后再考虑是不是缓冲区或发送时序问题。4.3 浮点格式化问题的根源用--specsnano.specs之后printf 的浮点格式化默认是关闭的目的是减小代码体积。你写printf(value%f\r\n, 1.23f)可能输出value0.00或者直接什么都没有。解决办法是在链接选项里加上一个库符号拉入参数set(CMAKE_EXE_LINKER_FLAGS ${CMAKE_EXE_LINKER_FLAGS} -u _printf_float)这个做法是让链接器必须把_printf_float相关代码拉进来。代价是代码体积会增加 2KB 到 5KB但换来的是完整的浮点打印能力调试期完全值得。同样的如果要用 scanf 读浮点需要-u _scanf_float。4.4 “write to location 0000000000000020 caused an access violation”这类报错这个提示字面上很像 _write 函数出了问题但实际它是调试器报告的一条异常信息意思是程序在写某个地址时触发访问违规异常地址常常是 0x0 附近或者极小的地址值。在重定向场景里最典型的原因是 UART 句柄没有初始化。比如你在 retarget.c 里直接写了HAL_UART_Transmit(huart1, ...)但 main 函数里还没调用MX_USART1_UART_Init()huart1 里的底层句柄指针可能是随机值进去调用某一层时就会触发非法访问。另一种情况是 _sbrk 返回了不合理的地址malloc 内部继续写导致堆区碰撞栈区程序最终崩溃。处理思路是先检查时钟和串口初始化顺序再检查堆和栈的地址范围。CLion 的调试器里可以打开 Memory 窗口分别看_estack、_end、链接脚本里的堆栈区域确保_sbrk的堆顶没有超过栈底。4.5 “bad file descriptor” / write failure on stdout 这类提醒有些读者在 CLion 里写的是主机端程序不是嵌入式交叉编译却在控制台看到类似fatal: write failure on stdout: bad file descriptor这种情况一般发生在 JNI 工程或者本地命令行工具里stdout 被某种程序或者调试插件重定向到了无效管道。它和裸机上重写 _write 没有直接关系但容易让人混淆。遇到时先区分一点你是跑在开发板上还是跑在 PC 上。PC 端的问题请检查 stdout 的句柄和运行时环境不要也去纠结 _write。4.6 printf 被优化没了怎么定位调试时还有个反直觉的场景你在 main 里写了printf(hello)但打断在 _write 上死活不命中。原因可能是编译器把 printf 直接优化成了 puts或者因为你的printf结果没有被调用方使用整个语句被优化掉了。排查方法很简单一是在 main 里给一个全局变量赋值然后在 printf 后面读取它防止优化二是把优化等级改成-O0三是用volatile修饰相关变量。也可以换个思路直接在 _write 入口处写一个static volatile int write_count 0; write_count;每次发送时把它递增调试时看这个值的变化比打断点更省心。4.7 _read 死等导致程序卡住很多人重定向完 _write发现整个程序跑起来后过一段时间就卡住点暂停一看卡在 _read 的 while 循环里。原因通常是代码里用了scanf、getchar这类输入函数程序阻塞在等待串口数据上。这在调试时很容易被误判为死机。解决方式有三种一是调试期不用 scanf所有输入统一用逻辑变量模拟二是给 _read 加超时等不到数据就直接返回 0三是只读取准备好的数据没有数据就返回-1并设置errno EAGAIN采用非阻塞模式。4.8 RTOS 和中断场景下的 _write 注意事项如果工程里引入了 FreeRTOS 或者其它 RTOS多任务同时调用 printf 时会有一个经典问题两个任务的字符串交织在一起。原因是 _write 内部不是一个原子操作。两个任务可能在for (i 0; i len; i)中间被调度器切走导致数据穿插。解决办法是在 _write 里加临界区比如用taskENTER_CRITICAL()/taskEXIT_CRITICAL()包裹整个发送循环或者用互斥量。简单的裸机循环里没必要加但一旦上系统这个坑几乎必然出现。5. 后记一些实用的工程习惯重定向这个功能看起来只是几行代码但牵扯到编译器、标准库、链接脚本、串口驱动多个环节任何一个地方对不上printf 都出不来。我在自己的项目里现在已经习惯把 retarget.c 做成独立文件每个新工程直接复制进去改一下串口句柄和寄存器名就能用。文件里包含 _write、_read、_sbrk再配合 CMake 里的-u _printf_float基本一劳永逸。还有一个亲测有效的小技巧新工程第一次调 printf 时不要一上来就打印复杂格式先用最简单的一行printf(OK\r\n)跑通链路。如果这行都出不来问题大概率在检查链接和串口初始化如果这行能出来再逐步加中文、浮点、rtos、长字符串这样可以把问题一层层隔离开排查速度快很多。CLion 的调试体验确实比传统 IDE 更顺配置窗口也直观但工具链底层的这些细节不会因为 IDE 更现代就自动避开。希望这篇记录能帮你少踩几个坑后面再碰到 fputc 和 _write 之争时心里能清楚它们到底在争什么。
返回列表