目录
1.文件操作
2.文件描述符
3.缓冲区
4.系统的缓冲区
1.文件操作
在C语言学习中,我们就已经使用了一些文件操作相关的接口,在学习IO之前,我们首先要复习一些以前讲过的概念, 1. 空文件也要在磁盘中占用空间,因为文件 = 内容 + 属性 ; 2.对文件的操作分为对文件内容的操作、对文件属性的操作以及对这两者的操作 ; 3 表示一个文件要用 文件路径+文件名,具有唯一性 ; 4 如果我们操作文件没有指定对应的文件路径,默认就是在当前路径进行查找 ; 5 当我们在代码中使用了 fopen、fclose、fwrite、fread等接口之后,代码编译形成二进制的可执行程序之后,但是我们没有运行起来,对应的文件操作有没有被执行? 当然没有,因为我们没有执行对应的代码。所以,对文件的操作,本质是进程在对文件进行操作 ; 6 一个文件如果没有被打开,能够直接进行文件访问和操作吗?不能,反过来说,一个文件要被访问,及必须要被打开。而要打开文件需要 进程调用系统接口,然后由操作系统执行内核的系统调用的代码。所以,文件从应用角度来看能分为两类:被打开的文件和没有被打开的问价。
综上,文件操作的本质就是:进程与被打开的文件的关系。
文件操作
我们知道C语言有对应的文件操作的接口,C++也有自己的文件操作的接口,java、python等语言也都有自己的一套文件操作的接口,而不同语言的接口又不一样,那么对于我们使用者来说,学习这些接口的成本是很高的。但是万变不离其宗,所有的接口要进行文件操作底层都是需要调用系统接口来完成的,为什么呢?这是由我们的冯诺依曼体系结构以及我们的软硬件层状结构决定的,因为文件是存储在磁盘上的,磁盘是硬件也是外设,访问外设的操作只能由OS来完成,上层所有的操作想要访问硬件都需要由操作系统来完成,都需要调用操作系统提供的一套接口,这也间接说明了,操作系统是必须给我们提供一套文件级别的系统接口的。 所以不管上层语言的文件接口如何变化,他们的接口的底层使用的都是操作系统的接口,那么我们只要掌握了文件操作的系统调用,不管使用什么语言都不影响我们使用文件操作。
下面我们就直接使用C语言的文件操作接口与系统调用接口来对比学习。
C语言的一套接口:
打开文件 : fopen(const char*path , const char* mode);
打开文件的选项或者说模式有 " r " (以只读形式打开,文件不存在则打开文件出错) 、 "w" (以只写方式打开,会清空文件的所有数据,是覆盖式的写) 、 " r+ " (以读写的方式打开,文件不存在就出错), “w+”(以读写的方式打开,覆盖式的读写,文件不存在会创建) ,"a"(追加,文件不存在创建) ,"a+" (读写式的打开文件,写是追加的写) ,这些模式使用的时候要注意的就是读写式的打开文件时,要分清楚文件指针当前所处的位置。
关闭文件 fclose( FILE* pf)
写入 : fprintf,以标准格式写入,使用的方法就是 printf 的参数前面加一个 流
读取:fscanf ,以标准格式读取 和 fgets 以行为单位从指定的流中读取 ,fgets(char* s , int size ,FILE*stream); fgets读取成功会将读取到的数据放到指定的缓冲区 (数组)中,size 是指定的读取的最多字符个数 , 当读取了 size-1个字符或者读取到换行符(换行符会丢弃)或者读到文件末尾时,fgets 默认会在缓冲区结尾添加 \0 ,所以我们传size的时候可以直接使用sizeof(s)传参。 当然,fgets也有可能读取失败,比如你的文件打开失败而你没有检验,然后传了一个空指针给fgets,读取失败会返回 NULL。
系统调用接口:
打开文件的接口 open
open接口使用需要包含三个头文件
这两个接口的区别就是,如果文件存在,我们使用这两个接口都行,如果文件不存在,需要再打开文件的时候创建,那么我们就只能用 第二个接口 ,mode 参数就是设置文件创建的初始权限的。当文件是已经存在的文件时,mode参数就不会用到,而如果当我们需要创建文件时,但是我们使用的是第一个接口,那么文件的初始权限就是一个随机值,这就很麻烦。第一个参数则是文件所在路径和文件名,如果不指定路径,就默认在当前路径下搜索和创建。
第二个参数 flags ,就是我们打开文件的选项,常用的就是以下几个
这些选项都是一些宏,都是一些标记位 。
对于标记位我们怎么理解呢?
我们的C语言中,我们可以使用一个整数,它的值为0或者为 1来表示是否执行某些操作,比如下面的在这个函数
我们的Add函数并不是一定就执行加法运算,而是会依据参数传过来的标记位的不同,来执行不同的操作。
但是,有的函数是不只有一个标记位的,一个函数可能要执行多个功能,那么就需要多个标记位来判定哪些功能要执行,哪些功能不执行,而如果按照我们上面的方法使用一个int类型的整数来表示一个标记位,那么如果有 很多种不同的功能,每一个功能都需要传一个 int 的参数来进行标记,这是很不方便的,既浪费了空间,降低效率,同时,调用函数时我们传参也会十分繁琐,其实最主要的还是浪费了大量的空间,因为一个功能只有执行和不执行这两中选择,那么在实际中,我们只需要一个比特位就能确定是否执行该操作,比如这个比特位为1就执行该操作,为0就不执行该操作,没必要拿一个 32 个比特位的int‘类型的数据来标记,浪费了太多空间。
而在操作系统层面就是这样做的,他的标记位是以比特位来传递的,那么一个 int 类型的整数就能传递32个标记位,但是我们要注意,标记位不能有二义,也就是同一个功能只能用一个比特位来标记,同时一个比特位只能标识一个功能。,位置不能重复。在函数中就能通过位运算或者直接用一些宏来判断哪些选项或者说功能是要执行,哪些功能不执行,那么如果想要执行多个功能呢? 比如执行 0001 和 0010 对应的功能,这也很简单,我们可以传参的时候,传这两个标记为的 或 运算的结果,也就是 0001 | 0010 。
那么我们上面的 open 的选项也是这样的,操作系统一般都是通过比特位来传递标记为或者说选项的,而我们上面的 open 选项也就是几个常用的宏 , 通过或操作将选项组合起来传参,就能实现多个功能 ,但是还是有一个前提,就是传递的选项不能够冲突,比如只读和只写这两个不要一起传。
比如我们写了这样一个代码,以只写的方式打开文件,外面传的文件名不存在,同时我们也给了文件的初始权限,我们这样传参能够创建并打开文件吗?答案是不能的,因为我们只传了 O_WRONLY,也就是只写选项,而没有传 O_CREAT ,文件不存在时创建文件的选项(这个选项只是一个建议选项,如果文件已经存在,这个选项和 mode 参数就不起作用了,但是不会影响已经存在的那个文件)。
我们发现只写的方式打开并不会我们创建一个文件,如果我们对open的返回值进行判断的话,就会发现打开文件失败了,但是由于我们到这里还没有讲它的返回值,所以就不演示了,马上就会讲到 。
当我们把O_CREAT加上,就能完成创建文件的操作了。
同时,如果我们想要打开一个文件进行写入,但是不想覆盖掉原来的内容,而是进行追加式的写入,那么我们可以 | O_CREAT 。
同时,我们也发现,文件虽然被创建出来了,但是他的权限并不是我们指定的权限,这很容易理解,是由于权限掩码的作用,那么如果我们想要在程序中设置权限掩码要怎么设置呢?
使用 umask 函数
这样一来,我们就能将以指定的权限创建文件并进行写入了。我们使用的 umask 函数只是修改了我们的进程的权限掩码,修改的是当前进程的 umask ,发生写时拷贝,所以并不会影响到我们的bash进程的 umask。
open函数的返回值我们称之为文件描述符,你没有听错,文件描述符其实就是一个整形,这个文件描述符要比C语言的 FILE 结构体要更加底层。如果open打开文件失败,会返回 -1 ,同时也会将错误码设置在errno中。
关闭文件:close
关闭文件只需要传文件描述符就可以了。
文件写入:write
write就是将缓冲区的数据向文件描述符指向的文件中写入。三个参数,文件描述符,缓冲区以及要写入的字节个数,write返回值则是成功写入的字符个数
这里的缓冲区的类型时cosnt void*类型,可以接受任何类型的数据,同时,write的写入是二进制写入,而不像C语言还给我们提供了字符写入等接口,因为在操作系统看来,所有的数据都是二进制,不分类型,那么为什么我们打开文件时看到的却是我们输入的内容而不是一些我们看不懂的二进制呢?文本文件的编码方式,将我们的数据转换成的二进制数据重新转换为了源数据。
同时,我们在使用C语言的写入文件的接口时,当我们需要写入字符串的时候,一般都会把\0也算进个数,以\0作为字符串的结尾,将\0也写入,方便我们读取文件。但是在Linux中,这个\0却是没有必要的,因为操作系统是不会把\0当成字符串的结尾的,而是将其当成一个正常的数据,不会说要找到\0才会停止写入,当然,如果你需要这个\0作为读取时的标记,当然也可以写进去,只是他在文件中的形式可能不是 \0 的形式。
当我面对一个已经存在的文件进行写入时,我们会发现一个与C语言的不同。
虽然O_WRONLY的写入默认也是覆盖式的写入,但是他是写一个覆盖一个,而不是像C语言的“w“一样,打开文件的同时就直接将数据清空了。
如果我们要对文件进行清空再写入,则要加上O_TRUNC选项
如果是要追加式的写入,那么就是用O_APPEND选项。
所以C语言的一个简单的 "w" 模式,底层就是 O_WRONLY | O_CREAT | O_TRUNC这三个选项的传参,而 "a" 则是 O_WRONLY | O_CREAT | O_APPEND
读取文件 read
从文件中读取最多count个字节的数据放到缓冲区中。依旧是读取二进制的数据,但是我们可以将二进制的数据强制转换成我们想要的类型,如果是字符串,在读取的时候,我们需要在缓冲区后面预留一个位置用来放 \0 。 read的返回值是读取到的字节个数,我们可以判断 返回值是否大于 0 来判断是否读取成功,如果读取失败则会返回 -1.
这就跟C语言的时候累死了,要预留字符串结尾。
当我们以读写方式打开文件的时候,在C语言中需要使用 fseek 来调整文件指针的位置,而在linux系统层面,则是需要一个系统接口 lseek 来设置读取或者写入的偏移量,他的一些选项如下图
这个偏移量目前我们就理解成文件指针就行了,因为都是指定读取和写入的位置的,偏移量在后面的章节会讲。
语言提供的接口再不同,都是对系统调用的封装,底层调用的都是系统的这一套接口,将系统接口掌握了文件操作就很容易理解了。
2.文件描述符
在讲文件描述符之前,我们要先聊一个话题:进程可以打开多个文件吗? 这当然可以,很简单就可以验证出来,那么系统中有那么多的进程,同时都会打开文件,那么系统中就一定会存在大量打开的文件,而操作系统需不需要对这些被打开的文件进行管理呢?当然需要,操作系统需要管理我们所有的软硬件资源,当然需要对这些文件进行管理,那么操作系统如何管理这些被打开的文件呢? 先描述再组织。操作系统为了管理对应的被打开的文件,必定要为这些文件创建对应的内核数据结构来表示这些文件,这就是 struct file 结构体 ,在这个 struct file 结构体中包含了文件的大部分属性,组织的方式就是用相应的数据结构来管理这些结构体,比如链式结构。操作文件/打开文件并不会将整个文件从磁盘加载到内存中(试想一下一个超大文件),只会讲一些文件的属性以及一些其他的东西加载到内存。
这些被打开的文件又是如何与打开它的进程关联起来的呢?
这个问题我们留着后面来讲。既然我要将文件描述符,而文件描述符又是一个int类型的数,那么我我们把它打印出来看一下。
int main(){//直接循环打开多个文件int i=0;char filename[15];//保存文件名int fd[10]; //保存文件描述符while(i<10){//生成文件名 sprintf(filename,"%s%c","file",i+'0'); fd[i]=open(filename,O_WRONLY | O_CREAT,0666);++i; } for(i=0;i<10;++i) { printf("%d\n",fd[i]); close(fd[i]); } return 0; }
我们发现open返回的文件描述符是一些连续的小整数,从 3 开始的,那么晚这里就有两个问题呢,为什么是一些小整数呢,小整数代表着什么呢? 为什么是从3开始而不从0开始呢?
我们先解决第二个问题,为什么从3开始而不是从0开始。在学习C语言的时候我们讲过一个基本概念就是每一个C语言程序启动时,都会默认打开三个流,标准输入(stdin)、标准输出(stdout)以及标准错误(stderr),这三个流在C语言中都是FILE*类型的,也就是文件结构体的指针,这里刚好有三个文件,是不是就对应着文件描述符缺失的 0 1 2 呢?我们可以来验证一下。
int main() { printf("%d\n",stdin->_fileno); printf("%d\n",stdout->_fileno); printf("%d\n",stderr->_fileno); return 0; }
我们知道语言是上层,而操作系统是底层,那么语言中的FILE结构体中就一定包含了文件描述符的字段,这也就是我们上面的 _fileno 成员。
这就是我们当前进程的fd为 0 、1和2 的文件, 这是我们通过上面的程序就能直接验证出来的.
那么为什么文件描述符是一些连续的小整数呢?这就需要我们了解文件与进程的关联的关系了,以一个进程为例 , 一个进程是同时需要关联多个被打开的文件的,这一点光从三个标准流就能看出来,那么晚每一个文件都是一个 struct file 结构体来表示,而进程的所有属性都是在PCB里的,那么晚进程的PCB就一定有字段能够找到进程打开的问价的结构体,其实这中间还有一层.
PCB中有一个 struct file_struct* files ,这是一个指针,指向的是一个属于该进程的用来保存 struct file的数据结构对象. 而在 struct file_struct* files 指向的结构体中,包含了一个数组,这个数组的就是 struct file* fd_array[ ] 不难看出这是一个指针数组,数组的元素就是struct file的指针,也就是指向文件描述的内核结构体的指针,如此一来,进程就能通过PCB中的struct file_struct指针找到一个保存该进程相关文件的结构体,在从其中的 struct file* fd_array[ ]中找到 fd 对应的文件,然后进行操作.
这样一来我们就能理解文件描述符fd是什么了 ,他其实就是文件的结构体的指针在struct file fd_array中的下标,那么 fd 的分配规则是什么样的呢?我们前面创建文件都是直接从 3 开始分配,按照先后顺序,因为 0 1 和 2 都已经被占用了 ,那么如果我们把这三个默认打开的文件流关掉呢(其实是解除关联)? 这时候会如何分配??? 是从 3 开始还是从前往后找到第一个被使用的拿来用呢??这个问题其实很好验证,
int main() { close(0); close(3); int fd1=open("a.txt",O_RDONLY); printf("%d\n",fd1); int fd2=open("my.txt",O_WRONLY | O_CREAT,0666); printf("%d\n",fd2); return 0; }
我们发现,他确实是从前往后遍历数组,找到第一个没有被使用的下标来分配给新打开的文件的.文件描述符的本质就是数组下标.
到这里就又有了一个小疑惑,就是,当 三个默认打开的文件被关闭之后,这时候再打开新的文件, stdout stdin stderr 这三个文件结构体还能用吗? 当然是能的,其实这三个结构体就是在底层对fd为0 1 2 进行了封装,那么当 0 1 2 这三个文件描述符指向的对象变了,这三个结构体所指向的文件也就变了,因为他们只是指向 0 1 2 文件描述符的文件流,而不是说固定指向显示器和键盘,显示器和键盘在操作系统眼中也就是文件,和其他文件没有很大的不同.
同时我们知道,scanf和printf默认是从stdin中读取数据以及向stdout输出数据,那么当我们的stdin和stdout指向的不是键盘和显示器了,他们也就会向新指向的文件中去读取和输出数据.
//stdin指向我们自己打开的文件int main(){close(0);int fd = open("a.txt",O_RDONLY);char buf[64];while(scanf("%s",buf)!=EOF)printf("%s\n",buf);return 0; }
当我们让stdin指向我们的文件时,scanf读取输入的时候就不是去键盘中读取了,而是直接在我们的文件中读取.
那么修改fd为1的stdout 也是一样的
//修改stdout指向int main(){ close(1);int fd=open("a.txt",O_WRONLY | O_TRUNC);//覆盖写入 char*buf="this is a string for stdout!"; printf("%s\n",buf);return 0; }
这时候输出到stdout的内容就顺利的输出到了 fd为1 的文件中了.
这就叫重定向,输入重定向( < )和输出重定向( > ),以及追加重定向( >> )
重定向的本质就是上层的stdin等 内部使用的 fd 不变,在内核中修改数组中 fd 对应的 struct file*
但是我们发现上面的写法很麻烦,首先要关闭一个文件,然后再打开一个文件,使用起来不舒服.而系统中为了支持我们进行重定向,提供了一个系统接口, dup2
int dup2 ( int oldfd , int newfd )
这个接口的参数命名很坑,虽然他下面解释了他的功能 .简单来说,dup2 就是一个用来进行复制文件描述符的,将下标为 neewfd 的内容复制成 oldfd 一样的内容 ,也就是说, newfd 是要被替换的下标, oldfd 是我们最后要用的下标.
//输入重定向int fd=open("a.txt",O_RDONLY);dup2(fd,0); //输出重定向int fd=open("a.txt",O_WRONLY | O_TRUNC);//覆盖写入dup2(fd,1); //追加重定向int fd=open("a.txt",O_WRONLY | O_APPEND);//追加写入dup2(fd,1);
这时候我们就能实现 模拟shell 的重定向的实现了。子进程虽然也会继承拷贝一份父进程的文件描述符数组,父子进程是独立的,重定向时修改的时都属于自己的文件描述符表(数组)。
一个进程是可以同时打开多个文件的,同时,一个文件也是可以被多个文件打开的,但是文件的结构体只有一个,这些进程都是指向这一个结构体,当有一个进程关闭文件时,不会真的关闭该文件,而是将自己的文件描述符表的该项内容删除,也就是断开联系,同时,文件描述结构体中有一个引用计数,这个引用计数会减一,当计数为0时,说明没有进程在使用该文件了,这时候操作系统会自动将该文件关闭。所以,close其实并不是关闭文件,而是断开此进程于该文件的关系。
那么如何理解键盘和显示器这些外设也是文件呢?
操作系统要管理软硬件,要对每一个软硬件进行先描述再组织,所以每一个硬件都有对应的结构体来描述,包含了硬件的所有属性。硬件与文件其实差不多,都是需要读属性或者写属性,比如读取磁盘就是读属性,保存文件到磁盘就是写属性。 但是不同的设备可能读写方法不一样,驱动不同,但是其他的所有属性都是可以统一描述的。于是Linux做了一个设计,struct file结构体里包含了各种文件的属性,虽然打开的方式可能不一样,但是所有的属性都是可以用数值来抽象描述的,而他们的访问方式也不一样,对应的就是他们的读写函数不一样,在结构体中有两个函数指针来指向该硬件的对应的读写函数,这样一来,操作系统就能够对所有软硬件进行统一的管理,属性的描述都是统一的,同时需要读写时就会在结构体中去调用对应的读写函数。
虽然struct file内部是有区别的,但是在struct file的上层看来,所有的文件都是一样的,上层使用的是统一的接口或者结构体就能访问不管何种软硬件(在上层摒弃了底层软硬件的区别之间),底层的差异是通过各自的方法去弥补的,这可以称之为多态。
每一个进程可以打开的文件个数是有限制的,我们可以使用ulimit -a命令来查看,不过现在的服务器一般都没有严格的限制了。
3.缓冲区
我们前面说,我们常见的缓冲区是在用户层的,比如我们之前使用缓冲区的特性来制作了一个进度条。我们来观察下面的一段代码
printf("this is printf\n");fprintf(stdout,"this is fprintf\n");fputs("this is fputs\n",stdout);write(1,"this is write\n",14);fork();
这段代码就是在屏幕上打印四行信息,没有问题。
但是,当我们在添加一点东西,比如将输出内容全部重定向到一个文件中
int fd=open("a.txt",O_WRONLY|O_TRUNC|O_CREAT,0666); dup2(fd,1); printf("this is printf\n");fprintf(stdout,"this is fprintf\n");fputs("this is fputs\n",stdout);write(1,"this is write\n",14);fork(); return 0;
这时候如果按照我们的逻辑的话,应该也是打印四行内容到文件中,因为我们每一个打印都加了 \n,那么事实真的是这样吗?
可是当我们执行这段代码时,却发现他打印了七行内容,除了write之外都打印了两次,这是为什么呢?
我们再把最后一行创建子进程的代码去掉之后,结果发现,文件中就只有四行了,这时候就没问题了,这说明了上面的问题就是由于创建子进程而造成的。但是奇怪的是,fork是在最后的,打印的代码执行完了才执行fork的,为什么会出现这个结果呢?我们从结果反推,中间肯定发生了写时拷贝之类的操作。
联想到我们讲过的一点点关于缓冲区的知识,我们讲过,printf执行之后,数据并不是执行打印到了显示器上,而实现放在了缓冲区,而由于显示器是行缓冲,遇到换行符就刷新到显示器上了,当数据从缓冲区刷新出去的那一刻,数据就不属于进程了。当上面的程序是输出到显示器上的时候,确实是行缓冲的,所以结果我们能够猜到,而当输出的目标变成一个普通问价之后,就出现了刷新两次,这是否说明了普通文件和显示器等特殊设备文件的缓冲区刷新策略不同?从而导致了这样的情况。
要清楚解释上面的现象,我们需要理解两个问题 :1 什么叫缓冲区? 2 缓冲区是谁申请的?属于谁?为什么要有缓冲区?
第一个问题其实不难理解,缓冲区不管怎么样,它需要临时存储数据,所以他本质上就是一段内存。类比我们的现实生活,将数据写入到外设就好比送一个东西给一个人,由于外设速度非常慢(相当于cpu),这就导致了,如果你是采用亲自动手将东西送到别人手上,这时候就会浪费你的很多时间,也就是进程需要花费很多时间来等待外设准备就绪,从而导致效率低下,那么这时候就需要现实生活中的快递公司,当我们两个人相距很远时(模拟需要等待时间),我们可以将我们的物品交给我们所在地的快递站点(进程缓冲区),而快递公司会在合适的时间将物品发送出去(刷新策略),知道送到对方所在地的快递站点(...),最终交付到对方手上(刷新到外设)。 这时候,当物品被交给快递站点的时候,我们自己就可以做其他的事了,不用一直等着。
在上面的例子中,发送方就是我们的进程或者说用户,接收方就是外设,而我们所在地的快递站点就是进程的缓冲区,缓冲区也是在内存里面的,所以我们的进程将数据发送到缓冲区速度是取决于内存的访问速度的,然后从缓冲区刷新到外设中 、而如果我们进程是直接和外设进行IO,那么速度就取决于外设的速度了,这样效率很低。而将数据送到缓冲区的过程就是将数据拷贝到缓冲区,这样一来,其实 printf、fprintf、fputs等函数,本质上其实是一个拷贝函数,将数据从进程拷贝到缓冲区。
那么缓冲区存在的作用就很明显了,节省进程进行数据IO的时间,提高整体效率
当我们将数据拷贝到缓冲区之后,缓冲区什么时候才会将数据拷贝或者刷新到文件或者外设呢?这就涉及到了缓冲区的刷新策略。 我们知道缓冲区就是一块内存,如果数据的大小刚好就是这块内存的大小,那么一次性将数据写入到外设中的效率肯定是要比分多次每次少批量的将数据写到外设中的效率要高得多的。(每一次与外设的IO的大部分时间都是在等待外设准备就绪),所以一般来说是一次性将所有数据刷新出去是效率最高的,但是有的设备其实是有一点特殊的,比如我们的显示器,显示器不是给其他外设或者机器看的,而是给用户看的,人的阅读习惯通常是一行一行和从左往右读的,所以显示器一般是行刷新,能够提高用户体验,而类似于磁盘中的文件,则没有这方面的要求,所以一般是一次性将缓冲区的所有数据刷新。
一般而言有三种策略
1. 立即刷新(比如用户手动刷新)--无缓冲
2.行刷新(显示器) --行缓冲
3.缓冲区满再刷新(磁盘文件) -- 全缓冲
还有两种特殊情况
1.用户强制刷新(fflush)
2 进程退出 -- 一般都要进行缓冲区刷新
我们上面讲的缓冲区都是应用层的缓冲区,我们也能够知道这个缓冲区绝对不再操作系统内核中(因为write没有打印两次),也就是我们C语言的缓冲区,那么既然是C语言的缓冲区,我们应该是能够在源代码中看到的。 而我们学习C语言的时候,对于缓冲区的操作(比如fflush,fclose)传的参数都是 stdout ,stdin等,这些都是FILE类型的结构体,所以我们能够推测出缓冲区是在FILE的结构体中。
我们打开/usr/include/libio.h 这个文件,找到一个结构体 IO_FILE,这之中就是FILE结构体,然后我们能够看到一系列相关信息,比如缓冲区。简单理解的话我们可以理解为一个char类型的数组,每次有数据刷新到缓冲区都是memcpy拷贝到了这个数组中,然后根据刷新策略进行输入的刷新。我们能够看到缓冲区是真实存在的
这时候我们就能解释为什么重定向的时候fork之后C语言的接口会打印两次了,而没有重定向的时候则只打印一次。因为没有重定向的时候,是在向显示器输出数据,这时候缓冲区是行刷新策略,而我们每一条打印都有\n ,则每一次调用完函数之后都会进行刷新,缓冲区的数据在fork之前就已经刷新清除了,FILE内部的缓冲区就不存在数据了。
而当我们重定向之后,由于目标变成了普通的文件,这时候采用的刷新策略是全缓冲,也就是要缓冲区满了之后才刷新,而上面三个函数的数据显然并没有把缓冲区放满,所以缓冲区并没有刷新,数据还存在stdout的缓冲区中。这时候fork创建子进程的时候,子进程会拷贝父进程的PCB,而缓冲区里面的数据一开始是被父子进程共享的,而当进程退出的时候,不管父子进程谁先退出,由于要刷新缓冲区,也就是将缓冲区的数据写到外设,然后清除缓冲区,由于存在清除缓冲区的行为也就是写入的行为,先退出的进程会发生写时拷贝,将缓冲区拷贝一份,然后刷新以及清除这份拷贝出来的缓冲区,这时候就已经打印一次了。而后退出的进程也会刷新缓冲区,也就是原来的空间,这时候会再打印一次,于是C语言的接口的数据都被打印了两次。
为什么write没有被打印两次呢?write是系统调用,不使用FILE,使用fd,不存在C语言的缓冲区。
缓冲区光这么说也能够理解的差不多了,我们还可以实现一个简单的缓冲区来加深理解。实现缓冲区也就是实现我们的FILE结构体以及几个简单的C函数,比如fopen,fclose,fwrite,fflush。
首先将我们需要用到的头文件包含在 .h文件中
实现fopen的时候,底层调用的是open,同时我们需要判断模式,是"a" "w" "r"中的哪一种,我们只考虑这三种简单情况
3 _FILE* _fopen(const char* filename , const char* mode) 4 { 5 _FILE* pf=(_FILE*)malloc(sizeof(_FILE)); 6 assert(pf); 7 //简单写,默认行刷新 8 pf->flags=SYNC_LINE; 9 pf->size=0; 10 pf->capacity=MAX_SIZE; 11 memset(pf->buf,0,MAX_SIZE); 12 //打开方式 13 if(*mode=='r') 14 { 15 pf->fd=open(filename,O_RDONLY); 16 } 17 else if(*mode=='w') 18 { 19 pf->fd=open(filename,O_WRONLY|O_CREAT|O_TRUNC,0666); 20 } 21 else if(*mode== 'a') 22 { 23 pf->fd=open(filename,O_WRONLY|O_CREAT|O_APPEND,0666); 24 } 25 else 26 { 27 //... 28 } 29 return pf; 30 31 }
flose也是很简单,调用fflush同时释放掉动态开辟的结构体
37 void _fclose(_FILE* pf) 38 { 39 _fflush(pf); 40 free(pf); 41 }
重点是 fflush 和 fwrite 的实现,fwrite要实现返回刷新到缓冲区的数据个数
37 int _fwrite(void*ptr ,size_t size,size_t num,_FILE*pf)38 {39 //返回值(能够写入的数据个数)40 int ret=num;41 42 //判断能否全部写入
W> 43 if(pf->size + size*num > pf->capacity)44 {45 ret=(pf->capacity - pf->size)/size;46 }47 //将数据写入到缓冲区48 memcpy(pf->buf+pf->size,ptr,ret*size);49 pf->size+=(ret*size); 50 51 printf("ret:%d size:%d\n",ret,pf->size);52 //判断是否需要刷新53 switch(pf->flags)54 {55 case SYNC_NOW:56 //....57 break;58 case SYNC_FULL:59 //....60 break;61 case SYNC_LINE:62 {63 if(*(pf->buf+size-1)=='\n')64 _fflush(pf);65 break;66 }67 default:68 break;69 }70 printf("写入成功\n");71 return ret;72 }
fflush则需要将数据刷新到外设中,简单实现就是直接用write。
76 void _fflush(_FILE*pf) 77 {78 write(pf->fd,pf->buf,pf->size); 79 memset(pf->buf,0,pf->size); 80 pf->size=0;81 }
4.系统的缓冲区
我们上面讲的一系列缓冲区都是讲的语言层面的缓冲区,而实际上,操作系统也为文件创建了缓冲区。当我们把数据从语言的缓冲区刷新的时候其实并不是直接将数据写到了外设中,这样做对操作系统的管理是不利的,实际上,数据从语言层面或者说我们自己实现的缓冲区刷新到啦系统为文件创建的缓冲区中,这也就是write的功能,所以,本质上来说,write也是一个拷贝函数,将数据从语言的缓冲区拷贝到了内核的缓冲区。
内核缓冲区刷新才是写入到外设中,而内核的刷新策略则是极其复杂的,由操作系统自己决定,从内核缓冲区刷新到外设的过程与用户毫无关系。
所以总结来看,数据从我们的程序写到外设中需要经过三次拷贝,第一次是将数据拷贝到用户缓冲区中,第二次则是将以后缓冲区的数据刷新到内核缓冲区中,第三次将内核缓冲区的数据刷新写入到外设中。
怎么证明存在内核缓冲区呢?这个我们不好证明,得看源码。但是我们也可以通过一些接口来间接证明(fsync)。
由于操作系统将数据从内核缓冲区写入到外设的过程与用户无关,那么用户怎么知道数据是否已经写入到外设或者怎么保证我们的数据写到外设呢?因为有的时候我们的数据还在内核缓冲区时,如果这时候操作系统崩溃或者宕机的话,那么数据在内存中岂不是丢失了?我们不希望让操作系统掌握一些重要数据的刷新策略,而是我们想要强制主动刷新。而操作系统针对这种问题也提供了解决方案,就是给我们提供了接口来主动刷新内核缓冲区。
int fsync ( int fd )
如果成功刷新则返回 0 ,刷新失败则返回 -1 ,同时错误信息会更新到errno中。
这时候我们就需要再次更新上面的缓冲区的实现,也就是我们的 fflush 不只是将数据写到内核缓冲区中,而且要将数据写到外设中
本篇文章主要讲的是基础IO,也就是被打开的文件与进程之间的数据之间传送的过程,这属于文件系统的一部分,下一章节还会继续补充文件系统的另一部分,即未打开的文件如何被管理。