目录
开始正式内容之前,会先大致描述一下我们在说些什么。所有是内容都是围绕文件描述符这一概念进行展开,但是并不会直接切入这一概念,因为这一概念并不是很难,但是因为文件描述符而涉及的周边概念是很广泛的,我会由周边涉及到的知识逐步递进,逐渐引入文件描述符。
首先我们先解答下面的疑问。
请参考C语言文件操作 ,总结一下就是,文件 = 内容 + 属性(属性也是数据)
无外乎两种:a.对内容的操作 b.对属性的操作
进程。为什么?
用户访问文件,会先写代码 ---> 编译 ---> 形成可执行程序 ---> 运行 ---> 通过接口进行访问,而运行起来的程序不就是进程嘛。
文件存储在哪里?磁盘(磁盘是硬件),用户可以直接向硬件进行写入嘛?答案是肯定不可以的,只有操作系统才有权限向硬件进行写入。
如果普通用户也想向硬件中进行写入呢?比如说我们就想直接修改磁盘上的文件,怎么办???此时,就需要操作系统提供文件类的系统调用接口了。
要注意,这样的接口只有一套,因为系统只有一个。什么意思呢?Linux关于文件类的系统接口只有一套,但是C/C++/Java/Python...都有各自关于文件类的接口,为什么它们不直接使用系统接口呢?
一是因为系统接口比较难:语言上对这一套系统接口进行封装,可以让用户更好的使用。也就导致了不同的语言,有不同的语言级别的文件访问接口(都不一样),但是,底层都是一样的。
二是因为跨平台:如果语言不提供对文件的系统接口的封装,那么所有的访问文件的操作,都必须直接使用系统的接口,而使用语言的用户访问文件一旦使用系统接口,编写所谓的文件代码,那么就无法在其他平台中直接运行!
感性的认识:
文件:可以read , write
显示器: printf 、 cout --> 也可以理解为是一种 write
键盘 :scanf、cin ---> 也可以理解为是一种 read
站在程序的角度,读取/写入的数据需要加载至内存(input)
站在内存的角度,需要向文件/显示器写入/读取这些数据(output)
普通文件 ---> fopen/fread ---> 进程的内部(内存 )---> fwrite ---> 文件内部 (前面的部分是input,后面的部分是output)
那么什么叫做文件呢?
站在系统的角度,能够被input读取,或者能够output写出的设备就叫做文件!
狭义文件:普通的磁盘文件
广义上的文件:显示器,键盘,网卡,声卡,显卡,磁盘,几乎所有的外设,都可以称之为文件
结论就是所有的硬件也都是可以被看作成文件,都是可读可写的,但是在实现的时候,键盘〈只能读,不能写)显示器(只能写,不能读)
那么经过上面的铺垫,欢迎阅读下面的内容。
注意,下面内容很长很长,涉及面很宽泛,欢迎恰饭。
a.什么叫做当前路径???
当一个进程运行起来的时候,每一个进程都会记录自己当前所处的工作路径
示例1:
"w" :打开一个文件进行写入,如果不存在该文件,创建之
示例:
其实想说明的是,无论我是哪个路径,运行上述的程序,如果该文件不存在,那么就会在当前路径下创建该文件。问题是,系统是如何得知当前路径的???
Windows(Vs)下,一般源文件和可执行程序是不在一个路径的,编译链接的时候,会生成一个debug文件夹,可执行程序是放在这个文件夹的
Linux下当前路径是根据可执行程序在改变(有些不准确)
Linux下,进程在从上往下运行的时候,当没有看到这个文件,就会去调用系统接口,进行创建
所以我们能解释这个问题:为什么在当前路径运行可执行程序,形成的临时文件就在当前路径,在上级路径运行可执行程序,形成的临时文件就在上级路径 。 因为:进程运行也在这个路径,所以创建的临时文件当然也在这个路径
小结①(上述所讲的是关于“当前路径”这一个知识点)
示例:
输出:
那么,问题来了,请问往文件中写入字符串的时候要不要加上'\0’(C语言中字符串结束标志是'\0',这里'\n'只是一个普通字符)
这里要不要加1?答案是不需要。因为'\0'是C语言的规定,文件不需要遵守。文件存储是在磁盘,属于系统的管理范围,不属于语言的范畴
恶补一个小知识点:可以利用 > 重定向来清空文件
因为C语言规定,w写入,会先清空文件再进行写入
> :重定向,先打开文件,然后清空文件,再进行重定向
>:可以理解为是一条指令,底层是C语言“w”形式实现的
示例:
小结②(上述所讲的是a.关于C语言文件操作是不需要考虑'\0'的,因为'\0'不属于系统的规定; b.利用 > 重定向可以清理文件)
函数:
FILE *fopen( const char *filename, const char *mode );
size_t fwrite( const void *buffer, size_t size, size_t count, FILE *stream );
fgets
char *fgets( char *string, int n, FILE *stream );
int fprintf( FILE *stream, const char *format [, argument ]...);
示例:
说明:argc:命令行参数的个数 argv:存放命令行参数地址的指针数组; 示例:./myfile log.txt argv[0] = ./myfile argv[1] = log.txt 所以这里fopen打开的就是log.txt这个临时文件,fgets每次会读取一行的数据,再由while循环,fprintf打印,就形成了一个简易的cat指令
输出:

