CLion STM32 printf串口调试:重写_write而非fputc
2026/9/8 12:14:01 网站建设 项目流程

作家在 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 直接操作硬件”,而是分了三层。最上面是格式化层,printffprintfsprintf都在这层,负责把格式化参数变成字符流。中间是流层,由fputcfwritefputs这些函数构成,负责把字符写入到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 重定向的钩子放在fputc
IAR 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或者cmsissystem_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/2;ptr是缓冲区的指针;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 驱动在len=0时会报参数错误。
  • 如果发送过程中出现硬件错误(比如 UART 没初始化就调用 printf),可以通过errno = EIO返回-1告诉上层出错,否则 newlib 会以为数据已经送出去了。
  • file不要只看是不是 1(stdout),有时候用户会往 stderr(2)也输出,可以干脆不区分,统一往同一个串口丢。

这些细节平时不会爆雷,但一旦你用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(-specs=nano.specs) # 不使用半主机模式,关闭 rdimon add_link_options(-specs=nosys.specs) # 或者如果你的链接脚本里已经有了完整的 syscalls 实现,可以不加 nosys

nano.specs决定用 newlib-nano;nosys.specs的意思是不提供半主机的系统调用实现(也就是让_write等变成纯空壳,等待用户自己实现)。有些教程会写成rdimon.specs,那是明确启用半主机模式的,而我们要的是纯裸机 UART 输出,所以不要用 rdimon。

另外还要确认启动文件里有_sbrk的实现,否则 malloc 相关的代码会链接失败。CubeMX 生成的工程自带syscalls.c,里面通常有完整的_sbrk_write等内容,需要把它的_write部分改掉,或者干脆直接删掉syscalls.c,自己写一份干净的。

5.2 逐文件改造步骤

第一步,删除或屏蔽 syscalls.c 里的 _write

CubeMX 生成的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 系列是SR+DR,F4 是SR+DR,F7/H7 改成了ISR+TDR,千万别拿 F1 的代码往 F4 或者 H7 上直接抄。

第四步,配置链接器

在 CMakeLists.txt 或者 IDE 的链接器设置里,确认以下选项:

-specs=nano.specs -specs=nosys.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查了编译出来的 ELF:

arm-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); #endif

6.5 第三个坑:访问违例与 HardFault

有一次我把 retarget.c 里的_sbrk写错了,堆指针跑飞,然后 printf 一执行,malloc内部操作了非法地址,直接报了 “write to location 0x00000020 caused an access violation”。这跟 _write 本身没关系,但症状让人误以为还是重定向的问题。

这种 case 的排查方法很有套路:在 HardFault_Handler 里断下来,看LRPCCFSR(可配置故障状态寄存器)的值,用 GDB 的info registers查看现场。如果 PC 指向了 malloc 或者 free 内部,基本可以断定是堆区被踩或者堆大小不够;如果 PC 指向自己的函数,就查数组越界和野指针。

C 库函数在使用时还会牵扯到_sbrk提供堆空间。启动文件里堆配置太小,比如只有 1KB,printf 的浮点转换或者某些格式化操作一旦需要临时堆内存,就会立即爆掉。遇到动不动 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 里的串口日志,确实有种豁然开朗的感觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询