Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.4 缓冲与 stdio

本章回答

printf 什么时候真正调用 write?为什么重定向到文件后输出「晚了」?setvbuf 该在什么时候调用?为什么 fork 之后同一行会打印两次?

读完你会

画出 FILE 的用户态缓冲区与 fd 的关系;按场景选择全缓冲/行缓冲/无缓冲;识别 fread/fwrite 的「项数」语义;复现并修复 fork 导致的重复输出。

2.3 里我们两次提醒「先 fflush 再 dup2/fork」,但没解释为什么。这一章补上:printf 不是系统调用,它只是把字节搬进一块用户态缓冲区;真正调用 write 的时机由缓冲模式决定。理解这块缓冲区,你才能解释「程序崩了但日志最后一行没了」「重定向后输出顺序变了」「fork 后同一行出现两次」。

stdio 是 C 标准库最成功的抽象之一,也是最容易在系统编程里反咬一口的一层。它把「格式化」「缓冲」「编码」都塞进 FILE,让你写 fprintf(f, "%d", x) 而不用拼字符串再 write。代价是:你看到的写入顺序,不等于内核收到的顺序。

这一章只讲 Linux/glibc 上的行为;C 标准给的是下限,glibc 的实现细节(默认缓冲区大小、st_blksize 的使用)会明确标出。

2.4.1 FILE 是什么:一层用户态缓冲

FILE 是不透明结构(glibc 里叫 struct _IO_FILE),你只能通过 FILE * 操作它。它至少有四样东西:

成员(概念)作用
_fileno底层的 fd
_flags读/写/追加、EOF、错误、用户缓冲等标志
读缓冲区_IO_read_base/ptr/end:预读进来的数据
写缓冲区_IO_write_base/ptr/end:待写出的数据
锁多线程下保护这个 FILE(flockfile)
  FILE *f
┌────────────────────────────────┐
│ struct _IO_FILE                │
│   _fileno = 3                  │
│   _flags  = 写 | 错误 | ...     │
│   _IO_write_base ──┐           │
│   _IO_write_ptr  ──┤ 用户态缓冲区(malloc,通常 4 KiB)
│   _IO_write_end  ──┘           │
└────────────────────────────────┘
               │ fflush / 缓冲区满 / fclose / exit
               ▼
      write(3, base, ptr - base)      ← 系统调用
               │
               ▼
        page cache ──► 磁盘

读图:printf 只移动 _IO_write_ptr,不碰内核。只有三种情况会把缓冲区交出去:显式 fflush、缓冲区满、流关闭/进程 exit。所以「printf 之后立刻 kill -9」会丢数据;「printf 之后 write 同一 fd」顺序会乱。

stdin/stdout/stderr 是三个预打开的 FILE *,分别包着 fd 0/1/2。printf(...) 等价于 fprintf(stdout, ...)。

为什么要有用户态缓冲

每次 write 都是一次系统调用(约几百纳秒到几微秒)。逐字节 printf 一个 1 MB 的文本会触发上百万次调用。缓冲把「很多小写」合并成「少量大写」,这是 stdio 存在的主要理由。代价是时机不再由你控制,除非你显式 fflush。

2.4.2 三种缓冲模式与默认规则

模式常量何时写出
全缓冲_IOFBF缓冲区满、fflush、fclose、exit
行缓冲_IOLBF遇到 \n、缓冲区满、fflush、fclose、exit
无缓冲_IONBF每次 stdio 调用都直接 write

glibc 的默认规则:

  1. stderr 永远无缓冲。错误信息必须立刻可见,哪怕程序随后崩溃。
  2. 连接到终端的流是行缓冲。判断依据是 isatty(fileno(stream))。
  3. 其他流是全缓冲。缓冲区大小通常取 st_blksize(常见 4096),否则用 BUFSIZ(glibc 是 8192)。

这解释了一个常见现象:同一份 printf("hello\n"),在终端上立刻出现;./app > out.txt 之后要等程序退出才写进文件。不是内核变慢了,是 stdio 从行缓冲切成了全缓冲。

# 终端:行缓冲,每行一次 write
strace -e trace=write ./hello
write(1, "line 1\n", 7) = 7
write(1, "line 2\n", 7) = 7

# 重定向到文件:全缓冲,退出时一次 write
strace -e trace=write ./hello > out.txt
write(1, "line 1\nline 2\n", 14) = 14

setvbuf:必须在第一次 I/O 之前

int setvbuf(FILE *stream, char *buf, int mode, size_t size);
void setbuf(FILE *stream, char *buf);

setvbuf 成功返回 0,失败返回非 0(POSIX 允许它总是成功)。mode 是 _IOFBF/_IOLBF/_IONBF;buf 为 NULL 时由 libc 分配;size 为 0 时用默认值。

FILE *f = fopen("app.log", "a");
if (!f) errExit("fopen");

/* 必须在任何 fwrite/fprintf 之前调用 */
if (setvbuf(f, NULL, _IOLBF, 0) != 0)
    errExit("setvbuf");
`setvbuf` 与 `fflush` 的四个坑
  1. 「第一次 fprintf 之后再 setvbuf 也行」——标准说必须在任何 I/O 之前;glibc 可能忽略你的设置或行为未定义。fopen 之后立刻调用。
  2. 「buf 可以是局部数组」——缓冲区必须在流关闭前一直有效。传栈上的数组、函数返回后就悬空,后续写会踩坏内存。
  3. 「fflush 等于落盘」——fflush 只把数据交给内核(write),要落盘还得 fsync(fileno(f))。
  4. 「setbuf(f, NULL) 和 setvbuf(f, NULL, _IONBF, 0) 一样」——setbuf(f, NULL) 是无缓冲;setvbuf(..., NULL, _IOFBF, ...) 是让 libc 分配全缓冲。参数 NULL 的含义取决于函数。

用文件大小证明缓冲存在

/* bufsize.c — 观察 stdio 何时真正写文件 */
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <unistd.h>

static long file_size(const char *path)
{
    struct stat st;
    if (stat(path, &st) == -1)
        return -1;
    return (long)st.st_size;
}

