目录
一、基本共识
二、复习C语言中的文件操作
三、与文件操作有关的系统调用接口
1. open 与 close
1.1 umask
2. write
3. read
四、如何理解文件
1. 文件描述符 fd
2. 文件fd分配规则
3. 重定向的引入
4. 重定向的本质
5. dup2
6. 理解 >、>>、<
7. 理解Linux下一切皆文件
五、缓冲区
1. 缓冲区理解
2. 缓冲区的三种刷新策略
3. 两种特殊情况
4. 缓冲区的位置
5. 进一步理解缓冲区
一、基本共识
我们这里先来形成一些共识:
- 空文件也要在磁盘中占据空间;
- 文件 = 内容 + 属性;
- 文件操作 = 对内容和对属性的操作;
- 要标定一个文件,必须使用文件路径 + 文件名【唯一性】;
- 如果没有指明对应的文件路径,默认是在 当前路径(进程当前的路径) 进行文件访问;
- 当我们把fopen,fclose,fread,fwrite等接口写完之后,代码编译之后,形成二进制可执行程序之后,但是没运行,文件对应的操作也没有被执行。因此,对文件的操作,本质是进程对文件的操作。所以,以后我们所讲的文件的关系,都是进程与文件的关系,而不是程序与文件的关系,因为只有当程序运行起来之后,文件才可以被访问。
- 一个文件如果没有被打开,不可以直接进行文件访问。也就是,一个文件要被访问,就必须先被打开。其中,打开文件是由用户进程和操作系统共同完成的,用户进程负责调用接口,操作系统负责打开文件。此外,要访问一个文件,并不是要将磁盘上的所有文件都打开。因此,在应用角度上,文件可以分为 被打开的文件 与 未被打开的文件。
- 所以,文件操作的本质是:进程 和 被打开文件 的关系;
并且,我们知道:文件是在磁盘中的,磁盘是一个硬件,而要访问硬件就只有OS具有资格,因为OS是硬件的管理者,所以,所有人想访问磁盘都绕不过OS,都必须使用OS提供的接口。
所以,OS必定会提供文件级别的系统调用接口。
而操作系统只有一个,上层语言却有很多种,并且每种语言都会有文件操作。每种语言的接口还不一样。但是,这些不同的接口底层其实都是会调用系统调用接口的(即:库函数可以千变万化,但是底层不变)。因此,我们只需要先把底层学习了,那么,上层学习起来就简单了。
我们在学习系统调用接口前先来复习一下C语言中关于文件操作的接口吧。
二、复习C语言中的文件操作
编写如下代码:
#include<stdio.h>#define FILE_NAME "log.txt" //没有指明路径,文件在当前路径下形成int main(){FILE *fp = fopen(FILE_NAME, "w");//r(读,不存在则报错),w(写,不存在则创建), r+(读写,不存在则报错), w+(读写,不存在则创建), a(追加), a+(在文件尾读写,不存在则创建)if(NULL == fp){ perror("fopen");return 1;}fclose(fp);return 0;}
运行结果:
我们会发现,当我们运行程序后,log.txt 文件被创建出来了。并且,由于我们没有向里面写内容,故其大小为0。
代码演示:
#include<stdio.h>#define FILE_NAME "log.txt"int main(){FILE *fp = fopen(FILE_NAME, "w");if(NULL == fp){ perror("fopen");return 1;}int cnt = 5;while(cnt){fprintf(fp, "%s:%d\n", "hello world", cnt--);}fclose(fp);return 0;}
运行结果:
当我们使用 fprintf 将特定的数据格式化到特定的文件流(fp),并且运行我们写的程序时,会发现文件已经被写入指定的内容了。
当我们通过 r 的方式来读取文件的内容:
代码演示:
#include<stdio.h>#include<string.h>#define FILE_NAME "log.txt"int main(){FILE *fp = fopen(FILE_NAME, "r");if(NULL == fp){ perror("fopen");return 1;}char buffer[64];while((fgets(buffer, sizeof(buffer) - 1, fp) != NULL)//fgets默认会为读取到的字符串添加'\0',所以,这里需要-1,给'\0'留空间{buffer[strlen(buffer)-1] = 0;//处理多读的'\n',如果不这么做,会多打印一行空行,因为puts打印字符串可能会带'\n',而我们写的文本里面是以行为单位陈列的,所以,如果有多余的'\n',就可以去掉puts (buffer);}fclose(fp);return 0;}
运行结果:
【fgets表示以行为单位,从特定的文件(stream)当中读取数据,并将读取到的数据放在 s 所指明的缓冲区当中,其中,size表示元素个数大小。】
【puts表示把读进来的字符串直接显示到对应的显示器上。】
当我们通过 a 的方式来给文件追加内容:
代码演示:
#include<stdio.h>#define FILE_NAME "log.txt"int main(){FILE *fp = fopen(FILE_NAME, "a");if(NULL == fp){ perror("fopen");return 1;}int cnt = 5;while(cnt){fprintf(fp, "%s:%d\n", "hello world", cnt--);}fclose(fp);return 0;}
运行结果:
我们会发现,我们的文件内容变的越来越多了。
然后,我们再用w方式打开文件,并且只进行打开,不做其它操作。
代码演示:
#include<stdio.h>#define FILE_NAME "log.txt" int main(){FILE *fp = fopen(FILE_NAME, "w");if(NULL == fp){ perror("fopen");return 1;}fclose(fp);return 0;}
运行结果:
我们会发现:单纯以w方式打开的文件,C会自动清空内部的数据。
此外,我们会发现:我们在创建文件的时候,被创建文件的默认权限位664,这是因为默认文件掩码umask,这个我们以前在讲权限的时候讲过。 现在应该很清晰了。这些都是一些细节,下面我们来讲一下关于文件操作的系统调用接口。
三、与文件操作有关的系统调用接口
1. open 与 close
我们会发现这里有三个接口,我们这里主要讲前两个。
这里先讲第二个。
我们会发现:这个接口的第三个参数是 mode,它其实就是指 权限。它被用于当文件不存在需要被创建时,来指定文件的权限是多少。
pathname 指的是 路径,flags 是指 一些特定选项。(flags具体含义等一下专门来说)
我们先来看这个接口的返回值。
通过上图,我们会看到:如果open调用成功,会返回一个文件描述符,它是一个整数(区别于C语言中 fopen 的返回值是一个文件指针)。(文件描述符是什么也等到后面专门来说)
我们这里就可以专门来讲一下 flags 了。
通过上图,我们会发现,上面的三个单词是由纯大写字符和下划线构成的。而在C语言中,这样形式的命名我们其实是不难想到它就是宏。
此外,我们以往写C程序的时候,由于没有bool类型,我们一般会使用一个整数作为一个标志位。那如果需要传多个标志位呢?那就传多个整数?这是不是太麻烦了?那有什么方法可以解决这个问题呢?
首先,我们知道:一个整型类型是有32个比特位的,那我们是不是可以使用比特位来传递选项。我们只需要给不同的比特位定义不同的含义就可以了。下面,我们就先讲一下如何使用比特位来传递选项。
编写如下代码:
#include<stdio.h>
//每一个宏对应的数值只有一个比特位是1,彼此位置不重叠
#define ONE (1<<0)
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
void show(int flags)
{if(flags & ONE) printf("one\n");if(flags & TWO) printf("two\n");if(flags & THREE) printf("three\n");if(flags & FOUR) printf("four\n");
}
int main()
{show(ONE);printf("---------------\n");show(TWO) ;printf("---------------\n");show(ONE | TWO);printf("---------------\n");show(ONE 丨 TWO 丨 THREE);printf("---------------\n");show(ONE 丨 TWO 丨 THREE 丨 FOUR);printf("---------------\n");return 0;
}
运行结果:
这样,我们就可以很好的做到标志位传参了。
那么,我们现在再来看 flags 就可以很好的理解了。
其中,O_RDONLY 表示 只读;
O_WRONLY 表示 只写;
O_RDWR 表示 读写。
它们三个其实就是不同的标志位,我们也可以知道它们三个一定是用不同的比特位表示不同的含义。
因为在经过一系列文件操作后,最后要关闭文件。所以,我们再来看看 close。
我们会发现,这个接口对比起 open 就简单多了。它指的就是关闭一个文件描述符。文件描述符我们虽然没讲,但是通过上图我们知道:当打开一个文件时,如果打开成功会返回一个文件描述符。我们只需要定义一个变量来接收 open 的返回值,然后用 close 来关闭这个变量不就可以了吗。
那我们就可以开始编写代码了。
编写如下代码:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{int fd = open(FILE_NAME, O_WRONLY);if(fd < 0){//文件打开失败perror("open");return 1;}close(fd);return 0;
}
运行结果:
我们会发现:诶,为什么没有创建成功呢? 我们不就是以写的方式打开的文件吗?不是说以写的方式打开文件,文件不存在会自动创建吗?
这其实是C语言自己这么做的,而系统调用接口默认不会自动创建。如果我们想创建,就必须再加一个 O_CREAT。
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{int fd = open(FILE_NAME, O_WRONLY | O_CREAT);if(fd < 0){//文件打开失败perror("open");return 1;}close(fd);return 0;
}
运行结果:
我们会发现:文件真的被创建出来了,但是好像怪怪的,为什么创建出来的文件是红色的呀。而且,我们会发现:被创建出来的文件的权限都是乱码。
我们这里就可以回到之前讲权限的时候了。
我们之前讲权限的时候,我们说过:默认情况下,我们创建一个文件时,目录的默认权限是以777开始的,普通文件的权限是以666开始的。
可是凭什么呢?凭什么这么规定呢?那又由谁来保证创建文件/目录是以666/777开始的?
这其实就是凭 open 的第三个参数。
添加权限:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{int fd = open(FILE NAME, O_WRONLY | O_CREAT, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}close(fd);return 0;
}
运行结果:
这样,我们就可以正确的把文件创建出来了(别忘了umask影响文件权限哦)。
这里又有小伙伴问了:那为什么要有两个open呢?一个不就可以了吗?
这其实是因为第一个open是用来处理文件已经存在的情况; 第二个open是用来处理文件不存在,需要创建的情况。
那我们如果想创建很多文件,但是不受到系统影响(即:不使用系统默认的文件掩码),怎么办呢?
这就需要用到另一个接口 umask 了。
1.1 umask
umask 指的就是定义自己的创建文件的掩码。
我们以往默认使用的 umask 是系统默认的,即父进程 shell 为我们提供的,而其它进程由于是子进程,就会继承父进程的文件掩码。
将umask设置为0:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE NAME, O_WRONLY | O_CREAT, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}close(fd);return 0;
}
运行结果:
这样,即使我们改了 umask,但是改的是子进程的 umask,不影响父进程 shell 的 umask。
既然我们学会了打开文件和关闭文件,紧接着就应该学习如何读写文件了
2. write
write 表示 把数据向一个文件描述符写入。
它的第一个参数表示往哪个文件写;
第二个参数表示写的时候缓冲区数据在哪里;
第三个参数表示缓冲区当中数据的字节个数;
返回值是写的数据的字节个数。返回值一般和第三个参数一样。
但是,我们在用write之前需要先知道它的第一个参数fd。
我们这里先不讲fd到底是什么,只需要知道它的值到底为多少。
当我们将fd打印出来:
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}printf("fd: %d\n", fd);close(fd);return 0;
}
运行结果:
我们会发现,fd的值为3,这个3是什么呢? 我们等一下再说,这里先记住。
当我们查看write接口的第二个参数时,会发现其类型为 void* 类型的。而我们曾经在C语言中学过:在读写文件的时候,有两种读写方案,一种是文本类读写,一种是二进制类读写。那是谁给我们提供的文件读写的分类呢?这其实是语言本身为我们提供的。然而,通过上面的 void* 类型可以看出:对于操作系统,无论语言层面上是采用文本类读写还是二进制类读写,在操作系统看来,都是二进制。(可别忘了我们上层使用的是 fwrite,而 fwrite 又是由 write 实现的) 操作系统只管我们要向文本内写多少字节。
那么,假设我们要实现最开始使用C语言输出 5 行 hello world 的这种现象,在使用系统调用接口的条件下,如何实现呢?
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}int cnt = 5;char outBuffer[64];while(cnt){sprintf(outBuffer, "%s:%d\n", "hello world", cnt--);//cnt的值会变成字符串类型,然后放入outBuffer数组中//从outBuffer开始,写strlen(outBuffer) + 1个大小到fd中。outBuffer为我们自己定义的缓冲区write(fd,outBuffer,strlen(outBuffer) + 1);//向文件中写入string的时候, C语言中使用strlen要+1.}close(fd);return 0;
}
运行结果:
【sprintf 表示将特定的内容格式化形成到字符串里。】
当我们将程序运行起来后,会发现:数据已经按照预期被写入文件中了。
但这就完了吗?其实不然。
当我们使用记事本(vim)打开被创建的文件时:
我们会发现:该文件中多了一些乱码。为什么呢?其实就是因为在用 strlen 的时候多加了一个 1。
我们曾经说的字符串以 '\0' 结尾,这其实是C语言的规定,和文件没有关系。文件要的只是字符串的有效内容,而不要 '\0' 。
修改代码如下:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}int cnt = 5;char outBuffer[64];while(cnt){sprintf(outBuffer, "%s:%d\n", "hello world", cnt--);write(fd,outBuffer,strlen(outBuffer));//不加 1}close(fd);return 0;
}
运行结果:
但是我们会发现,这个文件里面还是多了一些东西。
当我们将该文件删除后,再运行我们的程序。
我们会发现:被创建的文件已经干净了。
但是,当我们更改向文件中写入的内容,并且不删除原来的 log.txt 文件时。
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}int cnt = 5;char outBuffer[64];while(cnt){sprintf(outBuffer, "%s:%d\n", "oranges", cnt--);write(fd,outBuffer,strlen(outBuffer));}close(fd);return 0;
}
运行结果:
我们会发现:最开始的时候,log.txt 文件中的内容是我们想要的,但是,当我们重新向该文件中写入内容时, 会发现:前面的内容确实是我们想要的,但是,后面却多了一些内容。我们也知道,其实多的内容就是之前文件中的内容,当我们重新向文件写入时,没有将原来文件中的内容覆盖完,所以才会有多余内容。
而我们之前也讲过:如果以 w 方式打开文件,C会自动清空文件内部的数据。
但是我们使用系统调用接口时,不是这样的呀。它并没有将文件内容清空呀。
这其实是因为我们在打开文件时少了一个参数 O_TRUNC,它表示对文件内容做清空。
添加 O_TRUNC:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}int cnt = 5;char outBuffer[64];while(cnt){sprintf(outBuffer, "%s:%d\n", "oranges", cnt--);write(fd,outBuffer,strlen(outBuffer));}close(fd);return 0;
}
运行结果:
这样,我们就可以实现:在向文件写入数据时先清空文件内容。
我们甚至可以直接用 open 打开文件后再关闭文件来让文件内容清空。
所以说,当我们上层使用C语言并且使用w选项时,操作系统在底层其实是要传 O_WRONLY、O_CREAT、O_TRUNC 和 0666 的。这就是C语言和系统调用接口之间的关系。
那如果我们想要追加呢?
其实我们只需要将 O_TRUNC 改为 O_APPEND 即可。
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE NAME, O_WRONLY | O_CREAT | O_APPEND, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}int cnt = 5;char outBuffer[64];while(cnt){sprintf(outBuffer, "%s:%d\n", "hello world", cnt--);write(fd,outBuffer,strlen(outBuffer));}close(fd);return 0;
}
运行结果:
这样,我们就可以完成追加了。
那如果我们想读呢?
这里就需要用到 read 接口了。
3. read
我们会看到:read就是指从一个文件描述符当中读取文件。
其返回类型 ssize_t 其实是一个有符号整数。
所以,该接口的意思就是:从特定的文件(fd)当中,将特定的数据读到缓冲区(buf)中,期望读count个数据。count的大小可能和返回值的大小不同。
此外,我们其实会发现,参数buf的类型也是 void* 的,这就说明read在读取的时候也是没有类型的概念的。所以说,在读取的时候,具体读上来的是图片、音视频、文本或者是可执行程序完全是由我们自己定的。
我们在这里假设我们读到的是字符串,我们就可以写出这样的代码。
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE NAME, O_RDONLY, 0666);if(fd < 0){//文件打开失败perror("open");return 1;}char buffer[1024];ssize_t num =read(fd, buffer, sizeof(buffer)-1);if(num > 0) buffer[num] = 0;//0、'\0'、NULL ---> 0。将最后一个字符设置为'\0'printf("%s", buffer);close(fd);return 0;
}
运行结果:
这样,我们就可以将文件中的内容给读出来了。
四、如何理解文件
我们在上面已经说过:文件操作的本质就是进程与被打开文件之间的关系。而所有访问文件的行为都必须先把文件打开。那也就是说,所有进程如果想要访问文件,它就必须调用 fopen/open 接口来先将文件打开。
那我们如何理解文件呢?文件在打开之前究竟做了什么呢?
在理解文件之前,我们首先要知道:
进程是可以打开多个文件的,那么,系统中就必然存在大量的被打开的文件,而这些被打开的文件自然也需要被操作系统管理起来。那么,如何管理呢?
先描述,再组织。
所以,操作系统为了管理对应的被打开的文件,必定要为被打开的文件创建对应的 内核数据结构 来标识文件,这个结构体在操作系统中表现为 struct file {} ,该结构体里面包含了文件的大部分属性。(这个file和我们C语言中的FILE没有关系)
所以说,每一个被打开的文件都必定有一个 struct file 对象,那我们就可以将每一个被打开的文件的struct file 通过链式结构连接起来,OS只需要找到这个 struct file 的起始地址,那么,对被打开文件的管理就变成了对链表的管理。
那么,被打开的文件又如何和进程关联起来呢?也就是说,进程和被打开文件的关系是如何维护的呢?
要回答这个问题,就必须要先讲一下之前的 文件描述符fd 。
1. 文件描述符 fd
编写如下代码:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME(number) "log.txt"#number // # 表示将宏参数转换成字符串,然后两个字符串具有自动连接特性int main()
{umask(0);int fd0 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_TRUNC, 0666);int fd1 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_TRUNC, 0666);int fd2 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_TRUNC, 0666);int fd3 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_TRUNC, 0666);int fd4 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_TRUNC, 0666);printf("fd: %d\n", fd0);printf("fd: %d\n", fd1);printf("fd: %d\n", fd2);printf("fd: %d\n", fd3);printf("fd: %d\n", fd4);close(fd0);close(fd1);close(fd2);close(fd3);close(fd4);return 0;
}
运行结果:
我们会发现一个现象:打印出来的文件描述符为3、4、5、6、7。
而我们知道,open调用,-1是出错,那么0、1、2呢?也就是说,这个fd为什么是从3开始呢?
此外,这里的3、4、5、6、7是连续的整数,那么,我们以往在C / C++学习中,哪里学到过连续的整数呢?
答案是:数组。
所以说,这里的连续的整数其实就跟数组有关。自然,这里的0、1、2、3、4……其实就是数组下标。
我们这里先来回答为什么这里的fd从3开始。
我们以往在C语言学习文件的时候,可能听到过:C程序默认会打开3个标准输入输出流,分别是stdin(标准输入,设备对应于键盘)、stdout(标准输出,设备对应于显示器)、stderr(标准错误,设备对应于显示器)。
此外,我们曾经用C语言时,一般都是写的“ FILE* fp = fopen(); ”,而我们在使用系统调用接口的时候,open的返回类型却是 int 。那么,这里的 FILE 到底是什么呢?其实它就是一个结构体。
而C语言的 fopen 底层是由系统调用接口 open 实现的,底层 open 访问文件时又必须使用文件描述符。但是,在C语言层面上,我们使用的不是文件描述符,而是FILE。可是,fopen底层调用的的是open,底层不认FILE,它只认文件描述符。但是这里我只有FILE没有文件描述符呀。而FILE是一个结构体,所以,我们可以得到:FILE这个结构体里绝对会有一个字段叫做文件描述符。
像我们C语言学的很多文件接口,如 fgets、fputs、fprintf、fread、fwrite 等都是会传 FILE* 类型的对象。甚至上面的三个标准流,它们的类型其实也是 FILE* 类型的结构体对象。
所以说,C语言不仅在接口层面上有封装,甚至连数据类型都有封装。
怎么证明 FILE 这个结构体里面有我们的文件描述符呢?我们可以编写如下代码。
代码演示:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>#define FILE_NAME(number) "log.txt"#numberint main()
{printf("stdin -> fd: %d\n", stdin->_fileno);printf("stdout -> fd: %d\n", stdout->_fileno);printf("stderr -> fd: %d\n", stderr->_fileno);return 0;
}
运行结果:
所以说,其实 FILE 结构体里的 fileno 就对应于文件描述符。并且,我们也从这段代码中知道为什么文件描述符是从 3 开始的了,因为 0、1、2 默认被占用了。
那我们再来回答一下:这里的数字为什么是从 0、1、2 开始的。
这里我们假设一个进程调用了open 接口,然后执行了一系列文件操作,最后用 close 把文件关闭掉。
通过上面说的:进程可以打开多个文件,系统当中存在多个被打开的文件,OS 需要把被打开的文件在内存中管理起来,并且 OS 是通过先描述,再组织的方式管理的。
假设这里有个文件叫做 log.txt ,而我们系统在启动的时候,默认会有 3 个文件会被打开,分别是键盘、显示器和显示器。当操作系统把我们的文件 log.txt 加载进内存的时候,它并不是把我们的文件内容加载进内存里,而是先要把这个文件管理起来,即描述这个文件。因此,log.txt 必定会有自己对应结构,叫做 struct file 结构,它里面保存的是文件的属性。因此,这里的键盘、显示器和显示器以及新加载进内存的文件,它们都是 struct file 这个类型所定义出来的对象。我们可以将其连接起来。
当进程看见这些对象后,想:“怎么来这么多文件?我作为一个进程,一下子要面对这么多被打开的文件,我怎么办?”
而且,在系统中还会有其它进程,也就自然会包括其它进程所打开的文件。因此,操作系统里面就会有特别多的被打开的文件。
进程就在想:“到底哪些文件是属于我的呢?哪些是我打开的文件呢?”
那么,既然我们要讨论文件,而文件的本质又是进程和被打开文件之间的关系。那么,我们怎样才能将不同的进程和不同的文件之间的关系表明呢?
所以,我们的PCB里面包含了一个叫做 struct files_struct *files 的指针,这个指针指向了一个属于进程的 struct files_struct 结构体【专门用来构建进程和文件对应关系的结构体】,这个结构体里面包含了一个数组,叫做 struct file* fd_array[] ,它是一个指针数组,它里面的所有成员全都是 struct file* 类型的指针,既然是数组,自然它就会有下标0、1、2、3、4……而被打开的文件它所对应的类型刚好又为 struct file 类型的对象,那么,自然struct file* 可以指向 struct file 。
因此,当我们打开文件,0 下标指向键盘,1 下标指向显示器,2 下标指向显示器。这叫做进程创建时,它会默认为我们打开输入输出流(stdin、stdout、stderr)。当我们再打开一个文件时,操作系统在数组中从上往下为我们找第一个没有被使用的文件描述符,结果就找到了 3。
然后,操作系统又将磁盘中的文件加载进内存中,为其构建 struct file 类型的对象,此时,再将这个对象的地址填到我们对应的 3 号文件描述符里,此时,3 号描述符就指向了我们新打开的文件。然后,我们再把 3 号文件描述符通过系统调用给用户返回,此时,我们就得到了一个数字,叫做3。
所以,当一个进程在访问文件时,它需要传入3,通过系统调用接口,操作系统会直接去找文件描述符表,找到之后,再根据它找到对应的文件,文件找到了,就可以对文件做操作了。
所以说,文件描述符为什么是0、1、2这样的数字呢?
因为文件描述符的本质就是数组下标!
这也就是为什么我们操作系统读到的文件描述符必须是整数,而且是连续的小整数,因为操作系统内标定进程和文件的关系,用的就是文件描述符表,用数组标定文件内容。
2. 文件fd分配规则
到这里,我们已经知道了文件描述符,知道了文件描述符就是数组的下标。那么,我们这里就需要去研究一下文件描述符的分配规则了。
编写代码,查看文件fd:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("fd: %d\n", fd);close(fd);return 0;
}
运行结果:
结果如我们所料,fd为3。
那么,既然 0、1、2 默认是被打开的,我们可以将它关闭吗?
编写代码,将 0 关闭:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(0);umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("open fd: %d\n", fd);close(fd);return 0;
}
运行结果:
我们会发现:我们自己打开的文件对应的描述符为 0。
那如果我们把 2 关掉呢?
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(2);umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("open fd: %d\n", fd);close(fd);return 0;
}
运行结果:
我们会发现:我们自己打开的文件对应的描述符为 2。
那如果我们把 0 和 2 都关掉呢?
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(0);close(2);umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("open fd: %d\n", fd);close(fd);return 0;
}
我们会发现:我们自己打开的文件对应的描述符又变为了 0。
那如果我们不关闭 0 和 2 呢?
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("fd: %d\n", fd);close(fd);return 0;
}
运行结果:
我们会发现:我们自己打开的文件对应的描述符又变为了 3。
通过上面一系列现象,我们就得到了:如果当我们把 0 号文件描述符给关掉了,此时,0 号位置也就不再指向键盘了,当我们在内存中创建了一个文件对象,那么我们就会从自己进程的文件描述符表里面从小到大按照顺序寻找最小的且没有被占用的fd来供我们使用,这就是fd的分配规则。
3. 重定向的引入
那我们如果把1关掉呢?
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(1);umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("open fd: %d\n", fd);close(fd);return 0;
}
运行结果:
我们会发现:按照原理,应该打印 1 呀,可是结果却没有显示出来。这是为什么呢?
这是因为 printf 本质是向 stdout 打印的。
我们这里可以使用 fprintf 实现同样的效果。
使用 fprintf 打印 fd:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(1);umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("open fd: %d\n", fd);//printf --> stdoutfprintf(stdout, "open fd: %d\n", fd);//fprintf --> stdoutclose(fd);return 0;
}
运行结果:
我们会看见:使用 fprintf 打印,照样没有把fd打印出来。
由于 printf 与 fprintf 都是向 stdout 打印,而 stdout 又对应于显示器,所以,数据才会显示出来。
而现在,我们先把 1 关闭了,然后让 1 指向我们新创建的文件 log.txt ,此时 1 指向的不再是显示器,而是我们自己创建的文件,所以,fd 才没有显示出来。
那是不是数据就被写到 log.txt 了呢?
我们可以看一下它的内容。
我们会发现:log.txt 中并没有内容,为什么呢?其实这是和缓冲区有关的。
我们的数据确实是向文件中写的,只是这里需要涉及到缓冲区。当我们的数据向显示器或者普通文件打印时,它的刷新策略不一样。所以,这里为了让我们的数据立即被显示出来,可以用 fflush 来刷新缓冲区。
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{close(1);umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("open fd: %d\n", fd);fprintf(stdout, "open fd: %d\n", fd);fflush(stdout);//即使我们刷新的是stdout,它也不会向显示器打印close(fd);return 0;
}
运行结果:
这样,我们就看到了我们想要的结果。
现在我们就可以得到:
当我们不关闭 1 ,我们的打印就会打印到显示器。
如果我们关闭 1 ,我们的打印就会打印到我们的文件里。
那么,本来我的程序打印时应该向显示器(文件)打印,结果经过我们一系列操作,最后打印的结果却输入到了我们的文件里,这种特性叫什么呢?重定向。
4. 重定向的本质
当我们打开了一个文件(log.txt),它对应的 fd 为 3。然后,我们把 3 号位置所对应的值覆盖式的写入到 1 里面,会发生什么呢?是不是 1 号位置就指向了我们新打开的文件了。
那如果我们把 3 号位置所对应的值覆盖式的写入到 0 里面呢?那么,是不是 0 号位置也指向我们新打开的文件了。
所以说,重定向的本质就是:上层用的 fd 不变,在内核中更改 fd 对应的 struct file* 的地址。
这样,我们是不是就可以将显示在其他文件中的数据显示在我们指定的文件里了。
这样,我们就了解了文件描述符的分配规则以及重定向的原理。
但是,我们上面这种先关闭 1 然后再打开是不是太低级了。虽然我们知道:当关闭 1 后再打开,此时打开的 fd 一定是 1,但是,如果我们把 0、1、2 都关闭了,这个fd就是 0 了,此时,我们如果再想做输出重定向是不是就做不到了。所以,就存在一个接口可以让我们重定向更加方便,它就是 dup2。
5. dup2
这个接口的作用就是让两个文件描述符彼此之间进行拷贝。如:把3号文件描述符里面的内容拷贝到1。
那如果这里我们想要实现上面的效果,即:把本该显示在显示器上的内容写入 log.txt,我们如何用 dup2 来实现呢?
我们通过上图的 “newfd be the copy of oldfd” 会发现:在拷贝完后,newfd 和 oldfd 最后只会剩下 oldfd 。因为我们这里需要将3号文件描述符里面的内容拷贝给1号位置,所以,我们就应该写 dup2(fd, 1) 【假设fd为3】。
那我们就可以重新写一个重定向的代码了。
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}dup2(fd, 1);printf("open fd: %d\n", fd);fprintf(stdout, "open fd: %d\n", fd);fflush(stdout);close(fd);return 0;
}
运行结果:
这样,我们就实现了输出重定向的功能。
那我们如何实现追加重定向呢?
其实,我们只需要在打开这个文件的时候不清空里面的内容就行了。也就是把 O_TRUNC 改为 O_APPEND 即可。
代码演示:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);if(fd < 0){perror("open");return 1;}dup2(fd, 1);printf("open fd: %d\n", fd);fprintf(stdout, "open fd: %d\n", fd);fflush(stdout);close(fd);return 0;
}
运行结果:
这样,我们就实现了追加重定向。
我们甚至可以用 write 再往里面写点内容。
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);if(fd < 0){perror("open");return 1;}dup2(fd, 1);printf("open fd: %d\n", fd);fprintf(stdout, "open fd: %d\n", fd);const char *msg = "hello world\n";write(1, msg, strlen(msg));fflush(stdout);close(fd);return 0;
}
运行结果:
那么,如果我们要做输入重定向呢?
我们这里先让写的数据显示在显示器上。
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{char line[64];while(1){printf("> ");if(fgets(line, sizeof(line), stdin) == NULL)//fgets:从stdin中读取字符并将其作为C字符串存储到line中,直到读取 sizeof(line) 个字符,或者到达换行符或文件末尾结束读取。break;printf("%s", line);}return 0;
}
运行结果:
【在 linux 中,我们可以用 ctrl + d 来结束文件输入,以表达文件结尾。】
现在,我们就可以将其重定向到我们自己创建的文件里,以实现输入重定向。
代码演示:
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{umask(0);int fd = open("log.txt", O_RDONLY);//文件要先存在才可以打开if(fd < 0){perror("open");return 1;}dup2(fd, 0);//将fd下标里面的内容覆盖掉0号下标里面的内容,让0号指向我们的log.txt ---> 输入重定向char line[64];while(1){printf("> ");if(fgets(line, sizeof(line), stdin) == NULL)break;printf("%s", line);}close(fd);return 0;
}
log.txt
运行结果:
我们会发现:当输入的时候,此时不再是从键盘中输入了,而是从文件中输入。这就是输入重定向。它相当于:cat < log.txt 。
这里,我们就差不多理解了重定向的原理了。
但是,我们在命令行上使用重定向是这样写的:
而我们这里实现的重定向是这样写的:
那么,shell是如何实现重定向的呢?
6. 理解 >、>>、<
【这里同时也是为我们之前制作的 shell 添加重定向功能。】
我们上层曾常用: >(输出重定向)、>>(追加重定向)和 <(输入重定向)。
我们这里就可以利用上次写的 shell 来解释上面三个符号了。
假设我们执行 "ls > log.txt " 命令:
我们知道,该命令会将 ls 的结果写入到我们对应的 log.txt 文件中。
所以,我们这里就要实现它。
首先,我们假设输入的是 "ls -a -i -l > log.txt" ,我们就应该将其分割成 "ls -a -i -l" 和 "log.txt",然后再来解析 '>' 。然后,我们让 "ls -a -i -l" 遵守我们之前的写法,因为它是命令。"log.txt" 这部分我们要根据我们输入的是 ">" / ">>" / "<" 来做重定向分析解析,"log.txt" 就是我们的重定向目标文件。
同样地,可以得到 ">>" 与 "<" 如何操作。
下面,我们就是要对我们输入的内容做检查,即:对 lineCommend 里面的内容做检查。目的就是将一个大字符串 "ls -a -i -l > log.txt" 转换成 两个小字符串 "ls -a -i -l" 和 "log.txt" ,以及一些关于重定向的准备工作。
那么,我们如何将它拆开呢?
其实,我们只需要将对应的">" 、 ">>" 和 "<" 改成 '\0' 即可。这样,前半部分就是我们对应的命令,后半部分就是对应的目标文件了。
所以,我们可以这么做:
1、在获取到用户输入后调用一个 commandCheck 函数进行命令检查:
2、commandCheck 函数的实现:
#define NONE_REDIR 0 //无重定向
#define INPUT_REDIR 1 //输入重定向
#define OUTPUT_REDIR 2 //输出重定向
#define APPEND_REDIR 3 //追加重定向
#define trimSpace(start) do{\while(isspace(*start)) ++start;\}while(0)int redirType = NONE_REDIR; //获取重定向类型
char *redirFile = NULL; //获取要重定向的目标文件名//"ls -a -l -i > log.txt" ---> "ls -a -l -i", "log.txt"
void commendCheck(char* commands)
{assert(commands);char *start = commands;char *end = commands + strlen(commands);//strlen 不用+1,如:输入"abcd",commends就为0,strlen(commands)就为4,所以,end为4,刚好指向'\0'while(start < end){if(*start == '>'){*start = '\0';start++;if(*start == '>'){//"ls -a -l -i >> log.txt"redirType = APPEND_REDIR;start++;}else{//"ls -a -l -i > log.txt"redirType = OUTPUT_REDIR;}//过滤空格trimSpace(start);redirFile = start; //log.txtbreak;}else if(*start == '<'){//"cat < log.txt"*start = '\0';*start++;trimSpace(start);//填写重定向信息redirType = INPUT_REDIR;redirFile = start;break;}else{start++;}}
}
其中,当调用完该函数后,我们的字符串就被解析出来了。前半部分的命令会继续执行我们之前写的代码,后半部分的 重定向目标文件名 会保存在 redirFile 变量中,重定向类型 会保存在 redirType 变量中。
并且,这里我们写了一个关于过滤空格的宏函数。可以看到:我们在宏函数中,while(0) 后面没有 ' ; ' 。所以,我们可让其在很多场景下都可以使用,比如放在 if 后的括号里。这种编写方式在很多开源项目以及Linux内核中都经常使用。
【isspace 是用来判断字符是否为空格的。】
然后,当重定向信息被得到后,就是创建子进程,让子进程去执行我们的重定向任务了。
//执行命令pid_t id = fork();assert(id != -1);if(id == 0){//因为命令是子进程执行的,所以,真正重定向的工作一定是要子进程来完成//但是,如何重定向,是需要父进程告诉子进程的//这里的重定向不会影响父进程switch (redirType){case NONE_REDIR://什么都不做break;case INPUT_REDIR:{int fd = open(redirFile, O_RDONLY);{if(fd < 0){perror("open");exit(errno);}}//重定向的文件已经打开了dup2(fd, 0);}break;case OUTPUT_REDIR:case APPEND_REDIR:{umask(0);int flags = O_WRONLY | O_CREAT;if(redirType == APPEND_REDIR) flags |= O_APPEND;else flags |= O_TRUNC;int fd = open(redirFile, flags, 0666);if(fd < 0){perror("open");exit(errno);}dup2(fd, 1);}break;default:printf("bug?\n");break;}execvp(myargv[0],myargv);//执行程序替换的时候,会不会影响曾经进程打开的重定向文件?exit(1);}int status = 0;pid_t ret = waitpid(id, &status, 0);assert(ret > 0);(void)ret;lastCode = ((status>>8) & 0xFF);lastSig = (status & 0x7F);}
这里的重定向会影响父进程吗?答案是不会,具体理由我们等一下具体来说。
当我们完成以上这些工作后,我们还需要在程序最开始运行的时候对数据做一下初始化。
因为我们在做完一次重定向后,由于我们的 redirType 和 redirFile 都是全局的,所以,如果不初始化,它所对应的值是不变的,就有可能每次都被当做重定向的指令执行。
int main()
{while(1){//初始化redirType = NONE_REDIR;redirFile = NULL;errno = 0;//……} return 0;
}
完整代码:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<ctype.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/wait.h>
#include<assert.h>
#include<errno.h>#define NUM 1024
#define OPT_NUM 64//选项最大数目#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3#define trimSpace(start) do{\while(isspace(*start)) ++start;\}while(0)char lineCommand[NUM];
char *myargv[OPT_NUM];//指针数组
int lastCode = 0;
int lastSig = 0;int redirType = NONE_REDIR;
char *redirFile = NULL;//"ls -a -l -i > log.txt" ---> "ls -a -l -i", "log.txt"
void commendCheck(char* commands)
{assert(commands);char *start = commands;char *end = commands + strlen(commands);//strlen 不用+1,如:输入"abcd",commends就为0,strlen(commands)就为4,所以,end为4,刚好指向'\0'while(start < end){if(*start == '>'){*start = '\0';start++;if(*start == '>'){//"ls -a -l -i >> log.txt"redirType = APPEND_REDIR;start++;}else{//"ls -a -l -i > log.txt"redirType = OUTPUT_REDIR;}trimSpace(start);redirFile = start;break;}else if(*start == '<'){//"cat < log.txt"*start = '\0';*start++;//过滤空格trimSpace(start);//填写重定向信息redirType = INPUT_REDIR;redirFile = start;break;}else{start++;}}
}
int main()
{while(1){//初始化redirType = NONE_REDIR;redirFile = NULL;errno = 0;//打印命令行提示符printf("[用户名@主机名 当前路径]# ");//刷新缓冲区fflush(stdout);//获取用户输入char *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);assert(s != NULL);(void)s;lineCommand[strlen(lineCommand)-1] = 0;//printf("test:%s", lineCommand);//假设输入"ls -a -l -i" 会变成 "ls" "-a" "-l" "-i"//"ls -a -l -i > log.txt" ---> "ls -a -l -i", "log.txt" //"ls -a -l -i >> log.txt" ---> "ls -a -l -i", "log.txt" //"cat < log.txt" ---> "cat", "log.txt" commendCheck(lineCommand);//字符串切割myargv[0] = strtok(lineCommand, " ");int i = 1;if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0){myargv[i++] = (char*)"--color=auto";}while(myargv[i++] = strtok(NULL, " "));//如果是cd命令,不需要创建子进程,而是让shell自己执行对应的命令if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0){if(myargv[1] != NULL){chdir(myargv[1]);continue;}}//如果是echo命令if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0){if(strcmp(myargv[1], "$?") == 0){printf("%d, %d\n", lastCode, lastSig);}else{printf("%s\n", myargv[1]);}continue;}//测试是否截取成功
#ifdef DEBUGfor(int i = 0; myargv[i]; i++){printf("myargv[%d]: %s\n", i, myargv[i]);}
#endif//执行命令pid_t id = fork();assert(id != -1);if(id == 0){//因为命令是子进程执行的,所以,真正重定向的工作一定是要子进程来完成//但是,如何重定向,是需要父进程告诉子进程的//这里的重定向不会影响父进程switch (redirType){case NONE_REDIR://什么都不做break;case INPUT_REDIR:{int fd = open(redirFile, O_RDONLY);{if(fd < 0){perror("open");exit(errno);}}//重定向的文件已经打开了dup2(fd, 0);}break;case OUTPUT_REDIR:case APPEND_REDIR:{umask(0);int flags = O_WRONLY | O_CREAT;if(redirType == APPEND_REDIR) flags |= O_APPEND;else flags |= O_TRUNC;int fd = open(redirFile, flags, 0666);if(fd < 0){perror("open");exit(errno);}dup2(fd, 1);}break;default:printf("bug?\n");break;}execvp(myargv[0],myargv);//执行程序替换的时候,会不会影响曾经进程打开的重定向文件?exit(1);}int status = 0;pid_t ret = waitpid(id, &status, 0);assert(ret > 0);(void)ret;lastCode = ((status>>8) & 0xFF);lastSig = (status & 0x7F);}return 0;
}
运行结果:
最后,我们再来讲一下上面那个 “重定向是否会影响父进程” 这个问题。
首先,我们知道:创建子进程会把父进程的 PCB 拷贝一份给子进程,同时,为了保证进程独立性,OS 也会给子进程拷贝一份父进程的文件描述符表给子进程。
因为如果不拷贝一份给子进程,子进程和父进程就会共用一张文件描述符表,如果子进程对表中的文件做重定向操作了,就会影响父进程,这就不能保证进程独立性了。
而如果是给子进程拷贝一份,子进程在进行重定向的时候,影响的就只是子进程的文件描述符表,不会影响父进程的文件描述符表。
那么,父进程打开的所有的文件要不要给子进程也拷贝一份,然后让子进程指向它们呢?
答案是:不要。
因为我们这里的目的是创建子进程,虽然我们才开始学文件,文件系统也还没讲,但是,我们知道:创建子进程 以及 进程和文件之间的映射关系 这些都是由进程来管理的。
【当我们判断一个对象或者结构体是属于进程的还是文件的,我们只需要将其拿掉,看它对哪个影响最大。比如:如果我们这里将父进程的 files_struct 给拿掉,那么父进程就没法找到文件了,但是对文件却没有影响,所以,files_struct 是属于进程管理的。】
而对于被打开的文件,它是属于文件系统部分的。我们这里是创建进程又不是创建文件,所以,就不需要拷贝。
最后,我们也可以解决最后一个问题了,即: 执行程序替换的时候,会不会影响曾经进程打开的重定向文件?
答案是:不会。
因为我们上图讲的所有的东西都属于内核数据结构,也就是由操作系统内部维护的数据结构,从定位上看,它们其实和 task_struct 是一样的,都是内核数据结构。
而我们在做进程程序替换的时候,进程会有自己对应的代码和数据,当我们将磁盘中的代码和数据替换进内存时,替换的只是代码和数据,和内核数据结构是没有关系的。也就是说,当我们在执行进程程序替换的时候,并不影响内核数据结构的数据。
最典型的就是程序替换的时候并不影响 PCB 及其 PCB 内部的各种 pid 细节。
7. 理解Linux下一切皆文件
我们曾经说过:Linux下一切皆文件,那么,如何理解呢?
首先,我们的电脑一定存在很多硬件, 如:键盘、显示器、磁盘和网卡等,这些设备在冯诺依曼体系结构看来都属于外设,既然属于外设,那么任何的数据处理都必须先把数据读到内存等处理完毕之后再将内存当中的数据刷新到外设当中,这叫做 IO。
但是,我们曾经说过:OS 为了管理我们的软硬件,因为我们的软硬件很多,OS 就必须对我们的软硬件资源进行先描述,再组织。所以,每一个设备内部它都会有对应的结构体。而其中,这些设备一定会存在对应的 IO 方法。
有些同学可能会想:我们的键盘一般都是作为输入设备,如果它有数据了,我们一般都是读取它,那我们还需要它的写方法吗?其实是可以有的,我们让其内容为空不就可以了。同样地,对于显示器,我们可以让其读方法的内容为空。
而这些具体的读写方法一定是存放在各种硬件所匹配的驱动程序里面的。并且,每种硬件所对应的访问方法一定是不一样的。比如:读磁盘怎么可能和读网卡是一样的呢。
但是,我们这里如何表示我们所对应的键盘、显示器、磁盘和网卡等这些设备呢?
在 Linux 中,它是通过 struct file{} 来实现的,我们上面说过,这个内核数据结构里面包含了各种文件的属性。虽然我们底层硬件的读写方法一定不一样,但是对于属性,我们是可以在数据层面上将它们统一下的。
比如:每一种文件它都有一个 type 属性,那我们就可以用不同的 type 值来表示不同的硬件。又如:文件都会有对应的状态 status,虽然每种文件的打开方式不一样,但是。我们可以通过 status 查看这个文件是否被打开。所以,其实我们是可以将公共属性给提取出来的。
对于不同的文件,它所对应的读写方法是不一样的,那么,怎么保证一切皆文件呢?
其实,在 struct file{} 里面还包含许多的函数指针 readp 和 writep。当我们打开对应的设备,比如键盘,OS 就会为键盘创建对应的 struct file 对象,再将 readp 和 writep 初始化后,OS 就会将我们的函数指针指向键盘对应的读方法和写方法。同样地,可以类比得到如何让其它设备通过函数指针的方式指向我们对应的不同的读写方法。
然后我们再组织,让各个 struct file 对象连接起来。当 OS / 用户 需要 读 / 写 文件的时候,由于他们根本就不关心文件底层所有的不同。无论是OS内部还是用户要读取这个文件,他们只看得到 struct file 对象,当他们要读写文件时,只需要调用对应的函数指针就可以指向对应的具体的读写方法,然后开始读写文件了。
所以,站在 struct file 上层看来,所有的设备和文件,统一都是 struct file 。因此,在用户级层面上,就有了 Linux 下一切皆文件的说法。
【其实这就是用 C 语言来实现多态的特征。struct file 结构体就相当于基类,下面的所有东西就相当于子类。这里是不是就相当于:上层调用不同的文件,底层可以调用不同的方法,在上层看来,我们只需要使用统一的 struct file ,就可以访问同样的文件,底层的差异其实就可以体现出来了。比如:如果我们要打开磁盘,OS 就为我们创建对应的 struct file 对象,然后用函数指针指向磁盘的方法,当进程进行读写时,读写的就是磁盘的操作内容了。】
那么,我们怎么证明上面所说的呢?
我们这里可以看一下 Linux 内核的源代码。
当我们查看 PCB 时,我们会看见一个 files 指针,它表示的就是 PCB 所指向的所有被打开的文件。
当我们转到定义就可以看到这个指针所指向的结构体 files_struct ,它表示的就是打开的文件表结构(也就是文件描述符表)。我们会看见一个 fd_array ,它里面包含的元素类型为 struct file *。 ( _rcu 这里先不管)
【图中的 NR_OPEN_DEFAULT 表示一个进程最多可以同时打开的文件数量。】
NR_OPEN_DEFAULT 通常被定义为 BITS_PER_LONG 这意味着它的取值依赖于系统的位长度。 在大多数现代Linux系统中,BITS_PER_LONG 被定义为64,因此 NR_OPEN_DEFAULT 的默认值也是64。
我们也可以扩展它的大小。云服务器中可以打开的文件数量一般为10万或65535个。
当我们再次双击 file 时,就进入了文件系统部分了。
我们会看见里面有一个 f_count ,它表示文件的引用计数。
假设我们在堆上申请了一块空间,就有可能会有多个指针指向这块空间,我们就可以用一个 count 来表示指向这块空间的指针数量,每有一个指针指向这块空间我们就可以让其自加,当有指针不再指向这块空间时,我们就可以让其自减。当count为 0 时,这块空间才被释放。
类似这样的,就叫做引用计数。
所以,我们上面说过:当父进程创建子进程,会给子进程拷贝一份文件描述符表,然后,让子进程的文件描述符表指向父进程曾经打开的文件。
可能有同学会认为:如果子进程把某一个文件给关掉了,会不会父进程就没法访问这个文件了?
这其实是不会的,因为一个被打开的文件里面会包含引用计数,当我们创建子进程的时候,操作系统会自动的将父子进程所指向的同样的被打开的文件中的引用计数加 1 。
所以,当子进程在关闭文件的时候,并不是真正的将文件关闭了,而只是让引用计数减 1 了,当引用计数减到 0 的时候,OS 才会自动释放这个被打开的文件。
其中,这里还有一个 f_pos ,它表示文件当前的操作位置。我们以前在学C语言的时候,当我们进行文件操作的时候,如果我们使用追加,我们就会从文件结尾去写。当我们想从开头写,可以使用 rewind 函数。那么,我们是如何做到上层调用一个函数,我们所对应的内容就可以写到指定位置呢?其实就是通过 f_pos 来实现的。
最后,我们再来看一下 file_operations 。我们会发现里面会有很多的读写方法,这些函数指针会指向具体的硬件所对应的读写方法。
我们上面讲过 Linux 一切皆文件就是通过 struct file 来实现的。我们会看到 struct file 里面有一个结构体指针 f_op ,我们就可以通过操作这个指针,然后使用里面对应的函数指针,最后调用各个硬件模块对应的各种方法。
五、缓冲区
我们上面有的时候会提到缓冲区,那么缓冲区到底是什么呢?它到底在哪里呢?它又有什么细节呢?我们现在就来讲一下。
编写如下代码:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{// C接口printf("hello printf\n");fprintf(stdout, "hello fprintf\n");fputs("hello fputs\n", stdout);// 系统调用const char *msg = "hello write\n";write(1, msg, strlen(msg));return 0;
}
运行结果:
这里,我们使用了C语言和系统调用接口来向显示器打印信息,结果如我们所料,都被打印出来了。
当我们使用 fork 创建子进程,然后什么都不做:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{// C接口printf("hello printf\n");fprintf(stdout, "hello fprintf\n");fputs("hello fputs\n", stdout);// 系统调用const char *msg = "hello write\n";write(1, msg, strlen(msg));fork();//什么都不做return 0;
}
运行结果:
我们会看到,结果也是打印 4 条信息,没有什么问题。
但是,当我们将运行结果重定向到一个文件时,会出现这样的结果:
我们会发现:当我们向显示器打印时,结果为 4 条信息。但是当我们重定向后,结果却为 7 条信息了。其中,我们的C接口被打印了两次,系统接口前后都只被打印了一次。(顺序变了没事,因为这个是受fork之后谁先运行决定的)
那是什么原因导致的呢?
我们这里将 fork 注释掉再来看运行结果:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{// C接口printf("hello printf\n");fprintf(stdout, "hello fprintf\n");fputs("hello fputs\n", stdout);// 系统调用const char *msg = "hello write\n";write(1, msg, strlen(msg));//fork();//什么都不做return 0;
}
运行结果:
我们会发现:此时,打印到显示器和重定向到其它文件中的结果都为 4 条信息。
所以,我们大概可以知道:导致出现这种现象的原因是和 fork 函数有关的。
我们要知道:当我们在进行 printf 调用的时候,数据只要显示到显示器上,就算是写到外设上了,数据就不属于这个父进程了,但是,当这个数据没有被显示到显示器上,那么,此时这个数据依旧属于父进程。
另一方面,难道我们调用了printf、fprintf、fputs,数据就被刷新到了显示器上了吗?我们曾经在讲进度条的时候,我们说过:如果没有带 '\n' ,printf 即使已经被调用完了,数据也有可能没有立即显示出来。
没有被显示,本质就是没有把数据从内存刷新到外设。所以,这份没有被显示的数据依旧属于该进程,依旧属于这个进程的话,当我们再去 fork ,并且后面紧接着就是进程退出。
当进程退出,它要刷新缓冲区。此时,刷新的过程就是将数据从内存刷新到外设,这就是写入的过程。而刷新到外设的同时,它也会把程序内部缓冲区里缓冲的数据直接清走,清走的过程不就是在做写入吗?所以,它一定是和写时拷贝有关系。
这里,有的同学可能会说:“不对呀,我不是给每条数据后面都跟了 '\n' 吗?你凭什么说它没有刷新呢?”
那这里就需要先讲一下 “关于缓冲区刷新策略的理解” 和 “确定清楚缓冲区的位置” ,这样才可以解释上面的现象。
我们这里先来理解一下缓冲区。
1. 缓冲区理解
缓冲区的本质:缓冲区的本质就是一段内存。 它就是一块专门用来做缓存的内存空间。它不在磁盘里,不在显示器里,它就在内存里,它是内存的一部分。
那只要是内存,我们就会想:它是谁申请的?它是属于谁的?为什么要有?
我们这里举一个例子:
假设你有一个朋友在浙江,而你在四川。假设你和你朋友特别要好,要好到你用过的所有东西你都会发给他。无论是你用过的鼠标、键盘、篮球或者其它什么东西,你都会发给他。他喜欢的东西或者玩腻了的东西,他也会发给你。如果最开始你是自己骑个自行车,然后干个一千多公里给他送过去,然后说:“朋友,这是我用包浆了的鼠标,送给你。”
后面,你室友给你说:“你为什么要自己把东西送过去呢?楼下不是有快递点吗?你为什么不寄过去呢?”
然后,你恍然大悟。后面,你就通过楼下的顺丰快递将东西寄给你的朋友了。我们不难想到:你的朋友那里也肯定会有一个顺丰快递点,这样他才可以取到你发给他的东西。
那么,我们假设你和你朋友那里的快递点都在宿舍楼下,你肯定是将你要发给你朋友的东西通过快递寄过去,并且,你朋友也可以下楼到他那里的快递点去取到你寄给他的物品。
那么,其中,如果我们按照你自己将物品寄过去的这种方式,你在来回途中是会花很多时间的。
如果你采用的是将物品交给顺丰,然后让顺丰将物品给你朋友送过去,这样你就不会花很多时间。
那么,现实生活中,快递行业最大的意义是什么呢?
答案是:节省发送者的时间。
那么,在这里,我们可以将四川比作内存,浙江比作磁盘。并且,进行写入数据的是进程,要写入的目的地是文件。假设你是进程,你有一个朋友叫文件,你要送的东西叫做数据。那么,其中,你要把你的数据从内存直接发送到磁盘里面,就相当于你把东西从四川直接送到浙江一样。但是,这样做太耗费时间了,因为磁盘是外设。
所以,我们就可以在内存里开辟一块空间,然后把数据拷贝到这块空间里,然后,你自己这个拷贝函数就可以直接返回了,返回之后,你就可以继续向后执行你自己的代码了。那么,在你继续向后执行代码的这段期间,你发送的这些数据,它会定期的发送给对方。【在内存中相互拷贝得速度肯定是快于将内存中的数据拷贝到磁盘中的】
那么,缓冲区的意义是什么呢?
答案是:节省进程进行数据 IO 的时间。
这样,我们就知道了为什么要存在缓冲区了。
此外,我们上面说过:进程要把数据拷贝到缓冲区里。但是,我们在写代码的时候没有拷贝呀,我们都不知道拷贝到缓冲区的函数是什么呀。我们用的都是 fwrite 来向文件做写入的呀。所以,与其理解 fwrite 是写入到文件的函数,倒不如理解 fwrite 是拷贝函数。【拷贝函数就是指将数据从进程拷贝到“缓冲区”或者外设中】
然后,我们再来讨论第二个问题。
当我们把东西交给顺丰,他什么时候才把我的快递发送给对方呢?所以,这里就涉及到缓冲区刷新策略的问题了。
2. 缓冲区的三种刷新策略
我们知道:当顺丰在发快递的时候,不可能将快递一个一个发,而是等快递积累到一定量之后才统一发出。这样才能够降低成本。同样地,如果一个进程将数据拷贝到缓冲区里面,如果缓冲区里的数据立即刷新,虽然对这个进程来说可能节省了时间,但是却没有将缓冲区的价值体现到极致。
要知道:如果有一块数据,将其一次写入到外设 对比 将其多次少批量的写入到外设,这里肯定是第一种效率是最高的,因为 只进行一次 IO 的效率 肯定是 高于要进行多次 IO 的效率的。
那么,我们就可以得到:缓冲区一定会结合具体的设备来定制自己的刷新策略。
所以,对于缓冲区,它一般有三种刷新策略和两种特殊情况:
- 立即刷新,相当于无缓冲;
- 行刷新,相当于行缓冲,显示器就是采取行刷新的策略;
- 缓冲区满再刷新,也就是指全缓冲,磁盘文件就是采用的是全缓冲。
第1种场景很少,这里暂时不考虑。
对于行缓冲,对应的文件设备就是显示器。我们之前在写进度条的时候,我们会发现:当我们不带 '\n' 的时候,对应的数据不会立即显示,当我们使用 fflush 刷新缓冲区后,这个消息才显示出来。这个现象一方面可以证明一定会存在缓冲区,要不然数据会立即显示到显示器上,另一方面,当我们使用 printf 并在后面跟上 '\n',数据就会立马显示到显示器上。这可以说明显示器的刷新策略是行刷新的。
那为什么显示器要采取行缓冲策略呢?我们上面不是说了:如果有一块数据,将其一次写入到外设,它的效率是最高的。显示器不也是外设吗,它为什么采取的是行缓冲而不是全缓冲呢?
这是因为显示器这个设备很特殊,它是给人看的,不是给机器看的。而我们人的习惯一般都是从左向右一行一行读取的。那既然我们是按行读取的,我们自然也希望显示器能够将数据一行一行的给我们显示出来。而不是要么不显示,要么一显示就显示一大堆。所以,显示器为了既保证它的刷新效率,又保证用户体验。所以才采取的行缓冲的刷新策略。
3. 两种特殊情况
缓冲区除了有 3 种刷新策略,它还有 2 种特殊情况:
- 用户强制刷新,如:用户通过调用 fflush 函数强制刷新缓冲区;
- 进程退出,进程在退出时,一般都要刷新缓冲区。
4. 缓冲区的位置
我们上面已经讲了缓冲区是一块内存,并且也讲了缓冲区的3种刷新策略和 2 种特殊情况。
我们这里就需要知道:我们上面所说的缓冲区到底在哪里呢?它又具体指的是什么缓冲区呢?
我们上面写过一个打印数据的代码,最终的得出的现象为:当我们向显示器打印时,打印出了 4 条信息。但是当我们重定向后,结果却打印出了 7 条信息。其中,我们的C接口被打印了 2 次,系统接口前后都只被打印了 1 次。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{// C接口printf("hello printf\n");fprintf(stdout, "hello fprintf\n");fputs("hello fputs\n", stdout);// 系统调用const char *msg = "hello write\n";write(1, msg, strlen(msg));fork();//什么都不做return 0;
}
对于这种现象,首先,既然我们在讲缓冲区,那么,这里肯定是和缓冲区有关的。(具体原因后面具体来说。)
其次,我们虽然还不知道这里的缓冲区在哪里,但是我们应该可以知道缓冲区肯定不在哪里。
这个缓冲区一定不在内核中,因为如果在内核中,write 也应该打印两次。我们这里使用的是C语言的接口和系统的接口,而我们知道:fprintf、printf、fputs 这些接口底层就是由 write 实现的,如果这个缓冲区是 OS 提供的而不是C语言提供的,那么,write 也就应该打印两次。
所以说,我们之前谈论的所有的缓冲区,都指的是用户级语言层面给我们提供的缓冲区。
这个缓冲区其实就是在 FILE 这个结构体里面。当我们在进行任何的文件读写操作的时候,都是会传入一个 FILE* 类型的文件指针给我们所对应的文件操作函数的(比如传入stdin、stdout、stderr等)。而 FILE 它是结构体,我们之前说里面封装有 fd ,同时,它里面其实还包括了一个缓冲区。也就是说,我们之前在使用 printf,fprintf 等这些函数进行打印的时候,数据其实是被写入到 FILE 这个结构体所对应的缓冲区里面的。
所以,当我们要使用 fflush 强制刷新的时候,传入的是文件指针。
当我们要关闭一个文件时,要传入的也是一个文件指针。因为我们传入的文件指针,里面包含了我们内部的缓冲区。
我们这里就可以看一下 FILE 这个结构体了。
在 /usr/include/stdio.h 我们能找到这段代码,表示 FILE 被重命名了。
在 /usr/include/libio.h 我们能找到 FILE 这个结构体的定义。
我们会发现里面确实包含了我们所对应的缓冲区与文件描述符。
#include<stdio.h>#define FILE_NAME "log.txt"int main(){FILE *fp = fopen(FILE_NAME, "a");if(NULL == fp){ perror("fopen");return 1;}int cnt = 5;while(cnt){fprintf(fp, "%s:%d\n", "hello world", cnt--);}fclose(fp);return 0;}
那也就是说:我们以前进行的所有C语言层面上的文件操作,包括 fgets、fputs、fprintf 等所有的这些消息,最终都会被写入到我们所定义的 FILE* 这个指针(如上面的 fp)所指向的结构体内部的缓冲区里。然后,因为 FILE 里面封装了 fd,所以,C语言会自动的在合适的时候,将我们的数据刷新到外设里。
所以,我们自己在使用C语言接口进行刷新的时候,如果我们往显示器上打印,那么,它的刷新策略就是按行进行刷新,遇到 '\n' 就刷新出来。如果是普通文件,不是显示器文件的时候,就采取全缓冲。
那么,如何解释上面 fork 的问题呢?
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{// C接口printf("hello printf\n");fprintf(stdout, "hello fprintf\n");fputs("hello fputs\n", stdout);// 系统调用const char *msg = "hello write\n";write(1, msg, strlen(msg));fork();//什么都不做return 0;
}
我们再来回想一下现象:当我们如果没有进行重定向,看到的就是 4 条信息。而我们如果重定向了,结果就打印了 7 条信息,并且被重复打印的是C语言的信息。
那自然这里就跟系统调用接口没有关系,自然也就跟write的参数 1 没有关系。即:和文件描述符没有关系。我们应该讨论的是 stdout 。
因为 stdout 默认采用的是行刷新,并且我们在每个接口后面都跟了 '\n' 。所以,在进程 fork 之前,3 条C函数已经将数据进行打印输出到显示器(外设)上了。你的 FILE 内部(进程内部)就已经不存在对应的数据了。(先显示,再fork)
而如果我们进行了重定向,写入文件不再是显示器,而是普通文件了,对于普通文件,由于其采取的刷新策略是全缓冲,所以,虽然之前的 3 条C函数都带了 '\n' ,但是它们是不足以将 stdout 的缓冲区写满的。因此,在 fork 之前,数据其实并没有被刷新到外设或者对应的文件里。
当缓冲区没满的时候,我们此时再执行 fork 的时候,stdout 这个文件对应的缓冲区是属于父进程的。
我们上面创建子进程的时候,紧接着就是进程退出。而我们上面说过:进程在退出的时候是会刷新缓冲区的。谁先退出我们虽然不确定。但是,谁先退出,就一定要进行缓冲区刷新。
而所谓的刷新,不就是把我们的数据从缓冲区拿走,然后放到外设里吗。
所以说,刷新的本质其实就是修改。
修改的时候,就会发生写时拷贝。因为 stdout 是属于C程序在用户层给我们自己定义的空间,所以,当它 fork,后来,父子任何一个进程在进行刷新时,就会进行对应的写时拷贝。
另一个进程随后退出,它就又会将数据再刷新一份。
所以,数据最终会显示两份。
【写时拷贝是写什么东西就拷贝什么东西,它是不会对整个 FILE 重新做写时拷贝的。这些都是由 OS 帮我们维护的,OS 内部是没有所谓的 FILE 的概念的。你自己有一块空间,在 OS 内部看来这就是一块空间,你要写时拷贝,OS 就给你把数据拷贝一份。你不需要做修改,那么,OS 就不会改。所以,要不要对FILE做拷贝,取决于操作者是否要对 FILE 里面的所有字段都进行操作。】
而我们的 write 为什么没有呢?因为上面的过程都和 write 无关,因为 write 没有使用 FILE ,而是用的 fd ,既然没有 FILE ,自然就没有C语言提供的缓冲区。
我们这就解决了上面的问题。
我们这里再进一步:
5. 进一步理解缓冲区
上面的关于缓冲区的原理我们已经知道了,那么,我们这里就可以自己写一部分代码来简单封装一下缓冲区了。
//myStdio.h
#pragma once#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define SIZE 1024
#define SYNC_NOW 1
#define SYNC_LINE 2
#define SYNC_FULL 4typedef struct _FILE
{int flags; // 刷新方式int fileno;int cap; // buffer的总容量int size; // buffer的使用量char buffer[SIZE];} FILE_;FILE_ *fopen_(const char *path_name, const char *mode);
void fwrite_(const void *ptr, int num, FILE_ *fp);
void fflush_(FILE_ *fp);
void fclose_(FILE_ *fp);
#include "myStdio.h"FILE_ *fopen_(const char *path_name, const char *mode)
{int flags = 0;int defaultMode = 0666;if (strcmp(mode, "r") == 0){flags |= O_RDONLY;}else if (strcmp(mode, "w") == 0){flags |= O_WRONLY | O_CREAT | O_TRUNC;}else if (strcmp(mode, "a") == 0){flags |= O_WRONLY | O_APPEND;}else{// TODO}int fd = 0;if (flags & O_RDONLY)fd = open(path_name, flags);elsefd = open(path_name, flags, defaultMode);if (fd < 0){const char *err = strerror(errno);write(2, err, strlen(err));return NULL; // 文件打开失败返回NULL}// 文件打开成功FILE_ *fp = (FILE_ *)malloc(sizeof(FILE_));assert(fp);fp->flags = SYNC_LINE; // 默认设置成行刷新fp->fileno = fd;fp->cap = SIZE;fp->size = 0;memset(fp->buffer, 0, SIZE);return fp; // 打开一个文件就会返回一个 FILE* 的指针
}
void fwrite_(const void *ptr, int num, FILE_ *fp)
{// 把数据写入到缓冲区中memcpy(fp->buffer + fp->size, ptr, num); // 这里不考虑缓冲区溢出问题fp->size += num;// 判断是否刷新if (fp->flags & SYNC_NOW){write(fp->fileno, fp->buffer, fp->size);fp->size = 0; // 清空缓冲区}if (fp->flags & SYNC_LINE){if (fp->buffer[fp->size - 1] == '\n') // 中间有'\n'暂时不考虑:如abc\ndef{write(fp->fileno, fp->buffer, fp->size);fp->size = 0;}}if (fp->flags & SYNC_FULL){if (fp->size == fp->cap){write(fp->fileno, fp->buffer, fp->size);fp->size = 0;}}else{}
}
void fflush_(FILE_ *fp)
{if (fp->size > 0)write(fp->fileno, fp->buffer, fp->size);
}
void fclose_(FILE_ *fp)
{fflush_(fp);close(fp->fileno);
}
测试:
代码:
//main.c
#include "myStdio.h"
int main()
{FILE_ *fp = fopen_("./log.txt", "w");if (fp == NULL){return 1;}const char *msg = "hello world";int cnt = 10;while (1){cnt--;fwrite_(msg, strlen(msg), fp);sleep(1);printf("count: %d\n", cnt);if (cnt == 0)break;}fclose_(fp);return 0;
}
通过代码,我们可以预测结果应该是这样的:因为我们的 "hello world" 后面没有跟 '\n' ,所以,在前 10s 时,log.txt 里面是没有数据的。10s 后,数据才会全部一起刷新到这个文件中。
运行结果:
那我们如果加上 '\n' 呢?
代码:
//main.c
#include "myStdio.h"
int main()
{FILE_ *fp = fopen_("./log.txt", "w");if (fp == NULL){return 1;}const char *msg = "hello world\n";int cnt = 10;while (1){cnt--;fwrite_(msg, strlen(msg), fp);sleep(1);printf("count: %d\n", cnt);if (cnt == 0)break;}fclose_(fp);return 0;
}
此时,由于我们在实现 fopen_ 的时候,默认设置的是采取行刷新,所以,如果我们加上了 '\n' ,数据就会一行一行的在 log.txt 中显示出来。
运行结果:
那如果我们继续不带 '\n' ,然后让程序先运行 5s 后再用我们自己实现的 fflush_ 去强制刷新,是不是就会出现:在 cnt = 5 的时候,数据才刷新一次,等到进程退出的时候再刷新一次。
代码:
#include "myStdio.h"
int main()
{FILE_ *fp = fopen_("./log.txt", "w");if (fp == NULL){return 1;}const char *msg = "hello world";int cnt = 10;while (1){cnt--;fwrite_(msg, strlen(msg), fp);sleep(1);printf("count: %d\n", cnt);if (cnt == 5)fflush_(fp);if (cnt == 0)break;}fclose_(fp);return 0;
}
运行结果:
这样,我们就可以更加清晰的认识到缓冲区是在我们的 FILE 中的。
那么,这里的缓冲区和 OS 有什么关系?
我们知道:当我们使用 fflush_ 刷新缓冲区的时候,我们上面是用 write 实现的。那么,我们的数据是直接被写入到磁盘中的吗?其实不是的。
它其实是先被写入到操作系统内的文件所对应的缓冲区里的。
我们现在已经知道:一个文件,它是有自己的 struct file 结构体的,这个结构体里面包含了一系列操作的方法(上面讲的函数指针),它里面其实还有自己对应的内核缓冲区。
当我们将数据写到磁盘的时候,其实这些数据不是直接写到磁盘的,而是通过文件描述符写入到内核缓冲区里的。当内核缓冲区的数据被刷新到磁盘的时候,才是真正的将数据写入到磁盘。
【将内核缓冲区里的数据刷新到磁盘里,这是由 OS 自主决定的,和用户毫无关系。既然是自主决定,那就不是我们之前讲的无缓冲、行缓冲和全缓冲这么简单了。OS 可能是等到自己的内存空间不足时才刷新,或者是间隔一段时间再去刷新,又或者是内核缓冲区满了再刷新等。总之,OS 要权衡自己整体的内存使用情况来对数据做刷新,而不是之前讲的那 3 种刷新策略这么简单。】
那么,按照上面说的。如果我作为一个用户,在上层经过调用 write 把数据交给了操作系统,我自己就认为我已经将数据给了系统了。但是,数据此时却暂时缓存在内核缓冲区里,那么,如果此时操作系统宕机(崩溃)了怎么办呢?操作系统里面此时可还存在大量的缓存着的数据呐。所以,这就可能导致数据丢失。
那么,如果此时上层用户是银行,它是一个对数据丢失零容忍的机构。你操作系统说好了去帮我管理这些数据,可是你要是挂了怎么办呢?
所以,我们有一个接口叫做 fsync,它可以强制的将我们的数据从内核缓冲区刷新到磁盘里。
所以,我们之前的 fflush_就可以改成这样了:
void fflush_(FILE_ *fp)
{if (fp->size > 0){write(fp->fileno, fp->buffer, fp->size);fsync(fp->fileno);//将数据强制要求OS进行外设刷新fp->size = 0;}
}
//main.c
#include "myStdio.h"
int main()
{FILE_ *fp = fopen_("./log.txt", "w");if (fp == NULL){return 1;}const char *msg = "hello world";int cnt = 10;while (1){cnt--;fwrite_(msg, strlen(msg), fp);fflush_(fp);sleep(1);printf("count: %d\n", cnt);if (cnt == 0)break;}fclose_(fp);return 0;
}
不难想象,虽然我们没有带 '\n',但是我们在使用 fwrite_ 向 log.txt 中做写入时,每写一次,我们都强制将数据直接写入磁盘对应的文件,所以,现象应该是:在 10s 时间里,每隔 1s,磁盘对应的文件里面的数据都将不断增加。
运行结果: