结论速览

  • printf最后一跳是 libc 留给你的一个空符号,但填哪个符号由库决定:newlib 是 _write,microlib 是 fputc,Arm 标准 C 库是 _sys_*,IAR 是 __write。跨库没有统一的约定。
  • printf / vprintf / fprintf / vfprintf 只是”目标流 × 参数来源“的 2×2 组合,本质是同一个格式化内核。
  • 流(FILE*fd 多一层缓冲和状态;fd 只是”目标是谁”的编号。
  • newlib 里三个标准流的缓冲是写死的:stdin 只读、stdout 行缓冲stderr 无缓冲(实测 __sinit)。所以换行结尾的日志不用手动 fflush,而复位会丢掉缓冲区里没刷出的数据。
  • MCU 上最容易踩的坑:newlib-nano 默认不支持 %f自定义流永远判不成 tty → 默认全缓冲

1. 四个格式化函数

函数 目标流 参数来源
printf stdout(写死) 可变参数 ...
vprintf stdout(写死) va_list
fprintf 你指定的 FILE * 可变参数 ...
vfprintf 你指定的 FILE * va_list
1
2
3
                ...(直接给实参)        va_list(已打包的参数表)
stdout printf vprintf
任意 FILE* fprintf vfprintf

两条等价关系记住即可:

  • printf(fmt, ...)fprintf(stdout, fmt, ...)
  • vprintf(fmt, ap)vfprintf(stdout, fmt, ap)

v 系列存在的唯一理由:C 语言没有任何语法能把一个 va_list 摊开成 ...。所以在可变参数函数内部转发参数时(比如自己写日志库、包一层打印函数),只能用 v*printf。用 printf(fmt) 代替 vprintf(fmt, ap) 会丢掉全部参数,还会去读垃圾栈。

f 前缀的含义在不同命名族里并不一致:

命名族 不带 f f f 的含义
fd 层 vs 流层 open/read/write/close fopen/fread/fwrite/fclose 有缓冲的 FILE 流层
目标流是否硬编码 printf/puts/putchar fprintf/fputs/fputc 必须显式传入流
历史例外 putc/getc(也收 FILE* 不表示参数差别,只表示”允许是宏”

2. 流(stream)、FILE* 与 fd

2.1 什么是 fd

fd = file descriptor(文件描述符):一个小非负整数,代表”这个程序里已打开的一个 I/O 目标”。

  • 三个值被固定占用:0 = stdin1 = stdout2 = stderr——程序一启动它们就算”已经打开”。
  • 之后 open() 新建的从 3 开始依次分配;close() 之后号码会被回收复用。
  • 它是本程序内的索引,不是全局资源编号:同一个数字在不同程序里含义不同。Linux 上 fd 指向内核里的 file 对象;裸机上没有内核,fd 的含义完全由你实现的 _write/_read 决定——所以你可以像很多工程那样干脆忽略它(这也是为什么 debug.c_write(int fd, ...)fd 参数从头到尾没被用过)。
  • fd 本身没有缓冲、没有格式、没有状态,它只回答”目标是谁”。
  • 底层 syscall 的签名一眼可辨:_write(int fd, char *buf, int size)_read(int fd, char *buf, int size)

2.2 什么是 FILE*

FILE 是 C 标准库里的一个结构体类型FILE * 就是指向它的指针——stdoutstderrfopen() 的返回值、日志库里的 ev->udata 都是 FILE *

流(stream)= C 标准库对”一串按顺序进出的字节”的抽象,而 FILE 就是这个抽象在内存里的实体:它把一个裸 fd 包装成”带缓冲和状态的通道对象”。

标准故意把 FILE 定义为不透明类型(只该通过 stdio 函数去操作它,不该直接碰字段)。newlib 把头文件里的结构体暴露了出来,字段大致是这些:

字段 作用
_bf / _p 缓冲区本体 + 当前读写位置
_w / _r 剩余可写 / 可读空间(快路径判断靠它)
_flags 状态:读/写、错误、EOF、行缓冲/无缓冲、宽字符方向
_file 底层 fd(-1 表示不是 fd,比如内存流)
_lbfsize 行缓冲阈值(0 或 -_bf._size

两个实用细节:

  • 标准流在 newlib 里其实是#define stdout (_REENT->_stdout),含义是”当前重入上下文里的那个 FILE *“;多线程/多上下文用它来隔离状态。
  • FILE * 取回 fd 有现成口子:POSIX 的 fileno(fp)、newlib 内部的 __sfileno(p)(展开就是 (p)->_file);反过来 fdopen(fd, "w") 是拿 fd 造一个流。流不一定有 fd(内存流 _file = -1,字节不进 _write)。

2.3 三个标准流的对应关系

0/1/2 这三个 fd 和三个 FILE * 是一一对应的,前面所有讨论最后都落在这张表上:

fd 流(FILE * 方向 newlib 初始缓冲 说明
0 stdin 只读 默认(由 __smakebuf_r 决定) 输入流,不能当输出目标
1 stdout 只写 行缓冲__SWR | __SLBF printf/puts/putchar 的默认目标,遇 \n 自动刷
2 stderr 只写 无缓冲__SRW | __SNBF 写进去立刻出去,日志常选它

这张”初始缓冲”不是猜的:newlib 的 __sinit 会给三个流分别调用 std(),反汇编里三次调用的 flags 参数是 4 / 9 / 18——

1
2
3
stdin  : 4  = __SRD               → 只读
stdout : 9 = __SWR | __SLBF → 只写 + 行缓冲
stderr : 18 = __SRW | __SNBF → 读写 + 无缓冲

(标志位见 newlib stdio.h__SLBF 0x1 行缓冲、__SNBF 0x2 无缓冲、__SRD 0x4__SWR 0x8__SRW 0x10。)

newlib 启动时只建这三个流(__sf[3]stdin/stdout/stderr 就是 _REENT 里那三个 FILE *),之后想要别的流得自己 fopen/fdopen,对应的 fd 从 3 开始分配。

2.4 分工:谁负责什么

流(FILE * fd(整数)
高层,带缓冲与状态 底层,裸字节
API printf/fwrite/fopen _write/read/open
一次写多少 你说多少就多少,库自己分块 给多少字节写多少
额外能力 格式化、缓冲、错误标志、多流并存 只有一个目标编号

标准要搞两套抽象,就是因为这两件事职责不同:**fd 负责”发到哪”,流负责”怎么高效地发、出错了怎么记、现在是读还是写”**。你写 fprintf(fp, ...) 是在流层操作;攒够缓冲后,newlib 才替你调 _write(fp->_file, buf, n) 落到 fd 层。

3. 从 printf 到串口的完整调用链

1
2
3
4
5
printf("x=%d", 5)
└─ _vfprintf_r 把结果按格式写进 FILE 的缓冲
└─ __sfvwrite_r / __swbuf_r 缓冲满、行缓冲遇 \n、或 fflush 时才继续
└─ _write_r(reent, fp->_file, buf, n) 带 fd 的 syscall 入口
└─ _write(fd, buf, size) ← 你实现它,落到 UART

这一路的三个关键事实:

  1. puts 之类的函数不经过 fputc。实测 newlib-nano 的 _puts_r 只调用 __sinit__swsetup_r__swbuf_r
  2. 不实现 _write 会落到 libnosys 的空桩:反汇编 libnosys.a(write.o) 只有两条指令——写 errno = 88(ENOSYS)、return -1,一个字节都发不出去。
  3. 你实现了 _write,libnosys 的 write.o 根本不会被抽出来。这是”静态库成员按需提取”,不是弱符号覆盖:链接产物 map 里 _write 来自工程自己的 debug.o,libnosys 只被抽了 close/fstat/isatty/lseek/read

4. 各库的 retarget 点

环境 约定符号 粒度 备注
GCC + newlib(RISC-V / ARM) **_write**(另需 _read/_sbrk 等) 整块 buf/size 位置最低,所有 stdio 输出都汇聚到此,可以 DMA
Keil MDK + microlib **fputc**(输入侧配 fgetc/__backspace 1 字符 microlib 没有 POSIX 层,成员表里没有 write.o
Keil Arm 标准 C 库 _sys_write / _sys_open 家族 整块 fputc 只能覆盖 stdout/stderr 那条路
IAR EWARM __write 整块 IAR 自家 DLib 约定
ESP-IDF / Linux 类 注册 VFS 驱动 / write syscall 有 OS 和文件系统

microlib 这一列是有实测依据的:把 Keil 里的 microlib(ARMCC\lib\armlib\mc_p.lARMCLANG\lib\armlib\mc_*.l)按成员拆开看符号引用,fputs.o / puts.o / fwrite.o / printf0.o **全部引用 fputc**,而 fputc.o 自己不引用任何外部符号——它默认由半主机的内联 SVC 顶上,所以你重定义 fputc 是整条替换。

也就是说:microlib 下接 fputc 基本能拦住所有走 stdout/stderr 的输出(这跟 newlib 恰好相反,newlib 里重定义 fputc 拦不到 printf)。

5. putc 与 fputc

标准里两者语义等价putc(c, stream)fputc(c, stream) 行为相同),区别只在”它是不是函数”:

fputc putc
标准地位 必须是函数 fputc 等价,但允许实现为宏
参数求值 只求值一次 宏版本可能多次求值 streamputc(c, *p++) 属未定义行为
性能 至少一次调用(库内部同样走快路径) 宏/内联版本可省掉这次调用
可拦截 一定能做 retarget 点、能取地址 是宏就拦不到、不能取地址

所以 putcfputc 的”可宏化替身“:库可以把最常见的情况摊平成”缓冲区还有空位就直接写指针、自增”,只有缓冲满等特殊情况才跳进函数。而 putchar(c) 就是 putc(c, stdout)

⚠ 在 newlib 里 putc 是函数还是宏由配置决定stdio.h#ifndef __CUSTOM_FILE_IO__ 那一段里,__SINGLE_THREAD__ 下会 #define putc(_c,_p) __sputc_r(...);而如果构建定义了 __CUSTOM_FILE_IO__,整段被预编译掉,putc 就留成普通函数。要 retarget,一律认准 fputc

6. 缓冲、行缓冲与 tty

6.1 三个标准流的缓冲是写死的

stdin / stdout / stderr 的缓冲策略不靠运行时探测,而是 __sinit 初始化时就定好了(见 2.3 的 flags:4 / 9 / 18):

  • stdout = 行缓冲:数据里出现 \n 就把这一行刷出去。
  • stderr = 无缓冲:每个字节都直接落到 _write

行缓冲在 newlib 的批量写路径里是真的生效的:反汇编 __sfvwrite_rprintf/puts 的公共出口)能看到 andi a5,a5,1(测 __SLBF)→ memchr 在待写数据里找 \n → 找到就调 _fflush_r 刷出。

所以”日志写 stderr 立刻能看见、改成 printfstdout)就得加 fflush“这个说法并不成立——只要每条日志以 \n 结尾,stdout 也会逐行刷出。

6.2 什么是 tty,stdio 为什么关心它

tty = teletypewriter(电传打字机),一个历史遗留缩写,今天指终端:人机交互的字符设备——键盘+屏幕的终端、串口控制台都算。

判断一个 fd 是不是终端,POSIX 给的接口是 isatty(fd);newlib 内部还会先用 _fstat(fd, &st)S_ISCHR(st.st_mode)(是不是字符设备),把结果叫 couldbetty(”可能是个终端”)。

stdio 之所以关心它,是因为两种场景的优化目标相反

目标 策略
交互式终端:立刻可见比吞吐重要 行缓冲(遇 \n 就刷)
文件/管道/裸设备:吞吐优先 全缓冲(攒满一块再发)

具体决策发生在 __smakebuf_r(第一次写时调用),实测调用序列是:

1
2
3
4
__swhatbuf_r   →  内部 _fstat,算出 couldbetty 和缓冲区大小
_malloc_r → 分配缓冲区(成功则置 __SMBF)
_isatty_r → 只有 couldbetty && isatty() 都为真,才置 __SLBF(行缓冲)
失败分支 → andi a5,a5,-4 / ori a5,a5,2 置 __SNBF(退化成无缓冲)

裸机上没有 tty 这个概念:libnosys 的 _isatty 固定返回 0、_fstat 直接报错,于是 couldbetty = 0。这意味着:

  • 你自己 fopen/fdopen 出来的流,永远判不成终端,默认走全缓冲;
  • stdout/stderr 不受影响——它们的标志位 __sinit 已经写死了;
  • 想给自定义流强行加行缓冲,只能手动 setvbuf(fp, NULL, _IOLBF, size)

6.3 fflush 什么时候才真的必要

场景 要不要 fflush
stdout 上输出以 \n 结尾 不用,行缓冲会替你刷
stderr 不用,本来就无缓冲(此时 fflush 基本是空操作)
输出不含换行(进度条、半行状态) 必须,否则要等缓冲满才出现
setvbuf 改成全缓冲/自定义缓冲 必须
即将复位、进临界区、或依赖”已经发出去” 建议,保证缓冲区没有残留

一句话:fflush 不是”printf 必须配的仪式”,而是”在行缓冲不管用的时候手动推一把”。多写一次无害,但误以为必须误以为不需要都会踩坑。

7. MCU 上容易踩的坑

说明
%f 不可用 newlib-nano 默认裁掉浮点格式化(工程配置里 use_float_with_nano_printf: false),要打得开这个开关
缓冲要堆 行缓冲/全缓冲都要 _malloc_r 一块缓冲(__SMBF),堆来自 _sbrk;malloc 失败会退化成无缓冲(实测 __smakebuf_r__SNBF),行为随之变化
复位丢数据 缓冲区里没刷出的内容会随复位一起消失;日志若要求”断在哪儿都知道”,就别依赖缓冲
半主机 Keil 下不 retarget 时输出走 semihosting(内联 SVC),依赖调试器,脱机运行会卡住甚至异常
localtime() 是假时间 没有 RTC / 没实现 _gettimeofday 时,newlib 的 time() 返回 -1,localtime() 得到 1969-12-31 23:59:59,永远不变
逐字节阻塞发送 _write 已经拿到整块 buf/size,却常被写成”逐字节等 USART_FLAG_TC“,白白浪费了做 DMA 的机会

8. 速查

问题 答案
printf 最后落到哪? 由库决定:newlib _write、microlib fputc、IAR __write、Arm 标准库 _sys_*
日志里要不要 fflush newlib 里 stdout 默认行缓冲(遇 \n 刷)、stderr 无缓冲;换行结尾的日志不用手动刷,无换行或改过 setvbuf 才需要
为什么有 vprintf? 只有 v*printf 能消费 va_list,转发可变参数没有别的办法
为什么有 putc? 给库一个”可以宏化/内联”的口子;fputc 则保证存在函数实体
tty 跟 printf 什么关系? 决定一个流拿到行缓冲还是全缓冲;裸机上没有 tty,自定义流恒为全缓冲
拦截要覆盖全部输出? 先用 fputc/_write 这类最低点,但不走 stdio 的输出永远拦不到

附录:四个函数接口原型与用法

原型(取自 newlib stdio.h,括号里是 C 标准写法):

1
2
3
4
5
6
7
/* #include <stdio.h> */
int printf (const char *__restrict, ...); /* printf(const char *restrict format, ...) */

/* #include <stdio.h> + <stdarg.h> */
int vprintf (const char *, __VALIST); /* vprintf(const char *restrict format, va_list arg) */
int fprintf (FILE *__restrict, const char *__restrict, ...); /* fprintf(FILE *restrict stream, const char *restrict format, ...) */
int vfprintf (FILE *__restrict, const char *__restrict, __VALIST); /* vfprintf(FILE *restrict stream, const char *restrict format, va_list arg) */
  • __VALIST 就是 va_list(newlib 里定义为 __gnuc_va_list,RISC-V/ARM 上通常就是 void *)。
  • __restrict / restrict 只是给编译器的优化提示(这两个指针不重叠),语义上可以当没有。

返回值:成功返回写出的字符数,出错返回负值

调用示例:

1
2
3
printf("x=%d\n", 5);                  /* ≡ fprintf(stdout, "x=%d\n", 5) */
fprintf(stderr, "err=%d\n", e); /* 指定流:日志常写 stderr */
fprintf(fp, "%s:%d\n", name, line); /* fp 来自 fopen(),或 log_add_fp() 收下的那个 FILE* */

v 系列不能直接调用(你手上没有现成的 va_list),它只在可变参数函数内部用来转发。两个 v 函数的关系是 vprintf(fmt, ap)vfprintf(stdout, fmt, ap)——唯一区别就是流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdarg.h>

/* 只写 stdout:vprintf */
void my_log(const char *fmt, ...) /* ← 可变参数在这一层 */
{
va_list ap;
va_start(ap, fmt); /* 把 "..." 打包成 ap */
vprintf(fmt, ap); /* 这里只能用 v 版 */
va_end(ap);
}

/* 想指定流(stderr、文件、或日志库那个 FILE*):vfprintf */
void log_to(FILE *fp, const char *fmt, ...) /* 比 my_log 多一个流参数 */
{
va_list ap;
va_start(ap, fmt);
vfprintf(fp, fmt, ap); /* 唯一的区别:多传了 fp */
va_end(ap);
}

调用:my_log("x=%d\n", 5);log_to(stderr, "err=%d\n", e);log_to(fp, "%s\n", s);

两个 wrapper 的调用示例

1
2
3
my_log("boot ok, sysclk=%lu\n", (unsigned long)SystemCoreClock);
log_to(stderr, "i2c timeout, reg=0x%02X\n", reg);
log_to(fp, "%s:%d\n", name, line);

函数体就是上面那两个 wrapper(一个转 vprintf、一个转 vfprintf),固定三步:打包 → 交给 v 版消费 → 收尾。

常见增强

想加前缀 / 等级,先打前缀、再转发正文,别把前缀塞进格式串(否则前缀会被当成用户格式的一部分):

1
2
3
4
5
6
7
8
9
10
11
int my_log_tag(const char *tag, const char *fmt, ...)
{
va_list ap;
int n;

printf("[%s] ", tag); /* 前缀先出去 */
va_start(ap, fmt);
n = vprintf(fmt, ap); /* 正文,顺便拿到返回值 */
va_end(ap);
return n; /* 实际写出的字符数,出错为负 */
}

多任务 / 中断里打印要加锁,临界区包在 va_start..va_end 外层(原子性来自锁,不是来自 va_list):

1
2
3
4
5
6
7
8
9
void my_log_locked(const char *fmt, ...)
{
va_list ap;
lock_acquire();
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
lock_release();
}

这其实就是日志库内部的样子:log.cstdout_callback 拿到的 ev->fmt / ev->ap,最终就是一次 vprintf(ev->fmt, ev->ap)

使用要点:

  • va_list 只能遍历一次,遍历完即失效;要再输出一遍必须重新 va_start(log.c 里就是每个消费方各自 va_start),或用 va_copy 复制一份。
  • v 函数内部**不要再 va_start**——传进去的已经是打包好的列表。
  • 参数类型必须与格式串严格对应:%schar *%cintsize_t%zu;裸机上 %f 在 newlib-nano 里默认不可用(见第 7 节)。
  • va_end 必须在每条返回路径上都调到,提前 return 就是漏掉它。
  • 只包一层。wrapper 套 wrapper 只是多一次栈帧和一次 va_start,第二层转发的还是同一个 ap,不如在第一层直接消费。

va_list 是什么

va = variable arguments(可变参数),同族命名一览:

名字 全称 作用
va_list variable argument list 保存”当前读到哪儿”的状态
va_start 开始取参(把 ... 打包进 ap
va_arg variable argument 取下一个参数
va_end 收尾
va_copy 复制一份(C99)

它的类型是实现定义的,标准只要求它是”能保存 ... 状态的对象类型”。本工具链(RISC-V,GCC 8.2.0)的实际链条:

1
2
3
/* GCC 的 stdarg.h:lib/gcc/riscv-none-embed/8.2.0/include/stdarg.h */
typedef __builtin_va_list __gnuc_va_list;
typedef __gnuc_va_list va_list;

__builtin_va_list 是编译器内置类型,**RISC-V 上就等于 void ***——让编译器自己交代(故意写个类型错误看它怎么报):

1
warning: initialization of 'int' from '__builtin_va_list' {aka 'void *'}

对比:x86-64 System V ABI 上它是 struct __va_list_tag[1]数组类型),ARM 上视 ABI 是结构体或 void *。这正是”va_list 传参后外层 ap 会不会被改动”在不同平台表现不同的根源。

四个宏形式上是宏、用起来像函数,stdarg.h 里直接映射到编译器内建:

1
2
3
4
5
6
7
8
9
void va_start(va_list ap, last);          /* last = 最后一个具名参数 */
type va_arg(va_list ap, type); /* type 必须与实参兼容 */
void va_end(va_list ap); /* 与 va_start 配对 */
void va_copy(va_list dest, va_list src); /* C99 */

#define va_start(v,l) __builtin_va_start(v,l)
#define va_end(v) __builtin_va_end(v)
#define va_arg(v,l) __builtin_va_arg(v,l)
#define va_copy(d,s) __builtin_va_copy(d,s)

三条硬规则:

  • va_start 的第二个参数必须是最后一个具名参数,且不能是函数/数组类型、不能是 register 变量。
  • va_arg 的 type 必须与实际实参兼容,还要考虑默认实参提升:floatdoublechar/shortint;取错类型是 UB(实参是 double 却写 va_arg(ap, float))。
  • va_end 必须与 va_start 配对;va_copy 出来的每一份也要各自 va_end

为什么”只能遍历一次”va_list 本质就是”读到哪了”的状态;RISC-V 上它是 void *,传参拷贝的是指针,但它指向的参数保存区已经被遍历过。标准的规定更绝对:v*printf 返回后 ap 的值是不确定的。所以想再输出一遍只有两条路——重新 va_start(日志库 log.c 里每个消费方各自 va_start 就是这个原因),或在消费之前va_copy