int main(void)
{
    const char *path = "buf_demo.txt";
    FILE *f = fopen(path, "w");
    if (!f) { perror("fopen"); return 1; }

    printf("默认缓冲:写入前 size=%ld\n", file_size(path));
    fprintf(f, "line one\nline two\n");
    printf("fprintf 之后、fflush 之前 size=%ld\n", file_size(path));

    if (fflush(f) == EOF) { perror("fflush"); return 1; }
    printf("fflush 之后 size=%ld\n", file_size(path));

    if (fclose(f) == EOF) { perror("fclose"); return 1; }
    printf("fclose 之后 size=%ld\n", file_size(path));
    unlink(path);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 bufsize.c -o bufsize
./bufsize
# 默认缓冲:写入前 size=0
# fprintf 之后、fflush 之前 size=0
# fflush 之后 size=20
# fclose 之后 size=20

fprintf 之后文件仍是 0 字节,是「缓冲」最直接的证据。fflush 之后才变成 20 字节(两行共 20 字节)。

2.4.3 fflush、fclose 与混用 write

int fflush(FILE *stream);   /* 成功 0;失败 EOF,errno 来自底层 write */
int fclose(FILE *stream);   /* 成功 0;失败 EOF(可能 flush 失败) */

fflush(NULL) 刷新所有输出流。对输入流调用 fflush 在 C 标准里是未定义行为;POSIX 允许对可寻址输入流用它丢弃缓冲,但不要依赖。

fclose 会先 flush 再 close。必须检查返回值:磁盘满时,fprintf 可能早就返回成功(只是写进了缓冲区),错误直到 fclose 才出现。忽略 fclose 的返回值等于丢掉最后一次写错误。

延迟错误:fprintf 成功,fclose 才报错

/* late_error.c — 写 /dev/full,观察错误延迟到 fflush/fclose */
#include <errno.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    FILE *f = fopen("/dev/full", "w");
    if (!f) { perror("fopen"); return 1; }

    int rc = fprintf(f, "hello\n");
    printf("fprintf 返回 %d,errno=%d\n", rc, errno);
    fflush(stdout);                  /* 让上面的输出先于错误信息 */

    int e1 = 0, e2 = 0;
    if (fflush(f) == EOF)
        e1 = errno;
    if (fclose(f) == EOF)
        e2 = errno;

    printf("fflush errno=%d (%s), fclose errno=%d (%s)\n",
           e1, strerror(e1), e2, strerror(e2));
    return 0;
}

/dev/full 是「写入永远返回 ENOSPC」的设备。Linux 上的预期输出:

$ ./late_error
fprintf 返回 6,errno=0
fflush errno=28 (No space left on device), fclose errno=0 (Success)

fprintf 返回 6(hello\n 六个字符),errno 仍是 0;错误直到 fflush 才出现。fclose 返回 0,因为缓冲区已经在失败的 fflush 里被丢弃——具体行为依赖实现,所以两个返回值都要看。

fseek 与缓冲区

fseek/fseeko 在写流上会先 flush,在读流上会丢弃缓冲区并调整内核偏移。C 标准要求:同一个流上交替读写时,写后读必须 fflush 或 fseek,读后写必须 fseek(除非读到了 EOF)。不遵守是未定义行为,不是「碰巧能跑」。

与 write 混用:顺序会反过来

printf("A");              /* 进 stdout 缓冲区 */
write(STDOUT_FILENO, "B", 1);   /* 直接进内核 */
printf("C");              /* 继续进缓冲区 */
/* 程序退出时 flush:内核看到的顺序是 B A C */

正确写法只有两种:混用前 fflush,或者只用一层。读方向同理:read(0, ...) 绕过 stdio,stdio 缓冲区里预读的数据就丢了;fgets 与 read 混用会出现「少读了一行」。

printf("A");
fflush(stdout);                    /* 先把 A 交给内核 */
write(STDOUT_FILENO, "B", 1);      /* 顺序正确:A B */
printf("C");
混用 stdio 与系统调用的三个后果
  1. 输出顺序错乱:缓冲区里的旧数据在 write 之后才被 flush。
  2. 输入丢数据:read 把字节读走后,stdio 缓冲区里还留着上一次预读的副本,fgets 从副本继续,逻辑上「重复/跳过」。
  3. 偏移不一致:lseek(fd) 改了内核偏移,但 FILE 的缓冲区仍按旧位置解释数据。

2.4.4 返回值:字符数、项数、赋值数

stdio 函数的返回值语义不统一,这是 bug 高发区:

函数成功返回失败/边界易混点
fprintf/printf写出的字符数负数,errno 置位不是写入 fd 的字节数(可能还在缓冲区)
fscanf/scanf成功赋值的项数EOF(首个转换前输入失败)、0(匹配失败)0 不是错误,是「格式不匹配」
getline读到的字节数(含 \n)-1:EOF 或错误必须用 feof/ferror 区分
fread/fwrite读/写的项数短计数:EOF 或错误不是字节数;size=1 才等于字节数
fgetc/getcharunsigned char 转成的 intEOF必须用 int 接,不能用 char
fclose0EOFflush 失败在这里才暴露

fread 的「项数」语义最容易错:

char buf[4096];
size_t n = fread(buf, 1, sizeof buf, f);   /* size=1:返回值就是字节数 */
if (n < sizeof buf) {
    if (ferror(f)) { perror("fread"); }
    else if (feof(f)) { /* 正常读到末尾 */ }
}

struct rec recs[100];
size_t k = fread(recs, sizeof recs[0], 100, f);  /* 返回「完整记录」数 */
/* 可能读到一个不完整的尾部记录,它被留在流里或丢弃,取决于实现 */

用 fread(ptr, size, nmemb, f) 时,短计数可能意味着「读到了 k 个完整项 + 半个项」。如果数据是变长的,改用 size=1 自己解析。

fscanf:0 与 EOF 不是一回事

/* scan_demo.c — sscanf 的三种返回值:3、0、EOF */
#include <stdio.h>

static void try(const char *input)
{
    int a = -1, b = -1;
    char unit[8] = "?";
    int rc = sscanf(input, "%d %d %7s", &a, &b, unit);

    if (rc == 3)
        printf("input=%-10s rc=3   a=%d b=%d unit=%s\n", input, a, b, unit);
    else if (rc == 0)
        printf("input=%-10s rc=0   匹配失败,a/b 未被赋值\n", input);
    else if (rc == EOF)
        printf("input=%-10s rc=EOF 首个转换前就失败\n", input);
    else
        printf("input=%-10s rc=%d   只匹配了部分\n", input, rc);
}

