一. 文件fd
1.1 理解“文件”
(1) 文件=内容+属性。
(2) 文件分为 打开的文件 和 没打开的文件 ,打开的文件在内存中,而没打开的文件则在磁盘中。
(3) 打开的文件是由进程在内存中打开的,在所以研究打开的文件本质上就是研究进程和文件的关系!
——》∴文件要被打开,必然要先被加载到内存中。并且一个进程可能打开多个文件,一个文件也可能被多个进程打开。∴在操作系统内部一定存在大量被打开的文件 !按照之前学习的知识,在操作系统也一定会以先描述再组织的方式将这些被打开的文件管理起来!
(4) 没打开的文件在磁盘中,所以研究没打开的文件关键在于文件如何被分门别类地放置好从而方便用户快速找到文件并进行相关的增删查改工作。
1.2 回顾C语言对文件操作的接口
1.2.1 fopen文件打开
先看看在我们使用fopen时,遇到的一些疑问。
❓问题1:为什么我们默认会新建在当前路径,凭什么?
——》当前路径,其实就是进程的路径,因为进程在执行的过程中,他需要知道自己从哪来,也要知道如果自己产生一些临时性的文件时应该放在哪里,所以他需要一个默认路径cwd。cwd表明的是他当前的工作目录,进程根据cwd路径默认创建文件。
——》因为进程PCB结构体内部有一个cwd属性,如果我们更改了进程的,cwd属性,就可以将文件新建到别的地方!
❓问题2: 先被加载到内存的是文件的属性还是文件的内容??
——>当你fopen的时候,其实就需要创建一个文件的内核数据结构,里面包含了文件的一些必要属性,所以是属性先被加载进去了! 至于内容需不需要被加载,关键看你有没有通过一些接口来对该文件进程增删查改!
1.2.1 fopen+fwrite文件修改
解答图中问题,原因在于fopen的w选项:在写入之前,会对文件进行清空处理。
——》echo重定向方式写入也会先清空文件,所以我们知道echo底层必然也是w形式!
——》》如果我想追加内容,该怎么做? 答案是,在fopen的第二个参数带 a 选项
1.3 系统调用
❗我们在c语言所使用的fopen,fwrite,本质上都不是他们在对文件打开或修改的操作,而是封装了底层的系统调用,是底层的系统调用实现了对文件的打开和修改。
❓为什么一定是底层的系统调用才能实现对文件的操作?
因为文件其实是在磁盘上的,磁盘是外部设备,访问磁盘文件其实是访问硬件!访问硬件只能经过操作系统,而上层想对硬件进行操作,只能使用操作系统对上层提供的系统调用
❗所以其实我们可以得出: 在std lib/c/c++ 几乎所有的库只要是访问硬件设备,必定要封装系统调用。
1.3.1 系统调用open
man手册:
参数pathname是文件名,
参数flags是打开的模式:O_WRONLY (对文件写入),O_CREAT(创建文件),O_TRUNC(对文件进行清空),O_APPEND(追加)…
而mode是权限设置 ,比如0666就是对文件权限的三者都设置rw-。
——》第一个open是用来打开一个已经存在的文件,而第二个open打开的是新建的文件(因为我们需要给新建的文件设置权限!)
实例:
#include <stdio.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
int main()
{
//以写入,并且没有该文件就创建的形式,打开文件
int fd = open("test.log",O_WRONLY | O_CREAT , 0666);
}
运行结果:我们观察到创建了文件
但是,文件权限并不是我们想要的 -rw-rw-rw- ,这是因为在系统中还有权限掩码umask的作用,所以我们没有得到想要的文件权限。
这时候,需要调用接口umask,设置该进程的umask为0,就能解决问题,因为是设置进程的umask,所以不会影响到系统的权限掩码:
使用:
#include <stdio.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
int main()
{
umask(0);
//以写入,并且没有该文件就创建的形式,打开文件
int fd = open("test.log",O_WRONLY | O_CREAT , 0666);
}
1.3.2 ⼀种传递标志位的方法
在使用open接口的时候,我们发现在open的第二个参数中,它的类型是int,传入的参数是以宏 + | 的方式向open内部传入标志位信息,这是怎么做到只用一个int类型就可以表明这些情况?是位图!!
以往我们需要很多标记位的时候我们本能想到的是多创建几个参数来表示,但当位图出现后,我们想到了可以用int类型的32个bit位来表示各种不同的组合。
这样说有点抽象,可以模拟实现一下看看到底是怎样的:
#include <stdio.h>
#define ONE 0001 //0000 0001
#define TWO 0002 //0000 0010
#define THREE 0004 //0000 0100
void func(int flags) {
if (flags & ONE) printf("flags has ONE! ");
if (flags & TWO) printf("flags has TWO! ");
if (flags & THREE) printf("flags has THREE! ");
printf("\n");
}
int main() {
func(ONE);
func(THREE);
func(ONE | TWO);
func(ONE | THREE | TWO);
return 0;
运行结果:这样就以位图的方式传入我们想要的状态了!
1.3.3 文件描述符fd 与文件表述符表
在调用open函数时,我们注意到它的返回值是一个整形,这个整形就是文件表述符fd。
❓以前我们学C语言的时候,fopen的返回值是一个FILE* (那个时候我们知道这个是C库封装的一个结构体),但是为什么系统调用open的返回值是一个int 呢?它是怎么只通过一个整形完成对被打开文件的管理?
❗因为一个进程可能打开多个文件,那么我们想要快速地找到任意一个文件,如果仅仅是用链表的方式组织,确实太慢了,在底层pcb中,管理被打开的文件是以数组的方式存储的!!
❗在PCB结构体内部,有一个 file_struct * 指针,该指针指向一个file_struct结构体,该结构体就是操作系统给该进程提供的一个文件描述符表,里面除了一些必要的字段信息,还有一个存放file * 指针的指针数组,这些file * 指针分别指向一个个被该进程打开的文件!!
——>fd我们称之为文件描述符,他的本质就是文件描述符表的下标,系统可以通过fd在文件描述符表中查询,来找到那些被打开的文件!
❓file是一个怎样的结构体?它与从c语言的FILE又有什么不同?
——》里面有被打开文件的各种属性和信息,其中包括但不限于:1在磁盘的什么位置,2基本的属性:权限、大小、读写位置、谁打开的),3文件的内核缓冲区,4引用计数(因为一个文件可能会被多个进程打开,所以当一个进程关闭该文件的时候不代表这个文件的结构体就被释放了,而是要引用计数为0时才释放),5file * next:链表节点结构,可以跟其他的文件链接成双链表的形式做管理!它是文件描述符表中内关于描述文件的结构体!
——》FILE * 是一个C库自己封装的结构体,由于系统调用接口用的是fd文件描述符来对指定的文件进行操作,所以我们可以知道FILE结构体内部必然封装着文件描述符fd!
1.3.4 stdin, stdout , stderr
从上文的结论我们可以知道,文件描述符fd其实就是其实就是文件描述符表这个数组的下标,我们可以尝试保存一个被打开文件的fd,看看他是否是数组下标的第一个位置:
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
using namespace std;
int main()
{
//以写入,并且没有该文件就创建的形式,打开文件
int fd = open("test.log",O_WRONLY | O_CREAT , 0666);
cout << fd << endl;
}
我们发现,他并不是数组下标的第一个位置(数组下标的第一个位置就是0),而是3
❓这是为什么? 要解释这个问题,就要来介绍 stdin, stdout , stderr这三个流了
——》Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入stdin - 0,标准输出stdout - 1,标准错误stderr - 2
——》有没有觉得很眼熟?因为我们在学C语言的时候也知道C程序会默认打开3个流! 标准输入流、标准输出流、标准错误流其实并不是C语言的特性!!而是操作系统的特性!!
——》流的本质就是文件!我们向stdin流读取数据,stdout写入数据,其实就是向键盘文件读取数据,向显示器文件写入数据!对流的操作,就是对文件的操作。
1.3.5 系统调用close,write
在c语言中,我们可以通过fclose,fwrite,来关闭被打开的文件和对文件写入,他们所封装的系统调用分别是close,write。
close: 用于关闭对应的文件描述符
write:对文件描述符所指向的文件中写入内容
使用实例
:
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>
using namespace std;
int main()
{
//以写入,并且没有该文件就创建的形式,打开文件
int fd = open("test.log",O_WRONLY | O_CREAT , 0666);
//向文件写入 write(int fd, const void *buf, size_t count)
string inbuffer = "hello,world";
write(fd, inbuffer.c_str() , sizeof(inbuffer)+1);
//关闭文件描述符
close(fd);
}
运行结果:
细节
:
关于write(fd, inbuffer.c_str() , sizeof(inbuffer)+1);为什么 sizeof(inbuffer)要 + 1 ? 因为sizeof(inbuffer)是不包含 ‘/0’的长度,所以我们在写入的时候,要把’/0’一起写入进去。
1.4 文件描述符的分配规则
先说结论:⽂件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的⼀个下标,作为新的文件描述符。
证明
:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
——》打开一个文件,打印它的文件描述符,发现是3
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}
——》关闭0或者2,再打开一个文件,打印它的文件描述符,发现是0或者2。
二. 重定向再理解
2.1 输入重定向
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
close(1);//关闭 stdout
//再打开myfile
int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
if(fd < 0){
perror("open");
return 1;
}
//再打印内容
printf("fd: %d\n", fd);
fflush(stdout);
//关闭myfile
close(fd);
exit(0);
}
——》承接上文实验,我们关闭stdout,再打开文件myfile,printf打印内容,我们发现,原本应该printf到命令行的内容,竟然printf到了myfile文件内,这样的现象叫做输出重定向
❗输出重定向的本质就是将文件的输出描述符对应的指针信息替换成文件的指针信息!!
2.2 dup2
❓借助上面的实验,难道我们必须要先把1号文件关闭了再打开新的文件才能完成输入重定向吗?
❗并不是——》其实本质上就是将新文件的指针覆盖掉原来1号位置的指针就行了,系统提供了一个接口叫dup2来帮助我们解决这个问题!!所以在命令行解释器当中进行的输出重定向底层肯定使用了dup接口!
dup2
:
——》dup2会用 oldfd位置的file * 覆盖掉 newfd 的file*的
使用实例
:用dup2实现输出重定向
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("./log", O_CREAT | O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
close(1);
dup2(fd, 1);
for (;;) {
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0) {
perror("read");
break;
}
printf("%s", buf);
fflush(stdout);
}
return 0;
}
2.3 为什么要有stderr
❓stdout,stderr都是打开的显示器文件,两个流都是向显示器打印,那为什么要有两个流,只打开一个流的效果不还是一样的吗?
❗从上述的学习中我们知道,stdout,和stderr都是可以重定向的,将程序的运行结果分别重定向到两个不同的文件(这样我们可以把运行结果放到我们的正常文件里,然后把错误的一些信息放到我们的另一个文件里,方便我们观察——>这就是为什么要有stderr的原因)
实例应用:
./file 1>nomal.log 2>err.log
❓如果要将两个输入流都放在一个文件呢?
./file 1>nomal.log 2>&1
三. 缓冲区深入理解
3.1 用户级缓冲区
3.1.1 几个基于现场产生的疑问
❓现象1:为什么向文件写入的时候 write会先被打印出来??
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <cstring>
int main()
{
const char * fstr = "我是fwrite打印出来的\n";
const char * str = "我是write打印出来的\n";
//c语言提供的打印接口
printf("我是printf打印出来的\n");
fprintf(stdout,"我是fprintf打印出来的\n");
fwrite(fstr,strlen(fstr),1,stdout);
//系统调用
::write(1,str,strlen(str));
}
❓现象2:为什么加了fork之后,向文件写入时C接口会被调了两次??且向文件写入时write先被打印?
❓ 现象3:写入完成后,close1号文件,为什么c语言接口的打印数据的结果不见?
int main()
{
const char * fstr = "我是fwrite打印出来的\n";
const char * str = "我是write打印出来的\n";
//c语言提供的打印接口
printf("我是printf打印出来的\n");
fprintf(stdout,"我是fprintf打印出来的\n");
fwrite(fstr,strlen(fstr),1,stdout);
//系统调用
::write(1,str,strlen(str));
close(1);
fork();
}
3.1.2 基于现场产生的结论
❗通过现象3,我们发现一旦close之后,c接口的打印内容就不见了!! close在关闭文件描述符时,会先刷新该文件的输出缓冲区,write接口的数据被刷新出来了,但是c接口的打印内容没有被刷出来,说明c接口的打印内容一开始根本不在这个缓冲区当中!
❗ 我们的c库函数接口是先把内容放到一个C提供的缓冲区,当需要刷新的时候,才会去调用write函数进行写入,这个缓冲区就是用户级缓冲区!!
❗所以close后刷新不出来的原因就是: 进程退出后想要刷新的时候,文件描述符被关了,所以即使调了write也写不进去,缓冲区的数据被丢弃了
❗注意:进程退出的时候是会刷新缓冲区的!!这就是为什么我们不主动刷新缓冲区的情况下也能打印出结果
3.1.3 用户级缓冲区的刷新策略
标准I/O提供了3种类型的刷新策略。
1️⃣全缓冲:这种缓冲方式要求填满整个缓冲区后才进⾏I/O系统调⽤操作。对于磁盘⽂件的操作通常使⽤全缓冲的方式访问。
2️⃣行缓冲:在⾏缓冲情况下,当在输⼊和输出中遇到换⾏符’\n’时,标准I/O库函数将会执行系统调⽤操作。当所操作的流涉及⼀个终端时(例如标准输⼊和标准输出),使⽤⾏缓冲⽅式。因为标准I/O库每⾏的缓冲区⻓度是固定的,所以只要填满了缓冲区,即使还没有遇到换⾏符,也会执⾏I/O系统调⽤操作,默认⾏缓冲区的⼤⼩为1024。 对于显示器文件的操作通常使⽤行缓冲的方式访问。
3️⃣⽆缓冲:⽆缓冲区是指标准I/O库不对字符进⾏缓存,直接调⽤系统调⽤。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显⽰出来。
❓问题1:为什么要有这些不同的方案??
❗一般来说写满再刷新效率高,因为这样可以减少调用系统接口的次数,而显示器之所以是行刷新,因为显示器是要给人给的,按行看符合我们的习惯,而文件采用的就是全缓存策略,因为用户不需要马上看到这些信息,所以这样可以提高效率。 而对于一些特殊情况我们就可以用fllush之前强制刷新出来。 ——>所以方案是根据不同的需求来的!
❓ 问题2:解释现象1
❗当我们从向显示器写入转变为向普通文件打印时,此时刷新策略从行刷新转变为全刷新,所以前三个C接口并没有直接写入,而是暂时保存在了缓冲区里面,而write是系统调用接口优先被打印了出来,之后当进程退出的时候,缓冲区的内容才被刷新出来。
❓问题3:解释现象2
❗跟现象1一样,前三个C接口的数据暂时被存在了缓冲区,而write的调用先被打了出来。当fork的时候,子进程会和父进程指向相同的代码和数据,当其中一方打算刷新缓冲区时,其实就相当于要修改数据,操作系统检测到之后就会发生写时拷贝,于是缓冲区的数据被多拷贝了一份,而后该进程退出时就会再刷新一次,因此C接口写入的数据被调了2次!!
3.1.4 用户级缓冲区和内核级缓冲区在哪❓
我们回忆一下exit和_exit,他们区别就是exit会先调用一次fllush把缓冲区的数据刷新出来——》我们去查fllush接口,可以看到他实际是传入了一个FILE*类型去刷新的
❗FILE * 不仅封装了fd的信息,还维护了对应文件的缓冲区字段和文件信息!!
❗FILE * 是用户级别的缓冲区(任何语言都属于用户层),当我们打开一个文件的时候语言层给我们malloc(FILE),同时也会维护一个专属于该文件的缓冲区!! 所以如果有10个文件就会有10个缓冲区!!
❗文件的内核级缓冲区被 内核数据结构file管理
——》内核缓冲区也是由操作系统的file结构体维护的一段空间,和语言的缓冲区模式是类似的,作为用户我们不需要太关心操作系统什么时候会刷新,我们只需要认为数据只要刷新到了内核,就必然可以到达硬件,因为现代操作系统不做任何浪费空间和时间的事情
四. 如何理解一切皆文件
-
计算机上进行的所有操作,所有任务都会被系统检测成进程,由进程作为我们任务的行动载体,我们目前对文件的所有操作其实都依赖于进程操作!!
-
所有的硬件在操作系统中都会由一个统一的结构体file进行描述,这个数据结构会包含对于这个硬件的读方法和写方法,这与文件的描述相似,所以我们会尝试把外设也当成是文件来处理。但是并不是所有的硬件都有读方法和写方法(比如说键盘只有写方法,而显示器只有读方法)那我们的file要如何做区分呢。
-
所以作为描述他们的结构体file,存储读写方法的方式是函数指针。(对于显示器硬件,读方法设为null,写方法填充,对于键盘硬件,读方法填充,写方法设为null)。
-
这就是VFS 虚拟文件系统,所以可以理解Linux一切皆文件