结论速览
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 | ...(直接给实参) va_list(已打包的参数表) |
两条等价关系记住即可:
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 = stdin、1 = stdout、2 = 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 * 就是指向它的指针——stdout、stderr、fopen() 的返回值、日志库里的 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 | stdin : 4 = __SRD → 只读 |
(标志位见 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 | printf("x=%d", 5) |
这一路的三个关键事实:
puts之类的函数不经过fputc。实测 newlib-nano 的_puts_r只调用__sinit、__swsetup_r、__swbuf_r。- 不实现
_write会落到 libnosys 的空桩:反汇编libnosys.a(write.o)只有两条指令——写errno = 88(ENOSYS)、return -1,一个字节都发不出去。 - 你实现了
_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.l、ARMCLANG\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 等价,但允许实现为宏 |
| 参数求值 | 只求值一次 | 宏版本可能多次求值 stream → putc(c, *p++) 属未定义行为 |
| 性能 | 至少一次调用(库内部同样走快路径) | 宏/内联版本可省掉这次调用 |
| 可拦截 | 一定能做 retarget 点、能取地址 | 是宏就拦不到、不能取地址 |
所以 putc 是 fputc 的”可宏化替身“:库可以把最常见的情况摊平成”缓冲区还有空位就直接写指针、自增”,只有缓冲满等特殊情况才跳进函数。而 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_r(printf/puts 的公共出口)能看到 andi a5,a5,1(测 __SLBF)→ memchr 在待写数据里找 \n → 找到就调 _fflush_r 刷出。
所以”日志写 stderr 立刻能看见、改成 printf(stdout)就得加 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 | __swhatbuf_r → 内部 _fstat,算出 couldbetty 和缓冲区大小 |
裸机上没有 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 | /* #include <stdio.h> */ |
__VALIST就是va_list(newlib 里定义为__gnuc_va_list,RISC-V/ARM 上通常就是void *)。__restrict/restrict只是给编译器的优化提示(这两个指针不重叠),语义上可以当没有。
返回值:成功返回写出的字符数,出错返回负值。
调用示例:
1 | printf("x=%d\n", 5); /* ≡ fprintf(stdout, "x=%d\n", 5) */ |
v 系列不能直接调用(你手上没有现成的 va_list),它只在可变参数函数内部用来转发。两个 v 函数的关系是 vprintf(fmt, ap) ≡ vfprintf(stdout, fmt, ap)——唯一区别就是流:
1 |
|
调用:my_log("x=%d\n", 5);、log_to(stderr, "err=%d\n", e);、log_to(fp, "%s\n", s);
两个 wrapper 的调用示例
1 | my_log("boot ok, sysclk=%lu\n", (unsigned long)SystemCoreClock); |
函数体就是上面那两个 wrapper(一个转 vprintf、一个转 vfprintf),固定三步:打包 → 交给 v 版消费 → 收尾。
常见增强
想加前缀 / 等级,先打前缀、再转发正文,别把前缀塞进格式串(否则前缀会被当成用户格式的一部分):
1 | int my_log_tag(const char *tag, const char *fmt, ...) |
多任务 / 中断里打印要加锁,临界区包在 va_start..va_end 外层(原子性来自锁,不是来自 va_list):
1 | void my_log_locked(const char *fmt, ...) |
这其实就是日志库内部的样子:log.c 的 stdout_callback 拿到的 ev->fmt / ev->ap,最终就是一次 vprintf(ev->fmt, ev->ap)。
使用要点:
va_list只能遍历一次,遍历完即失效;要再输出一遍必须重新va_start(log.c 里就是每个消费方各自va_start),或用va_copy复制一份。v函数内部**不要再va_start**——传进去的已经是打包好的列表。- 参数类型必须与格式串严格对应:
%s→char *、%c→int、size_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 | /* GCC 的 stdarg.h:lib/gcc/riscv-none-embed/8.2.0/include/stdarg.h */ |
而 __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 | void va_start(va_list ap, last); /* last = 最后一个具名参数 */ |
三条硬规则:
va_start的第二个参数必须是最后一个具名参数,且不能是函数/数组类型、不能是register变量。va_arg的 type 必须与实际实参兼容,还要考虑默认实参提升:float→double、char/short→int;取错类型是 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。