int main(void)
{
    try("42 7 ms");
    try("oops 7 ms");
    try("");
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 scan_demo.c -o scan_demo
./scan_demo
# input=42 7 ms    rc=3   a=42 b=7 unit=ms
# input=oops 7 ms  rc=0   匹配失败,a/b 未被赋值
# input=            rc=EOF 首个转换前就失败

三种结果的含义完全不同:

  • 3:三个转换都成功;
  • 0:输入不匹配格式(oops 不是整数),流的位置没有前进,直接重试会死循环;
  • EOF:还没开始转换就遇到输入结束或读错误。

%7s 的宽度不能省:没有宽度限制的 %s 会写爆缓冲区。fscanf 循环的正确写法是 while (fscanf(f, "%d", &x) == 1),并在 0 时消费掉当前字符再继续。

完整示例:getline 逐行读

/* linecount.c — getline 读文件,统计行数与最长行 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[])
{
    if (argc != 2) { fprintf(stderr, "usage: %s FILE\n", argv[0]); return 1; }

    FILE *f = fopen(argv[1], "r");
    if (!f) { perror("fopen"); return 1; }

    char *line = NULL;
    size_t cap = 0;
    ssize_t n;
    long lines = 0, longest = 0;

    while ((n = getline(&line, &cap, f)) != -1) {
        lines++;
        if (n > longest) longest = n;
    }

    if (ferror(f)) {                 /* 必须先区分错误与 EOF */
        perror("getline");
        free(line);
        fclose(f);
        return 1;
    }

    printf("lines=%ld longest=%ld bytes\n", lines, longest);
    free(line);
    if (fclose(f) == EOF) { perror("fclose"); return 1; }
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 linecount.c -o linecount
printf 'a\nbbb\ncc\n' > /tmp/l.txt && ./linecount /tmp/l.txt
# lines=3 longest=4 bytes

getline 会按需 realloc 缓冲区,调用者只需 free(line)。n 含换行符,所以最长行 4 字节是 bbb\n。循环结束后必须用 ferror 判断是「正常 EOF」还是「读错误」——只看 -1 分不出来。

2.4.5 fork 之后为什么会重复输出

fork 复制的是整个用户地址空间,包括 stdio 缓冲区。如果缓冲区里还有没写出的数据,父子进程各有一份;两者退出时都 flush,同一段字节就被写了两次。

/* fork_dup.c — 复现 fork 复制 stdio 缓冲区;fix 参数给出修复 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    int fix = (argc > 1 && strcmp(argv[1], "fix") == 0);

    printf("record:");               /* 没有换行,留在缓冲区 */
    if (fix)
        fflush(stdout);              /* 修复 1:fork 前刷缓冲 */

    pid_t pid = fork();
    if (pid == -1) { perror("fork"); return 1; }

    if (pid == 0) {
        if (fix)
            _exit(0);                /* 修复 2:子进程不 flush 复制来的缓冲 */
        exit(0);                     /* 子进程 flush,写出重复的一份 */
    }

    waitpid(pid, NULL, 0);
    exit(0);                         /* 父进程也 flush */
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 fork_dup.c -o fork_dup
./fork_dup > out.txt; cat out.txt
# record:record:
./fork_dup fix > out2.txt; cat out2.txt
# record:

两处修复任选其一即可:fflush 让缓冲区在 fork 前清空;_exit 让子进程跳过 flush。不要只改一处又保留另一处——如果子进程 exit,即使父进程 flush 过,子进程缓冲区里也可能有 fork 之后新写的数据。

关于 fork 与 stdio 的四个误解
  1. 「printf 带 \n 就不会重复」——输出到终端时行缓冲会 flush;重定向到文件时是全缓冲,\n 不触发 flush,照样重复。测试必须重定向。
  2. 「fork 会复制 fd,所以不会重复」——fd 复制的是文件描述,缓冲区是用户内存,两回事。
  3. 「子进程用 exit 更好,能刷新数据」——在 fork 场景里恰恰相反:子进程应 _exit,否则会把继承来的缓冲再写一遍。
  4. 「exec 会 flush 缓冲区」——不会。exec 直接替换地址空间,未 flush 的数据丢失。fork 后 exec 前要 fflush。

正确的心智模型

把 stdio 想成「每个进程一份的写队列」:

  • fork 复制队列,两个进程各写各的;
  • exec 丢弃队列;
  • exit 刷队列,_exit 不刷;
  • fflush 是唯一能让你精确控制队列何时变成 write 的操作。

所以任何「fork/exec/dup2 之前」都应该问一句:这个流的队列里还有东西吗?

2.4.6 fileno、fdopen、freopen

函数语义返回/失败
int fileno(FILE *f)取底层 fd失败 -1,EBADF
FILE *fdopen(int fd, const char *mode)把已有 fd 包成流失败 NULL,EBADF/EINVAL
FILE *freopen(const char *path, const char *mode, FILE *f)关闭 f 再按路径重开失败 NULL(f 已关闭)

fdopen 不复制 fd:流关闭时底层 fd 也关闭。要保留原 fd,先 dup:

FILE *f = fdopen(dup(fd), "r");    /* 关闭 f 不影响原 fd */
if (!f) errExit("fdopen");

freopen 的典型用途是重定向标准流:

if (freopen("/var/log/app.log", "a", stdout) == NULL)
    errExit("freopen");            /* 之后 printf 都进日志 */

失败时 stdout 已经被关闭,不要再用它。fdopen 的 mode 必须与 fd 的打开模式兼容(只读 fd 不能 "w"),否则行为未定义;"a" 不会改变内核的 O_APPEND,它只是 stdio 的写位置策略。

线程安全:flockfile 与 _unlocked

POSIX 要求 stdio 函数对同一个 FILE 是线程安全的:glibc 在每次 fprintf/fgets 内部加锁。但「两条 fprintf 不被其他线程插进来」需要你自己包住:

flockfile(f);
fprintf(f, "%s %d\n", tag, value);   /* 这两行必须原子地出现在文件里 */
fprintf(f, "%s done\n", tag);
funlockfile(f);

已经持锁时,用 putc_unlocked/getc_unlocked 可以省掉重复加锁;单独使用它们(不先 flockfile)不安全。feof/ferror 只在上一次读操作之后才有意义:读到 EOF 后 feof 才为真,clearerr 清除 EOF 与错误标志。

stdio 排障顺序
  1. 输出没出现:先看是否全缓冲;加 fflush 或 setvbuf(_IONBF) 验证。
  2. 输出顺序乱:查是否混用 write/printf;统一层或每次 fflush。
  3. 最后一行丢了:查是否 _exit/崩溃/exec 跳过了 flush。
  4. 重复输出:查 fork 前是否 flush;子进程是否用了 exit。
  5. 写失败没报:查 fclose/fflush 返回值;错误可能延迟到最后。

2.4.7 缓冲区大小从哪来:st_blksize、BUFSIZ 与默认规则

