前两篇已经从两个层次学习了 Linux 文件 I/O:
第一层:
fopen / fread / fwrite / fclose
第二层:
open / read / write / close
这一天继续向下研究,我遇到了一个非常经典的问题:
为什么同样是输出,printf、fwrite 和 write 的行为有时候不一样?
这背后就涉及:
缓冲区
一、为什么需要缓冲区?
假设程序不断读取磁盘:
read
read
read
read
read
…
如果每一次操作都直接发起一次系统调用,就会频繁发生:
用户态
↓
内核态
↓
返回用户态
↓
再次进入内核态
这种用户态和内核态之间的切换是有成本的。
而磁盘等 I/O 设备本身速度相对较慢。
所以如果每次只读一点点数据,效率会很低。
引入缓冲区的主要目的就是:
减少系统调用次数,提高 I/O 效率。
二、缓冲区到底是什么?
其实缓冲区就是:
内存中的一块空间。
用于临时保存输入或者输出的数据。
例如:
程序
↓
缓冲区
↓
磁盘
程序不一定每写一点数据就立即访问磁盘。
而是可以:
先积累
↓
达到一定条件
↓
再一次性写入
这样可以明显减少 I/O 次数。
三、标准 I/O 的三种缓冲方式
1. 全缓冲
当缓冲区填满后才进行实际 I/O。
磁盘文件通常使用这种方式。
2. 行缓冲
遇到换行符时,就可能进行 I/O。
终端相关的标准输入输出通常采用这种方式。
3. 无缓冲
不进行额外缓存,直接进行系统调用。
stderr 通常是不带缓冲的。
四、这能解释一个非常经典的问题
为什么:
printf("hello");
有时候屏幕上马上看不到?
因为:
printf
↓
标准 I/O 缓冲区
↓
还没有满足刷新条件
↓
数据暂时没有真正写出去
而:
fflush(stdout);
可以主动刷新。
所以在上一篇进度条的学习中,我们经常看到:
fflush(stdout);
现在终于知道它真正的意义了:
把标准输出缓冲区中的数据主动刷出去。
五、重定向以后,缓冲方式还会发生变化
正常输出到显示器:
stdout
↓
终端
通常采用:
行缓冲
但是如果:
./myfile > log.txt
发生重定向:
stdout
↓
普通文件
那么缓冲方式可能变成:
全缓冲
于是:
printf("hello world\\n");
虽然带有换行,也不一定马上写进文件。
六、为什么程序结束后文件里可能什么都没有?
例如:
close(1);
int fd = open("log.txt",
O_WRONLY | O_CREAT | O_TRUNC,
0666);
printf("hello world: %d\\n", fd);
close(fd);
return 0;
此时可能出现:
log.txt
存在,但是里面没有预期的内容。
为什么?
因为:
stdout
↓
已经变成全缓冲
↓
数据还在用户缓冲区
↓
缓冲区没有填满
↓
没有自动刷新
这种情况下可以使用:
fflush(stdout);
强制刷新缓冲区。
七、stderr 为什么又不一样?
如果改成:
close(2);
int fd = open("log.txt",
O_WRONLY | O_CREAT | O_TRUNC,
0666);
perror("hello world");
因为 stderr 通常是不带缓冲的,所以数据可以直接写到文件。
所以可以先形成一个简单认识:
stdout
通常有缓冲
stderr
通常无缓冲
八、最经典的问题:fork + printf 为什么会重复输出?
这一部分是我觉得整个文件 I/O 学习中最有意思的地方。
看这样一段代码:
printf("hello printf\\n");
fwrite("hello fwrite\\n", ...);
write(1, "hello write\\n", ...);
fork();
当正常输出到终端时,可能看到:
hello printf
hello fwrite
hello write
但如果:
./hello > file
进行重定向,结果可能变成:
hello write
hello printf
hello fwrite
hello printf
hello fwrite
为什么:
printf
fwrite
输出了两次,而:
write
只输出一次?
九、真正原因:用户级缓冲区 + fork
关键在这里:
printf / fwrite
属于 C 库函数。
它们有自己的:
用户级缓冲区。
而:
write
是系统调用,并不使用这个 C 标准库缓冲区。
当:
stdout → 普通文件
以后,标准 I/O 采用全缓冲。
所以:
printf
fwrite
写入的内容可能还停留在缓冲区里面。
然后:
fork
发生。
因为 fork 存在写时拷贝:
父进程
↓
缓冲区一份
子进程
↓
缓冲区也得到一份
于是:
父进程退出
↓
刷新一次
子进程退出
↓
再刷新一次
结果:
printf
fwrite
的数据就被写了两遍。
而:
write
没有这层用户级缓冲,所以只写了一次。
十、这也解释了为什么 printf 和 write 不一样
现在我们可以建立:
printf
↓
C标准库
↓
用户级缓冲区
↓
write等系统调用
而:
write
↓
直接进入系统调用
所以:
printf/fwrite
→ 有用户级缓冲
write
→ 没有这层C库缓冲
这也是理解 FILE 和文件描述符关系的关键。
十一、FILE 里面到底有什么?
前面一直在使用:
FILE *fp;
现在终于可以继续深入。
下面是Glibc 中 _IO_FILE 的结构。
其中可以看到:
_IO_read_ptr
_IO_read_end
_IO_write_base
_IO_write_ptr
_IO_write_end
_IO_buf_base
_IO_buf_end
这些字段都和缓冲区有关。
同时还有一个非常关键的字段:
_fileno
也就是:
文件描述符。
十二、所以 FILE 和 fd 是什么关系?
现在可以总结为:
FILE
↓
C标准库中的高级抽象
↓
内部封装 fd
↓
系统调用
↓
Linux内核
也就是说:
FILE 并不是替代文件描述符。
而是:
在文件描述符的基础上增加了一层更方便的 C 标准库抽象。
因为 C 库的 I/O 最终还是通过文件描述符完成,所以 FILE 内部必然封装了 fd。
十三、重新理解“一切皆文件”
现在再回来看:
Linux 下一切皆文件。
这个概念已经不再只是一个口号。
它背后的核心之一,就是 Linux 尝试使用统一的文件接口访问不同资源。
例如:
普通文件
磁盘
键盘
显示器
管道
网络相关资源
设备
很多资源都可以通过类似:
open
read
write
这样的接口访问。
不同设备可以拥有各自的 read、write 实现
十四、自己设计一个简单 libc
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
static MyFile* BuyFile(int fd, int flag)
{
MyFile* f = (MyFile*)malloc(sizeof(MyFile));
if(f == NULL) return NULL;
f->bufferlen = 0;
f->fileno = fd;
f->flag = flag;
f->flush_method = LINE_FLUSH;
memset(f->outbuffer, 0, sizeof(f->outbuffer));
return f;
}
MyFile* MyFopen(const char* path, const char* mode)
{
int fd = –1;
int flag = 0;
if(strcmp(mode, "w") == 0)
{
flag = O_CREAT | O_WRONLY | O_TRUNC;
fd = open(path, flag, 0666);
}
else if(strcmp(mode, "a") == 0)
{
flag = O_CREAT | O_WRONLY | O_APPEND;
fd = open(path, flag, 0666);
}
else if(strcmp(mode, "r") == 0)
{
flag = O_RDONLY;
fd = open(path, flag);
}
else
{
//TODO
}
if(fd < 0) return NULL;
return BuyFile(fd, flag);
}
void MyFclose(MyFile* file)
{
if(file->fileno < 0) return;
MyFFlush(file);
close(file->fileno);
free(file);
}
int MyFwrite(MyFile* file, void* str, int len)
{
// 1. 拷贝
memcpy(file->outbuffer + file->bufferlen, str, len);
file->bufferlen += len;
// 2. 尝试判断是否满足刷新条件
if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen – 1] == '\\n')
{
MyFFlush(file);
}
return 0;
}
void MyFFlush(MyFile* file)
{
if(file->bufferlen <= 0) return;
int n = write(file->fileno, file->outbuffer, file->bufferlen);
(void)n;
fsync(file->fileno);
file->bufferlen = 0;
}
十五、学习总结
现在回头把整个 Linux I/O 链条串起来:
应用程序
↓
printf / fwrite / fread
↓
FILE
↓
C标准库缓冲区
↓
文件描述符 fd
↓
read / write / open / close
↓
Linux系统调用
↓
内核中的 struct file
↓
file_operations
↓
具体文件 / 设备
这个关系一旦理解,之前很多看似零散的知识就全部串起来了。
以前我对 Linux I/O 的理解只是:
打开文件
↓
读取
↓
写入
↓
关闭
现在开始知道:
一个简单的 printf(),背后其实隐藏着标准库、缓冲区、文件描述符、系统调用以及 Linux 内核中的文件对象。
而这可能就是学习 Linux 系统编程最有意思的地方:
不断从一个简单的 API 往下挖,直到看到它背后的操作系统机制。




