结论:文件操作的本质,是进程和被打开文件之间的关系。
上面对文件的认识,是我们所有使用Linux用户的共识。
在将我们的程序编译完成以后,再运行,发现生成了一个新的文件,并且文件中的内容和我们代码中写的一样。
- 这个过程中,使用的是C语言的接口进行文件操作。
- 以写的方式打开文件名问log.txt的文件,没有的这个文件的话就会创建。
- 使用C接口向该文件中写入内容。
本喵曾在文章C语言文件操作一文中详细介绍过C语言的文件操作接口,有兴趣的小伙伴可以去看看。
不同的编程语言都有文件操作的接口,包括C++,Java,Python,php等等语言,并且它们的操作接口函数都不一样,但是它们所在的系统都是Linux系统。
无论上层语言如何变化,但是进行文件操作的时候,各种语言最终都会调用Linux的文件操作的系统调用接口。
open函数:
可以看到,函数声明有两个,一个是两个参数的,一个是三个参数的,它们必然不是函数重载,因为Linux是用纯C实现的。
参数解释:
- const char* pathname:这是文件路径,也就是我们要打开的文件所在的路径,其中包括文件名,如果没有路径只有文件名的话,默认在当前路径打开。
- int flags:打开方式选项标志位。在使用C语言进行文件操作的时候,打开方式有“w”,“r”,“a”等方式,系统调用open也有,只是将这些标志放在了一个32位的变量中。
不同打开方式,其对应的比特位就会被置1。然后将这个设置好的flags变量传给open系统调用,就会按照相应的方式打开文件。- mode_t mode:它是权限值,如果这个文件不存在,那么以写的方式打开的时候就会创建这个文件,在创建文件的时候需要给这个文件设定权限(使用八进制数)。如果这个文件存在的话,那么就不用传第三个参数了,因为文件的权限已经确定了。
- 返回值:是一个int类型的参数,具体的在后面本喵会介绍,但是如果打开失败就会返回-1。
如果有多个选项需要按位或在一起,共同组成int flags变量传给open系统调用。
常用选项 | 功能 |
---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 读写 |
O_CREAT | 以写方式打开时,如果文件不存在则创建文件 |
O_TRUNC | 以写方式打开时,清空文件中原内容再写 |
O_APPEND | 追加方式打开文件 |
此时我们没有给open传第三个参数。
执行我们写的代码后,log.txt文件是创建了,但是它是红色的,说明它有错误。可以看到它前面的权限是乱的,因为我们没有指定创建文件时的权限。
将第三个参数加上,因为创建的是普通文件,所以就给它的默认权限是0666。
可以看到,此时创建的文件就正常了,但是权限并不是我们设定的0666,而是0664,这是因为有默认权限掩码(umask)的影响。
将默认权限掩码改成0以后,再创建的文件的权限就是我们设置的0666了。本喵曾在文章【Linux学习】权限详细讲解过权限相关的内容,有兴趣的小伙伴可以去看看。
close函数:
将打开文件时返回的int类型的fd值传给close系统调用后,这个被打开的文件就被关闭了。关闭成功返回0,如果关闭失败就返回-1。
write函数:
- int fd:打开文件时返回的int类型整数(文件描述符)。
- const void* buf:要写入的数组地址。对于系统调用来说,它并不在意写入的数据是什么类型的,它接收到的数据都是二进制的数字,然后按照字节为单位写入。
- size_t count:要写入的字节个数。
- 返回类型size_t:写入多个自己就返回多少。
清空方式写入:
追加方式写入:
read函数:
- int fd:打开文件时返回的文件描述符。
- void* buf:从文件中读取的数据放在这个数组中,同样系统不管文件中的数据类型是什么,都是按字节放入这个数组中。
- size_t count:要读取的字节个数。
- ssize_t:读取了多少个返回多少。
使用只读方式打开,并且将读取的内容放在ch数组中。
- 接收文件中数据的数组我们设定的大小是1024个字节,显然文件中的数据时没有这么大的。
- read系统调用的第三个参数传的也是1024,但是肯定没有读取到1024个字节。
- 每一个文件中都有一个文件结束符标志,在C语言中我们见过,也就是EOF,read函数会自动判断文件是否结束,所以即使设置读取的字节是1024个,但是能够符合我们对读取要求,将文件中的内容都读取出来。
以上便是我们常用的文件操作的系统调用。可以看到,对文件进行什么样的操作,取决于以什么样的方式打开文件,再用相应的操作函数去操作文件。
继续和C语言对应:
C语言的文件操作函数,封装了对应的系统调用接口函数。所以说,无论什么语言,文件操作相关的函数都是对系统调用的封装。
在使用系统调用open时,返回的那个整数就是文件描述符。
现在我们见到了文件描述符,发现它就是几个数字。
- 前面本喵已经讲过,文件操作的本质就是进程和被打开文件的关系。
- 系统中会存在大量被打开的文件,而操作系统同样会管理这些被打开的文件。
- 管理的方式和管理进程类似,也是采用先描述,再组织的方式。
当一个文件被打开后,操作系统会创建一个对应的结构体对象,类型是struct file。
struct file
{
//文件大小
//文件类型
......
//文件的各种属性
}
- 每打开一个文件,操作系统就会创建这样的一个结构体对象将被打开的文件描述出来。
- 将多个这样的结构体对象采用一定的方式组织起来,比如链表的方式,以方便操作系统管理这些被打开的文件。
在描述进程的结构体task_struct中,有一个指针,struct files_struct* files,这个指针指向一个结构体对象,该对象类型如下:
struct files_struct
{
//......
struct file* array[];
}
- struct files_struct结构体中存在一个指针数组array,该数组中的指针指向的是一个个struct file类型的结构体对象。
- 换言之,该数组中放的是被打开文件结构体对象的地址。
- 每一个被指向的struct file结构体对象都描述着一个被打开的文件。
在前面我们看到,打印出来的fd值是连续的小整数,这些小整数就是struct files_struct 结构体中指针数组struct file* array[]的下标。
文件描述符的本质,就是数组的下标。
下面本喵通过一张示意图来展示一下:
- 当一个程序被加载到内存中,操作系统会创建一个结构体struct task_struct对象,在该结构体中有一个指针struct files_struct* files,指向一个struct files_struct结构体对象。
- 这个结构体也被叫做进程描述符表,该结构体中有一个数组struct file* array[],数组中存放的是被打开文件的结构体对象的地址。如上图中,下标为3,也就是fd的是3的时候,访问到的是struct file* array[3]。
- 通过数组中访问到的地址,可以找到对应打开文件的结构体对象,如上图中的struct file log.txt。
只有被打开的文件才会在内存中创建struct file结构体对象,没有被打开的文件就静静的躺在磁盘上。
不是该进程打开的文件,该进程执行的文件描述符表中也没有这个文件的地址。
在上面打开多个文件的时候,我们将打开文件的fd值打印出来,发现它是从3开始的。
C默认会打开三个输入输出流,分别是stdin,stdout,stderr。
可以看到,这三个流是FILE*类型的指针,暂时不用管FILE是什么,只需要知道它是一个结构体。
使用C语言的文件操作结构打开一个文件,再使用系统调用去向文件中写内容。
成功的写入了。
- 系统调用write第一个参数需要传文件描述符fd。
- 上面代码中,传入的是FILE->_fileno,并且成功运行。
- 说明FILE中的_fileno就是文件描述符fd。
我们此时已经确定的知道了,FILE结构体中是有文件描述符的。
在之前的代码中,加上打印三个流的文件描述符的语句,如上图中红色框所示。
- fd = 0:标准输入流(stdin)
- fd = 1:标准输出流(stdout)
- fd = 2:标准错误(stderr)
此时我们便清楚了为什么我们打开的文件,文件描述符是从3开始的,因为012被默认打开的三个流占据了。
为什么我们打开的文件,fd是从3开始的?不是从5或者6开始的呢?
我们将fd=0的标准输入流关闭掉,再打开文件,并且打印fd值。
根据这个现象,可以得出结论:文件描述符fd的分别规则是:从小到大,按顺序查找,将没有被占用的数组下标作为被打开文件的文件描述符fd值。
前面我们只关闭过0和2,没有关闭过1,现在我们关闭一下1来看看。
将标准输出关闭,然后打开文件,并且打印出打开文件的文件描述符fd。
- 因为将标准输出关闭了,所以无法显示。
根据前面分析的文件描述符分配规则,可以推断出,将标准输出关闭以后,再打开一个文件,此时这个文件的文件描述符fd等于1。
- 在将fd=1关闭后,再打开一个文件,从小到大按顺序查找,发现数组下标为1的位置没有被占用,所以新打开文件的fd就等于1。
- printf函数原本是要输出到标准输出的,也就是fd为1的数组中指向的struct file对象的地址。
- 此时下标为1的数组中不再是标准输出了,而变成了我们新打开文件的地址。
- 但是printf已经写死了,它仍然会写入到fd为1的文件中,所以原本打印在显示器上的内容此时会写入到新打开的文件中。
同样的,将1关闭以后,以追加的方式打开一个文件,并且写入多行内容。
这种将本应该输出到标准输出改为输出到其他文件中的行为称为重定向。
重定向的本质上:上层语言使用的fd不变,在内核中改变fd对应的struct file*地址。
上面重定向的实现总感觉怪怪的,还需要关闭,然后再打开新文件,而且也不是很方便,所以操作系统提供了一个系统调用,可以直接实现重定向。
参数解释:
- 第一个参数是我们新打开文件的fd。
- 第二个参数是标准输出到fd,也就是1。
我们上面一直演示的都是本应该输出到显示器重定向输出到了文件中,这种从显示器到文件的重定向叫做输出重定向。
在shell中有命令可以直接实现输出重定向:
ll命令原本是将文件包括属性显示到屏幕上的,使用大于号>输出重定向到了log.txt文件中,如上图绿色框中所示。
运行时直接输出log.txt中的内容,没有从键盘获取数据。也就是说,fgets函数是从文件中获取到内容,而不是标准输入。
这种从标准输入到文件的重定向叫做输入重定向。
shell中同样有输入重定向的命令:是小于号<,具体本喵就不显示了。
在原本文件内容都基础上追加内容。
这种以追加方式打开文件,并且采用输出重定向的方式称为追加重定向。
shell中同样有追加重定向:
使用双大于号>>,实现了追加重定向,在原本log.txt内容都基础上追加内容。
子进程重定向了以后,会影响父进程吗?根据进程独立性我们可以知道,肯定是不会影响到。
- 有两个进程,一个父进程,一个子进程,操作系统维护着两个task_struct结构体,如上图红色框所示。
- 每个进程的PCB中都有一个struct files_struct*的指针files。它们各自指向的struct files_struct结构体中都有一个文件描述符表。
- 两个文件描述符表中的内容在子进程刚创建时是一样的,所以它们都指向相同的被打开的文件。
- 当子进程将自己文件描述符表中下标为1的文件关闭以后,并不影响父进程文件描述符表中下标为1的数组中的内容。
每个进程都会维护自己的文件描述符表,所以多个进程就会存在多个文件描述符表,但是这些表中的指针指向的被打开文件只有一套。
某个进程进行文件的打开与关闭操作时,只需要修改自己的文件描述符表就可以,不会对其他进程造成任何影响。
Linux下一切皆文件,这句话相信每一个学习Linux的人都听过,那么如何理解呢?
同样以文件操作的角度来看待硬件,如下图所示:
- 每一个硬件,操作系统都会维护一个struct file类型的结构体,硬件的各种信息都在这个结构体中,并且还有对应读写函数指针(对硬件的操作主要就是读写)。
- 每个硬件的具体读写函数的实现方式都在驱动层中,使用到相应的硬件时,操作系统会通过维护的结构体中的函数指针调用相应的读写函数。
真正的文件在操作系统中的体现也是结构体,操作系统维护的同样是被打开文件的结构体而不是文件本身。
一切皆文件是指:在操作系统中一切都是结构体。
这篇文章主要讲解的是基础IO的应用,包括文件操作的系统调用,文件描述符fd的本质,重定向,以及如何理解Linux下一切皆文件。