这一节回答:fopen 之后那块缓冲区到底多大?为什么同一个程序在终端和文件上的行为不同?

glibc 的 fopen 在第一次 I/O 时决定缓冲区,规则可以概括为三条(Kerrisk 13.2,p223–225):

  1. stderr 永远无缓冲,保证错误立刻可见;
  2. isatty(fileno(stream)) 为真时行缓冲,否则全缓冲;
  3. 全缓冲的大小取 fstat 的 st_blksize(常见 4096),取不到时退回 BUFSIZ(glibc 定义为 8192)。

st_blksize 是「这个文件系统上最优 I/O 块大小」,不是页大小。它在不同文件系统上可能不同(ext4 通常 4096,某些网络文件系统更大),所以「缓冲区一定是 4096」是错的。BUFSIZ 只是兜底常量。

这也解释了 2.4.2 的现象:printf("hello\n") 在终端上立刻出现,是因为行缓冲遇到 \n 就 write;重定向到文件后变成全缓冲,\n 不再触发,数据要等缓冲区满、fflush 或 exit。

setvbuf 的 size 什么时候有用

int setvbuf(FILE *stream, char *buf, int mode, size_t size);
  • buf != NULL:用你提供的 size 字节内存作为缓冲区;
  • buf == NULL:由 libc 分配,glibc 会忽略 size(Kerrisk p195 明确说明)。也就是说 setvbuf(f, NULL, _IOFBF, 1<<20) 在 glibc 上不会得到 1 MiB 缓冲,只是请求全缓冲;要指定大小必须自己提供 buf。

buf 的生命周期必须覆盖流的整个使用期:用 malloc 或静态数组,不能用局部栈数组。setvbuf 必须在任何 I/O 之前调用;成功后返回 0,失败返回非 0(不一定是 -1)。

三种模式的实际差异

模式触发 write 的时机典型用途
_IONBF每次 stdio 调用stderr、崩溃日志、交互提示
_IOLBF遇到 \n、缓冲满、fflush终端、按行消费的日志
_IOFBF缓冲满、fflush、fclose、exit磁盘文件、批量输出
/* bufmodes.c — 三种模式下,未 flush 时文件大小的差异 */
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <unistd.h>

static long size_of(const char *path)
{
    struct stat st;
    return stat(path, &st) == 0 ? (long)st.st_size : -1;
}

static void try_mode(const char *path, int mode, const char *name)
{
    FILE *f = fopen(path, "w");
    if (!f) { perror("fopen"); return; }

    char *buf = NULL;
    if (mode != _IONBF) {
        buf = malloc(BUFSIZ);
        if (!buf) { fclose(f); return; }
    }
    if (setvbuf(f, buf, mode, BUFSIZ) != 0) {
        fprintf(stderr, "setvbuf(%s) failed\n", name);
        free(buf);
        fclose(f);
        return;
    }

    fprintf(f, "abc");                    /* 无换行,三种模式表现不同 */
    printf("%-8s before fflush: size=%ld\n", name, size_of(path));
    if (fflush(f) == EOF) perror("fflush");
    printf("%-8s after  fflush: size=%ld\n", name, size_of(path));

    fclose(f);
    free(buf);
}

