3UNIX环境高级编程(第3版)

文件I/O缓冲

标准I/O库

stdio缓冲流

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第3章:文件I/O缓冲(File I/O Buffering)

一、导读

1.1 本章学习目标

第3章介绍标准I/O库(stdio)对底层I/O的封装。标准I/O库是C语言中最常用的I/O接口,它通过用户空间缓冲区减少了系统调用的次数,提供了更高级的接口(如fprintf、fgets等),并处理了行缓冲、全缓冲、无缓冲等不同策略。理解标准I/O库的内部机制,对于写出高效的I/O代码至关重要。

本章的核心学习目标包括:

  • 理解标准I/O库的缓冲机制:全缓冲、行缓冲、无缓冲三种策略分别在什么场景下使用?
  • 掌握流(FILE*)的概念:流是对文件描述符的高级封装,包含缓冲区状态信息
  • 学会控制缓冲行为:fflush、setvbuf、setbuf如何修改缓冲策略?
  • 掌握各种I/O函数:fopen/fclose、fgets/fputs、fread/fwrite、fprintf/fscanf
  • 理解标准I/O库的优缺点:何时使用标准I/O库?何时使用底层I/O?
  • 了解标准I/O库与底层I/O的互操作:fileno和fdopen如何实现两者的转换?

1.2 为什么本章如此重要

标准I/O库是C程序中最常用的I/O接口。printf、scanf、fopen、fgets等函数几乎出现在每一个C程序中。然而,很多程序员对这些函数的内部机制知之甚少,导致写出低效甚至有bug的代码。

例如,为什么printf输出到终端时是行缓冲的,而输出到文件时是全缓冲的?为什么在网络编程中使用fgets可能导致死锁?为什么printf之后立即read可能读不到数据?这些问题的答案都在于标准I/O库的缓冲机制。

此外,标准I/O库是POSIX标准的一部分,理解它的工作原理对于编写可移植的C程序至关重要。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

标准I/O库的缓冲区到底在哪里?大小是多少?

为什么printf("hello")可能不会立即显示在屏幕上?如何让输出立即显示?

fgets读取一行时,如果行长度超过缓冲区大小会发生什么?

标准I/O库的fread和底层I/O的read有什么本质区别?

为什么网络编程通常不使用标准I/O库?

fflush(stdout)和fsync(STDOUT_FILENO)有什么区别?


二、核心概念详解

2.1 标准I/O库的缓冲机制

标准I/O库的核心设计思想是减少系统调用次数。系统调用需要从用户态切换到内核态,开销较大(几百到几千个时钟周期)。标准I/O库在用户空间维护一个缓冲区,积累足够的数据后再一次性通过系统调用传递给内核。

2.1.1 三种缓冲类型

标准I/O库提供三种缓冲策略:

全缓冲(Full Buffering):积累数据直到缓冲区满才执行实际的I/O操作。普通文件通常使用全缓冲。

c
// 全缓冲示例
FILE *fp = fopen("data.txt", "w");  // 文件流默认全缓冲
fprintf(fp, "Hello");  // 数据写入用户缓冲区,未触发write系统调用
fprintf(fp, " World"); // 继续写入缓冲区
fclose(fp);  // fclose刷新缓冲区,触发write系统调用

行缓冲(Line Buffering):遇到换行符\n或缓冲区满时执行I/O操作。终端(stdin/stdout)通常使用行缓冲。

c
// 行缓冲示例
printf("Enter your name: ");  // 没有\n,但stdout是行缓冲
// 如果后面没有fflush或\n,提示可能不会立即显示
fflush(stdout);  // 强制刷新,确保提示显示

无缓冲(No Buffering):每次写操作立即执行I/O。stderr通常是无缓冲的,确保错误信息立即显示。

c
// 无缓冲示例
fprintf(stderr, "Error occurred!\n");  // 立即输出,不经过缓冲

2.1.2 缓冲区的分配和释放

当打开一个流(fopen)时,标准I/O库并不立即分配缓冲区。缓冲区在第一次I/O操作时由malloc分配。fclose时缓冲区被释放。

c
#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE *fp = fopen("test.txt", "w");
    // 此时缓冲区尚未分配
    
    fprintf(fp, "Hello");
    // 第一次I/O操作触发缓冲区分配
    // 缓冲区大小通常为8192字节(8KB)
    
    fclose(fp);
    // 缓冲区刷新并释放
    return 0;
}