小结③(上述所讲的是实现一个简单的 "cat" 指令)
其实C语言接口也是封装的系统接口,对应情况如下:

感觉是不是很像,但是用法嘛,系统接口还是有点难以学习的,因为C语言接口可以直接使用,系统接口需要一定的知识储备量。
- NAME
- open, creat - open and possibly create a file or device //打开或创建一个文件
-
- SYNOPSIS
- #include <sys/types.h>
- #include <sys/stat.h>
- #include <fcntl.h>
-
- int open(const char *pathname, int flags); //pathname :文件路径+文件名 flags:选项
- int open(const char *pathname, int flags, mode_t mode); //mode :权限
-
- int creat(const char *pathname, mode_t mode); //使用很少很少
-
- //...
-
- The argument flags must include one of the following access modes: O_RDONLY, O_WRONLY, or O_RDWR.
- These request opening the file read-only, write-only, or read/write, respectively.
- //注意:必须包含一个读,写,读和写;可以配合其他选项
-
- //...
-
- O_APPEND //追加
- The file is opened in append mode. Before each write(2), the file offset is positioned at the end of the file, as if with lseek(2).
- O_APPEND may lead to corrupted files on NFS file systems if more than one process appends data to a file at once. This is because NFS does
- not support appending to a file, so the client kernel has to simulate it, which can't be done without a race condition.
- O_CREAT //如果文件不存在,进行创建
- If the file does not exist it will be created. The owner (user ID) of the file is set to the effective user ID of the process. The group
- ownership (group ID) is set either to the effective group ID of the process or to the group ID of the parent directory (depending on file
- system type and mount options, and the mode of the parent directory, see the mount options bsdgroups and sysvgroups described in mount(8)).
- O_TRUNC //清空文件
- If the file already exists and is a regular file and the open mode allows writing (i.e., is O_RDWR or O_WRONLY) it will be truncated to
- length 0. If the file is a FIFO or terminal device file, the O_TRUNC flag is ignored. Otherwise the effect of O_TRUNC is unspecified.
- //...
补充一个知识点:位图
上述 open 的接口选项其实还有很多,那么是如何进行传递的呢?一次只能传一个? 那样也达不到我们的预期啊,为什么这样说,不着急,先来解释下Linux是如何实现传递多个标记位置的:
首先再我们的认知中,什么会是纯大写带下划线 ---> 宏定义 , 上面所有的选项都叫做宏定义 。注意,这里flags底层结构是位图,32个比特位记录了是或否,每一个位置表明上述一个选项, 所以flags是可以传入多个选项的
示例:
验证 --- Linux下的 宏
小结④(上述知识涉及“位图”)
回到open这个系统文件接口,再来看一下它的返回值: 注意:成功返回的就是文件描述符
- RETURN VALUE
- open() and creat() return the new file descriptor, //成功了返回文件描述符,失败了返回-1
- or -1 if an error occurred (in which case, errno is set appropriately).
注意,这里的返回值提到了“文件描述符”!好,先来看一下第一个接口如何使用:
int open(const char *pathname, int flags);
示例:
运行我们会发现,程序的确成功的打开了文件,并且输出了文件描述符。但是,当文件不存在的时候,它是不会进行创建的!!!为什么C语言接口会去进行创建,因为C语言是被封装过的。用户在应用层看到一个很简单的动作,在系统接口层面甚至OS层面,可能要做非常多的动作!
所以上述为什么要加入位图这一概念,就是为了增加选项,这里不仅要O_WRONLY, 还需要加上选项O_CREAT(如果文件不存在,创建该文件)
示例:
但是上述的权限是不是很奇怪?不是只有rwx?为什么会这样??因为我们没有设置权限!所以其实第一个open接口用的并不是很多,或者使用的场景一般是O_RDONLY,一般写入或者创建文件我们会使用这个接口:
int open(const char *pathname, int flags, mode_t mode);
示例:
示例:
为什么最后other的权限少了??因为有权限掩码的存在,想要不受权限掩码的制约,umask设置成0就可以了。参考:Linux权限
示例:
所以用户想要使用系统的文件接口需要多费劲,需要了解选项标记位、位图、权限、进制转换、权限掩码、路径、以及最终的文件描述符,这也是语言层面上不得不提供封装接口的原因。
- NAME
- close - close a file descriptor //关闭文件描述符
-
- SYNOPSIS
- #include <unistd.h>
-
- int close(int fd);
- NAME
- write - write to a file descriptor
-
- SYNOPSIS
- #include <unistd.h>
-
- ssize_t write(int fd, const void *buf, size_t count);
示例:
变形1:
观察输出,可以看到,write写入并不会清空源文件之前的内容,C语言’w‘ 写入为什么会清空文件之前的内容?
因为C语言是做过封装的,这也印证了那句话:用户在应用层看到一个很简单的动作,在系统接口层面甚至OS层面,可能要做非常多的动作!
所以这里需要继续添加选项:O_TRUNC
示例:
追加字符串选项:O_APPEND
示例:
- NAME
- read - read from a file descriptor
-
- SYNOPSIS
- #include <unistd.h>
-
- ssize_t read(int fd, void *buf, size_t count);
示例:
小结⑤(上述讲述知识系统文件接口 open ,close, write,read)
所以上述我们兜了那么大一个圈子,又说文件,又说C语言接口,又说系统接口,究竟想要说些什么,而文件描述符与这些又有什么关系???
其实说明白文件描述符之前,还要兜最后一个圈子。
示例:
这里为什么文件描述符打出来是3,4,5?先不讨论为什么是小整数,这里的0, 1,2去哪里呢???
另外,我们需要知道,C语言会为我们默认打开三个文件流:标准输入,标准输出,标准错误,那么着之间又有什么联系呢??
其实对应关系是这样的:
那么到底是不是这样呢?验证一下
示例:往 ‘1’里面进行写入
示例:在‘0’里面进行读取
所以经过上面的验证,我们发现,的确0,1,2这三个文件描述符所代表的就是三个文件标准输入,标准输出,标准错误。
底层不做深究,因为键盘,显示器必定有属于自己的读写方法,但是上层都被FILE进行封装。
小结⑥(上述相关知识点文件描述符1,2,3对应输入,输出关系)
FILE *fopen(const char *path, const char *mode);
这里的FILE是什么???
- 文件指针
- 缓冲文件系统中,关键的概念是“文件类型指针”,简称“文件指针”。
- 每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及 文件当前的位置等)。
- 这些信息是保存在一个结构体变量中的。该结构体类型是有系统声明的,取名FILE.
- 例如,VS2008编译环境提供的 stdio.h 头文件中有以下的文件类型申明
- struct _iobuf {
- char *_ptr;
- int _cnt;
- char *_base;
- int _flag;
- int _file;
- int _charbuf;
- int _bufsiz;
- char *_tmpfname;
- };
- typedef struct _iobuf FILE;
所以FILE是结构体,C文件库函数内部一定要调用系统调用,那么在系统角度,是认识FILE还是fd ??? 只认识 fd ! ! 总结就是,既然FILE是一个结构体,既然FILE底层一定调用系统接口,既然系统只认fd,FILE结构体里面,必定封装了fd! ! !
那么stdin,stdout,stdout内部有没有fd,必定是有的,请看:
这里的_fileno就是文件描述符
示例:
所以上述所有想要说的就是,文件描述符就是小整数!!!它有什么意义呢?请接着往下看:
进程要访问文件,必须要先打开文件!
一个进程可以打开多个文件吗?可以! 一般而言进程︰打开的文件=1 : n
文件被打开的目的是为了被访问,一个文件要被访问,前提条件是它也要被加载到内存中才能被访问
进程︰打开的文件= 1: n ---> 如果是多个进程都打开自己的文件呢? 系统中会存在大量被打开的文件!
那么操作系统要不要把如此之多的文件也管理起来呢? 怎么管理呢? ---> 先描述,再组织
在内核中,如何看待打开的文件? 系统内部要为了管理每一个被打开的文件,构建一个结构体
- struct file
- {
- //包含了一个被打开的文件的几乎所有内容(属性、权限、缓冲区...)
- }
创建一个struct file对象,充当一个被打开的文件(其中,每打开一个文件,就创建一个对象)
如果有很多struct file对象呢? 内核再使用链表组织起来
- struct file
- {
- struct file* _next;
- struct file* _prev;
- }
那么进程是如何找到这些文件对象的呢???答案是通过创建数组,通过哈希映射的方式!!!
例图:
所有,我们看到的文件描述符fd ---> 0、1、2、3、4、5.... 在内核当中,本质就是数组的下标
上面兜那么大的圈子,所有的一切,都是为阐述这一概念。
侧面也能理解open的本质是在做什么?
1.在内核中创建文件对象
2.在数组内部找一个没有被使用的空间,将地址填入到数组中
3.把对应的数组下标返回给用户
4.用户拿到这个数组下标,就可以调用对应的接口,根据当前进程的PCB找到数组,根据数组索引到文件对象,文件对象里面,包含了文件的所有内容
这就是文件操作(注意:上述所讲到的文件都是“被进程打开的文件”,被称为内存文件)
这里并没涉及到没有被打开的文件(磁盘文件)
例图:
而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files,指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
小结⑦(上述所说的是文件描述符的底层到底是什么)
示例:
验证1:关闭 0 号文件
验证2:关闭 2号文件 ---> close(2); 输出:
所以通过验证可以得知,文件描述符的分配规则是:最小的,没有被占用的文件描述符(从0开始,按顺序向后遍历)
- 伪代码:
-
- int i = 0;
- while(1)
- {
- if(fd_array[i])
- ++i;
- else
- break;
- }
- return i;
其实按照上面的示例,可以玩一个好玩的东西,那就是,stdout(显示器)不是1号文件吗,如果我们关闭这个文件,会发生什么?
示例1:
上述的printf应该是往显示器上打印的(标准输出stdout),但是现在打印在了自己的文件log.txt
这里发现,close不认识显示器,它只认识1
如果把close (1)显示器关闭,那么我们自己的文件地址就在(1)的位置,就打印到了我们自己的文件
示例2:删除临时文件log.txt,再运行程序
注:上述埋了一个坑,缓冲区那里会回来添,什么坑?示例1中,为什么要把close(fd)给注释掉?示例2中为什么要fflush(stdout)?
回到重定向,现象是什么???就是原本应该打印到显示器上面的内容,竟然打印到了临时文件log.txt里面,这个操作是什么???
把本来应该写进显示器的内容写进文件就叫输出重定向!!!
例图:
观察可以发现:重定向的本质,其实是在OS内部,更改fd对应的内容的指向!!
当我们发现了这一结论,是不是就可以整点花活?如下:
输入重定向:
输出重定向:
追加重定向:
但是上述的整活是不是太拉了?就这,每次重定向还得关闭文件?当然,上述的操作是野路子的操作,正规军是这样的:
系统提供了dup接口
- NAME
- dup, dup2, dup3 - duplicate a file descriptor
-
- SYNOPSIS
- #include <unistd.h>
-
- int dup(int oldfd);
- int dup2(int oldfd, int newfd); //了解这个
-
- //...
-
- dup2() makes newfd be the copy of oldfd, closing newfd first if necessary, but note the following:
- //拷贝的是文件指针对应的值,newfd被合理关闭
-
-
- //...
这里谁拷贝谁??? newfd vs oldfd ?描述的是 oldfd copy to newfd ---> 意味着最终的值要和 oldfd 保持一致
int dup2(int oldfd, int newfd);
那么谁是newfd? 谁是oldfd?
例图:
所以这样是不是就清晰明了。
示例:
输出重定向:
聊这个之前先来看一个现象:
示例1:未注释dup2,重定向成功
示例2:注释dup2,重定向失败
示例3:注释dup2,fflush(stdout), 重定向成功
为什么会出现上述情况?首先,fflush是用来刷新缓冲区的,所以上述示例足以证明,其一dup2是自带刷新缓冲区的功能的。其二,为什么不刷新缓冲区,重定向就会失败???还记得重定向一开始埋的坑吗,请接着往下看:
这里需要重新提到一个概念:Linux下一切皆文件
怎样去理解呢?Linux设计哲学,体现在操作系统的软件设计层面
Linux是使用C语言写的。如何使用C语言实现面向对象,甚至是运行时多态?? ?
C语言可以定义一个struct结构体,可以定义成员变量(属性),但是可以在结构体内定义成员方法吗(能把函数实现写进去吗)??
答案是肯定不可以,那么,如何让C语言的结构体支持包含成员方法呢??? ---- > 函数指针
例图:
有了这一认识,我们再重新来聊聊缓冲区:
一块内存空间。
示例1:小a有一本书想要交给小b
示例2:
这里顺丰就可以理解为是“缓冲区”,为什么要存在,主要就是提高整机效率,提高用户的响应速度
接上面,那么顺丰是一收到物品就立即进行发送吗?顺丰会不会将这一本书直接飞机过去???不会。 而是等待校区的其他学生:例如小c,小d,小e...它们也有发快递到武汉的需求,打包一起进行发送 (积累足够多的数据)
那么顺丰的发快递的策略是什么?a.货架满了就发送 b.发往某一地区的物品数量多就发送 c.加急立即发送 。这些都是顺丰的发送策略。
那么缓冲区的刷新策略呢???
一般情况:
特殊情况:
出于对效率的考虑,一般而言:
所有的设备,永远都倾向于全缓冲! ---> 缓冲区满了,才刷新 --- > 需要更少次的 I/O 操作 --- >更少次的外设的访问 (由冯诺依曼体系结构决定的)
和外部设备I0的时候,数据量的大小不是主要矛盾,用户和外设预备I/O的过程是最耗费时间的!
其他刷新策略是,结合具体情况做的妥协!
显示器:直接给用户看的,一方面要照顾效率,一方面要照顾用户体验
极端情况:用户是可以自定义规则的(采用fflush)
接下来看一段有意思的示例:代码执行完毕之后,创建子进程
可以看到,输出没有任何问题,当我们进行重定向试一下:
为什么同样一段代码,重定向之后,输出的结果不同?
尝试一下,不创建子进程,重定向再试一下:
没有任何问题,那么一定是fork的问题,但是为什么???
同样的一个程序,向显示器打印输出4行文本
向普通文件(磁盘上),打印的时候,变成了7行,其中:
1. C语言接口 I0接口是打印了2次的
2.系统接口,只打印一次和向显示器打印一样!
上面的测试,并不影响系统接口!
如果有所谓的缓冲区,我们之前所谈的“缓冲区”应该是由谁维护的呢?
上述"我们所谈的缓冲区”,绝对不是由OS提供的!!
如果是OS统一提供,那么我们上面的代码,表现应该是一样的! 所以缓冲区是由C标准库维护的
回到上面的问题,我们是在最后调用的fork,上面的函数已经被执行完了,但是并不代表进程的数据已经被刷新了!!
1.如果向显示器打印,刷新策略是行刷新,那么最后执行fork的时候 --- 一定是函数执行完了&&数据已经被刷新了! fork无意义!
2.如果你对应的程序进行了重定向 -- 要向磁盘文件打印 -- 隐形的刷新策略变成了全缓冲! —— \n 变没有意义了,fork的时候 --- 一定是函数一定执行完了,但是数据还没有刷新! ! -- 在当前进程对应的C标准库中的缓冲区中!
这部分数据是不是父进程的数据?是的! 那么刷新是不是写的过程呢?此时 会发生写时拷贝(fork之后,子进程”写“,拷贝父进程,系统会生成两份数据
总结:说到底还是因为刷新策略,显示器是行刷新,磁盘是满刷新,上面函数结束之后,数据留在了缓冲区
此时,如果是行刷新,子进程拿不到里面的数据,因为已经被显示出来,缓冲区为空,子进程无意义;如果是满刷新,此时里面的数据还在,子进程直接拿到了,(刷新也是写入),所以有两份(写时拷贝),而这个缓冲区是C语言标准库提供的,所以系统接口是看不到缓冲区的,所以只会写入一次,而C语言的函数会写入两次
所以以上我们所熟知的“缓冲区”都是C标准库给我们提供的用户级缓冲区,这也就意味着还有“内核级缓冲区”(暂时不做解释)
fflush(stdout); 这里只是传入了一个stdout,那么fflush是如何知道缓冲区在哪里??
上述我们说到,FILE是一个结构体,那么一定包含了fd对应的语言层面的缓冲区结构
typedef struct _IO_FILE FILE; 在/usr/include/stdio.h
- 在/usr/include/libio.h
- struct _IO_FILE {
- int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
- #define _IO_file_flags _flags
- //缓冲区相关
- /* 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. */
- struct _IO_marker *_markers;
- struct _IO_FILE *_chain;
- int _fileno; //封装的文件描述符
- #if 0
- int _blksize;
- #else
- int _flags2;
- #endif
- _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
- #define __HAVE_COLUMN /* temporary */
- /* 1+column number of pbase(); 0 is unknown. */
- unsigned short _cur_column;
- signed char _vtable_offset;
- char _shortbuf[1];
- /* char* _save_gptr; char* _save_egptr; */
- _IO_lock_t *_lock;
- #ifdef _IO_USE_OLD_IO_FILE
- };