int main(void)
{
    printf("BUFSIZ=%d PAGE=%ld\n", BUFSIZ, sysconf(_SC_PAGESIZE));
    try_mode("/tmp/buf_none.txt",  _IONBF, "IONBF");
    try_mode("/tmp/buf_line.txt",  _IOLBF, "IOLBF");
    try_mode("/tmp/buf_full.txt",  _IOFBF, "IOFBF");
    unlink("/tmp/buf_none.txt");
    unlink("/tmp/buf_line.txt");
    unlink("/tmp/buf_full.txt");
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 bufmodes.c -o bufmodes
./bufmodes
# BUFSIZ=8192 PAGE=4096
# IONBF    before fflush: size=3
# IONBF    after  fflush: size=3
# IOLBF    before fflush: size=0
# IOLBF    after  fflush: size=3
# IOFBF    before fflush: size=0
# IOFBF    after  fflush: size=3

IONBF 下 fprintf 立刻变成 write,所以 fflush 前后都是 3 字节;IOLBF 因为没有换行,abc 仍在缓冲区;IOFBF 同理。把 fprintf(f, "abc") 改成 "abc\n",IOLBF 的前一行会变成 4。

缓冲模式的五个坑
  1. 「setvbuf(f, NULL, _IOFBF, n) 能指定 n 字节缓冲」——glibc 忽略 size;要指定大小就自己传 buf。
  2. 「缓冲区默认一定是 4096」——取 st_blksize,不同文件系统可能不同。
  3. 「stderr 也可以改成全缓冲」——可以,但错误信息会延迟到崩溃前才出现,通常不该改。
  4. 「setvbuf 可以在 fprintf 之后调用」——标准要求在第一次 I/O 之前;之后调用行为未定义。
  5. 「fflush 只对输出流有效」——POSIX 允许对输入流 fflush 丢弃缓冲;C 标准说未定义,跨平台不要依赖。

2.4.8 setvbuf 家族:setbuf、setbuffer、setlinebuf

这一节回答:setbuf/setbuffer/setlinebuf 与 setvbuf 是什么关系?什么时候该用哪个?

函数原型等价于备注
setvbufint setvbuf(FILE *, char *, int, size_t)—POSIX,返回 0/非 0
setbufvoid setbuf(FILE *, char *)buf ? setvbuf(f, buf, _IOFBF, BUFSIZ) : setvbuf(f, NULL, _IONBF, 0)无返回值,无法报错
setbuffervoid setbuffer(FILE *, char *, size_t)setvbuf(f, buf, buf ? _IOFBF : _IONBF, size)GNU/BSD 扩展
setlinebufvoid setlinebuf(FILE *)setvbuf(f, NULL, _IOLBF, 0)GNU/BSD 扩展,无返回值

setbuf(f, NULL) 是无缓冲,不是「让 libc 分配缓冲」;这一点最容易记反。setbuffer 允许指定大小,但仍然要求缓冲区生命周期覆盖流。

setlinebuf(f) 是「把这个流改成行缓冲」的最短写法,常用于「日志文件希望按行落盘,但不想每次 fflush」的场景。注意行缓冲只保证遇到 \n 时 write,不保证写到磁盘;要落盘仍需 fsync(fileno(f))。

fpurge 与输入缓冲

void __fpurge(FILE *stream);    /* glibc 扩展,无返回值 */
int  fpurge(FILE *stream);      /* BSD 扩展;glibc 不提供,用 __fpurge */

fflush 对输入流的行为在 C 标准里未定义;glibc 的 __fpurge 明确丢弃输入缓冲区,下一次读重新从内核取。交互式程序在 fork/切换输入源时可能需要它,但更稳的做法是不要混用 read 与 stdio 读同一个 fd(2.4.3)。

`stdbuf` 是怎么改缓冲的

stdbuf -o0/-oL/-oB cmd 通过 LD_PRELOAD 加载 libstdbuf.so,在程序启动时修改 stdin/stdout/stderr 的缓冲模式。它只能影响这三个标准流,且对已经 setvbuf 过的流无效;对静态链接程序也无效。调试时它很方便,但不要把「生产环境靠 stdbuf 修缓冲」当成方案。

2.4.9 内存流:fmemopen 与 open_memstream

这一节回答:怎样让 fprintf/getline 直接读写内存,而不是文件?

fmemopen:把已有内存块包成流

FILE *fmemopen(void *buf, size_t size, const char *mode);
  • mode 为 "r"/"w"/"a"/"r+" 等,语义与 fopen 类似,但作用在内存上;
  • "w" 会从 buf[0] 开始写,不会自动在末尾补 \0,除非缓冲区还有空间且你显式写入;
  • "a" 的追加位置是 buf 中第一个 \0(不是 size 末尾);
  • size == 0 返回 NULL、errno=EINVAL;
  • 关闭流不会 free(buf),缓冲区归调用者。

它适合「解析一段已经在内存里的文本」:把 buf 包成 FILE *,然后 getline/fscanf,不用手写解析器。

open_memstream:让流自己长

FILE *open_memstream(char **ptr, size_t *sizeloc);
  • 初始 *ptr = NULL、*sizeloc = 0;
  • 写入时 libc 自动 realloc;
  • fflush 或 fclose 之后,*ptr 指向缓冲区,*sizeloc 是长度(不含结尾 \0);
  • fclose 会补 \0,但 *sizeloc 不计它;
  • 缓冲区由 libc 分配,调用者负责 free(*ptr)。

open_wmemstream 是宽字符版本。它们适合「先格式化到内存、再一次性写文件/发送」的场景,避免预估缓冲区大小。

/* memstream.c — open_memstream 生成文本,fmemopen 再按行解析 */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char *buf = NULL;
    size_t len = 0;
    FILE *out = open_memstream(&buf, &len);
    if (!out) { perror("open_memstream"); return 1; }

    for (int i = 0; i < 3; i++)
        fprintf(out, "line-%d: value=%d\n", i, i * i);

    /* 不 fflush 时 buf/len 可能还没更新 */
    printf("before fflush: buf=%p len=%zu\n", (void *)buf, len);
    if (fflush(out) == EOF) { perror("fflush"); return 1; }
    printf("after  fflush: len=%zu\n", len);

    if (fclose(out) == EOF) { perror("fclose"); return 1; }
    printf("after  fclose: len=%zu content:\n%s", len, buf);

    /* 用 fmemopen 把同一块内存包成输入流,逐行读 */
    FILE *in = fmemopen(buf, len, "r");
    if (!in) { perror("fmemopen"); free(buf); return 1; }

    char *line = NULL;
    size_t cap = 0;
    ssize_t n;
    int lines = 0;
    while ((n = getline(&line, &cap, in)) != -1) {
        lines++;
        printf("parsed[%d] n=%zd %s", lines - 1, n, line);
    }
    if (ferror(in)) perror("getline");

    free(line);
    fclose(in);
    free(buf);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 memstream.c -o memstream
./memstream
# before fflush: buf=(nil) len=0
# after  fflush: len=48
# after  fclose: len=48 content:
# line-0: value=0
# line-1: value=1
# line-2: value=4
# parsed[0] n=16 line-0: value=0
# ...

第一行 buf=(nil) len=0 是关键:open_memstream 的 ptr/sizeloc 只在 flush 后才有意义。不要在 fflush 前读它们,也不要在 fclose 后继续用 FILE *。

内存流的四个坑
  1. 「fmemopen 会在写完时自动补 \0」——只有缓冲区还有空间且你写了 \0 才有;size 用满时没有终止符。
  2. 「open_memstream 的 size 包含 \0」——不包含;fclose 补的 \0 不计入 *sizeloc。
  3. 「fmemopen 关闭时会释放 buf」——不会,缓冲区归调用者。
  4. 「open_memstream 可以在 fflush 前读 buf」——buf 可能是 NULL 或旧值。

2.4.10 getline/getdelim 与定长读的边界

这一节回答:为什么 fgets 读不全一行?getline 的返回值到底含不含 \n?怎样读二进制记录?

ssize_t getline(char **lineptr, size_t *n, FILE *stream);
ssize_t getdelim(char **lineptr, size_t *n, int delim, FILE *stream);
  • 返回读到的字节数,包含分隔符(getline 是 \n),不含结尾 \0;
  • 返回 -1:EOF 或错误;必须用 feof/ferror 区分;
  • *lineptr 为 NULL 且 *n 为 0 时由 libc 分配;之后传入的缓冲区会被复用/扩容;
  • 嵌入的 \0 不会截断读取(getline 按分隔符而不是字符串结尾工作),返回值仍然是字节数;
  • 失败:EINVAL(lineptr 或 n 为 NULL)、ENOMEM。

fgets(buf, size, f) 最多读 size-1 字节,遇到 \n 停止,总是补 \0;如果一行超过 size-1,剩下的部分留在流里,下一次 fgets 继续读。这就是「用 fgets 读长行会得到多条记录」的原因。getline 没有这个限制,代价是可能反复 realloc。

getdelim 适合非换行分隔的记录,例如 \0 分隔的字段、; 分隔的配置。读二进制记录不要用 getline:它遇到 \0 不停止,但返回值语义与「字符串」不同,容易把长度算错;用 fread 按固定长度读,并处理短读。

/* 片段:用 getdelim 读以 ';' 分隔的记录;完整错误处理见 linecount.c */
char *rec = NULL;
size_t cap = 0;
ssize_t n;
while ((n = getdelim(&rec, &cap, ';', f)) != -1) {
    /* n 含结尾 ';';rec 以 '\0' 结尾,但记录内可能有 '\0' */
    printf("record bytes=%zd\n", n);
}
if (ferror(f)) perror("getdelim");
free(rec);

这段是完整程序的一部分;单独编译需要补 #include <stdio.h>、<stdlib.h> 和 f 的定义。getline 的完整程序见 2.4.4 的 linecount.c。

`getline` 的五个坑
  1. 「返回 -1 就是 EOF」——也可能是错误;必须 feof/ferror。
  2. 「返回值不含 \n」——含;"abc\n" 返回 4。
  3. 「lineptr 可以指向栈数组」——可以,但 getline 可能 realloc 它;栈数组不能被 realloc,必须用 malloc 的指针或 NULL。
  4. 「getline 遇到 \0 会停」——不会,它按分隔符读;字符串函数会在 \0 处停,长度要用返回值。
  5. 「getline 是标准 C」——POSIX.1-2008,不是 C17;需要 _POSIX_C_SOURCE>=200809 或 _GNU_SOURCE。

2.4.11 格式化 I/O 的返回值与安全边界

这一节回答:snprintf 截断时返回什么?asprintf 什么时候该用?%n 为什么危险?

snprintf:返回值是「本该写多少」

int snprintf(char *buf, size_t size, const char *fmt, ...);
int vsnprintf(char *buf, size_t size, const char *fmt, va_list ap);

成功返回若缓冲区足够大本应写入的字符数(不含 \0);截断不是错误。所以判断截断要看 ret >= size(size > 0):

char path[64];
int n = snprintf(path, sizeof path, "%s/%s", dir, name);
if (n < 0) { /* 编码错误 */ }
else if ((size_t)n >= sizeof path) { /* 被截断,需要更大缓冲 */ }

snprintf(NULL, 0, ...) 是合法的,用来计算所需长度(C99)。不要写 strcpy/strcat 拼路径;snprintf 的返回值检查是必须的。

asprintf:让 libc 分配

int asprintf(char **strp, const char *fmt, ...);   /* GNU/POSIX.1-2008 扩展 */

成功返回写入字符数,*strp 由 libc malloc,调用者 free;失败返回 -1,*strp 未定义。它省掉「先算长度再分配」的两步,适合长度不可预测的格式化。不要把它用在信号处理器里(会 malloc)。

%n、%s 与 %m

  • %n 把「已写字符数」写进一个 int * 参数。格式化字符串来自用户输入时,%n 可以写任意地址,是经典漏洞;Glibc 的 _FORTIFY_SOURCE 会在部分场景拒绝 %n。永远不要把用户输入当格式串,用 printf("%s", user)。
  • %s 没有宽度限制时会一直读到 \0;用 %.*s 或 %10s 限制。
  • %m 是 glibc 扩展,打印 strerror(errno),不消耗参数;不可移植。
  • scanf 的 %m 是另一个含义:让 libc 分配字符串(GNU 扩展),如 %ms;与 printf 的 %m 无关。
/* snprintf_demo.c — 截断检测与 asprintf */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char small[8];
    int n = snprintf(small, sizeof small, "%s", "0123456789");
    printf("snprintf ret=%d size=%zu truncated=%d buf=%s\n",
           n, sizeof small, n >= (int)sizeof small, small);

    char *dyn = NULL;
    int m = asprintf(&dyn, "%s-%d-%s", "build", 42, "linux");
    if (m < 0) { perror("asprintf"); return 1; }
    printf("asprintf ret=%d str=%s\n", m, dyn);
    free(dyn);

    /* 正确:用户输入当数据,不当格式串 */
    const char *user = "%s%s%n";
    printf("safe: %s\n", user);

    /* 计算所需长度,不写缓冲区 */
    int need = snprintf(NULL, 0, "%s/%s", "/var/log", "app.log");
    printf("need=%d\n", need);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 snprintf_demo.c -o snprintf_demo
./snprintf_demo
# snprintf ret=10 size=8 truncated=1 buf=0123456
# asprintf ret=14 str=build-42-linux
# safe: %s%s%n
# need=16

snprintf 返回 10 但只写了 7 个字符加 \0;truncated=1 是唯一可靠的截断信号。asprintf 返回 14(build5 + -1 + 422 + -1 + linux5 = 14)。

格式化 I/O 的五个坑
  1. 「snprintf 截断返回 -1」——返回本应写入的长度;只有编码错误才返回负值。
  2. 「snprintf 一定补 \0」——size > 0 时补;size == 0 不写任何字节。
  3. 「asprintf 失败时 *strp 是 NULL」——不保证;失败后不要读它。
  4. 「printf(user_input) 只是少写个 %s」——这是格式化字符串漏洞,可读栈、可写内存(%n)。
  5. 「%m 是标准 C」——glibc 扩展;可移植代码用 strerror(errno)。

2.4.12 locale 对 stdio 的影响

这一节回答:为什么同一个 printf("%.2f") 在不同语言环境下输出的小数点不同?scanf 会不会因此读错?

C 程序启动时 locale 是 "C",小数点固定为 .。调用 setlocale(LC_ALL, "") 后,printf/scanf 的数值格式、strerror 的错误文本、strftime 的月份名都会随环境变量变化:

#include <locale.h>
setlocale(LC_ALL, "");          /* 按 LANG/LC_* 环境变量 */
printf("%'d\n", 1234567);       /* 千分位:1,234,567(取决于 locale) */
printf("%.2f\n", 3.14);         /* 德语环境可能输出 3,14 */

locale 不是「一种语言」,而是一组分类,每类独立生效:

分类影响的函数典型差异
LC_CTYPEisalpha、mbstowcs、printf 的宽字符哪些字节算字母
LC_NUMERICprintf/scanf 的浮点、localeconv小数点 . vs ,
LC_TIMEstrftime、strptime月份/星期缩写
LC_COLLATEstrcoll、strxfrm排序规则
LC_MONETARYlocaleconv货币符号、千分位
LC_MESSAGESstrerror、gettext错误文本语言

这会带来两个真实问题:

  1. 序列化不能用 locale 相关的格式。把 3,14 写进 JSON/配置文件,其他程序按 . 解析会出错。写机器可读数据时用 "C" locale,或自己格式化整数/用 %.17g 并确保 LC_NUMERIC=C。
  2. scanf 的 %f 受 LC_NUMERIC 影响。在德语环境读 3.14 可能只匹配到 3,剩下 .14 留在流里,下一次解析错位。

setlocale 是进程级状态,多线程同时改会竞争;POSIX.1-2008 的 uselocale()/newlocale() 可以给线程设置独立 locale。服务端通常在校验配置时显式 setlocale(LC_ALL, "C"),只在面向用户的输出上使用用户 locale。

strerror 返回的错误文本也受 LC_MESSAGES 影响;日志里想要稳定文本,用 strerror_l(err, locale) 指定 "C" locale。

`LC_ALL` 与 `LANG` 的优先级

LC_ALL 覆盖所有分类;否则各 LC_* 分别生效;都没有时用 LANG;最后退回 "C"。locale 命令输出当前分类;locale -a 列出可用 locale。容器里常只有 C/C.UTF-8,程序不要假设 en_US.UTF-8 存在。

2.4.13 非阻塞 fd 上的 stdio:EAGAIN 与 clearerr

这一节回答:把 O_NONBLOCK 的 fd 包成 FILE * 后,fread 会怎样报告「现在没数据」?

stdio 不知道「非阻塞」这回事。fread 内部调用 read,如果返回 -1/EAGAIN,glibc 会:

  • 返回 0(或已读到的项数);
  • 设置流的错误标志(ferror 为真);
  • 保留 errno = EAGAIN。

它不是 EOF(feof 为假)。要继续读,必须先 clearerr(f),否则流处于错误状态,后续调用可能直接返回失败。正确循环:

size_t n = fread(buf, 1, sizeof buf, f);
if (n == 0 && ferror(f) && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    clearerr(f);
    /* 等 epoll 可读,稍后再试 */
}

写方向同理:fwrite 可能只把数据放进 stdio 缓冲并返回成功;真正 write 发生在 fflush,EAGAIN 在 fflush 返回 EOF 时才出现。混用 epoll 与 stdio 很容易漏掉「缓冲区里还有数据」这一层,所以事件驱动程序通常直接用 read/write,只在格式化输出时用 snprintf 拼字符串。

/* stdio_err.c — 非阻塞管道上的 fread:EAGAIN、clearerr、feof 的区别 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    int p[2];
    if (pipe2(p, O_CLOEXEC | O_NONBLOCK) == -1) { perror("pipe2"); return 1; }

    FILE *in = fdopen(p[0], "r");
    if (!in) { perror("fdopen"); return 1; }

    char buf[64];
    errno = 0;
    size_t n = fread(buf, 1, sizeof buf, in);
    printf("fread empty: n=%zu ferror=%d feof=%d errno=%d (%s)\n",
           n, ferror(in), feof(in), errno, strerror(errno));

    /* 写入数据后必须 clearerr,否则流仍处于错误状态 */
    if (write(p[1], "hello\n", 6) != 6) { perror("write"); return 1; }
    clearerr(in);
    errno = 0;
    n = fread(buf, 1, sizeof buf, in);
    printf("after clearerr: n=%zu ferror=%d feof=%d\n", n, ferror(in), feof(in));
    if (n > 0) printf("data=%.*s", (int)n, buf);

    /* 关闭写端后,下一次读才是 EOF */
    close(p[1]);
    clearerr(in);
    n = fread(buf, 1, sizeof buf, in);
    printf("after close writer: n=%zu feof=%d ferror=%d\n",
           n, feof(in), ferror(in));

    fclose(in);
    return 0;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 stdio_err.c -o stdio_err
./stdio_err
# fread empty: n=0 ferror=1 feof=0 errno=11 (Resource temporarily unavailable)
# after clearerr: n=6 ferror=0 feof=0
# data=hello
# after close writer: n=0 feof=1 ferror=0

第一行同时出现 ferror=1 与 feof=0,说明「没数据」不是「结束」;第三行才是真正的 EOF。把 clearerr 去掉,第二次 fread 仍会返回 0 并保持错误状态。

非阻塞 stdio 的四个坑
  1. 「fread 返回 0 就是 EOF」——非阻塞 fd 上可能是 EAGAIN;看 ferror/feof。
  2. 「EAGAIN 后直接重试 fread」——流仍是错误状态,必须先 clearerr。
  3. 「fwrite 成功就写进内核了」——可能只在 stdio 缓冲区;错误在 fflush 才出现。
  4. 「epoll 可读就等于 fread 能读到」——stdio 缓冲区可能还有旧数据,事件触发与缓冲状态不同步。

2.4.14 内核路径:一次 printf 走到哪

这一节回答:从 printf 到磁盘/终端,中间经过哪些函数?为什么 strace 只看到 write?

glibc 内部

层次位置动作
格式化stdio-common/vfprintf-internal.c解析格式串,生成字符到 FILE 缓冲
写缓冲libio/fileops.c_IO_file_xsputn 把字节拷进 _IO_write_base..ptr
溢出libio/fileops.c_IO_file_overflow → _IO_do_write
系统调用libio/fileops.c_IO_file_write → write/writev
内核fs/read_write.cksys_write → vfs_write(见 2.2.17)
回写mm/page-writeback.c脏页由回写线程刷盘;fsync 才等待

printf 本身不做系统调用;只有缓冲区满、遇到换行(行缓冲)、fflush、fclose、exit 时才触发 write。strace -e write 看到的是 _IO_file_write 发出的那一次,不是 printf 的每一次。

fclose 的路径

fclose → _IO_new_fclose → _IO_file_close_it → _IO_do_flush(写回)→ _IO_file_close → close(fd) → filp_close → f_op->flush(如 NFS 提交)→ fput。所以 fclose 可能因为 write/close 失败而返回 EOF;错误可能来自磁盘满、NFS 提交失败或配额。

`exit` 与 `_exit` 的差别在 glibc 里是什么

exit 调用 __run_exit_handlers:按注册逆序执行 atexit/on_exit,然后 _IO_cleanup 刷新所有流,最后 _exit。_exit 直接系统调用,不刷 stdio、不跑 atexit。fork 后子进程用 exit 会把继承来的缓冲区再写一遍(2.4.5);用 _exit 才能避免。

2.4.15 两个实验:strace 看 flush 与 stdbuf 改缓冲

这一节回答:怎样用外部工具证明「缓冲存在」,以及怎样在不改代码的情况下改变它?

实验一:终端 vs 文件

# 写一个每行 sleep 的小程序
cat > tick.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void) {
    for (int i = 0; i < 3; i++) { printf("tick %d\n", i); sleep(1); }
    return 0;
}
EOF
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 tick.c -o tick

