目录
1.1、那么在内核当中如何看待进程打开的文件并将它们管理起来呢?
2、下图的运行结果为什么是3、4、5、6呢?0、1、2去哪了呢?
5.1、输出重定向和追加重定向的函数int dup2(int oldfd, int newfd)
7.2.1、为什么向显示器文件上打印的结果和向磁盘文件上打印的结果不一样?
1、前言
进程要访问文件,必须先打开文件,即文件要被访问,前提是加载到内存中,才能被直接访问。一个进程可以打开多个文件,所以系统中进程的数量:打开的文件的数量=1:n。如果是多个进程都打开自己的文件,系统中会存在大量的被进程打开的文件。所以,OS非常需要将如此之多的文件管理起来。
1.1、那么在内核当中如何看待进程打开的文件并将它们管理起来呢?
答案:操作系统会为每一个通过open函数打开的文件创建一个struct file结构体的变量,struct file结构体变量中包含了一个文件的所有内容(包括文件的属性、文件创建的连接队列、文件的内核缓冲区),可以认为找到struct file变量后,对文件的读写也就不成问题了,所以可以让这个变量充当这个被打开的文件,之后将所有struct file变量通过某种数据结构管理起来即可。
补充几个知识点,如下:
- C语言函数fopen本质就是复用了open函数,调用fopen函数打开一个文件后,在应用层中(注意不是在内核层中)会创建一个FILE结构体类型的变量,然后把该FILE类变量的地址作为返回值返回给应用层。
- 注意因为fopen是复用了open函数,所以调用fopen函数后,除了在应用层会创建FILE类型的结构体变量表示该文件,还会在内核层中创建struct file类型的结构体变量表示该文件,所以FILE类变量和struct file变量都可以表示目标文件,只不过FILE只在应用层生效,struct file只在内核层中生效(即操作系统只认识内核数据结构struct file,不认识C语言的FILE结构体)。
- FILE结构体中有一个C库提供的应用层缓冲区,struct file结构体中有一个系统提供的内核缓冲区,这两个缓冲区是不同的,通过C语言的读写接口对FILE类型的变量进行读写时,只能读写FILE变量中的C库缓冲区,通过系统接口如write/read对struct file类型的变量进行读写时,才能读写struct file变量中的内核缓冲区。
- 在单机IO中说的内核缓冲区就是当前进程打开的某个文件的文件描述符对应的struct file结构体中的文件缓冲区,比如write向磁盘文件写入时,内核缓冲区就是表示磁盘文件的struct file结构体中的文件缓冲区,写入后,write函数就调用完毕了,至于文件缓冲区中的数据如何被交给磁盘就全看OS的策略了;再比如write向显示器文件写入时,内核缓冲区就是表示显示器文件的struct file结构体中的文件缓冲区,写入后,write函数就调用完毕了,至于文件缓冲区中的数据如何被交给显示器就全看OS的策略了。
-
问题:为什么调用cin或者scanf函数时,当键盘没有输入数据时,当前进程就会陷入阻塞呢?
答案:没有在外设键盘中输入数据就会导致外设不会把数据交给OS为键盘文件创建的struct file中的内核缓冲区,导致内核缓冲区中没有数据,导致cin或者scanf函数中的read函数无法将内核缓冲区的数据交给C库缓冲区,而是让当前进程阻塞在cin或者scanf函数中的read函数处(cin或者scanf本质是把位于应用层的C库转缓冲区中的数据移到位于应用层的用户指定的缓冲区里;cin或者scanf底层调用的是read函数,read函数就负责把内核缓冲区中的数据拷贝到应用层的C库缓冲区里,现在内核缓冲区没有数据,所以read函数就阻塞了、进而导致cin/scanf阻塞;C库缓冲区在哪呢?cin或者scanf默认是对FILE*类型的文件指针变量stdin进行操作,其中C库缓冲区就在stdin指向的FILE变量中)。
1.2、文件描述符fd和FILE是什么?
如下图,PCB中有一个指针指向结构体struct files_struct的变量,变量中存在成员,即指针数组array,本质上说,文件描述符fd就是下图中array这个指针数组的下标。正如运行一个程序将其变成进程时操作系统会创建对应的PCB,使用open函数打开文件时,OS同样会创建对应的结构体struct file的变量,并将该变量的地址填入array数组中,然后将地址所在的下标号作为open函数的返回值返回给用户,也就是说,当open函数调用成功时,会返回文件描述符fd,fd是一个整形,用于表示当前进程打开的文件的“序号”。
现在我们就知道了文件描述符就是从0开始的小整数,当我们打开文件时,操作系统在内存中要创建相应的内核数据结构struct file来描述目标文件(或者说来表示一个已经打开的文件对象)。执行open系统调用的是进程,所以必须让进程和打开的文件关联起来,所以表示进程的PCB中有一个指针指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针,所以本质上文件描述符就是该数组的下标,所以只要拿着文件描述符就可以找到对应的文件。
(注意下面所说的内核缓冲区和C库缓冲区该怎么找到它们呢?struct file中有内核缓冲区的指针成员,FILE中有C库缓冲区的指针成员。)
因为C程序都会包头文件<stdio.h>,头文件里又有三个FILE*类型的文件指针stdin、stdout、stderr,所以我们常说运行C程序时会默认fopen打开三个文件。FILE是C标准库中的一个结构体(首先明确一点,我们常说C库提供缓冲区,或者说C库提供某某,那么C库在哪呢?其实就在我们的代码或者说就在我们的进程中,因为我们的代码中include了C标准库呀),在代码中,调用一次C库函数fopen打开某文件时,就会在用户层的内存中创建一个FILE结构体,FILE里有许多成员,由于fopen底层调用的是open函数,open函数返回一个fd,fopen返回一个FILE*指针变量,所以FILE结构体中必然存在成员fd,所以stdin、stdout、stderr这三个FILE*类型的文件指针指向的FILE变量中也一定有对应的fd成员,它们的值分别是0、1、2,如下图。C库提供的FILE中除了有fd,还有许多C库提供的用户层级的缓冲区,是干什么的呢?比如fputs函数需要一个类型为FILE*的参数,前面也说过FILE内部含有用户级缓冲区,fputs函数本质只完成将数据拷贝进FILE变量中C库提供的缓冲区,此时fputs函数就结束了,之后OS会根据C库缓冲区的刷新策略,当满足刷新条件时,再调用write等系统接口将C库缓冲区的数据刷新到内核缓冲区,最后会根据内核缓冲区的刷新策略(或者说根据OS的策略)将数据刷新到磁盘文件上。注意在系统层面上,系统只认识文件描述符fd,更精确的说,系统只认识内核级数据结构struct file,而不认识C库提供的FILE。
2、下图的运行结果为什么是3、4、5、6呢?0、1、2去哪了呢?
答案:0、1、2在当前进程中早已被使用了,因为程序启动变成进程时,默认会打开三个文件stdin、stdout、stderr,详见下文。
2.1、fd的分配规则是什么呢?
代码如下
运行结果如下
答案:每次给文件分配数组中最小的,未被占用的文件描述符,即分配未被使用的最小的下标。如下图中,首先close函数关闭了fd为2的文件,此时open函数新打开一个文件,由于0和1已被占用,2又比3小,所以给新打开的文件分配的fd为2。
3、常见的需要使用fd的场景
3.1、场景一
3.2、场景二
如上图,和scanf一样,等待用户输入hello后,将数组input变成了hello。之后可以将数组输出。如上图红框处,为什么一个\n换了两行呢?因为用户输入hello时,最后按了回车键 \n结束输入,所以数组中实际存储的是hello\n,所以换了两行。
4、输入重定向
所谓重定向本质就是关闭下标或者说文件描述符fd为xx的文件,然后用其他文件占用该fd。比如对标准输入,即键盘重定向,标准输入fd为0,那么首先close(0),然后open任意一个文件,之后任何需要键盘输入的场景,都不会再需要从键盘中读取数据了,转而从之前open的文件中读取数据。
4.1、奇怪的现象
代码如下
log.txt内容如下
运行结果如下
4.2、现象发生的原因(输入重定向)
可以认为stdin这个文件指针的值始终和下图array【0】的值保持一致,假如array【0】的值发生变化,那么stdin的值也应该随之变化,同理,stdout和stderr也如此思考,如stdout对应array【1】。上图的现象中,首先close(0),即关闭了键盘对应的文件,所以首先array【0】和stdin的值都应变成NULL,然后open(“log.txt”,O_RDONLY)打开了log文件,此时为log文件分配fd,发现数组中下标为0的值为null,于是将log文件的地址填入array【0】中,此时stdin的值也发生变化,所以stdin也指向log文件,之后调用 fget函数向stdin指向的文件中读取一行字符串,由于此时stdin指向log文件,而不是标准输入即键盘文件,所以读取了log文件中的aaaaaaaaaaa。本来是从键盘读取数据,而现在从文件中读取数据,所以这就叫做输入重定向。
5、模拟输出重定向和追加重定向
1.将下图代码红框中的O_TRUNC选项换成O_APPEND选项就是从输出重定向变成追加重定向。
追加重定向代码如下
运行七次代码(追加重定向)后,cat log.txt如下
5.1、输出重定向和追加重定向的函数int dup2(int oldfd, int newfd)
如上图,将红框中的O_TRUNC选项换成O_APPEND选项就是从输出重定向变成追加重定向。
1.头文件:<unistd.h>
2.函数的作用:如果下图中的array【newfd】的值不为NULL,则系统会将array【newfd】的值变成NULL,然后将array【newfd】的值变成oldfd对应的文件的地址。
3.返回值:若newfd等于oldfd,则返回newfd并且不关闭newfd所指的文件。若dup2调用成功则返回newfd,出错则返回-1。
4.在shell的重定向功能中,即输入重定向<和输出重定向>的实现,就是通过调用dup或dup2函数对标准输入和标准输出进行一些操作完成的。
6、对一切皆文件的理性认知
1.首先下结论,一切皆文件,这里的文件就是结构体struct file,每打开一个文件,操作系统就会创建一个file结构体,即file结构体是由操作系统创建并维护的。这样一来,操作系统看待所有文件的方式就统一成为了struct file。
2.每个硬件也都视为文件,即使当硬件不同时,访问硬件文件的函数的代码不同,但由于硬件的访问函数无非是输入或者输出,又因为Linux是C语言写的,而C语言不支持在结构体中写函数,所以file结构体中可以设计若干个函数指针,每个指针开始为NULL,每打开一个硬件,就让函数指针指向对应硬件的访问函数,即read或者write或者其他操作等等,这样所有的设备都可以有自己的访问函数,比如新接入一个设备鼠标,既然一切皆文件,OS给鼠标创建一个file变量,并将这个file变量的所有成员初始化,其中就包括函数指针,让指针指向鼠标的访问函数,这样就可以使用鼠标这个硬件了。
7、缓冲区
7.1、缓冲区是什么
(注意下面所说的内核缓冲区和C库缓冲区该怎么找到它们呢?struct file中有内核缓冲区的指针成员,FILE中有C库缓冲区的指针成员。)
在单机IO中说的内核缓冲区就是当前进程打开的某个文件的文件描述符对应的struct file结构体中的文件缓冲区,比如write向磁盘文件写入时,内核缓冲区就是表示磁盘文件的struct file结构体中的文件缓冲区,写入后,write函数就调用完毕了,至于文件缓冲区中的数据如何被交给磁盘就全看OS的策略了;再比如write向显示器文件写入时,内核缓冲区就是表示显示器文件的struct file结构体中的文件缓冲区,写入后,write函数就调用完毕了,至于文件缓冲区中的数据如何被交给显示器就全看OS的策略了
问题:为什么调用cin或者scanf函数时,当键盘没有输入数据时,当前进程就会陷入阻塞呢?
答案:没有在外设键盘中输入数据就会导致外设不会把数据交给OS为键盘文件创建的struct file中的内核缓冲区,导致内核缓冲区中没有数据,导致cin或者scanf函数中的read函数无法将内核缓冲区的数据交给C库缓冲区,而是让当前进程阻塞在cin或者scanf函数中的read函数处(cin或者scanf本质是把位于应用层的C库转缓冲区中的数据移到位于应用层的用户指定的缓冲区里;cin或者scanf底层调用的是read函数,read函数就负责把内核缓冲区中的数据拷贝到应用层的C库缓冲区里,现在内核缓冲区没有数据,所以read函数就阻塞了、进而导致cin/scanf阻塞;C库缓冲区在哪呢?cin或者scanf默认是对FILE*类型的文件指针变量stdin进行操作,其中C库缓冲区就在stdin指向的FILE变量中)。
1.buffer缓冲区可以分为用户层缓冲区和内核层缓冲区,本质上缓冲区就是一块内存空间,比如一个数组或者是一块指针指向的空间,用于存储一些数据。C库提供的缓冲区就是用户层缓冲区。
2.在FILE结构体中就存在一些各种缓冲区的指针,所以这也是为什么比如fputs等IO函数都需要FILE*的参数,有FILE*的指针就可以找到FILE结构体变量,变量里存在fd和缓冲区的指针,通过fd找到struct file,注意这个是内核数据结构,不要和C语言的FILE弄混了,通过缓冲区的指针找到缓冲区,所以可以认为缓冲区就在FILE结构体变量中。文件分为文件的属性和内容,这个文件的缓冲区在个人看来就是存储文件的内容的东西。
7.2、缓冲区的刷新策略
(注意下面所说的内核缓冲区和C库缓冲区该怎么找到它们呢?struct file中有内核缓冲区的指针成员,FILE中有C库缓冲区的指针成员。)
1.一般而言,所有的设备都倾向于全缓冲,即不管是内核级缓冲区,还是用户级缓冲区,只有缓冲区满了才刷新,因为这样可以减少内存和外设IO操作的次数。为什么要减少IO的次数呢?因为内存和其它外部设备交换数据的速度相对较慢,更少次的IO操作意味着更少次的访问外设,可以提高效率。从前面的结论可以看出:内存和其它外设交换数据时,数据的大小不是主要矛盾,和外设预备IO的过程才是最消耗时间的。
2.但除了全缓冲,也有其他的刷新策略,这些其它的刷新策略都是结合具体的情况做的妥协,比如行缓冲,缓冲区对显示器的刷新策略就是行缓冲,为什么呢?因为显示器是给用户看的,需要照顾用户使用时的体验,如果为了减少IO次数而使用全缓冲的策略,那么打印在显示器上的数据要么观察不到,要么一下打印海量的数据,这样肯定是不好的。
3.比较特殊的刷新策略:使用C库函数fflush强制刷新缓冲区。进程退出时自动刷新缓冲区。
4.缓冲区对哪些设备文件使用行缓冲的策略呢?常见的有显示器文件。
5.缓冲区对哪些设备文件使用全缓冲的策略呢?常见的有磁盘文件。
7.2.1、为什么向显示器文件上打印的结果和向磁盘文件上打印的结果不一样?
代码如下
1.向显示器文件stdout打印的运行结果如下
(注意下面所说的内核缓冲区和C库缓冲区该怎么找到它们呢?struct file中有内核缓冲区的指针成员,FILE中有C库缓冲区的指针成员。)
造成上图运行结果的原因:既然是将C库缓冲区或者内核缓冲区的内容打印到stdout即显示器上,那缓冲区采取的刷新策略就是行刷新,那么最后执行fork的时候,C库提供的缓冲区里的数据早已被刷新到了显示器文件上,fork后并在进程退出时,发现C库缓冲区内无数据,也就不需要刷新了,所以每条语句都只被正常打印了一次。
2.>重定向到磁盘文件log.txt后,向log.txt打印的结果如下
问题一:上图中的hello write明明在其他打印语句的最下面,为什么打印时反而在最上面呢?
(注意下面所说的内核缓冲区和C库缓冲区该怎么找到它们呢?struct file中有内核缓冲区的指针成员,FILE中有C库缓冲区的指针成员。)
答案:因为write函数是系统调用,所使用的缓冲区是内核缓冲区,刷新策略和C库提供的缓冲区不同,不会受到C库提供的缓冲区的干扰,函数会将数据刷新到内核,注意只要进程将数据交给内核后,该数据就已经是内核数据了,即和进程没有关系了,然后write函数就结束了,之后由内核自动调用其他函数,如上图红框中的syncfs函数将数据刷新到磁盘文件log.txt上,这些动作不像fputs等函数需要C库提供的缓冲区满足刷新条件才刷新数据,而是立刻执行。而其他打印函数是C库中的函数,拿fputs举例,fputs函数只负责将数据拷贝到C库提供的缓冲区中,然后fputs函数就结束了,等到满足刷新C库缓冲区的条件(当前情景的条件就是进程退出时),会根据C库刷新缓冲区的机制自动调用write函数将数据刷新到内核缓冲区,最后OS依照内核缓冲区的刷新策略将数据刷新到log.txt文件,所以由于write函数不会因为缓冲区未满就不刷新数据,hello write在最上面,由于fputs等函数在进程退出时才会打印数据,hello fputs等语句在下面。
问题二:从上图中可以发现系统调用write只打印了一次,而C语言的接口都打印了两次,为什么呢?
(注意下面所说的内核缓冲区和C库缓冲区该怎么找到它们呢?struct file中有内核缓冲区的指针成员,FILE中有C库缓冲区的指针成员。)
1.因为write是系统接口,不需要将数据拷贝到C库提供的属于某个进程的缓冲区里(C库缓冲区就在进程include的C库头文件中),而是会将数据刷新到内核,因为内核缓冲区独属于内核,所以缓冲区上的数据也只属于内核,而不属于除内核外的其他进程,既然数据压根就不属于某个进程,那么你某个进程fork创建子进程后再修改属于父子进程的缓冲区,然后发生写时拷贝和我内核有什么关系?或者说和我内核缓冲区有什么关系?因为没有任何进程会和我内核共享内核缓冲区,所以刷新内核缓冲区的数据到磁盘文件log.txt或者其他外设上时不会发生写时拷贝,所以只打印了一次。
2.因为>重定向到了磁盘文件log.txt上,而缓冲区对磁盘的刷新策略是全缓冲,所以刷新策略会从之前的行缓冲变成全缓冲,所以即使遇到 \n后也不会刷新。当执行到fork语句时,各种打印函数一定已经执行完毕,但由于C库提供的缓冲区还没有被占满,并且也没有使用fflush函数强制刷新,所以此时不会自动调用系统接口将C库缓冲区的数据刷新到内核缓冲区,数据依然在当前进程对应的C标准库中的缓冲区里,(注意因为进程中include了C库的头文件,也就是说整个C库都在我们编写的代码中,或者说整个C库都在我们的进程中,所以C库提供的缓冲区也在咱们的代码或者说在咱们的进程中,所以这里的C库缓冲区也和我们在编写代码时定义的一个个数组并没有什么不同。这个C库缓冲区的内存也是在进程中开辟的,所以说这个C库缓冲区中的数据也是属于进程的数据,因为fork创建子进程后,当修改其中一个进程的数据时会发生写时拷贝,所以fork创建子进程后,修改其中一个进程的C库缓冲区里的数据时也会发生C库缓冲区的写时拷贝),fork后,子进程被创建出来,由于此时父子进程都没有修改C库缓冲区内的数据,所以C库缓冲区中的数据父子共享,也就是两个进程的C库缓冲区所对应的同一个虚拟地址上映射的物理地址相同,但当进程执行到return 0时,由于进程退出时会自动刷新C库缓冲区,刷新后进程才退出,又因为刷新C库缓冲区会将缓冲区的数据清空,所以刷新C库缓冲区本质也是在修改数据,而且你这个进程在此时需要退出了,但你的父进程或者子进程不一定此时退出呀,这些没有退出的进程还需要C库缓冲区的数据呢,如果不发生写时拷贝,那其他进程的C库缓冲区就没有数据用了,换言之,这也是为什么父子进程之间若有某一个进程修改数据时会发生写时拷贝的原因,因为其他进程说不定还需要修改之前的数据,所以上图情况中return 0时会发生写时拷贝,导致父子进程的C库缓冲区会处于同一虚拟地址但不同物理地址上,即相当于子进程获得了一个独属于自己的C库缓冲区,所以此时父子进程都有独属于自己的C库提供的缓冲区,然后父子进程都自动调用write等系统接口刷新自己进程对应的C库缓冲区的数据到内核缓冲区,所以两个C库缓冲区的内容都被刷新到了内核缓冲区,最后根据内核缓冲区的策略自动将数据刷新到磁盘文件log.txt上,所以文件中C语言的接口都打印了两次。
如下图,在fork前加上fflush函数,那么在fork创建子进程之前就将C库提供的缓冲区里的数据刷新到了内核缓冲区,然后fflush函数体内部还会通过调用syncfs等函数直接将内核缓冲区的数据刷新到磁盘文件上,也就是说在父子进程退出时C库缓冲区数据为空,也就不存在修改C库缓冲区,所以就不会发生写时拷贝,父子进程会共享同一个C库提供的缓冲区,所以C库的打印函数也就只会打印一次了。
7.2.2、奇怪案例一
代码如下
运行结果如下
由于关掉了stdout,所以printf(hello world)不会打印在显示器上,而是打印进log.txt文件中,但是cat log.txt时发现文件里什么都没有,这是为什么呢?
答案:log.txt是个磁盘文件,所以刷新策略是全缓冲,所以hello world后的\n失效,数据目前在文件描述符fd为1对应的文件的缓冲区中,即stdout的缓冲区里(注意由于在开始close(1)了,所以stdout此时不指向显示器,而是指向log.txt),然后因为在刷新缓冲区(即return 0 )之前close(fd),把log.txt文件关闭了,所以缓冲区的数据无法刷新到log.txt中了,所以cat log.txt时什么也没有。
解决方式:如下图,在红框处加个fflush函数,提前刷新stdout文件的C库缓冲区,将数据刷新进内核,fflush函数体内部再通过调用syncfs等函数直接将内核缓冲区的数据刷新到磁盘文件,fflush函数就是这样将C库缓冲区的数据刷新到磁盘文件log.txt中的。
7.2.3、奇怪案例二
代码如下
运行结果如下
1.直接运行时,结果正常
2.将打印的数据输出重定向到log.txt文件时结果不正常,log.txt文件只有一部分数据,还有一部分数据在显示器文件中,即打印到了显示器上。(如果这里使用追加重定向,向stderr运行结果仍有部分数据打印在显示器上)
造成上图结果二的原因是什么?
答案:stdout和stderr(或者cout和cerr)都是显示器文件,但它们是不同的显示器文件,上面的情景中,将需要向stdout文件里打印的数据重定向到log.txt文件后,如下图,会改变stdout指针的指向,从指向显示器到指向log.txt,但并没有改变stderr指针的指向,stderr依然指向显示器,所以有一部分数据直接打印在了显示器上,然后由于stdout此时指向log.txt文件,所以有一部数据成功打印到了log.txt文件里。