2.2 流(FILE*)

流(stream)是标准I/O库的核心概念。FILE结构体封装了文件描述符、缓冲区指针、缓冲区大小、当前读写位置、错误标志等信息。

c
// FILE结构体的简化定义(实际实现因系统而异)
typedef struct _iobuf {
    int    _fd;        // 文件描述符
    char  *_buf;       // 缓冲区指针
    int    _bufsiz;    // 缓冲区大小
    char  *_ptr;       // 当前读写位置
    int    _cnt;       // 缓冲区中剩余字节数
    int    _flag;      // 状态标志(读/写/错误/EOF等)
} FILE;

程序员不需要直接操作FILE结构体的成员,而是通过标准I/O函数来操作流。这种封装隐藏了缓冲区的复杂管理逻辑。

2.3 fopen和fclose

c
#include <stdio.h>

FILE *fopen(const char *pathname, const char *mode);
int fclose(FILE *fp);

mode参数指定打开模式:

模式含义等价flags
"r"只读打开O_RDONLY
"w"只写打开,截断或创建O_WRONLY\O_CREAT\O_TRUNC
"a"追加打开O_WRONLY\O_CREAT\O_APPEND
"r+"读写打开O_RDWR
"w+"读写打开,截断或创建O_RDWR\O_CREAT\O_TRUNC
"a+"读和追加打开O_RDWR\O_CREAT\O_APPEND

可以在模式后加"b"表示二进制模式(如"rb"),但在POSIX系统上"b"被忽略。

c
// 打开文件进行读写
FILE *fp = fopen("data.txt", "r+");
if (fp == NULL) {
    perror("fopen");
    exit(1);
}

// 使用流...

fclose(fp);  // 关闭流,刷新缓冲区

注意:fclose在关闭流之前会刷新缓冲区。如果刷新失败(如磁盘满),fclose返回EOF。因此,对于重要的写入操作,应该检查fclose的返回值。

2.4 读和写

2.4.1 字符I/O

c
#include <stdio.h>

int fgetc(FILE *fp);
int fputc(int c, FILE *fp);

// 宏版本(可能更快,但不检查参数)
int getc(FILE *fp);
int putc(int c, FILE *fp);

fgetc返回下一个字符,到达文件末尾或出错时返回EOF。由于EOF是一个负值(通常为-1),而字符可能是unsigned char,因此返回值类型是int而不是char。

c
// 逐字符复制文件
int c;
while ((c = fgetc(fp_in)) != EOF) {
    fputc(c, fp_out);
}

2.4.2 行I/O

c
#include <stdio.h>

char *fgets(char *buf, int n, FILE *fp);
int fputs(const char *str, FILE *fp);

fgets读取最多n-1个字符,或者遇到换行符时停止(换行符会被保留在缓冲区中)。字符串以\0结尾。

c
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
    // 处理每一行
    // line中可能包含换行符
    printf("Line: %s", line);  // 不需要再加\n
}

注意:如果一行的长度超过n-1,fgets只会读取n-1个字符,剩余部分在下次调用时继续读取。这意味着一行可能被分成多次fgets调用。

2.4.3 二进制I/O

c
#include <stdio.h>

size_t fread(void *buf, size_t size, size_t nmemb, FILE *fp);
size_t fwrite(const void *buf, size_t size, size_t nmemb, FILE *fp);

fread和fwrite用于读写二进制数据(如结构体、数组)。它们返回成功读写的元素个数。

c
// 写入结构体数组
struct Record {
    int id;
    char name[32];
    double score;
};

struct Record records[100];
// 填充records...

FILE *fp = fopen("records.bin", "wb");
fwrite(records, sizeof(struct Record), 100, fp);
fclose(fp);

// 读取结构体数组
fp = fopen("records.bin", "rb");
struct Record loaded[100];
size_t n = fread(loaded, sizeof(struct Record), 100, fp);
printf("Read %zu records\n", n);
fclose(fp);

2.5 控制缓冲

c
#include <stdio.h>

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

2.5.1 fflush

fflush强制刷新流的缓冲区。fflush(NULL)刷新所有打开的输出流。

