文章目录
一、文件描述符
1.文件的认识
无论在什么操作系统下,文件 = 文件内容 + 文件属性,其中文件内容是数据,文件属性也是数据,也就是说如果你创建了一个空文件,里面没有任何内容,它也是要占据磁盘空间的,因为文件存在属性信息。
因此,我们后续做的所有文件操作,实质上是对文件内容操作或者对文件属性的操作。
我们之前在学习C语言文件操作的内容时,每次对文件的操作,一定是先要打开文件,这里的打开文件实质上是将文件的属性或者内容加载到内存中(因为根据冯诺依曼体系结构,CPU只能读取内存的数据)。而操作系统中一定还存在着很多没有被打开的文件,这些没有被打开的文件一定是在磁盘上存储着的。
因此,我们操作系统里的所有文件,在宏观上可以分为“打开的文件(内存文件)”和“磁盘文件”两类。
通常我们做的文件操作例如打开文件、关闭文件,读写文件等,是我们在写代码的时候调用相关的文件操作函数,这些文件操作函数帮我们实现的文件操作。但更深入一层次地理解,并不是我们所写的代码在进行文件操作,而是我们所写的代码变成程序运行起来以后,执行相应的文件操作代码,然后才会完成对文件的操作。
因此,这本质上是进程在进行文件操作!
2.C语言下的文件操作
由于本篇博客主要介绍的是操作系统层面的文件知识,所以我们会频繁使用到文件操作的方法,下面我们先来复习一下C语言的文件操作方法。
(1)文件指针
我们对文件进行操作,首先得要有该文件的文件指针。每一个被使用的文件都会在内存中开辟一个相应的文件信息区,用来存放文件的相关信息。和我们之前提到的管理的本质相同,也是 先描述再组织 ,描述文件的信息是保存在结构体当中的,这个结构体的指针就是文件指针。
(2)文件的打开和关闭
C语言中文件打开用的是fopen函数:
- 返回值:如果文件打开成功,则返回该文件的文件指针,否则返回NULL
- const char *filename:传入需要打开的文件地址
- const char *mode:文件打开的方式
文件的打开方式决定的是该文件打开以后是否可读、是否可写,以及怎么写,具体的打开方式如下:
- “r”(只读):只有读取文件数据的权限,不可以对文件进行任何形式的写入(如果指定文件不存在会报错)
- “w”(格式化写入):可以对文件进行写入,属于格式化写入,会先清空文件内容再写入(如果指定文件不存在会新建一个文件)
- “a”(追加写入):可以对文件进行写入,属于追加写入,会在文件内容末尾追加写入数据,并不会清空文件原有的数据(如果指定文件不存在会新建一个文件)
- “r+”(读写打开):这是在“r”方式的基础上加入了写功能,也就是读写打开,这种方式打开文件以后不会清空文件原有的内容,且写入内容时可以在文件的任意位置写入(如果指定文件不存在会报错)
- “w+”(读写打开):这也是读写打开的方式,同样也是写入内容时可以在文件的任意位置写入区别在于这种方式打开文件以后会清空原有的内容(如果指定文件不存在会新建一个文件)
- “a+”(读写打开):这也是读写打开的方式,这种方式打开不会清空文件原有的内容,但在写入内容时只能够在文件末尾写入(如果指定文件不存在会新建一个文件)
关闭文件使用的是fclose函数:
- 返回值:关闭成功返回0,否则返回EOF
- FILE *stream:需要关闭的文件指针
(3)文件顺序写入函数
文件写入函数是将数据写入到文件当中的,顺序写入的意思是按照从前往后的顺序,先写入的数据在前面,后写入的数据在后面,类似于顺序表的尾插操作。
第一个顺序写入函数是fprintf函数
- 返回值:如果写入成功,则返回写入的字符个数,否则返回EOF
- FILE *stream:要输出的流(即数据要写入到哪里)
- char *format[, argument, …]:要输出的格式,为可变参数
- 功能:格式化地将数据写入到指定的流中,输出格式与printf函数一样,每遇到一个%,就按规定的格式,依次输出argument的值到流stream中
下面我们用代码演示一下fprintf函数:
我们打开一个文件,用fprintf函数向文件写入一个字符串“hello myfile”以及一个整型数。
代码运行起来以后我们查看刚刚操作的文件内容,字符串与数字确实被写入到了文件中。
第二个顺序写入函数是fputs函数
- 返回值:如果写入成功,则返回一个非负值,否则返回EOF
- char *str:要写入到文件里的字符串
- FILE *stream:要写入的流,即写入到哪个地方
- 功能:将一个以’\0’结尾的字符串送入指定的流中,不加换行符‘\n’和结束符’\0’
下面我们用地代码演示一下fputs函数:
我们打开一个文件,用fputs函数向文件写入一个字符串str(手动带上换行符)
代码运行起来以后我们查看一下刚刚操作的文件内容,str字符串确实被写入到了指定的文件中。
第三个顺序输入函数是fputc函数
- 返回值:如果写入成功,则返回写入的字符(以int形式返回),否则返回EOF
- int ch:以int形式传递需要写入的字符
- FILE *stream:要写入的流
下面我们用代码演示一下fputc函数:
我们打开一个文件,用fputc函数向文件写入字符ch,让ch从字符A开始不断加加,写入26个大写字母(手动添加换行符,方便查看结果)
代码运行起来以后我们查看一下刚刚操作的文件内容,26个大写英文字母(以及换行符)确实被写入到了指定的文件中。
(4)文件顺序读取函数
文件读取函数就是将数据从文件中读取出来,顺序读取是从文件头开始按顺序依次往后读取,类似于顺序表的遍历访问操作。
第一个顺序读取函数是fscanf函数
- 返回值:如果读取成功,则返回读取的字符个数,否则返回EOF
- FILE *stream:要读取的流(即要到哪里去读取)
- char *format[, argument, …]:要按什么格式读取,为可变参数
- 功能:从一个流的起始位置开始扫描,从流中读取数据,每读取到一个数据,就依次从format所指的格式串中取一个格式(%d、%c这种格式控制),进行格式化之后存入对应的地址当中
下面我们用代码来演示一下fscanf函数:
我们打开一个文件,以只读的方式打开,然后用str字符数组来存储读取的数据,用fscanf函数从文件里读取字符串。
代码运行起来以后我们看到文件的内容被我们打印出来了,说明此时读取数据成功了。
第二个顺序读取函数是fgets函数
- 返回值:如果读取成功,则返回字符串的首地址,否则返回NULL
- char* s:用于保存读取到的数据
- int n:需要读取的n个字符(实际读取的是n-1个字符,最后一个字符自动设置为’\0‘)
- FILE* stream:需要获取字符串的流(即从哪里获取)
- 功能:从指定的流中读取一个字符串,可以规定读取的字符串长度,在读取的过程中,如果还未到达指定的读取长度,就已经遇到文件里的换行符’\n’,则会停止读取,把读到的内容保存到指定的地址中,并且保留这个换行符’\n’
下面我们用代码演示一下fgets函数:
我们打开一个只读的文件,用fgets函数从文件中读取内容,只读取十个,看一看读取出来的内容是什么以及读取到字符串的最后一个字符是什么。
我们看到字符串只打印出了文件的前九个字符,最后一个字符是’\0’
第三个顺序读取函数是fgetc函数
- 返回值:如果读取成功,则返回读取到的字符,否则返回EOF
- FILE* stream:要读取的流(即从哪里读取)
- 功能:从指定的流中读取字符
这个函数比较简单,使用方法与上面的类似,就不再演示了。
3.Linux操作系统下的文件操作
我们上面复习到的C语言下的文件操作方法,是在语言层面对文件进行的操作。当我们向文件写入/读取的时候,由于文件是存储在磁盘中的,所以最终是向磁盘进行写入/读取。磁盘是一个硬件,在计算机中能对硬件操作的只有操作系统,因为操作系统是计算机软硬件的管理者,也就是说我们对文件的所有操作,都是操作系统帮我们完成的。那么如果我们要让操作系统帮我们做某些事情,就只能够通过调用操作系统提供的系统调用接口来完成,所以我们在C语言下的文件操作本质上是调用了操作系统提供的文件操作接口,只不过C语言对这些接口进行了封装,至于为什么要有这一层封装,原因有两个:
- 如果不封装系统调用接口,这些接口的使用成本会很高,使用起来非常不方便
- 不同操作系统间各种系统接口是不一样的,如果直接使用系统调用接口的话,就不具有跨平台性了,所以高级程序语言会对这些系统调用接口做封装(对外只提供统一的封装方法,对内针对不同操作系统有不同的执行方式,这就是多态的思想)
因此,我们复习了C语言下的文件操作方法以后,也要来学习一下Linux操作系统下的文件操作方法,以加深对文件操作的理解。
(1)open
这是Linux操作系统下用来打开文件的接口,很有意思的是这个函数是一个重载函数,它有一个是需要传递两个参数的,有一个是需要传递三个参数的。
- 头文件:<sys/types.h>、<sys/stat.h>、<fcntl.h>
- 返回值:如果打开成功,则返回该文件的文件描述符(file descriptor),否则返回-1
- 参数:
(1)const char* pathname:要打开的文件的文件名(带路径的)
(2)int flags:标记位,标记文件打开的方式
(3)mode_t mode:设置打开文件的权限
open函数的pathname参数和mode参数都比较简单,flags参数比较复杂一点,我们先来看看这个参数都可以传递什么标记位:
- O_RDONLY: 文件以只读的方式打开
- O_WRONLY:文件以只写的方式打开(格式化写入),当只以O_WRONLY打开时,只会从头开始覆盖式的写入,原先的内容并不会清空
- O_RDWR:文件以读写的方式打开(格式化写入)
- O_APPEND:文件以只写的方式打开(追加写入)
- O_CREAT:如果文件不存在则创建之
- O_TRUNC:将文件进行截断清空
其实flags参数还可以传递很多标记位,这里只着重介绍我们必须掌握的用的最多的这几种。但很快就会意识到一个问题:flags参数是int类型的,并且它不是可变参数,如果我们要以只写的方式打开并且当文件不存在的时候我们要创建这个文件,那应该如何传递参数呢?
其实,flags参数的传递采用的是位图结构。
上面提到的O_RDONLY这些标记实质上是宏,要使用位图结构来传递这些标记的话,一般只需要每一个宏标记有一个比特位的值是1,并且其他比特位的值与其它宏对应比特位的值都不重叠。
我们举个小小的例子来理解一下:
我们写一个print函数,来模拟位图结构传递标记为,利用宏定义PRINT_A、PRINT_B和PRINT_C分别代表的十六进制是1、2和4,在函数内部,我们利用flags分别与三个宏进行按位与运算,如果结果不等于0的话,证明flags一定就是那个宏标记。在主函数我们调用print函数时,利用或运算来传递标记为。
最后我们运行程序看一看结果:
最后的结果也确实是按我们的要求打印出了相应的内容。
这就是位图结构传递标记位的演示,open函数的标记为也是采用这种方式传递的,利用这种方式可以传递不止一个标记位,从而实现不同标记位的共同效果(比如只写的情况下还需要实现创建文件)。
下面我们来使用一下open函数打开文件:
我们分别打开三个文件,第一个文件test_r.txt在当前路径下是已经存在的,而剩下的两个文件在当前路径下是不存在的。
我们运行程序查看结果,可以看到三个文件确实被我们打开了,原本就存在的test_r.txt文件正常打开,原本不存在的test_a.txt、test_w.txt也是正常打开,但这两个文件的权限确是乱码。
因此,如果我们打开一个在当前路径下已经存在的文件,那就可以不传权限,默认就是文件原有的权限;如果我们打开一个在当前路径下不存在的文件,需要系统新建的文件,那就不可以不传权限了,因为不传权限的话新创建的文件权限是乱码,我们必须手动为其加上权限。
下面再用代码演示一下如何手动加上权限:
先将文件掩码umask设置为0,然后调用需要传三个参数的open函数,第三个参数传递的就是文件打开时的权限。
我们先删除tsts_a.txt和test_w.txt,再运行程序查看结果:
刚才权限乱码的文件,这一次再被创建时,文件权限被设置成了我们想要的样子。
(2)close
这是Linux操作系统下用来关闭文件的接口,这个函数使用起来非常简单,只需要将文件描述符传递进去就可以关闭相应的文件。
- 头文件:<unistd.h>
- 返回值:如果关闭成功,则返回0,否则返回-1
- 参数:
(1)int fd:要关闭文件的文件描述符
(3)write
这是Linux操作系统下用来对文件进行写入操作的接口,它不像C语言下有fprintf、fputs、fputc这么多写入函数,Linux系统接口只有这一个写入函数。
- 头文件:<unistd.h>
- 返回值:如果写入成功,则返回写入数据的字节数,否则返回-1
- 参数:
(1)int fd:需要写入文件的文件描述符,表明要向哪个文件进行写入操作
(2)const void* buf:需要写入的缓冲区
(3)size_t count:缓冲区的数据长度
我们用代码演示一下write函数的使用:
定义一个字符数组存放需要写入到文件的内容,用strlen计算数组的长度(注意:这里长度不需要加一,因为长度加一代表把字符串的结束符\0也写入文件中,而结束符\0是C语言下的标准,文件是不认识的,写入到文件里只会成为乱码)。
最后我们运行程序查看结果:
write函数的返回值是13,因为我们写入的内容总字节数是13,log.txt文件里确实被写入了“hello myfile”。
(4)read
这是Linux操作系统下用来读取文件内容的接口,这个函数使用起来和write函数几乎一样
- 头文件:<unistd.h>
- 返回值:如果读取成功,则返回读取到的字节数,否则返回-1
- 参数:
(1)int fd:需要读取文件的文件描述符,表明要从哪个文件读取
(2)viod *buf:缓冲区,将文件内容读取到这里
(3)size_t count:需要读取的大小
我们用代码演示一下read函数的使用:
定义一个定长的字符数组buffer来存放读取到的文件内容,我们设定将文件的所有内容都进行读取。
程序运行起来后我们来查看结果:
read函数确实将文件里的内容全部读取出来了。
4.理解文件描述符
我们现在知道了open函数如果打开文件成功的话会返回该文件的文件描述符,我们不妨尝试一下一次性打开多个文件,并且将其文件描述符打印出来,看一下文件描述符长啥样。
如下代码:用open函数打开五个文件,并且将每个文件的文件描述符打印出来,最后再依次关闭这些文件。
我们运行程序查看一下打印出来的结果: 我们可以发现文件描述符是一组有顺序的正整数,先打开的文件其文件描述符比较小,以此类推。
每一个被打开的文件都有其独自的文件描述符,当文件打开失败时,文件描述符 fd<0 ;当文件打开成功时,文件描述符 fd>=0 。但是为什么我们上面打开的文件其文件描述符是从3开始的而不是从0开始的呢?文件描述符0、1、2又在哪呢?
实际上,文件描述符0、1、2是操作系统默认打开的文件,0号文件是标准输入(对应的硬件是键盘),1号文件是标准输出(对应的硬件是显示器),2号文件是标准错误(对应的硬件是显示器)。
下面我们用代码验证一下0号文件、1号文件和2号文件是不是分别对应标准输入文件、标准输出文件、标准错误文件:
我们利用read函数和write函数分别尝试着读取0号文件、向1号文件写入、向2号文件写入。
代码运行起来以后我们可以看到:
读取0号文件、向1号文件写入、向2号文件写入都成功了,这也证明了0、1、2号文件分别对应的是标准输入文件、标准输出文件、标准错误文件。
5.文件描述符的原理
一个被打开的文件是存在在内存里的,一个进程也是存在在内存里的。一个进程可以打开多个文件,所以在内核中,进程与被打开文件的数量比例可能是 1:n ,也就是说可能会存在大量的被打开的文件。操作系统针对这些被打开的文件也是要进行管理的,管理的本质还是先描述再组织。
一个文件被打开了,操作系统就会为这个被打开的文件创建一个结构体内核数据结构struct file,这个数据结构里包含了这个被打开文件的内容信息,这就是描述的过程;每一个被打开的文件都有描述其内容信息的数据结构,多个被打开的文件之间就通过链表将这些数据结构联系起来,这就是组织的过程。
我们上面说到一个进程可以打开多个文件,内存中也可能会存在多个进程和多个被打开的文件,那么进程和被打开的文件是怎么建立映射关系的呢?
在进程的PCB里有一个结构体指针struct files_struct* fs,这个指针指向一个结构体struct files_struct,在这个结构体中存在着一个数组结构struct file* fd_array[],这是一个结构体指针数组,里面存放的都是结构体指针struct file*,这样,就可以根据struct file*这个结构体指针指向当前进程打开的文件。
因此,文件描述符实际上是struct file *fd_array[]结构体指针数组的下标,对应的正是一个个被打开的文件。
每一个打开的文件都有一个struct file来描述该文件的内容信息,那么打开一个文件以后,它们是怎么和外设硬件建立联系的呢?用标准输入文件、标准输出文件和标准错误文件为例,我们虽然进行的是文件操作,是对这些文件进行写入/读取操作,但最终的效果确实从键盘中读取,显示到显示器上,这其中是怎么建立联系的呢?
每一个外设都有对应的驱动程序,在它们的驱动程序代码中会实现读和写的函数,通过调用这些函数来完成对硬件的读写操作。而在我们的struct file中存在着函数指针,这些函数指针分别指向对应的函数,所以我们就可以通过调用函数指针的方式来调用硬件的读写函数,从而实现相应的读写操作。
这也是Linux下一切皆文件的理解!
6.文件描述符的分配规则
文件描述符的分配规则是非常简单的,操作系统会从头遍历 fd_array[] 数组,找到其中一个没有被使用的最小下标,将这个最小下标分配给新打开的文件。
这也就是为什么当我们的标准输入文件、标准输出文件和标准错误文件默认打开的时候,新打开的文件描述符是从3开始的。如果我们关闭默认打开的文件,比如关闭了标准输出文件,那么 fd_array[] 数组里没有被使用的最小下标就是1,新打开的文件其文件描述符就是1。
二、重定向
1.重定向的概念
我们通过一个例子来简单理解一下什么叫文件的重定向:
我们先关闭默认打开的1号文件,即stdout文件,然后我们打开一个新的文件,按照文件描述符的分配规则,这个新打开的文件其文件描述符就应该是1,接着我们在文件打开成功以后,用fprintf()函数向文件里写入一段内容,我们指定内容写入的流为stdout,最后查看一下结果。
我们运行程序查看一下结果:
我们看到结果并没有打印在显示器上,再查看一下新打开的文件发现,刚刚写入到stdout里的内容写入到了新打开的文件里。
原本要打印到显示器里的内容,由于1号文件现在不是stdout而是log.txt,所以最终内容写入到了log.txt文件里,这就是文件的重定向。
为什么我们明明是向stdout写入,最终却写入到了log.txt文件呢?
原因是stdout是一个struct file变量,它里面封装了文件描述符fd,且它的fd值是1,而一开始默认打开的1号文件是标准输出文件,所以stdout理所当然指向标准输出文件。但当我们关闭了标准输出文件以后,再新打开一个log.txt文件,这个新打开的文件的文件描述符就是1,也就是说此时的1号文件是新打开的log.txt文件而不是原来的标准输出文件。但这是操作系统完成的工作,stdout是不知道这些变化的,它依然指向着1号文件,只不过此时指向的就是log.txt文件而不是标准输出文件了。所以最后内容写入到了log.txt文件中。
由于上层的数据结构只认0、1、2、3这样的文件描述符fd,并不知道具体的fd指向的是什么文件,所以我们可以在操作系统内部,通过一定的方式调整fd_array[]数组的特定下标的内容(即调整指针的指向),就可以完成重定向操作。
2.重定向的操作方法
重定向是操作系统层面的操作,因此实现重定向的操作就要使用到操作系统提供的系统接口。Linux操作系统下我们一般使用dup2()接口实现重定向操作。
- 头文件:<unistd.h>、<fcntl.h>
- 返回值:如果重定向成功,则返回重定向后新的文件描述符(即形参newfd),否则返回-1
- 参数:
(1)int oldfd:旧的文件描述符
(2)int newfd:新的文件描述符
我们可以看一下dup2这个函数接口的描述,它是将oldfd拷贝给newfd,而oldfd和newfd都是文件描述符,代表的是它们各自指向的文件,这就意味着oldfd的指向拷贝给newfd,使得最后newfd的指向就是oldfd的指向。
关于 oldfd 和 newfd ,我们可以举一个小例子来加深理解:
假设我们要进行输出重定向,即现在我们打开一个新的文件log.txt,其文件描述符是3,而默认打开的1号文件是标准输出文件,是将内容打印到显示器上的文件,我们要完成输出重定向,就应该将3号文件描述符的指向拷贝给1号文件描述符,使得最后3号文件描述符的指向文件依旧是log.txt,而1号文件描述符的指向不再是标准输出文件,而是log.txt文件。即此时 oldfd 传入的参数就应该是3, new 传入的参数就应该是1。
我们用代码演示一下这个函数接口的使用:
首先我们打开一个log.txt文件,用dup2函数进行重定向,oldfd参数传入fd(即新打开文件的文件描述符),newfd参数传入1,来进行输出重定向,然后尝试着用printf()函数向显示器打印dup2的返回值,用fprintf()函数向stdout文件写入内容。
最后运行程序我们看一下结果:
程序运行起来以后我们可以看到显示器没有打印任何内容,然后我们查看log.txt文件的内容可以看到,刚才打印到显示器的内容以及写入到标准输出文件的内容都写入到了log.txt文件中,这也就意味着完成了输出重定向。
三、缓冲区
缓冲区的本质,就是一段内存。
缓冲区是我们内存的一部分,它是在内存中预留出来的一段存储空间,用来缓冲输入数据或输出数据。
1.为什么要有缓冲区
缓冲区的存在是非常有意义的,我们可以举一个生动具体的例子来理解一下:
假设小明同学在广东读书,小张同学在北京读书,他们是好朋友,小张同学对小明同学说:“小明啊,我看你最近Linux操作系统学得不错呀,你是怎么学的呀?有没有什么好书送给我读一下呀?”小明也是个慷慨大方的人,他立马告诉小张说:“我最近在看XXX书特别好用,我给你送过去。”
那么小明同学去给小张同学送书,有两种方式可以选择,首先第一种是最原始的方式——小明同学亲自乘坐飞机/高铁从广东到北京,把书给小张同学送回去,然后再回来;第二种方式是比较现代化的方式——小明同学下楼到快递点,把书让快递员帮忙送过去。这两种方法的区别是很大的,如果小明采用原始的方法去送书,最后虽然也能够完成送书的目的,但是中途消耗的时间是他自己的时间。而如果小明选择寄快递的方式去送书,虽然中途花费的时间可能跟自己去送一样多,但是这消耗的是快递运送的时间,在这段时间内,小明还可以去做其他事情。
这个例子非常生动形象,我们假设小明同学是我们的进程,小张同学就是外设显示器,那么快递点就是缓冲区。如果小明同学选择自己送过去,就相当于进程选择自己将数据直接打印到外设显示器上,而我们都知道进程是在内存上的,显示器是外设,这之间数据传输是很慢的,这会很耽误进程的时间。而如果小明同学选择将书交到快递点,让快递员帮忙运送,这就相当于进程选择将自己的数据放在缓冲区里,最后数据由缓冲区向外设显示器刷新,即使同样可能会很慢,但这耽误的是缓冲区的时间,进程在这段时间内就可以执行其它任务。因此,我们得出第一个意义:
缓冲区的存在,可以解放使用该缓冲区的进程的时间。
还是刚刚的那个例子:小明同学将一本书送到了快递点,让快递员运送给北京的小张同学,但此时快递点只有一本书,快递员是不会开始运送先的,快递点会等到快件达到一定的数量(比如说够一车的快件了),再统一一起运送。这就相当于进程将数据写入缓冲区以后,缓冲区并不是立即刷新写入到外设当中的,而是有其自己的刷新策略,最后再统一一起刷新。因此,我们得出第二个意义:
缓冲区的存在可以集中处理数据刷新,减少IO的次数,从而达到提高整机效率的目的。
2.缓冲区在哪里
首先有几个概念我们需要认识一下:
- printf()函数是C语言的标准输出函数,它打印的数据不是直接打印到显示器上的,而是先写到缓冲区中,最后缓冲区刷新才会写入到显示器上。
- 如果我们用printf()函数输出时带上 ‘\n’ 换行符,缓冲区会立即刷新。
- printf()函数是C语言的库函数,它底层是封装了系统接口write()的。
下面我们用代码来验证一下上面的几个概念:
我们写两份代码,左边这份在使用printf()函数输出时带上 ‘\n’ 换行符,右边这份在使用printf()函数输出时不带上 ‘\n’ 换行符。
运行两个程序查看结果:
我们可以看到,左边这份代码最后的运行结果是printf()函数的内容先打印出来,再到write()函数的内容打印出来,最后休眠5s之后进程退出;而右边这份代码最后的运行结果却是write()函数的内容先打印出来,休眠5s后,printf()函数的内容再打印出来,最后进程退出。
上面的演示很好地说明了一点:
缓冲区一定不是操作系统内核提供的,而是我们的编程语言提供的。
原因非常简单,因为printf()函数是封装了write()接口的,printf()在输出内容时,带上 ‘\n’ 换行符,数据会立马刷新,而不带上 ‘\n’ 换行符,数据却不会立马刷新。但write()在输出内容时,无论带不带 ‘\n’ 换行符,数据都是立马显示出来。write()是操作系统提供的系统接口,也就说明该缓冲区不是由操作系统提供的,如果是由操作系统提供的话,write()函数在输出内容时,带不带 ‘\n’ 换行符最后出来的结果应该和printf()函数是一样的。printf()是C语言提供的库函数,所以该缓冲区是由编程语言提供的。
其实不止有printf()函数有缓冲区,fprintf()函数、fputs()函数都有缓冲区,它们只要向stdout文件写入内容,就能将数据打印到显示器上(fprintf()函数和fputs()函数需要指定写入到stdout文件中,而printf()函数默认写入到stdout文件中,不需要指定)。描述stdout文件的是FILE类型结构体,里面封装了文件描述符fd,以及缓冲区。C语言的库函数printf()函数、fprintf()函数、fputs()函数在对文件进行写入时,会先将数据写入到FILE结构体里的缓冲区当中,最后缓冲区刷新,操作系统调用write()函数接口将缓冲区的内容写入到对应的文件当中。这就是缓冲区的原理。
如果在缓冲区刷新之前,我们就将该文件关闭了,会有什么问题呢?
我们可以用代码演示一下:
由于有缓冲区的存在,printf()函数的数据并不会立即显示出来,需要等缓冲区刷新以后才能显示出来。我们在缓冲区刷新之前关闭该文件,看看会有什么样的结果。
运行程序查看结果:
我们可以发现,最后只有write()函数的内容被打印出来了,而printf()函数的内容并没有被打印出来。
因此,如果在缓冲区刷新之前关闭该文件,缓冲区会因为来不及刷新而导致最后没有内容显示。
3.缓冲区什么时候刷新
缓冲区什么时候刷新,是由缓冲区的刷新策略决定的,一般情况下,缓冲区的刷新策略有以下三种:
- 无缓冲:即没有缓冲区,立即会刷新出来
- 行缓冲:逐行缓冲,满一行就会刷新(对应的一般是显示器文件)
- 全缓冲:当缓冲区满了的时候才会刷新(对应的一般是磁盘文件)
除此之外,缓冲区刷新还有两种特殊情况:
- 当进程退出时,有一些像C语言这样被封装过的接口会强制要求进程退出前对缓冲区进行刷新
- 用户也可以强制刷新,利用fflush()函数即可
到这个地方我们就可以解释一个非常综合的问题,我们先写一份代码看一看这个问题:
我们分别用printf()函数、fprintf()函数、fputs()函数以及write()函数向显示器文件输出四个字符串,在输出完毕以后调用fork()函数创建子进程。
运行程序我们看一下结果:
我们可以看到结果是分别打印出了printf()函数、fprintf()函数、fputs()函数、write()函数的内容。
接下来我们再将程序运行的内容重定向到log.txt文件中,最后查看log.txt文件的内容,结果却和上面的不一样:write()函数的内容先打印出来了,紧接着是printf()函数、fprintf()函数和fputs()函数的内容被打印出来,并且后三者函数的内容被打印了两次,这是为什么呢?
之所以会出现这种现象,原因是在重定向之前,我们是向stdout文件写入,也就是向显示器文件写入,显示器文件的缓冲区刷新机制是行缓冲,所以我们每一个字符串都带有 ‘\n’ 换行符,都会刷新出来。但当我们进行重定向操作时,不再是将内容写入到原来的stdout文件,而是写入到log.txt文件,这是磁盘文件,对应的缓冲区刷新机制是全缓冲,所以printf()函数、fprintf()函数和fputs()函数会先将数据写入到缓冲区中,并没有刷新,也就暂时没有显示。而write()函数没有缓冲区,所以会立即显示,所以write()函数的内容出现在了最前面。另外,fork()函数创建了子进程,子进程一旦创建成功以后,一开始父子进程是共享资源的,但当一个进程想要刷新缓冲区的时候,就会发生写时拷贝,此时父子进程各有一份内容相同的缓冲区,所以进程最后结束的时候,父子进程都会刷新缓冲区,才会导致printf()函数、fprintf()函数和fputs()函数的内容出现了两次。