第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操作。普通文件通常使用全缓冲。
// 全缓冲示例
FILE *fp = fopen("data.txt", "w"); // 文件流默认全缓冲
fprintf(fp, "Hello"); // 数据写入用户缓冲区,未触发write系统调用
fprintf(fp, " World"); // 继续写入缓冲区
fclose(fp); // fclose刷新缓冲区,触发write系统调用行缓冲(Line Buffering):遇到换行符\n或缓冲区满时执行I/O操作。终端(stdin/stdout)通常使用行缓冲。
// 行缓冲示例
printf("Enter your name: "); // 没有\n,但stdout是行缓冲
// 如果后面没有fflush或\n,提示可能不会立即显示
fflush(stdout); // 强制刷新,确保提示显示无缓冲(No Buffering):每次写操作立即执行I/O。stderr通常是无缓冲的,确保错误信息立即显示。
// 无缓冲示例
fprintf(stderr, "Error occurred!\n"); // 立即输出,不经过缓冲2.1.2 缓冲区的分配和释放
当打开一个流(fopen)时,标准I/O库并不立即分配缓冲区。缓冲区在第一次I/O操作时由malloc分配。fclose时缓冲区被释放。
#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结构体封装了文件描述符、缓冲区指针、缓冲区大小、当前读写位置、错误标志等信息。
// 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
#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"被忽略。
// 打开文件进行读写
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
#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。
// 逐字符复制文件
int c;
while ((c = fgetc(fp_in)) != EOF) {
fputc(c, fp_out);
}2.4.2 行I/O
#include <stdio.h>
char *fgets(char *buf, int n, FILE *fp);
int fputs(const char *str, FILE *fp);fgets读取最多n-1个字符,或者遇到换行符时停止(换行符会被保留在缓冲区中)。字符串以\0结尾。
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
#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用于读写二进制数据(如结构体、数组)。它们返回成功读写的元素个数。
// 写入结构体数组
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 控制缓冲
#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)刷新所有打开的输出流。
printf("Processing");
fflush(stdout); // 立即显示"Processing"
// 常见用法:在fork前刷新,避免父子进程重复输出
fflush(stdout);
pid_t pid = fork();重要:fork前必须刷新所有输出缓冲区。否则,缓冲区中的数据会被复制到子进程,父子进程都会输出这些数据,导致重复输出。
2.5.2 setvbuf
setvbuf在打开流之后、第一次I/O操作之前调用,用于设置缓冲策略:
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的互操作
#include <stdio.h>
#include <unistd.h>
// 从FILE*获取文件描述符
int fileno(FILE *fp);
// 从文件描述符创建FILE*
FILE *fdopen(int fd, const char *mode);// 示例:混合使用标准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系列
#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是安全的字符串格式化函数,它确保不会超出缓冲区大小:
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系列
#include <stdio.h>
int scanf(const char *format, ...);
int fscanf(FILE *fp, const char *format, ...);
int sscanf(const char *str, const char *format, ...);// 解析字符串
char input[] = "John 25 1.75";
char name[32];
int age;
double height;
sscanf(input, "%31s %d %lf", name, &age, &height);
// 注意:scanf的空白字符处理
// %s会跳过前导空白,读取到下一个空白为止
// 空格、制表符、换行符都是空白字符scanf的常见陷阱:
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的性能对比
#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 自定义缓冲策略
#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 安全的字符串处理
#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库的系统调用模式,对比不同缓冲策略下的系统调用次数。