ARTICLE DETAIL

资讯详情

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

CLion STM32 printf串口调试:重写_write而非fputc

CLion STM32 printf串口调试:重写_write而非fputc 作家在 CLIion 里点开一个 STM32 工程想用 printf 往串口丢几行调试信息搜了一圈教程看到的大多数答案都是“重定义 fputc”于是照着在代码里写了个int fputc(int ch, FILE *f)烧进去打开串口屏幕一片空白。断点打在 fputc 里压根没进去。再看编译日志链接的是 arm-none-eabi-gcc 的 newlib-nano这时候才意识到在 CLion 这套工具链下printf 的底层出口根本不是 fputc而是_write。这个问题在中文社区里被反复问过很多遍但大多数回答都停留在“把 fputc 换成 _write 就能用了”这个层面没人讲清楚为什么换。这篇文章就把整条链路从标准库分层、符号绑定到 newlib 实现细节一次说透顺便把我自己踩过的缓冲、半主机、HardFault 这几个坑也一并记录下来给在 CLion 里做嵌入式开发的朋友省点时间。1. 先理清调用链printf 到 UART 到底走了多远1.1 标准库的三层结构C 标准库的 I/O 并不是“printf 直接操作硬件”而是分了三层。最上面是格式化层printf、fprintf、sprintf都在这层负责把格式化参数变成字符流。中间是流层由fputc、fwrite、fputs这些函数构成负责把字符写入到FILE结构体对应的缓冲区里。最底下是系统调用层也就是_read、_write、_close、_lseek这一组带下划线的底层函数它们才是真正和文件描述符、硬件驱动打交道的地方。[\text{printf} \rightarrow \text{stdout 缓冲区} \rightarrow \text{fputc / fwrite} \rightarrow _write \rightarrow \text{UART 驱动}]注意中间这层fputc并不是终点。它做的事情是把一个字符放进stdout的缓冲区缓冲区满了或者收到 flush 指令才会调用底层的_write把整块数据交出去。这个设计在桌面系统里很合理因为底层是操作系统_write就是那个write(int fd, const void *buf, size_t count)系统调用一次能处理一大块数据。1.2 嵌入式环境里的“系统调用层”是空壳问题在于裸机环境下没有操作系统ARM GCC 工具链默认链接的 newlib 库它的_write实现是空的或者更准确地说是指向半主机semihosting机制的。所谓半主机就是 ARM 架构里设计的一套调试辅助协议当目标板上电运行、没有操作系统可以提供服务时如果代码里调用了_write处理器会执行一条 BKPT 指令把控制权交给调试器由调试器在主机端完成文件的读写操作。这就是为什么许多人第一次在 CLion 里配置好 OpenOCD GDB 后会发现 printf 的内容居然能出现在 GDB 的控制台里——你的程序根本没访问 UART 外设而是通过半主机协议把数据交给了调试器打印。看起来“能用”实际上跟串口没有任何关系。强调一下这个调用链里的 fputc 是标准库自己的函数它不是为某个具体硬件准备的它内部拿着 stdout 的 FILE 指针做缓冲管理。你要改的应该是它底下那层真正被 newlib 认为是“硬件无关抽象层”的_write。1.3 三个主流工具链的重定向差异很多朋友是从 Keil 或者 STM32CubeIDE 转到 CLion 的这两个环境里的坑位跟 CLion 不太一样。下面这张表可以直观看出差别工具链标准库实现真正需要重写的函数原因Keil MDK (ARMCC/AC6)MDK 自带的 microlib / 标准 C 库fputc或__stdoutARMCC 的 stdio 库把 UART 重定向的钩子放在fputcIAR EWARMIAR 自带的 DLIB__write注意是双下划线开头DLIB 的底层输出接口是__write(int handle, const unsigned char *buf, int size)CLion ARM GCCnewlib / newlib-nano_write单下划线开头newlib 的底层系统调用接口是 POSIX 风格的_write(int file, char *ptr, int len)STM32CubeIDE (GCC)newlib-nano_write同样是 ARM GCC 工具链本质和 CLion 完全一致所以并不是 fputc 这个方法本身错了而是它只在 Keil 那套工具链里有效。到了 CLion ARM GCC 这套组合下你重写的 fputc 根本不会被调用得顺着 newlib 的设计思路找到它的系统调用层。2. CLion 下 ARM GCC 的 I/O 重定向入口fputc 为什么失灵2.1 newlib 与 newlib-nano 里的 fputc 到底是谁的先看一个反直觉的事实在 newlib 里fputc并不是一个“可以让你随意覆盖”的弱符号。它定义在 libc.a 里面是强符号。你现在用 GCC 链接一个工程代码里写了一份自己的int fputc(int ch, FILE *f)链接器并不会因为“你写了同名函数”就优先用你的反而有可能报出 multiple definition 的错误或者在库函数内部仍然调用库自己的实现。我之前在 CLion 里做过一个验证重写 fputc编译通过但加入-Wl,--print-map看映射文件发现fputc最终指向的还是 libc_nano.a 里的那一个。也就是说你的 fputc 被链接器丢弃了printf 走的仍然是库自带的 fputc而那个 fputc 最终又会找到 newlib 内部的_write半主机实现。这个设计其实可以理解newlib 的流层面向的是多种平台fputc 是实现细节而底层_write才是“平台相关层”是专门留给开发者替换的接口。嵌入式移植手册里写的也是改这一层不是让你去动流函数。2.2 用符号表验证 fputc 没有生效光说不练没用教大家两个命令在 CLion 里快速验证# 编译你的工程后假设目标文件叫 main.o arm-none-eabi-nm main.o | grep -i fputc\|_write # 查看最终可执行文件里的符号绑定 arm-none-eabi-nm firmware.elf | grep -i fputc\|_write第一次 VM 的时候我遇到过这样的情况main.o里明明有 fputs 和 fputc 的内容但最终 firmware.elf 里 fputc 的地址指向了 0x00000000 或者 libc_nano.a 的某个地址——这就是没接上。为了更精确地看是哪个库提供的用arm-none-eabi-nm -A firmware.elf可以显示出符号来自哪个对象文件。在我自己的工程里最终生效的实际情况是这样firmware.elf: 00008010 T _write - 这是我重写的 00008120 T fputc - 来自 libc_nano.a: _sputc.o 之类这时看 fputc 的汇编你会发现在_sputc后面它调用的正是_write。所以关键就是让那个_write指向你自己的实现。2.3 半主机模式的残留问题除了符号问题还有一个隐藏更深的问题——半主机模式的残留。ARM GCC 的默认链接脚本和启动文件里通常带着syscalls.c或者cmsis的system_stm32xxx.c这些文件里已经定义了一整套_sbrk、_write、_read的弱符号实现。注意这些实现默认是走半主机的。你的新_write只要写出来就不存在什么“覆盖”的魔法——它只是让链接器找到了一个比弱符号更强的强符号仅此而已。如果你是从 STM32CubeMX 生成的工程CMSIS 里自带的syscalls.c里_write直接就是BKPT 0xAB指令。如果没被替换掉你的 AC 程序一调用 printf处理器就停在断点上表现可能是“程序卡住”“跑飞”“直接 HardFault”唯独不是串口正常输出。3. 两种重定向写法的对比与选型3.1 fputc 写法在什么条件下是有效的不能一棍子打死 fputc在上面那张表里Keil 环境下重写 fputc 确实是对的。但即便在 Keil 下也有必要理解它的前提ARMCC 的 RTL(运行时库) 在 RETARGET.C 里把 stdio 的底层钩子预设为fputc它把“面向用户重定向”的入口露在了流层所以你在 Keil 里重写 fputc 实际上是在替换它预留的钩子。再补充一种情况在某些 RTOS 环境下比如 RT-Thread 的 FinSH 组件它也会通过重映射 fputc 或使用自身的rt_console_write做输出这又是另一套体系。因此“该重写谁”永远取决于“用谁的标准库、以什么方式接入硬件”。3.2 _write 为什么是 ARM GCC 场景下的正解CLion 里搭配 ARM GCC 工具链标准库是 newlib 或 newlib-nano。newlib 的底层 I/O 接口是固定的一套 POSIX 风格函数_write的签名长这样int _write(int file, char *ptr, int len)参数的含义很直接file是文件描述符stdin/stdout/stderr 分别是 0/1/2ptr是缓冲区的指针len是希望写入的字节数。你要做的事情就是把ptr指向的len个字节逐个或者批量发给 UART最后返回实际发送的字节数。这就是《嵌入式C编程》和早期 STM32 社区里“移植 printf”的标准方法。它的优势在于承接了 stdout 缓冲区的整块数据不是逐字符通知硬件返回值能准确反映发送状态newlib 内部可以据此处理错误不会出现“printf 认为发完了但 UART 根本没发完”的错位不碰流层不会跟标准库内部的缓冲机制打架下面给一个在 STM32F4 上的最小实现寄存器操作版不依赖 HAL#include unistd.h #include errno.h #define USART1_BASE 0x40011000UL #define USART1_SR (*(volatile uint32_t *)(USART1_BASE 0x00)) #define USART1_DR (*(volatile uint32_t *)(USART1_BASE 0x04)) #define USART_SR_TXE (1 7) int _write(int file, char *ptr, int len) { if (file ! 1 file ! 2) { errno EBADF; return -1; } for (int i 0; i len; i) { while ((USART1_SR USART_SR_TXE) 0) { // 等待发送数据寄存器为空 } USART1_DR (uint8_t)ptr[i]; } return len; }要用 HAL 库就把内部换成HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY)。注意 HAL 版本不能一次循环只发一个字节也可以直接整段发因为HAL_UART_Transmit本身就是阻塞发送一整块。3.3 返回值和 errno 的细节看网上的代码很多人_write里直接for循环发完就return len这没问题。但有些细节值得注意如果len 0不要进入循环直接返回 0。个别 UART 驱动在len0时会报参数错误。如果发送过程中出现硬件错误比如 UART 没初始化就调用 printf可以通过errno EIO返回-1告诉上层出错否则 newlib 会以为数据已经送出去了。file不要只看是不是 1stdout有时候用户会往 stderr2也输出可以干脆不区分统一往同一个串口丢。这些细节平时不会爆雷但一旦你用fprintf(stderr, ...)打印错误信息时发现没输出回来检查这里就能省一整晚的调试时间。4. 缓冲问题printf 不输出、乱码、段错误的真凶4.1 stdout 的缓冲策略与“最后一块数据丢失”_newlib 里 stdout 默认是全缓冲fully buffered也就是说printf 的内容先攒在缓冲区里攒满一个块通常是 1024 或 4096 字节才触发一次_write。这在桌面系统下没问题因为程序结束时会有 flush但在嵌入式里程序一般不退出于是非常常见的情况是printf 打了几行字串口一个字节都没收到因为数据全压在缓冲区里没到触发条件。最常见的表现就是程序跑着跑着串口突然吐出一大段日志——这不是“慢”是缓冲区一下子满了。更诡异的是如果程序跑飞或硬件复位了缓冲区全部丢失最后几行日志永远消失。这也是很多人误以为 _write 重定向失败的原因。4.2 无缓冲与 setvbuf 的正确使用方式解决方案无非两种方案一干脆不要缓冲在初始化阶段写一句setvbuf(stdout, NULL, _IONBF, 0);_IONBF表示无缓冲每次printf直接触发_write。这适合调试阶段实时性强但代价是频繁调用_write发送短字符串时效率降低。对嵌入式调试来说这点开销完全可接受。方案二保留缓冲但及时 flush在关键日志后手动调用fflush(stdout)printf(boot done\r\n); fflush(stdout);4.3 缓冲区与段错误的关系缓冲区还能引发段错误遇到过。如果写了这样的代码char buf[8]; snprintf(buf, sizeof(buf), hello, world!);缓冲区溢出破坏了相邻的变量。如果被破坏的正好是FILE结构体相关的内容后续调用 fputc 或 _write 时函数从stdout结构体里读出垃圾指针往那个地址写数据直接 HardFault。这种问题在普通 PC 上可能不崩溃但在嵌入式裸机上格外容易挂。调试时看到“write to location 0x00000020 caused an access violation”这类错误先检查是不是哪里的字符串越界了。剩下一个常见的坑来自printf的浮点支持。newlib-nano 为了体积默认不启用浮点 printf如果你直接printf(%f, 1.5f)结果可能打印出 r xor 之类莫名其妙的字符或者直接不输出。这是另一个话题但和 printf 调试常常纠缠不清提前知道不至于走弯路。5. CLion 工程里的完整改造流程5.1 工具链配置确认在 CLion 里新建或导入一个 STM32 工程后第一件事先确认 CMakeLists.txt 里的链接选项。重点看这几个关键点# 使用 newlib-nano 精简版 C 库 add_link_options(-specsnano.specs) # 不使用半主机模式关闭 rdimon add_link_options(-specsnosys.specs) # 或者如果你的链接脚本里已经有了完整的 syscalls 实现可以不加 nosysnano.specs决定用 newlib-nanonosys.specs的意思是不提供半主机的系统调用实现也就是让_write等变成纯空壳等待用户自己实现。有些教程会写成rdimon.specs那是明确启用半主机模式的而我们要的是纯裸机 UART 输出所以不要用 rdimon。另外还要确认启动文件里有_sbrk的实现否则 malloc 相关的代码会链接失败。CubeMX 生成的工程自带syscalls.c里面通常有完整的_sbrk、_write等内容需要把它的_write部分改掉或者干脆直接删掉syscalls.c自己写一份干净的。5.2 逐文件改造步骤第一步删除或屏蔽 syscalls.c 里的 _writeCubeMX 生成的syscalls.c里_write大都是这个形状int _write(int file, char *ptr, int len) { /* 这里往往是空实现或者半主机 BKPT */ return len; }原则上你把函数体替换成自己的 UART 发送代码就行。如果这个文件本身比较乱我习惯直接新建一个retarget.c把需要的底层函数全部集中放进去然后在syscalls.c里把重复的_write改成空或者从编译中排除。第二步写 retarget.c一个典型的 CLion STM32 工程里retarget.c 至少包含这些函数#include unistd.h #include errno.h #include stdio.h /* 如果不使用半主机_sbrk 也要在这里实现 */ void *_sbrk(ptrdiff_t incr) { extern char _ebss; static char *heap_end _ebss; char *prev heap_end; heap_end incr; return prev; } 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; }_sbrk的_ebss是链接脚本里定义的符号代表 bss 段结束地址。如果你的启动文件和链接脚本没定义它也可以用end符号newlib 的堆顶或者链接脚本里自定义一个__HeapBase之类的符号。第三步修改 _write按第三章给的写法把函数体换成目标芯片对应的串口寄存器操作。这里提醒一下不同的 STM32 系列UART 寄存器地址和位定义不一样。F1 系列是SRDRF4 是SRDRF7/H7 改成了ISRTDR千万别拿 F1 的代码往 F4 或者 H7 上直接抄。第四步配置链接器在 CMakeLists.txt 或者 IDE 的链接器设置里确认以下选项-specsnano.specs -specsnosys.specs -Wl,--undefined_write--undefined_write是防止链接器把没人用的_write做垃圾回收GC优化掉。如果用了-ffunction-sections -Wl,--gc-sections未引用的函数会被剔除如果_write没有被标准库的某个路径引用到它就可能被优化删掉。5.3 验证重定向是否真正生效改完之后别急着烧录用三个方法逐级确认方法一查看 map 文件在 CMakeLists.txt 里加一行target_link_options(${PROJECT_NAME} PRIVATE -Wl,-Map${CMAKE_BINARY_DIR}/firmware.map)编译完成后打开 map 文件搜索_write。如果它指向retarget.o说明你的实现被链接进去了如果指向 libc_nano.a 或 syscalls.o说明覆盖失败。方法二设置断点用 CLion 内置的 GDB 调试功能在_write函数第一行下断点。运行后如果 printf 有输出断点必然命中。注意要先把缓冲区设成无缓冲否则断点命中时机可能延迟。方法三串口回环把串口的 TX 短接到自己的 RX或者用 USB 转 TTL 模块连回电脑直接看串口助手的输出。如果打开串口时复位开发板第一行日志应该立刻出现。6. 踩坑实录从“串口没输出”到“进 HardFault”的完整排查链路6.1 第一次遇到的诡异现象我最早在 CLion 里调 printf 时情况是这样的烧录后程序能跑LED 在闪但串口没有任何输出。当时我重写的是 fputc还加了断点根本没触发。然后我在 fputc 里面手动调用 HAL_UART_Transmit 直接发送“test”发现这个函数自己执行了说明串口本身没问题。结论开始清晰printf 根本没有走我写的 fputc。6.2 查找标准库的实现路径那段时间我把目标锁定在 newlib 的内部实现上。用arm-none-eabi-nm查了编译出来的 ELFarm-none-eabi-nm --print-size --size-sort firmware.elf | grep -i fputc\|_write\|printf结果00008120 t _fputc_r 00008130 T fputc 00008a00 T _write_write的地址在 0x8a00不是自己写的地址区域。再看反汇编arm-none-eabi-objdump -d firmware.elf | grep -A30 _write:果然里面有一句bkpt 0xab。这就是 semihosting 的实现。6.3 根因确认syscalls.c 里的半主机残留打开 CubeMX 生成的 syscalls.c在_write里看到的正是对_sys_write的调用或者是直接BKPT 0xAB。之前之所以“看起来一切正常编译通过”是因为链接器找到了这个弱符号它不需要你做任何事情。但它实际上是在想跟调试器说话不是跟 UART 说话。我把 syscalls.c 里的所有函数体清掉替换成自己那一套 retarget.c重新编译串口恢复输出。6.4 第二个坑缓冲区造成的“假死”fputc 问题解决后又遇到了新情况程序启动后前几行日志没收到过了好久突然一下子全出来了。用逻辑分析仪看波形发现 UART 的 TX 引脚在开机后大约 1 秒都没有电平变化——就是缓冲区在攒数据。于是我老老实实加上了setvbuf(stdout, NULL, _IONBF, 0);再测试日志逐行即时输出。如果你不想全工程无缓冲只在调试阶段打开可以安排一个条件编译#ifdef DEBUG setvbuf(stdout, NULL, _IONBF, 0); #endif6.5 第三个坑访问违例与 HardFault有一次我把 retarget.c 里的_sbrk写错了堆指针跑飞然后 printf 一执行malloc内部操作了非法地址直接报了 “write to location 0x00000020 caused an access violation”。这跟 _write 本身没关系但症状让人误以为还是重定向的问题。这种 case 的排查方法很有套路在 HardFault_Handler 里断下来看LR、PC和CFSR可配置故障状态寄存器的值用 GDB 的info registers查看现场。如果 PC 指向了 malloc 或者 free 内部基本可以断定是堆区被踩或者堆大小不够如果 PC 指向自己的函数就查数组越界和野指针。C 库函数在使用时还会牵扯到_sbrk提供堆空间。启动文件里堆配置太小比如只有 1KBprintf 的浮点转换或者某些格式化操作一旦需要临时堆内存就会立即爆掉。遇到动不动 HardFault检查一下链接脚本里_Min_Heap_Size直接调到 8KB 甚至 16KB 先试试往往立竿见影。附我的日常工程模板最后把我在 CLion 工程里经常用的 retarget.c 核心部分贴出来供参考。#include unistd.h #include errno.h #include stdio.h /* 华单片机头文件按需包含 */ #include main.h extern UART_HandleTypeDef huart1; int _write(int file, char *ptr, int len) { if (file STDOUT_FILENO || file STDERR_FILENO) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; } errno EBADF; return -1; } int _read(int file, char *ptr, int len) { if (file STDIN_FILENO) { /* 有需要可以在这里实现串口接收 */ return 0; } errno EBADF; return -1; }初始化代码里别忘了设置无缓冲int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); setvbuf(stdout, NULL, _IONBF, 0); printf(system boot\r\n); while (1) { printf(tick %lu\r\n, HAL_GetTick()); HAL_Delay(1000); } }这套模板我在 F103、F407、H743 上都跑过直接换对应的 UART 句柄和初始化即可。调底层 I/O 重定位关键不是死记fputc还是_write而是先搞清楚自己用的工具链和 C 库再决定改哪一层。这次把 newlib 的底揭开之后再看 CLion 里的串口日志确实有种豁然开朗的感觉。
返回列表