# 终端:行缓冲,每行一次 write
strace -e trace=write ./tick

# 重定向:全缓冲,退出时一次 write
strace -e trace=write ./tick > /tmp/tick.out

典型输出:

$ strace -e trace=write ./tick
write(1, "tick 0\n", 7) = 7
write(1, "tick 1\n", 7) = 7
write(1, "tick 2\n", 7) = 7
$ strace -e trace=write ./tick > /tmp/tick.out
write(1, "tick 0\ntick 1\ntick 2\n", 21) = 21

同一个程序,write 的次数和内容完全不同。strace 的输出是「内核收到了什么」,不是「程序调用了什么」。

实验二:stdbuf 与伪终端

# 强制无缓冲:每行一次 write
stdbuf -o0 ./tick > /tmp/t0.out
strace -e trace=write stdbuf -o0 ./tick > /dev/null

# 强制行缓冲:即使重定向到文件,也每行一次
stdbuf -oL ./tick > /tmp/tl.out

# 用 script 造一个伪终端,让 isatty 为真
script -qc './tick' /dev/null | cat

stdbuf 只对标准流生效,实现方式是 LD_PRELOAD;静态链接或自己 setvbuf 的程序不受影响。script 让程序以为自己连着终端,从而触发行缓冲——这也是「在 CI 里输出顺序和本地不同」的常见原因。