c
printf("Processing");
fflush(stdout);  // 立即显示"Processing"

// 常见用法:在fork前刷新,避免父子进程重复输出
fflush(stdout);
pid_t pid = fork();

重要:fork前必须刷新所有输出缓冲区。否则,缓冲区中的数据会被复制到子进程,父子进程都会输出这些数据,导致重复输出。

2.5.2 setvbuf

setvbuf在打开流之后、第一次I/O操作之前调用,用于设置缓冲策略:

c
FILE *fp = fopen("data.txt", "w");

// 设置为无缓冲
setvbuf(fp, NULL, _IONBF, 0);

// 设置为行缓冲
setvbuf(fp, NULL, _IOLBF, 0);

// 设置为全缓冲,使用自定义缓冲区
char mybuf[4096];
setvbuf(fp, mybuf, _IOFBF, sizeof(mybuf));

// 设置为全缓冲,让库函数分配缓冲区
setvbuf(fp, NULL, _IOFBF, 8192);

mode参数:

  • _IONBF:无缓冲
  • _IOLBF:行缓冲
  • _IOFBF:全缓冲

2.6 标准I/O与底层I/O的互操作

c
#include <stdio.h>
#include <unistd.h>

// 从FILE*获取文件描述符
int fileno(FILE *fp);

// 从文件描述符创建FILE*
FILE *fdopen(int fd, const char *mode);
c
// 示例:混合使用标准I/O和底层I/O
FILE *fp = fdopen(STDOUT_FILENO, "w");
fprintf(fp, "Hello via stdio\n");
fflush(fp);  // 必须先刷新标准I/O缓冲区

// 现在可以安全地使用底层I/O
write(STDOUT_FILENO, "Hello via syscall\n", 18);

重要注意事项:混合使用标准I/O和底层I/O时,必须注意缓冲区的同步。在从标准I/O切换到底层I/O之前,必须先fflush;在从底层I/O切换到标准I/O之前,可能需要调整文件偏移量。

2.7 格式化I/O

2.7.1 printf系列

c
#include <stdio.h>

int printf(const char *format, ...);
int fprintf(FILE *fp, const char *format, ...);
int sprintf(char *buf, const char *format, ...);
int snprintf(char *buf, size_t size, const char *format, ...);

snprintf是安全的字符串格式化函数,它确保不会超出缓冲区大小:

c
char buf[32];
// 安全:snprintf保证不超出buf大小
snprintf(buf, sizeof(buf), "User: %s, ID: %d", username, uid);

// 不安全:sprintf可能溢出
sprintf(buf, "User: %s, ID: %d", username, uid);  // 危险!

2.7.2 scanf系列

c
#include <stdio.h>

int scanf(const char *format, ...);
int fscanf(FILE *fp, const char *format, ...);
int sscanf(const char *str, const char *format, ...);
c
// 解析字符串
char input[] = "John 25 1.75";
char name[32];
int age;
double height;
sscanf(input, "%31s %d %lf", name, &age, &height);

// 注意:scanf的空白字符处理
// %s会跳过前导空白,读取到下一个空白为止
// 空格、制表符、换行符都是空白字符

scanf的常见陷阱:

c
int n;
char str[32];

// 问题:scanf("%d", &n)后,换行符留在输入缓冲区
scanf("%d", &n);
// 如果接下来用fgets读取字符串,会读到空行
fgets(str, sizeof(str), stdin);  // str = "\n"

// 解决:在格式字符串中加空格跳过空白
scanf("%d ", &n);  // 注意%d后的空格
fgets(str, sizeof(str), stdin);

三、实战案例

3.1 标准I/O与底层I/O的性能对比

c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <sys/time.h>

double get_time() {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec / 1000000.0;
}

void stdio_copy(const char *src, const char *dst) {
    FILE *fin = fopen(src, "rb");
    FILE *fout = fopen(dst, "wb");
    char buf[4096];
    size_t n;
    while ((n = fread(buf, 1, sizeof(buf), fin)) > 0) {
        fwrite(buf, 1, n, fout);
    }
    fclose(fin);
    fclose(fout);
}

void syscall_copy(const char *src, const char *dst) {
    int sfd = open(src, O_RDONLY);
    int dfd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    char buf[4096];
    ssize_t n;
    while ((n = read(sfd, buf, sizeof(buf))) > 0) {
        write(dfd, buf, n);
    }
    close(sfd);
    close(dfd);
}

