Linux基础IO(下):文件重定向和缓冲区
文章目录
- 文件描述符fd
- 先描述,再组织
- 访问文件的本质
- 0 & 1 & 2
- 文件描述符的分配规则
- Linux中一切皆文件
- 重定向
- 1 VS 2
- 重定向的原理
- 利用指令重定向
- 利用函数重定向
- 缓冲区
- 发现问题,提出疑问
- 缓冲区刷新策略
- 解惑
文件描述符fd
通过对open函数的学习,我们知道了文件描述符就是一个int类型的整数。这是很令人困惑的,这玩意到底是个啥呢?
我们对比一下C文件接口fopen的返回值FILE和系统调用接口的返回值fd,会发现,fd和FILE的本质好像差不多,作用都是指向一个具体文件,让其他文件操作接口确定目标文件对象。
事实上,文件描述符fd
是系统层面的标识符,而FILE
类型必然包含了这个成员。
FILE的本质就是一个自定义的结构体struct,其对fd进行封装,fd是它的一个成员变量。
C库中FILE部分源码如图:
先描述,再组织
OS为了确保高效性,势必会对被打开的文件进行统一的管理。根据先描述,再组织的原则,OS将所有被打开的文件统一视为file对象(struct file),这个对象包含了所有描述文件的属性信息和指向下一个与上一个struct file的指针,使得所有被打开的文件以双链表的数据结构组织起来。
图解如下:
访问文件的本质
要想探究fd的本质,我们得先来看看访问文件的本质,图解如下(这张图是精髓):
现在我们知道,⽂件描述符就是从0开始的小整数。当我们打开⽂件时,操作系统在内存中要创建相应的,数据结构来描述⽬标⽂件。于是就有了file结构体。表⽰⼀个已经打开的⽂件对象。⽽进程执⾏open系统调⽤,所以必须让进程和⽂件关联起来。每个进程都有⼀个指针*files,指向⼀张表files_struct,该表最重要的部分就是包含⼀个指针数组,每个元素都是⼀个指向打开⽂件的指针!
所以,本质上,文件描述符就是该数组的下标。所以,只要拿着⽂件描述符,就可以找到对应的⽂件。
以上原理结论我们可通过内核源码验证:
注意:
- fd_array[]不存在的文件地址不一定未被打开,因为其他进程可能打开了这个文件。判断一个文件是否被打开,应该观察的是struct file中的引用计数。
0 & 1 & 2
Linux进程默认情况下会有3个缺省打开的文件描述符:
- 0:标准输入(stdin),从键盘读取数据
- 1:标准输出(stdout),将数据输出至显示器中
- 2:标准错误(stderr),将可能存在的错误信息输出至显示器中
0,1,2对应的物理设备⼀般是:键盘,显示器,显示器
还记得学习C语言的时候讲过:C程序默认在启动的时候,会打开三个标准输入输出流stdin、stdout、stderr。
现在我们来想一想,这是C语言的特性吗?
显然不是,是我们操作系统的特性!
文件描述符的分配规则
在files_struct数组当中,进程打开一个文件时,会找到当前没有被使用的最小的⼀个下标,作为新的文件描述符。
比如说,我们先关闭一个默认打开的标准输入0,然后再打开一个文件,然后我们再来查看一下该文件的文件描述符是否为0。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{close(0);int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);printf("%d\n", fd);close(fd);return 0;
}
结果的确为0
Linux中一切皆文件
如何理解这个概念呢?
- 现象:即使是像硬件(如键盘(标准输入)、显示器(标准输出)),在OS看来,都是一个一个的file对象
- 原理:无论是硬件还是软件,对于OS来说,只需要提供相应的读方法和写方法即可对其进行驱动,打开该文件流后,将file*存入fd_array中即可,因此在Linux中,一切皆文件。
图解如下:
重定向
1 VS 2
stdout和stdin居然都是显示器的文件,那它们有什么区别呢?
本质上它们并没有什么区别,都是写入信息到显示器屏幕。但是,它们两者是相互独立的,你关闭1,并不会影响2写入信息到屏幕,但你却无法通过1写入信息到屏幕了。
原理就是它们都要独属于自己的文件描述符,你关闭一个显示器文件并不代表会真的关闭显示器。
那标准输出 与 标准错误 都是向显示器中输出数据,为什么不合并为一个呢?
因为我们在进行排错时,可能需要单独查看错误信息,若是合并在一起,查看日志会十分麻烦。
但如果分开后,我们就可以通过重定向,将错误信息单独导入一个文件中,查看错误信息。
重定向的原理
做一个小测试:关闭1(stdout),然后再打开一个文件,然后我们再用printf打印信息到stdout,看看会发生什么?
代码如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>int main()
{close(1);int fd = open("myfile", O_WRONLY|O_CREAT, 00644);if(fd < 0){perror("open");return 1;}printf("fd: %d\n", fd);fflush(stdout);close(fd);return 0;
}
分析:根据文件描述符的顺序分配规则,后续我们打开文件的fd会被分配为1,那我们打印的信息显然不会到显示器,那会不会打印到我们打开的文件上呢?
结果:
printf果然打印在myfile上了,原因很简单,printf实际上就是固定将信息打印到文件描述符1的函数,最终信息会被打印到哪,取决于下标为1的元素是哪个文件。
这里,其实我们就实现了一个重定向了(stdout->myfile)。
图解如下:
这里逻辑就是重定向原理的雏形。
Linux中重定向实现原理比这简单粗暴多了,不需要关闭任何文件,一波“偷梁换柱”:以这里的stdout-myfile为例,将myfile的fd指向的内容(file地址)拷贝至stdout的fd指向的内容即可。
图解如下:
总结:文件重定向本质就是文件描述符的拷贝
利用指令重定向
重定向指令使用方式如下:
>
:输出重定向
将屏幕数据输出至指定文件myfile中:
echo hello linux! > myfile
>>
:输出追加重定向
将屏幕数据追加输出至指定文件myfile中:
echo hello linux! >> myfile
<
:输入重定向
从 myfile中读取数据,而非键盘:
cat < myfile
以上就是重定向指令的基本用法,然而解决实际问题时,可能需要更高级的用法。
下面以重定向标准输出和标准错误为例:
首先,标准输出和标准错误都是向显示器上打印的
#include <stdio.h>int main()
{//标准输出fprintf(stdout, "I an normal message!\n");fprintf(stdout, "I an normal message!\n");fprintf(stdout, "I an normal message!\n");fprintf(stdout, "I an normal message!\n");//标准错误fprintf(stderr, "I an error message!\n");fprintf(stderr, "I an error message!\n");fprintf(stderr, "I an error message!\n");fprintf(stderr, "I an error message!\n");return 0;
}
利用命令行只对标准输出进行重定向,file.txt 中只收到了来自标准输出的数据
./fd_rule > myfile
这是因为 标准输出 与 标准错误 是两个不同的 fd,现在只重定向了 标准输出 1。
现在对标准错误也进行重定向,打印内容至myfile
./fd_rule > myfile 2>&1
解读命令:
这里命令进行了部分省略,完整命令如下:
./fd_rule 1>myfile 2>&1
命令是从左向右解析的,1>myfile
是将1的数据输出至myfile中(1指向myfile),然后2>&1
是将1指向的内容拷贝给2指向的内容,最后1和2都指向myfile。
将标准输出打印至myfile,标准错误打印在myerr
分别对标准输出和标准错误进行重定向:
./fd_rule myfile 2>myerr
不省略:
./fd_rule 1>myfile 2>myerr
利用函数重定向
在实际开发中进行重定向操作时,使用的是系统调用接口 dup2
函数原型
#include <unistd.h>int dup2(int oldfd, int newfd);
函数解析
函数行为:将oldfd指向的内容拷贝到newfd指向的内容。
掌握重定向原理的我们知道,这样的行为产生的结果是:newfd->oldfd。
重定向后,只剩下oldfd了,因为newfd的内容被覆写为oldfd了。
这里参数设计感觉不太合理,因为一般都是老的文件重定向为新的文件,而这参数1oldfd表示新的文件fd,而newfd代表老的fd。我们就反着记就好了。
返回值:重定向成功,返回newfd,失败返回-1.
例如,上文的将标准输出和标准错误分别重定向至myfile和myerr:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>int main()
{int fd1 = open("myfile", O_WRONLY|O_CREAT|O_TRUNC, 0666);if(fd1 < 0) return 1;int fd2 = open("myerr", O_WRONLY|O_CREAT|O_TRUNC, 0666);if(fd2 < 0) return 1;dup2(fd1, 1);dup2(fd2, 2);//标准输出fprintf(stdout, "I an normal message!\n");fprintf(stdout, "I an normal message!\n");fprintf(stdout, "I an normal message!\n");fprintf(stdout, "I an normal message!\n");//标准错误fprintf(stderr, "I an error message!\n");fprintf(stderr, "I an error message!\n");fprintf(stderr, "I an error message!\n");fprintf(stderr, "I an error message!\n");close(fd1);close(fd2);return 0;
}
重定向成功:
缓冲区
发现问题,提出疑问
先来看一段代码:
分别用了两个C库函数和一个系统接口像显示器输出内容,在代码结尾还调用了fork函数,
#include <stdio.h>
#include <unistd.h>
int main()
{//cprintf("hello printf\n");fputs("hello fputs\n", stdout);//systemwrite(1, "hello write\n", 12);fork();return 0;
}
运行代码,发现printf、fputs和write函数都成功将对应的内容输出到了显示器上:
可是,当我们将程序输出到显示器的内容重定向到file.txt文件中后,发现文件当中的内容与我们直接打印输出到显示器的内容居然是不一样的:
为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口打印的内容还是原来的一份呢?
这肯定和fork有关!但是我们需要先了解缓冲区的刷新策略。
缓冲区刷新策略
缓冲区有多种刷新策略,大体分为以下三种:
- 无缓冲:直接刷新
标准错误stderr通常是该刷新策略,这使得出错信息能够尽快地显⽰出来。 - 行缓冲:不刷新,直到遇到\n才刷新
标准输⼊和标准输出一般采用这个刷新策略 - 全缓冲:不刷新,直到缓冲区满了,才刷新
对于磁盘⽂件的操作通常使⽤全缓冲的⽅式访问。
注意:
- 进程退出的时候,也会刷新缓冲区
- 执行fflush语句,也会刷新缓冲区
缓冲区在哪里?
我们常说,printf是将数据打印到stdout里面,而stdout就是一个FILE*的指针,我们知道,FILE就是一个封装文件描述符的一个结构体,其实这个结构体中含有一大部分成员用于记载缓冲区相关信息,如下:
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
可以联想到进程地址空间的空间区域划分,也是用start和end指针划分的!看来这个方法看似简单,实则非常实用。
也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。
操作系统有缓冲区吗?
在学习进程终止的exit函数和_exit函数时,我们判定了缓冲区一定不在操作系统内核中,因为系统调用接口_exit没有办法刷新缓冲区。
其实,操作系统实际上也是有缓冲区的,因为我们平时接触的都是用户级的缓冲区(如上文C语言提供的),所以我们判定一定不在操作系统的缓冲区是用户级的缓冲区。
当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统的缓冲区里面,然后再由操作系统将数据刷新到磁盘或是显示器上。
操作系统有自己的刷新机制,我们不关心操作系统的刷新策略。
为何一定要经过操作系统?
这是因为操作系统是进行软硬件资源管理的软件,用户区的数据要想刷新到外设必须要经过操作系统,如图:
解惑
现在我们可以对前文的问题作出解答了:
为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口打印的内容还是原来的一份呢?
- 当我们直接执行代码,将数据打印到显示器时所采的刷新策略是行缓冲,因为代码打印的每句话后面都要\n,所以我们执行完对应代码后就立即将数据刷新到了显示器上
- 但当我们将运行结果重定向到file.txt文件时,数据的刷新策略就变成了全缓冲,此时我们使用C库的printf和fputs函数打印的数据都打印到了C语言提供的用户级缓冲区中,之后fork函数创建子进程,由于进程间具有独立性,后来当子进程或是父进程要刷新缓冲区时(进程退出时,会刷新缓冲区),本质就是对父子进程共享的数据进行了修改,此时会对缓冲区数据进行写时拷贝,结果就是缓冲区的数据先后会有两份,一份是父进程的,一份是子进程的,刷新两次缓冲区,所以重定向到file.txt文件当中printf和puts函数打印的数据就有两份。
- 由于write是系统调用接口,它可以是说直接刷新出来了(无缓冲),因为它不经过用户级缓冲区,刷新策略取决于操作系统,因此write函数打印的数据就只打印了一份。
图解如下:
下篇预告:文件系统
有错误欢迎指出,万分感谢
创作不易,三连支持一下吧~
不见不散!