stdio 排障顺序
  1. 输出没出现:strace -e write 看有没有 write;没有就是缓冲区没刷。
  2. 顺序乱:查是否混用 write/printf;fflush(stdout) 后再 write。
  3. 最后一行丢了:查是否 _exit/崩溃/exec 前没 fflush。
  4. 重复输出:查 fork 前是否 flush、子进程是否用 exit。
  5. 写失败没报:查 fflush/fclose 返回值,错误可能延迟到最后。

2.4.16 本章陷阱清单

stdio 的十二个坑
  1. 现象:printf 后立刻 kill -9,日志最后一行没了。原因:数据在用户态缓冲区。修复:关键日志后 fflush,或用 _IONBF/write。
  2. 现象:重定向到文件后输出顺序变了。原因:全缓冲,write 先于 printf 的 flush。修复:混用前 fflush,或统一用一层。
  3. 现象:fread 返回 0,循环退出但数据没读完。原因:短读/EAGAIN 被当成 EOF。修复:看 ferror/feof,循环处理短读。
  4. 现象:fread(buf, sizeof(rec), 100, f) 返回 37,却读了 37 条半。原因:返回值是完整项数。修复:变长数据用 size=1。
  5. 现象:fclose 返回 EOF,之前 fprintf 都成功。原因:错误延迟到 flush。修复:检查 fflush/fclose。
  6. 现象:fork 后同一行打印两次。原因:缓冲区被复制,父子各 flush 一次。修复:fork 前 fflush,子进程 _exit。
  7. 现象:setvbuf 用了局部数组,之后崩溃。原因:缓冲区悬空。修复:用 malloc/静态数组,或传 NULL。
  8. 现象:snprintf 截断但程序没发现。原因:返回值是本应长度。修复:检查 ret >= size。
  9. 现象:printf("%s", user) 被写成 printf(user) 后崩溃/泄露。原因:格式化字符串漏洞。修复:永远把用户输入当参数。
  10. 现象:德语环境读 3.14 只得到 3。原因:LC_NUMERIC 用逗号作小数点。修复:机器可读数据固定 "C" locale。
  11. 现象:非阻塞 fd 上 fread 一直返回 0。原因:流的错误标志没清。修复:clearerr 后再试。
  12. 现象:getline 返回 -1 就认为读完。原因:可能是读错误。修复:feof/ferror 区分。

