简单了解Unix I/O下文件的打开与关闭以及共享文件
在了解文件之前,我们显然需要对本文讨论的文件所属的范畴,即Unix I/O有一个大致的了解。
Unix I/O 是什么
人们描述Linux系统中时,往往伴随着一句话:一切皆文件
一个Linux 文件就是一个m个字节的序列:
B0,B1,…,Bk,…,Bm-1
所有的I/О设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。这种方式允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
- 打开文件。 一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件
<unistd.h>
定义了常量STDIN_FILENO
、STDOUT_FILENO
和STDERR_FILENO
,它们可用来代替显式的描述符值。请一定要记住这一条,在这之后会非常重要 - 改变当前的文件位置。 对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。
- 读写文件。 一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置é开始,然后更新k。 - 关闭文件。 当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
让我们试着打开一个文件
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的:
#include <sys/types.h>
#include <sys/stat.h>#include <fcntl.h>
int open(char *filename,int flags,mode_t mode);
//返回:若成功则为新文件描述符,若出错为-1.
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:
- O_RDONLY:只读。
- O_WRONLY:只写。
- O_RDWR:可读可写。
例如,下面的代码说明如何以读的方式打开一个已存在的文件:
fd = Open("foo.txt",O_RDONLY, 0) ;
flags参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:
- O_CREAT:如果文件不存在,就创建它的一个截断的(truncated)(空)文件。
- O_TRUNC:如果文件已经存在,就截断它。
- O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。
例如,下面的代码说明的是如何打开一个已存在文件,并在后面添加一些数据:
fd = Open("foo.txt",O_WRONLY|O_APPEND,0);
最后,进程通过调用close函数关闭一个打开的文件。
#include <unistd.h>
int close(int fd) ;
//返回:若成功则为0,若出错则为—1。
关闭一个已关闭的描述符会出错。
我们现在可以试试看这个了:
#include "csapp.h"
int main(){
int fd1,fd2;
fd1 = Open( "foo.txt",O_RDONLY,0);close(fd1);
fd2 - Open( "baz.txt",O_RDONLY,0);printf("fd2 = %dkn" ,fd2);
exit(0) ;
}
也许你会认为打开fd1,关闭fd1,再打开fd2,这样会显示fd2 = 0
或者fd2 = 1
。实际上不是这样的,还记得刚才我们提到的吗?
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件
<unistd.h>
定义了常量STDIN_FILENO
、STDOUT_FILENO
和STDERR_FILENO
,它们可用来代替显式的描述符值。请一定要记住这一条,在这之后会非常重要
所以实际上,标准输入、输出和错误已经将0、1、2号文件位置占据了,之后的打开文件的操作都是从3开始的,因此我们得到的程序运行结果其实是fd2 = 3
。
何为共享文件
内核用三个相关的数据结构来表示打开的文件,我们简单地概括为:
- 描述符表(descriptor table)。每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。
- 文件表(file table)。打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成(针对我们的目的)包括当前的文件位置、引用计数(reference count)(即当前指向该表项的描述符表项数),以及一个指向v-node表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数。内核不会删除这个文件表表项,直到它的引用计数为零。
- v-node表(v-node table)。同文件表一样,所有的进程共享这张v-node表。每个表项包含stat结构中的大多数信息,包括st_mode和 st_size成员。
我们通过一张图来形象地了解典型情况
不难想象,多个描述符也可以通过不同的文件表表项来引用同一个文件。例如,如果以同一个 filename调用open函数两次,就会发生这种情况。关键思想是每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据。具体就会如下图所示:
让我们试试典型情况的分析
#include "csapp.h"
int main(){
int fd1,fd2;
char c;
fd1 = Open("foobar.txt",O_RDONLY,0);
fd2 = Open("foobar.txt",O_RDONLY,0);
Read(fd1,&c,1);
Read(fd2,&c, 1);
printf("c = %c\n", c);
exit(0);
}
很好理解,在这个例子中,fd1与fd2之间并没有什么关系,他们分别打开了foobar.txt
(其中由6个ASCII码字符“foobar”组成)因此,程序输出结果为
c = f
那如果是加上fork呢?(foobar.txt
文件不改变)
接下来谈论的题目牵扯到了 fork 和 wait,如果不记得的话,可以查看我之前那篇学习笔记,系统级I/O学习笔记——文件的打开、关闭以及共享,其中有说明。
#include "csapp.h"
int main(){
int fd;
char c;
fd = Open("foobar.txt",O_RDONLY,0);
if (Fork() == 0) {
Read(fd,&c,1);
exit(0);
}
wait(NULL);
Read(fd,&c,1);
printf("c = %c\n",c);
exit(0);
}
父子进程中,实际上文件是共享的。子进程有一个父进程描述符表的副本。父子进程共享相同的打开文件表集合,因此共享相同的文件位置。如下图所示:
所以我们不难分析,在我们给出的例子中,父子进程共享一个打开文件夹,fork后,子进程读取一位,而父进程则wait等待至子进程结束,之后再读取一位,实际上总共读取了2位,程序输出:
c = o
总结
Linux内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向v-node表中的表项。每个进程都有它自己单独的描述符表,而所有的进程共享同一个打开文件表和v-node表。理解这些结构的一般组成,就能使我们清楚地理解文件共享和之后会学习到的I/O重定向。
欢迎大家留言一起学习,共同进步。