int main() {
    double start, end;
    
    start = get_time();
    stdio_copy("largefile.bin", "out1.bin");
    end = get_time();
    printf("stdio: %.4f seconds\n", end - start);
    
    start = get_time();
    syscall_copy("largefile.bin", "out2.bin");
    end = get_time();
    printf("syscall: %.4f seconds\n", end - start);
    
    return 0;
}

结果分析:标准I/O库通常比底层I/O更快,因为它在用户态维护缓冲区,减少了系统调用次数。使用strace可以观察到:标准I/O版本只执行了少量的read/write调用,而底层I/O版本执行了大量调用。

3.2 自定义缓冲策略

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    // 创建自定义缓冲区
    char *mybuf = malloc(16384);  // 16KB缓冲区
    if (mybuf == NULL) {
        perror("malloc");
        exit(1);
    }
    
    FILE *fp = fopen("output.txt", "w");
    
    // 使用自定义缓冲区
    if (setvbuf(fp, mybuf, _IOFBF, 16384) != 0) {
        perror("setvbuf");
        exit(1);
    }
    
    // 写入数据
    for (int i = 0; i < 1000; i++) {
        fprintf(fp, "Line %d: This is a test line\n", i);
    }
    
    fclose(fp);  // 刷新并释放缓冲区
    free(mybuf);  // 注意:fclose后缓冲区已被释放,不要double free
    
    return 0;
}

3.3 安全的字符串处理

c
#include <stdio.h>
#include <string.h>

// 安全的字符串拼接
void safe_concat(char *dst, size_t dst_size, const char *src) {
    size_t dst_len = strlen(dst);
    if (dst_len < dst_size - 1) {
        snprintf(dst + dst_len, dst_size - dst_len, "%s", src);
    }
}

// 安全的字符串复制
void safe_copy(char *dst, size_t dst_size, const char *src) {
    snprintf(dst, dst_size, "%s", src);
}

int main() {
    char buf[32];
    
    safe_copy(buf, sizeof(buf), "Hello");
    safe_concat(buf, sizeof(buf), ", ");
    safe_concat(buf, sizeof(buf), "World!");
    safe_concat(buf, sizeof(buf), " This is a very long string that will be truncated.");
    
    printf("Result: %s\n", buf);
    // 输出: Result: Hello, World! This is a 
    
    return 0;
}

四、本章小结

第3章深入讲解了标准I/O库的内部机制。核心知识点包括:

三种缓冲类型:全缓冲(文件)、行缓冲(终端)、无缓冲(stderr)。理解缓冲类型对于控制I/O时机至关重要。

流(FILE*):封装了文件描述符、缓冲区、状态信息。标准I/O函数通过操作流来完成I/O。

缓冲控制:fflush强制刷新,setvbuf设置缓冲策略。fork前必须刷新缓冲区避免重复输出。

各种I/O函数:字符I/O(fgetc/fputc)、行I/O(fgets/fputs)、二进制I/O(fread/fwrite)、格式化I/O(printf/scanf)。

与底层I/O的互操作:fileno获取文件描述符,fdopen创建流。混合使用时注意缓冲区同步。

安全编程:优先使用snprintf而非sprintf,注意scanf的空白字符处理。


五、思考与练习

思考题

为什么stderr默认是无缓冲的?如果stderr是行缓冲的,会有什么问题?

fork后如果不刷新缓冲区,父子进程的输出为什么会出现重复?如何避免?

为什么网络编程通常不使用标准I/O库?标准I/O库的缓冲机制在网络场景下有什么缺点?

fflush(stdout)和fsync(STDOUT_FILENO)有什么区别?分别适用于什么场景?

编程练习

编写一个程序,使用setvbuf将标准输出设置为无缓冲,验证每次printf都立即显示。

实现一个程序,使用fgets逐行读取文件,统计文件的行数、单词数和字符数(类似wc命令)。

编写一个程序,使用fread/fwrite实现二进制文件的复制,并验证复制后的文件与原始文件完全相同。

使用strace跟踪你的程序,观察标准I/O库的系统调用模式,对比不同缓冲策略下的系统调用次数。


3/12文件I/
0%