2.4.17 本章出口检查

检查项达标线没达标去哪
画出 FILE 缓冲区到 write 的路径能说出三种 flush 时机2.4.1
判断一个流默认是哪种缓冲说出 stderr/终端/文件三条规则2.4.2
解释 fread 返回值的单位说出「项数」而非字节2.4.4
说出 setvbuf 三种模式与默认规则能解释 st_blksize/BUFSIZ2.4.7、2.4.8
用 open_memstream 生成文本知道 fflush 后才能读 buf2.4.9
复现 fork 重复输出能给出 fflush 与 _exit 两种修复2.4.5
说出 fflush 与 fsync 的区别前者到内核,后者到磁盘2.4.3
即时自测 · 2.4

Q1 printf("hello\n") 之后立刻 _exit(0),终端上能看到,重定向到文件却看不到。为什么?

Q2 fread(buf, sizeof(struct rec), 100, f) 返回 37。这一定表示读了 37 条完整记录吗?

Q3 fork 前没有 fflush,子进程用 _exit,还会重复输出吗?父进程用 _exit 呢?

Q4 fclose(f) 返回 EOF,但之前的 fprintf 都返回了正数。错误是什么时候发生的?

Q5 setvbuf(f, buf, _IOFBF, sizeof buf) 里的 buf 是局部数组,f 在函数返回后继续使用。会怎样?

答案

A1 终端是行缓冲,\n 触发 flush,数据已经 write 出去;重定向到文件是全缓冲,\n 不触发,_exit 又不 flush,数据还在用户态缓冲区,随进程消失。

A2 不一定。返回值是「完整项」数,可能读了 37 个完整项加半个第 38 项;半个项可能留在流里。变长数据应使用 size=1 或自己解析长度。

A3 子进程 _exit 不 flush,所以不会写出重复的一份;父进程若也 _exit,父子都不 flush,缓冲区里的数据全部丢失(不重复,但也没了)。正确做法是父进程正常 exit 或显式 fflush。

A4 错误发生在 fclose 内部的 flush:fprintf 只写进了用户态缓冲区并返回字符数;真正 write 到磁盘满/配额超限时,fclose 才拿到 ENOSPC。所以必须检查 fclose 的返回值。

A5 缓冲区内存已经失效(栈帧被回收),后续 stdio 写入会踩坏别的栈数据,行为未定义。缓冲区必须用静态数组、堆内存,或传 NULL 让 libc 分配。

母本对应

母本章节本书落点程度
K第 13 章(文件 I/O 缓冲:stdio 缓冲、setvbuf、fflush)、第 25 章 25.4(fork/stdio/_exit)2.4.1–2.4.8、2.4.14–2.4.15完整(缓冲模式与 fork 交互)
POSIX/glibcgetline/getdelim、fmemopen/open_memstream、asprintf2.4.9–2.4.13母本未单列,按 POSIX.1-2008 与 glibc 手册
C第 10 章 10.10(带缓冲的 I/O)2.4.1–2.4.4对照(RIO 与 stdio)
O第 39 章(文件与目录)2.4.1背景(fd 与文件对象)
G——未用:本章不涉及权限/账号/进程管理
N——未用:本章不涉及网络栈

下一站:2.5 文件属性、扩展属性与 ACL 从「文件里有什么」转向「文件本身是什么」:stat 的字段、稀疏文件、xattr 与 ACL 的落盘方式。