Linux 文件 IO:缓冲区、重定向与一切皆文件 引言在第一篇中我们掌握了 Linux 文件 IO 的系统调用——open、read、write、close、dup2也理解了文件描述符fd背后的数据结构struct file和files_struct。现在我们要追问一个更根本的问题为什么 Linux 要把键盘、显示器、磁盘、管道、套接字这些完全不同的东西全部抽象成文件这个设计背后的机制是什么本章将给出答案4. 理解一切皆文件一、一切皆文件的两层含义第一层Windows 中是文件的东西Linux 中也是文件。普通文本文件、图片、可执行程序、目录——这些在 Windows 中就是文件的东西在 Linux 中同样是文件。第二层Windows 中不是文件的东西Linux 中也抽象成了文件。进程信息、磁盘分区、显示器、键盘、网卡、管道、套接字——这些在 Windows 中各有各的操作接口在 Linux 中全部被抽象成了文件可以用访问文件的方法来访问它们。二、这样做的好处一套 API操作一切资源开发者仅需要掌握一套 API即可调取 Linux 系统中绝大部分的资源。读操作无论是读普通文件、读系统状态/proc、读管道、读 socket都可以用read函数写操作无论是写普通文件、修改系统参数、写管道、写 socket都可以用write函数打开/关闭open和close适用于所有可被打开的资源这就是统一接口的巨大优势——学习成本极低代码复用性极高。三、底层实现机制struct filefile_operations一切皆文件不是靠嘴实现的而是靠内核中的两个核心数据结构3.1struct file之前学过每当打开一个文件时内核都会创建一个struct file对象来描述这次打开操作的状态偏移量、打开模式、引用计数等。这个结构体定义在linux/fs.h中。3.2file_operations—— 多态的函数指针表struct file中有一个关键成员f_op指针它指向一个file_operations结构体struct file { ... struct inode *f_inode; /* cached value */ const struct file_operations *f_op; ... atomic_long_t f_count; // 表⽰打开⽂件的引⽤计数如果有多个⽂件指针指向它就会增加f_count的值。 unsigned int f_flags; // 表⽰打开⽂件的权限 fmode_t f_mode; // 设置对⽂件的访问模式,例如只读只写等。所有的标志在头⽂件fcntl.h 中定义 loff_t f_pos; // 表⽰当前读写⽂件的位置 ... } __attribute__((aligned(4))); /* lest something weird decides that 2 is OK */struct file { // ... 其他成员 ... const struct file_operations *f_op; // ← 指向操作函数集 // ... }; struct file_operations { struct module *owner; ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); int (*open) (struct inode *, struct file *); int (*release) (struct inode *, struct file *); loff_t (*llseek) (struct file *, loff_t, int); // ... 还有很多函数指针 ... };moduleIX/kernel_3.10.0_fs.h · 光电笑映/Linux - 码云 - 开源中国这里是更详细说明的链接file_operations中除了owner之外其余成员全部是函数指针。每个函数指针对应一个系统调用read系统调用最终会调用f_op-readwrite系统调用最终会调用f_op-write。3.3 不同设备不同的实现关键在于不同的文件类型其file_operations中这些函数指针指向的函数是不同的。但对用户程序来说调用接口始终只有read(fd, buf, size)和write(fd, buf, size)。内核会根据 fd 找到struct file再通过f_op调用对应的函数——这就是面向对象中的多态在 C 语言中的经典实现。四、完整调用链​用户只调了一个write内核根据文件类型自动分派到不同的底层实现。这就是一切皆文件的核心机制。五、先描述再组织在一切皆文件中的体现六、总结一切皆文件的本质不是把所有东西都存进磁盘而是用统一的文件接口open/read/write/close来操作一切资源。内核通过struct file中的file_operations函数指针表实现了同一个接口不同底层实现的多态机制——对上提供统一的系统调用对下分派到不同的设备驱动函数。这就是 Linux 用一套 API 管理所有资源的底层秘密。​5.缓冲区上一篇 3.6 节提到的 page cache 是内核级缓冲区由 Linux 内核管理。本章重点讨论的是用户级缓冲区由 C 标准库管理。两层缓冲各司其职用户级缓冲减少系统调用次数内核级缓冲减少磁盘 IO 次数。5.1 什么是缓冲区缓冲区是内存空间中预留出来的一段存储空间用来临时存放输入或输出的数据。它不是磁盘上的区域而是内存的一部分。数据在从源头到目的地的路上先在缓冲区里歇个脚等时机合适再一次性搬走。5.2 为什么需要缓冲区一、没有缓冲区每次读写都触发系统调用如果读写文件时不使用缓冲区每次对文件进行一次读写操作都需要直接调用系统调用read/write来操作磁盘。一次系统调用的代价CPU 状态切换从用户空间切换到内核空间进程上下文切换保存当前进程状态加载内核执行环境磁盘 IO 等待磁盘的寻道和读写是毫秒级的CPU 只能空等如果每读写一个字节都走一遍这个流程程序的大量 CPU 时间将消耗在切换和等待上而不是真正的数据处理上。二、缓冲区的两大核心优势缓冲区的解决思路批量搬运核心思想一次多搬点减少搬运次数。计算机对缓冲区的操作内存读写远快于对磁盘的操作应用缓冲区可大幅提高计算机的运行速度。C 的 allocator 和 C 库的缓冲区是同一个思想在不同领域的不同应用缓冲区攒数据减少write次数allocator 攒内存减少brk/mmap次数。都是先向 OS 拿一大块自己管理按需分发目的都是减少用户态与内核态的频繁切换。四、生活类比打印机打印机是低速设备打印速度远慢于 CPU 的处理速度。没有缓冲区CPU 把一页文档发给打印机然后停下来等打印机一页页打完才能继续处理其他任务。有缓冲区CPU 把整份文档一次性发送到打印机的缓冲区中然后立刻返回处理其他任务。打印机从缓冲区中自行逐步打印不再占用 CPU。缓冲区使得低速的输入输出设备和高速的 CPU 能够协调工作避免低速设备拖累 CPU解放出 CPU 去处理其他任务。总结对于用户级缓冲区通过减少系统调用以提高程序效率对于内核缓冲区对于内核缓冲区通过减少磁盘 IO 次数以提高系统整体性能。5.3 缓冲类型一、标准 I/O 提供的三种缓冲模式标准 I/OC 标准库为FILE *流提供了三种缓冲类型控制数据何时从用户态缓冲区通过系统调用交给内核。1. 全缓冲填满整个缓冲区后才执行 I/O 系统调用。典型场景对磁盘文件的操作通常使用全缓冲刷新时机缓冲区满、手动fflush、文件关闭fclose、程序正常退出2. 行缓冲遇到换行符\n时执行 I/O 系统调用。典型场景流涉及终端时例如标准输入stdin和标准输出stdout连接到终端刷新时机遇到\n、缓冲区满、手动fflush、程序正常退出注意即使没遇到换行符只要缓冲区满了也会触发刷新3. 无缓冲不对字符进行缓存直接调用系统调用。系统称为写透模式典型场景标准错误流stderr目的错误信息需要立即显示不能被缓冲延迟每个字符都立即触发一次write系统调用刷新模式在5.4五有详细的例子讲解这里先引出概念二、缓冲模式总结表三、特殊情况也会触发缓冲区的刷新除了上述默认刷新方式外以下情况也会引发缓冲区刷新缓冲区满时执行fflush语句时调用fclose关闭文件时程序正常退出return或exit时四、验证实验缓冲区刷新时机#include stdio.h #include sys/types.h #include sys/stat.h #include fcntl.h #include unistd.h #include string.h int main() { close(1); int fd open(log.txt, O_CREAT | O_TRUNC | O_WRONLY, 0664); // 库函数写入 C 库缓冲区 printf(hello world\n); printf(hello world\n); printf(hello world\n); // 系统调用直接写入内核 page cache const char *msg this is write\n; write(fd, msg, strlen(msg)); // close(fd); // 先注释掉 return 0; }运行结果注释close(fd)log.txt 内容 this is write hello world hello world hello world运行结果保留close(fd)log.txt 内容 this is write五、现象分析为什么保留close(fd)后printf的三行数据消失了printf/fprintf/fputs等库函数写入的是 C 库的用户态缓冲区不是内核缓冲区。​既没有手动fflush也不满足行缓冲的刷新条件stdout 已被重定向到普通文件变成了全缓冲\n不再触发刷新close(fd)在return 0之前执行关闭了 fd1 的通道程序退出时 C 库尝试fflush调用write(1, ...)但 fd1 已经无效数据丢失解决方法在close(fd)之前强制刷新缓冲区。fflush(stdout); // 强制将 C 库缓冲区的数据通过 write 交给内核 close(fd);六、缓冲区在哪里——FILE结构体为什么任何一个被打开的文件都有一个缓冲区因为 C 库为每个打开的FILE *流在堆上malloc了一个FILE结构体这个结构体内部维护了缓冲区及其管理指针。FILE结构体中的缓冲区相关字段typedef struct _IO_FILE FILE; struct _IO_FILE { int _flags; // 标志位 char *_IO_read_ptr; // 当前读指针 char *_IO_read_end; // 读区域末尾 char *_IO_read_base; // 读区域起始 char *_IO_write_base; // 写区域起始 char *_IO_write_ptr; // 当前写指针 char *_IO_write_end; // 写区域末尾 char *_IO_buf_base; // 缓冲区起始地址 ← 这就是缓冲区 char *_IO_buf_end; // 缓冲区末尾 int _fileno; // 封装的底层文件描述符 fd // ... 更多字段 };缓冲区就是_IO_buf_base到_IO_buf_end之间的这段内存空间。读写指针在这段空间内移动决定当前读写位置。fopen的完整流程调用open系统调用获取 fd在堆上malloc一个FILE结构体在堆上malloc一块内存作为缓冲区默认 8192 字节_IO_buf_base指向它将 fd 存入_fileno根据文件类型设置缓冲模式返回FILE *指针给用户5.4 FILE —— C 库对 fd 的封装一、核心结论因为 I/O 相关函数与系统调用接口对应并且库函数封装系统调用所以本质上访问文件都是通过 fd 访问的。因此 C 库中的FILE结构体内部必定封装了 fd。_fileno字段就是open返回的那个文件描述符。二、printf/fwrite和write的本质区别printf/fwrite的用户态缓冲区是 C 标准库在系统调用之上二次加上的目的是减少系统调用次数提高性能。三、缓冲区不属于内核属于 C 标准库​计算机数据流动本质就是拷贝所以计算机运行效率发展如更快的 CPU 主频多级缓存L1/L2/L3更快的内存本质就是增加拷贝效率所有性能优化归根结底就两个方向提高单次拷贝速度减少不必要拷贝次数。四、强制将内核缓冲区刷新到文件方法核心系统调用注意fdatasync比fsync少刷一些 inode 信息适合只关心数据内容、不关心修改时间的场景。性能也比较高函数原型#include unistd.h int fsync(int fd); // 刷数据 元数据 int fdatasync(int fd); // 只刷数据 void sync(void); // 全系统刷盘 int syncfs(int fd); // 刷 fd 所在的文件系统O_SYNC打开文件时强制每次写入都同步int fd open(file.txt, O_WRONLY | O_CREAT | O_TRUNC | O_SYNC, 0644); write(fd, buf, len); // 这次 write 等价于 write fsync自动等磁盘确认一般只对数据库的 WAL 日志这类关键文件使用O_SYNCfsync(fd)和fflush的对比五、完整数据流​六、经典验证实验fork 复制缓冲区int main() { const char *msg0hello printf\n; const char *msg1hello fprintf\n; const char *msg2hello fwrite\n; const char *msg3hello write\n; //库函数 printf(%s, msg0); fprintf(stdout,%s,msg1); fwrite(msg2,strlen(msg2),1,stdout); //系统调用 write(1, msg3, strlen(msg3)); //fork(); return 0; }运行结果#无fork xqqubuntu-server:~/linux/moduleIX/testcache$ ./file hello printf #涉及终端行缓冲立即刷新 hello fprintf hello fwrite hello write xqqubuntu-server:~/linux/moduleIX/testcache$ ./file log.txt #文件操作全缓冲 xqqubuntu-server:~/linux/moduleIX/testcache$ cat log.txt hello write hello printf hello fprintf hello fwrite #加入fork之后 xqqubuntu-server:~/linux/moduleIX/testcache$ ./file hello printf hello fprintf hello fwrite hello write xqqubuntu-server:~/linux/moduleIX/testcache$ ./file log.txt xqqubuntu-server:~/linux/moduleIX/testcache$ cat log.txt hello write#系统调用打印一次 hello printf#库函数打印两次 hello fprintf hello fwrite hello printf hello fprintf hello fwrite场景一没有 fork直接输出到终端标准输出为终端此时stdout是行缓冲。printf、fprintf、fwrite输出的字符串都以\n结尾所以每调用一个函数缓冲区被换行符触发刷新立即写入终端。write是系统调用直接写入文件描述符 1。由于四个函数依次调用并立即输出顺序严格按照代码逻辑。重定向到文件 log.txt当stdout被重定向到普通文件时标准 IO 变为全缓冲。printf、fprintf、fwrite的数据并没有立刻写入文件而是暂存在stdout的缓冲区中。write是系统调用没有用户态缓冲直接写入内核并最终落入文件。所以它第一个到达文件。当main结束return 0时C 运行时调用exit它会冲刷所有标准 IO 流此时stdout缓冲区中的三条消息才被写入文件顺序就是它们进入缓冲区的顺序。场景二加入 fork直接输出到终端输出与没有fork完全一样四条消息各出现一次顺序正确。原因行缓冲时fork执行前缓冲区已经被\n刷空父子进程都没有残留的缓冲区数据fork不影响终端输出。重定向到文件 log.txt重定向到文件即输出到文件里对磁盘文件的操作通常使用全缓冲所以缓冲模式从原本向stdout的行缓重变成了全缓冲这里发生了关键变化write(系统调用)只出现一次而库函数的三条消息全部重复了一次。原因分析重定向后stdout全缓冲。printf、fprintf、fwrite三条消息进入缓冲区尚未写入文件。write是系统调用无缓冲此时直接写入文件所以它最先出现在文件里并且只出现一次。随后执行fork()fork会完整复制父进程的地址空间包括stdout的用户态缓冲区。此时父子进程各自拥有一份相同的缓冲区里面都包含了那三条库函数消息。父进程、子进程各自最终执行return 0→exit→ 冲刷stdout。于是两份完全相同的缓冲区内容被先后写入同一个文件。文件里就出现了两组hello printf / hello fprintf / hello fwrite。重定向的底层原理dup2 替换 fd[1] 的指向已在上一篇 3.7 节详细讲解此处不再展开。5.5 简单设计⼀下libc库实现代码:mystdio.c#includemystdio.h //C 语言只给用户头文件.h声明和编译好的库文件.so/.a二进制实现 //。源码.c不公开。用户拿着头文件编译自己的代码链接器把库的机器码和用户的机器码合并成可执行文件 //static 让 BuyFile 成为 mystdio.c 的私有函数 //用户只能通过 MyFopen 间接使用它不能直接调用。 //这实现了封装避免了符号冲突和面向对象的 private 是一个道理 static MYFILE* BuyFile(int fd,int mode) { MYFILE*file(MYFILE*)malloc(sizeof(MYFILE)); if(fileNULL) { perror(malloc fail!); return NULL; } memset(file-out,0,sizeof(file-out)); file-bufflen0; file-filenofd; file-flagmode; // 如果是终端设备用行缓冲否则用全缓冲 if (isatty(fd)) file-flush_method LINE_FLUSH; else file-flush_method FULL_FLUSH; return file; } MYFILE* MyFopen(const char *pathname, const char *mode) { int fd-1; int _mode0; if(strcmp(mode,w)0) { _modeO_WRONLY|O_CREAT|O_TRUNC; fdopen(pathname,_mode,0666); } else if(strcmp(mode,r)0) { _modeO_RDONLY; fdopen(pathname,_mode); } else if(strcmp(mode,a)0) { _modeO_WRONLY|O_CREAT|O_APPEND; fdopen(pathname,_mode,0666); } else if(strcmp(mode,w)0) { _modeO_RDWR|O_CREAT|O_TRUNC; fdopen(pathname,_mode,0666); } else if(strcmp(mode,r)0) { _modeO_RDWR; fdopen(pathname,_mode); } else if(strcmp(mode,a)0) { _modeO_RDWR|O_CREAT|O_APPEND; fdopen(pathname,_mode,0666); } else { //... } if(fd0) return NULL; return BuyFile(fd,_mode); } void MyFclose(MYFILE *stream) { if(streamNULL||stream-fileno0) return; MyFFlush(stream); close(stream-fileno); free(stream); } //传入 onst char * 类型的字符串不需要强制类型转换 int MyFwrite(MYFILE*file,const void* src,int len) {//写入就是拷贝 memcpy(file-outfile-bufflen,src,len); file-bufflenlen; //写入尝试刷新 if((file-flush_method LINE_FLUSH)file-out[file-bufflen-1]\n) { MyFFlush(file); } return 0; } void MyFFlush(MYFILE*file) { if(fileNULL||file-bufflen0) return; //将缓冲区里的数据写到内核缓冲区里 int nwrite(file-fileno,file-out,file-bufflen); if(n0) perror(write); file-bufflen0; memset(file-out,0,MAX); }mystdio.h#pragma once #includestdio.h #includeunistd.h #include sys/types.h #include sys/stat.h #include fcntl.h #includestring.h #includestdlib.h #define MAX 1024 //定义刷新模式 #define NONE_FLUSH (10) #define LINE_FLUSH (11) #define FULL_FLUSH (12) typedef struct IO_FIILE { int fileno;//文件描述符 int flag;//打开方式 char out[MAX];//缓冲区 int bufflen;//有效元素 int flush_method; }MYFILE; MYFILE* MyFopen(const char *pathname, const char *mode); void MyFclose(MYFILE *stream); int MyFwrite(MYFILE*,const void* str,int len); void MyFFlush(MYFILE*);usercode.c#includemystdio.h int main() { const char* msghello world\n; MYFILE* fpMyFopen(test.txt,a); if(!fp) { perror(MyFopen fail); return 1; } int cnt10; while(cnt--) { MyFwrite(fp,msg,strlen(msg)); printf(缓冲区%s\n,fp-out); //MyFFlush(fp);//下面进行讲解 sleep(1); } MyFclose(fp); return 0; }验证缓冲区监测脚本while :; do cat test.txt;sleep 1;echo #########################;done可以看到缓冲区越来越满此时文件是空的直到循环结束调用MyFclose(fp)强制刷新缓冲区这样一次性全部刷到了系统内核缓冲区写入磁盘文件加上MyFFlush(fp);后