目录
前言:
stdin&& stdout && stderr
Linux文件操作之系统调用
打开文件
关闭文件
写入文件
读取文件
文件描述符fd
fd的分配规则与重定向原理
理解用户级缓冲区
前言:
在往期博客《Linux基础概念》中,我们聊过关于什么是文件,那时我们说的是文件=内容+属性
这也是我们今天铺垫的第一个结论
文件 = 内容+属性,所以如果对一个文件发生修改,那么修改的要么是内容,要么是属性
在我们使用C语言进行编程的时候,会发现如果需要对一个文件发生各种操作,比如读/写,那么我们做的第一件事一定是先打开这个文件。
所以我们说在我们访问文件之前,都得先打开这个文件,这是由我们得常识得到的
当然,单单打开文件的话是没有对文件进行任何修改的,实际上我们在C语言中修改一个文件的数据都是通过CPU执行代码的方式进行修改的。而代码属于程序的一部分,换句话来说执行代码实际上就是把程序加载到内存中变为进程以后执行的。
所以既然要把程序加载到内存中,那么通过程序的代码修改文件,文件本身也一定要加载到内存中,因为CPU不与外设直接交互!
打开一个文件是通过进程执行代码的方式进行打开的,所以文件本身其实是由进程来打开的。
而通过我们得常识可以知道,一个程序之中可以有多个fopen语句,也就是可以打开不同的文件,也就是说一个进程可以打开多个文件
也就是说在系统中,一个进程打开的文件可能有很多,那么接下来的问题是既然打开的文件有那么多,那么操作系统是否需要对进程打开的文件进行管理呢?
答案是肯定的,因为操作系统是文件的管理者
那么操作系统如何对被打开的文件进行管理呢?
答案是先描述后组织
而Linux操作系统是由C语言进行编写的,在C语言中我们如何描述一个事物呢?
答案是结构体
如何把这些结构体组织起来呢?
答案是数据结构,假设是链表
到这里,我们虽然对于被打开的文件还没有什么概念,但是我们可以知道,在Linux的内核中一定会有描述被打开的文件的结构体,并把它定义为对象,然后用链表组织起来。
完成上述工作后呢,操作系统就把对被打开的文件的管理变为了对链表的增删查改
但此时还有一个问题就是我们上面聊得都是被打开的文件,操作系统中难道所有的文件都是被打开的吗?
答案:并不是,被打开的文件在操作系统中是占少数的,我们一个系统中可能存在成千上万个文件,大部分文件都是放在磁盘中的
所以,对于操作系统中的文件我主要分为两种,一种是被打开的文件,称之为内存文件。而没有被打开的文件,称之为磁盘文件
接下来本文都是对被打开文件(内存文件)的介绍
stdin&& stdout && stderr
首先,我们先了解一个结论:Linux下的文件默认会打开三个文件流
分别是stdin,stdout,stderr,如下为这三个文件流的man手册介绍
STDIN(3) Linux Programmer's Manual STDIN(3)NAMEstdin, stdout, stderr - standard I/O streamsSYNOPSIS#include <stdio.h>extern FILE *stdin;extern FILE *stdout;extern FILE *stderr;
实际上,我们能看到上述介绍中,这三个文件流实际上就是文件指针,指向三个不同的文件。
那么这三个流指向的什么文件呢?为什么要默认打开这三个文件流呢?
标准输入/输出/错误文件流的本质
首先我们得先知道Linux下一切皆文件的概念,这是Linux操作系统的指导思想。
既然Linux下一切皆文件,那么对于Linux来说键盘、显示器、...这种硬件是否是文件呢?
答案是肯定的
所以所谓的stdin标准输入流,实际上对应的就是键盘文件
stdout标准输出流,实际上对应的就是显示器文件
stderr标准错误流,实际上也是显示器文件
输出到显示器和打开显示器文件的先后顺序
我们在学习C语言的时候第一次写代码,我们大概率写的其实是打印Hello,World!
当我们写好这一份代码的时候,会发现结果确实打印到了显示器上,我们上面说过由于Linux下一切皆文件,所以显示器也是文件。我们还说过凡是文件如果发生修改就一定要先打开,那么我们往显示器上打印是对显示器进行修改吗?
答案是肯定的,那么我们如果没有把显示器文件进行打开的话我们如何对它进行修改呢?由此我们可以得出结论,在我们对显示器进行写入的时候,一定是因为显示器在我们写入前,他所对应的文件被打开了。
为什么要默认打开三个文件流
在C语言中我们对一个文件的操作都是基于FILE*文件指针进行的,Linux是C语言写的,所以系统为进程默认打开的三个文件流可以使得用户可以对显示器、键盘进行操作/访问
而至于系统为什么默认要打开这三个文件流,最根本的原因其实是你作为用户,不管你有什么需求需要进行编写代码,大多都要使用键盘来输入数据,使用显示器来输出数据,使用显示器来输出错误信息。由于都有这样的需求,所以系统就默认打开了这三个文件流
文件操作函数与打开的3个文件流
所以我们除了使用C语言的printf函数把数据打印到显示器以外,还可以使用fprintf指定文件为显示器也就是stdout,也就是如下demo代码:
#include <stdio.h>int main()
{printf("Hello,World!\n");fprintf(stdout,"Hello,World!\n");return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
Hello,World!
Hello,World
当然,C库函数中还有不少是可以把一个字符串打印到指定文件流的函数,例如fputs,fwrite。这里不过多介绍!
而与printf配套的scanf其实也是如此,fscanf就是指定一个文件,从这个文件中读取数据,而scanf就是直接读取键盘,不需要指定文件,所以如果我们把fscanf的参数设置为stdin,那么fscanf和scanf就没有什么区别了
Linux文件操作之系统调用
为什么要提供文件操作系统调用?
首先,我们先思考一个问题,那就是当你用printf进行打印的时候,最本质的特征是不是你的显示器中显示出来了你要打印的信息呢?
显示是的,而显示器是一个硬件!
根据我们了解的操作系统是硬件的管理者,那么我们使用硬件显然是无法绕开操作系统这一层的!
而操作系统不允许用户直接访问硬件资源。所以操作系统为用户提供了各种各样的系统调用,方便用户访问硬件资源!
由此我们可以知道,对文件的访问不仅仅可以使用C库中的文件操作接口,还有系统提供的各种系统调用,而接下来我们要逐个学习这些系统调用
打开文件
首先介绍的第一个关于文件操作的系统调用:open
如下为man手册中对这个系统调用的介绍
NAMEopen, creat - open and possibly create a file or deviceSYNOPSIS#include <sys/types.h>#include <sys/stat.h>#include <fcntl.h>int open(const char *pathname, int flags);int open(const char *pathname, int flags, mode_t mode);int creat(const char *pathname, mode_t mode);
- open的功能,就是打开一个文件(可能会创建)
- open的头文件:sys/types.h、sys/stat.h、fcntl.h
- 对于第一个参数,也就是pathname,实际上就是要打开的文件所处的路径
而接下来要重点介绍的分别为:flags参数,mode参数,open的返回值
open参数之flags参数的基本功能
在我们C语言中要打开一个文件可能是r方式打开,可能是w方式打开,也可能是a方式打开。
C语言中是通过指定字符串来控制打开方式的
系统调用open则是通过这个flag参数来控制打开方式的。
flags参数如何控制打开方式
但我们能看到一个问题,flags只是一个整形变量,在我们的一般实现中,一个整形变量通常只能指定一个选项,而对于一个文件来说打开方式是有很多种的,比如读方式打开,比如读写方式打开,比如追加方式打开....。那么对于如此多的打开方式,如何使用一个整形来表示呢?
实际上系统是使用位图传参的方式使得一个整形可以表示不同的选项的。
换句话来说也就是可以根据一个整形中的一个比特位来表示某个是否可读/可写。。。
flags参数为什么要使用位图传参?
首先,对于一个文件来说,它的某个权限,比如读权限,对于别人只存在着两种情况,第一种别人可以读这个文件,第二种别人不可以读这个权限。而一个比特位则正好能表示出这两种情况,可以根据比特位为0或1进行区别
以下一个整形表示不同选项的具体操作
#include <stdio.h>#define READ 1<<0 //读
#define WRITE 1<<1//写
#define APPEND 1<<2//追加
void IsBite(int sub)
{if(sub & READ){printf("This file have read\n");}if(sub & WRITE){printf("This file have write\n");}if(sub & APPEND){printf("This file have append\n");}printf("\n------------------------------\n");
}int main()
{int sub1 = 0;int sub2 = 0;int sub3 = 0;//1.只有读权限sub1 = READ;//2.既有读又有写sub2 = READ | WRITE;//3.读写追加都有sub3 = READ | WRITE | APPEND;IsBite(sub1);IsBite(sub2);IsBite(sub3);return 0;}
运行结果:
This file have read
------------------------------
This file have read
This file have write------------------------------
This file have read
This file have write
This file have append------------------------------
通过上述,我们也能知道,所谓的flags控制打开方式,实际上是通过这个整形中的每一个为1的比特位来控制打开方式的,但接下来有一个问题就是,我怎么知道flags中的哪一个比特位对应的是哪个打开方式(读/写....)呢?
flags参数的宏值
实际上这一点我们不需要担心,就好比我们上述的代码,我是通过宏的方式来控制某个比特位的,在我使用时,我不需要关心这个变量哪个比特位为1或0,我直接通过宏的方式进行组合搭配即可
系统也为flags参数提供了宏值,如下我们介绍一些常用的
O_RDONLY
:只读模式打开文件。O_WRONLY
:只写模式打开文件。O_RDWR
:读写模式打开文件。O_APPEND
:在文件末尾写入数据,而不是覆盖文件内容。这意味着每次写入操作都会将数据添加到文件的末尾。O_CREAT
:如果文件不存在,则创建一个新的文件。
open系统调用之返回值
open的返回值其实是一个文件标识符,在C语言中我们调用fopen返回一个文件指针,之后对文件的操作都我们可以使用这个文件指针进行,而这里open返回的其实也是这么个东西,但需要注意的是open返回的是一个整形,换句话来说,在之后我们对被打开的文件的操作都可以用这个整形来控制,之后我们会详细解释文件描述符fd的内容,这里我们只需要知道这个整形我们需要进行保存,因为之后对文件读写等等操作时需要用到它!当然,文件描述符fd都是正数,所以如果返回的是<0的数,那么说明打开失败!
open的基本使用
而基于上述内容,我们就可以写一个demo代码:
其中我写的demo代码有两个代码块,一个执行的是只写方式打开log1.txt,一个执行的也是只写方式打开log2.txt,但如果log2.txt不存在的话我们就需要创建一个
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{int num = 0;scanf("%d",&num);if(num == 1){//以只写方式打开log1.txtint fd = open("log1.txt",O_WRONLY);//由于log1.txt不存在,所以执行的这个open调用会打开失败if(fd < 0){perror("open");}}else if(num == 2){//以只写方式打开log2.txt,不存在的话会创建一个log2.txtint fd = open("log2.txt",O_WRONLY | O_CREAT);//由于log2.txt不存在会创建,所以执行的这个open调用不会打开失败if(fd < 0){perror("open");}}else {perror("None");}
}
运行结果:
yyf@VM-24-5-centos 24.06.22]$ ./test
1
open: No such file or directory
[yyf@VM-24-5-centos 24.06.22]$ ./test
2
[yyf@VM-24-5-centos 24.06.22]$ ll
total 20
---------x 1 yyf yyf 0 Jun 22 15:54 log2.txt
-rw-rw-r-- 1 yyf yyf 58 Jun 22 02:34 Makefile
-rwxrwxr-x 1 yyf yyf 8464 Jun 22 15:54 test
-rw-rw-r-- 1 yyf yyf 1716 Jun 22 15:53 test.c
这个代码与我们的预期大部分相符,open在打开log1.txt时,由于没有使用不存在就创建的打开方式,所以log1.txt打开失败,而log2.txt打开成功,但我们会发现log2.txt虽然打开是成功的,但它的权限位是随机的,如果你自己去试的话你可能有不一样的结果,而为了控制创建文件的权限位,所以我们接下来引入open的第三个参数mode
open系统调用之mode参数
mode其实也就是说的你创建文件时,这个文件所对应的权限位,这个参数一般是搭配O_CREAT使用的,mode参数的格式与umask相同,并且这个mode参数设置出来的权限也受umask的限制,关于文件权限属性和权限掩码可以看《Linux基础概念》这篇博客,有详细介绍,这里不过多赘述
如下demo代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{//以只写方式打开log2.txt,不存在的话会创建一个log2.txtint fd = open("log2.txt",O_WRONLY | O_CREAT,0666);//由于log2.txt不存在会创建,所以执行的这个open调用不会打开失败if(fd < 0){perror("open");}return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ ll
total 20
-rw-rw-r-- 1 yyf yyf 0 Jun 22 16:24 log2.txt
-rw-rw-r-- 1 yyf yyf 58 Jun 22 02:34 Makefile
-rwxrwxr-x 1 yyf yyf 8408 Jun 22 16:24 test
-rw-rw-r-- 1 yyf yyf 1362 Jun 22 16:23 test.c
可以看到,此时文件log2.txt打开成功并且权限位也是我们自己指定的
当然,上述的权限位我指定的是0666,而log2.txt确是0664,这是因为它受umask的限制,而如果进程不想让自己受umask的限制就可以使用系统调用umask
NAMEumask - set file mode creation maskSYNOPSIS#include <sys/types.h>#include <sys/stat.h>mode_t umask(mode_t mask);
umask调用的mask参数也就是你想把umask设置为多少, 而umask调用的返回值也就是你成功设置的umask值
所以把上述代码修改一下就如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{//以只写方式打开log2.txt,不存在的话会创建一个log2.txtumask(0);int fd = open("log2.txt",O_WRONLY | O_CREAT,0666);//由于log2.txt不存在会创建,所以执行的这个open调用不会打开失败if(fd < 0){perror("open");}return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ ll
total 20
-rw-rw-rw- 1 yyf yyf 0 Jun 22 16:31 log2.txt
-rw-rw-r-- 1 yyf yyf 58 Jun 22 02:34 Makefile
-rwxrwxr-x 1 yyf yyf 8456 Jun 22 16:31 test
-rw-rw-r-- 1 yyf yyf 1380 Jun 22 16:31 test.c
此时,创建的文件的权限位就与我们指定的0666相符
关闭文件
当然,有了打开文件,我们还需要可以关闭文件,所以我们接下来介绍对文件操作的第二个系统调用close,如下为man手册中对它的介绍
NAME
close - close a file descriptorSYNOPSIS
#include <unistd.h>int close(int fd);
- 头文件:unistd.h
- close的参数就是你open打开文件时的返回值文件描述符fd
- close的功能就是关闭一个文件
这个系统调用较为简单,就不过多介绍了
写入文件
接下来介绍第三个系统调用,对文件写入时需要用到的write
NAMEwrite - write to a file descriptorSYNOPSIS#include <unistd.h>ssize_t write(int fd, const void *buf, size_t count);
- write的功能就是指定一个文件描述符,把数据写入到文件描述符所对应的文件当中
- write的头文件unistd.h
- write的第一个参数fd,实际上就是文件描述符fd,也就是你open打开文件成功时的返回值
- write的第二个参数buf,就是要写入到文件当中的数据
- write的第三个参数count,就是你写入的有效字符的个数(不要把'/0'也算入进去)
如下为它的demo代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{//以只写方式打开log2.txt,不存在的话会创建一个log2.txtumask(0);int fd = open("log2.txt",O_WRONLY | O_CREAT,0666);//由于log2.txt不存在会创建,所以执行的这个open调用不会打开失败if(fd < 0){perror("open");}//对文件进行写入const char* str = "Hello,Write\n";write(fd,str,strlen(str));close(fd);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ ll
total 24
-rw-rw-rw- 1 yyf yyf 12 Jun 22 16:47 log2.txt
-rw-rw-r-- 1 yyf yyf 58 Jun 22 02:34 Makefile
-rwxrwxr-x 1 yyf yyf 8616 Jun 22 16:47 test
-rw-rw-r-- 1 yyf yyf 1551 Jun 22 16:47 test.c
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
Hello,Write
可以看到,对文件的写入是成功的
fopen以"w"方式写入文件的原理
实际上当我们对已写入的文件再次写入时,会默认从这个文件的开头处覆盖式的写入,而其他内容不清空,如下为demo代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{int fd = open("log2.txt",O_WRONLY | O_CREAT,0666);//对文件进行写入write(fd,"aaaa",4);close(fd);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
Hello,Write
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
aaaao,Write
可以看到,当我们对这个文件再次写入时,是从文件的开头处覆盖式写入,但在我们的C语言当中,如果对一个文件进行写入,它首先是要清空这个文件,然后再进行写入,那么我们如何能做到C语言的这一系列操作呢?
这里就不得不介绍一个关于open打开时的宏选项了
O_TRUNC:如果打开文件成功,那么这个文件的内容会被清空
所以上述的代码我们只需要在打开时组合上这个宏选项即可
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{int fd = open("log2.txt",O_WRONLY | O_CREAT|O_TRUNC,0666);//对文件进行写入const char* str = "Hello,Write\n";write(fd,"aaaa",4);close(fd);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
aaaao,Write
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
aaaa
fopen以"a"方式打开文件的原理
当然,除了上述这种对文件写入时文件先清空以外,在C语言中我们可以选择“a”方式打开,这种打开方式就是从文件的末尾处开始进行追加写入,我们如何使用系统调用实现这个功能呢?
只需要在文件打开时组合一个宏即可:
O_APPEND:打开文件成功时,指定对该文件的写入为追加写入
如下为demo代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{int fd = open("log2.txt",O_WRONLY | O_CREAT|O_APPEND,0666);//对文件进行写入const char* str = "Hello,Write\n";write(fd,str,strlen(str));close(fd);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
Hello,Write
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ cat log2.txt
Hello,Write
Hello,Write
而上述,我们做的工作实际上就是用系统调用模拟实现的库函数,那么系统调用和库函数实际上有啥关联呢?(后面解答)
读取文件
接下来我们要介绍的是系统调用接口:read
NAMEread - read from a file descriptorSYNOPSIS#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);
- read的第一个参数fd就是你要从哪个文件描述符所对应的文件当中读取内容
- read的第二个参数buf就是你要把读取的内容放哪
- read的第三个参数count就是你要读取的字符个数
- read的返回值是一个有符号的整形,如果大于0,则表示的是成功读取字符的个数,如果小于0,表示读取失败!
如下为demo代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{char str[1024];int fd = open("log.txt",O_RDONLY);ssize_t s = read(fd,str,sizeof(str));if(s > 0){//文件中的内容不以\0结尾str[s] = 0;printf("%s",str);}return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ cat log.txt
abcd
[yyf@VM-24-5-centos 24.06.22]$ ./test
abcd
文件描述符fd
接下来我们想了解一下关于open的返回值,也就是一个一个的文件描述符
如下为demo代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{int fd1 = open("log1.txt",O_WRONLY | O_CREAT,0666);int fd2 = open("log2.txt",O_WRONLY | O_CREAT,0666);int fd3 = open("log3.txt",O_WRONLY | O_CREAT,0666);printf("%d\n",fd1);printf("%d\n",fd2);printf("%d\n",fd3);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
3
4
5
我们会发现,这个文件描述符是从3开始的
为什么文件描述符fd是从3开始的?
实际上,这是因为我们之前说的的标准输入、输出、错误,这三个默认打开的文件流在我们打开文件之前已经把文件描述符的0,1,2占用了
0:标准输入流(stdin)
1:标准输出流(stdout)
2:标准错误流(stderr)
文件描述符的本质
实际上,对于我们程序员来说对于这种从0开始连续的数字,应该会不知不觉的想起数组下标这一个东西,实际上它也确实是数组下标
C语言中的FILE结构体的本质
如上为计算机结构图
我们都知道,这个体系结构当中是不能跨层访问的,就比如当用户访问硬件时,首先得通过操作系统,然后操作系统再通过驱动来访问硬件
而操作系统不允许用户直接访问内核,所以提供了系统调用接口供用户使用
而在系统调用上层的库函数之类的,底层一定是对系统调用接口进行的封装
而系统调用接口对文件的访问都是基于文件描述符fd的,所以上层的库函数之类的一定在底层也是基于文件描述符fd才能对文件进行访问,因为操作系统只认文件描述符。但库函数对文件的访问操作返回的都是FILE*的一个指针,而FILE实际上也是被封装出来的一个类型,换句话来说,所谓的FILE结构体当中一定包含了文件描述符这个成员变量
所以,C语言对系统调用的封装不仅仅停留在库函数的封装,其实他对类型也进行了封装,FILE类型就是C语言把文件描述符封装起来的结果
当然,我们上面说过文件描述符fd中的0、1、2已经被标准输入、输出、错误占用,而我们又说FILE是一个C语言封装的结构体,它封装了文件描述符fd
所以我们可以对stdin和stdout和stderr,通过->的方式来看看文件描述符fd,在FILE结构体当中的_fileno就是文件描述符fd
如下为demo代码:
int main()
{printf("%d\n",stdin->_fileno);printf("%d\n",stdout->_fileno);printf("%d\n",stderr->_fileno);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
0
1
2
文件描述符的本质为什么是数组下标?哪个数组的下标?
我们之前说过,文件 = 内容 + 属性,所以一个文件在磁盘中的存储应该是如下这样的
我们上文中提到的,操作系统需要对内存文件进行管理,而所谓的管理也就是先描述后组织,而实际上所谓的描述,也就是操作系统为内存文件创建了一个结构体,它包含了文件的各个字段,这个结构体就是struct file,所谓的组织,也就是把这些结构体对象用链表连起来。所以操作系统对内存文件的管理应该如下图:
但我们说过文件的打开其实是进程通过执行代码的方式进行打开的,接下来的问题是进程怎么知道自己打开了哪些文件
所以进程中为了知道自己打开了哪些文件,task_struct中包含了一个指针,叫做struct files_struct* files,这个指针指向的内容中包含了一个数组,当然也有其他的字段,但现在我们只讨论这个数组字段
上图中数组旁边的数字是这个数组的下标,而这个数组的元素实际上是struct file*类型的,而struct file对象实际上就是内存文件管理所描述的对象,所以如下图
而当我们打开一个文件的时候实际上就是在内存文件管理这个链表中插入一个新的struct file结构体对象,并把files指向的数组中的某个下标的元素指向这个新创建的结构体对象,然后再把files指向的数组中这个文件所对应的下标返回给上层用户即可,而files指向的数组的下标也就是我们所说的文件描述符fd
使用文件操作系统调用为什么要传入文件描述符fd呢?
为什么当我们执行大多数系统调用,例如close,write,read时需要一个参数叫做文件描述符fd呢
就是因为当我们进程访问文件的时候,需要先找到文件,进程自己会拿着这个fd去自己的files指向的数组中找到文件所对应的索引,再通过这个索引找到文件的各种字段信息!
所以文件描述符的本质其实就是数组的下标
Linux实现一切皆文件的原理
而理解到这一步的时候,我们已经可以聊一聊关于一切皆文件这个话题了,对于这个话题我们虽然之前一直再用,但好像并没有好好理解过,当然,要理解这个问题我们首先要从硬件开始说起
对于一些硬件,比如键盘、显示器、磁盘....来说,他们的操作方法是不一样的,这是一个不证自明的结论!
我们以读写方法为例,键盘有自己的读方法,但键盘没有写方法,所以可以理解为键盘的写方法为空,而显示器有自己的写方法,但显示器没有读方法,所以显示器的读方法为空。而对于磁盘、网卡...也有自己的读写方法。也就是如下图这样
而操作系统对他们的管理,实际上就是创建一个链表,把struct file对象连起来,如下图
file当中的方法集(读写方法)为函数指针, 对于每一个file对象当中都有一个读写方法集,然后他们各自的方法集指向自己所对应的硬件的操作方法,如下图
而此时如果上层的read需要访问某个硬件的读方法,那么只需要调用这个file当中的这个read函数指针即可,此时虽然每个硬件的在最底层的读写操作都不一样,但我们可以用一个统一的视角来看待硬件
也就是如下伪代码:
read()
{return file->read();
}
此时我们不需要了解这个硬件到底是什么硬件,不需要了解它的读方法是什么,对file之上的软件层来说,关于硬件之间的差异就被屏蔽掉了,所以为什么Linux下一切皆文件的根本原因,是因为我们站在file之上的软件层来看待文件,而我们把file这一层软件层一般称之为VFS,全称virtual file system(虚拟文件系统)
实际上,这个file结构体中方法集的设计是用C语言实现的一种多态模式。
fd的分配规则与重定向原理
接下来,我们要聊的是关于文件描述符fd的分配规则
首先,我们之前说过0、1、2,这三个文件描述符默认是被打开的,那么如果我们把这三个中的其中一个给关掉,然后再重新打开一个文件会发生什么呢?
如下为demo代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main()
{close(0);//关掉stdinint fd = open("log.txt",O_WRONLY);printf("log.txt fd:%d\n",fd);return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
log.txt fd:0
我们会发现,我们关掉的0已经被新打开的文件占用了
当然,如果我们关掉了2这个文件描述符,那么新文件打开时就会把2号文件描述符占用掉
文件描述符fd的默认分配规则是:最小的没有被使用的数组下标,会分配给最新打开的文件
输出重定向
接下来我们试试关掉1这个文件描述符,直接看运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ cat log.txt
log.txt fd:1
我们会发现,当我们运行程序时,打印的这个内容没有写入到显示器上,写到了log.txt当中,并且log.txt的fd确实是1,本来printf是向显示器进行写入的,经过一系列操作最终写入到了指定文件当中。
我们仔细看看上面这段话,我们会发现这实际上就是我们bash中经常用到的输出重定向
实际上,printf在打印的时候只认stdout这个文件,而我们之前说过stdout实际上是封装了fd,stdout中有一个成员_fileno,它的值为1
换句话来说,printf在打印的时候会一直往fd为1的文件中打印
而系统层面关掉了数组下标为1的文件,创建了一个新文件,新文件占用了数组下标为1的位置
而printf往1写入时就是往这个新文件当中写入
而当我们在系统层面关掉以及打开文件时,printf函数并不知道这一系列操作,它只是会向stdout中存储的fd,也就是1当中写入
所以所谓的重定向的本质是通过更改文件描述符fd对应得数组下标中的内容实现的
输出重定向
上述我们说了输出重定向,而实际上输入重定向的原理都大差不差
首先scanf只认stdin,而stdin中存储的_flieno为0,所以我们只要在系统层面把0号文件描述符关闭,然后再打开一个新的文件把0号的位置占用掉,之后scanf在读取的时候就会默认读取这个新的文件
接下来我们要聊的是实现重定向的第二个方法:
首先,上层的printf只认stdout中的_fileno文件描述符,也就是1,所以在底层我们不需要把1号文件描述符关闭,我们只需要把文件描述符数组中fd所对应的内容拷贝到1当中即可,如图:
而此时,printf再向1写入时,就是写入到了log.txt当中
但我们用户不能对这个内核数据进行修改,所以操作系统提供了系统调用dup2
NAMEdup, dup2, dup3 - duplicate a file descriptorSYNOPSIS#include <unistd.h>int dup(int oldfd);int dup2(int oldfd, int newfd);
- dup2的参数是两个文件描述符fd
- dup2的功能是把文件描述符表中数组下标为newfd的内容变为数组下标为oldfd的内容
如下为使用dup2实现输出重定向的demo代码:
int main()
{int fd = open("log.txt",O_WRONLY|O_TRUNC);//0、1、2被占用,fd为3dup2(fd,1);printf("Hello,World\n");return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.22]$ ./test
[yyf@VM-24-5-centos 24.06.22]$ cat log.txt
Hello,World
理解用户级缓冲区
接下来我们要聊的话题是缓冲区的问题
首先,缓冲区就是一块内存区域,而至于这个缓冲区具体在哪,我们得先分场景讨论
我们今天聊的是C语言当中的缓冲区,即用户级缓冲区
从日常生活角度理解为什么需要用户级缓冲区
通俗理解:
实际上,这个缓冲区在我们现实生活中是有与之对应的事物的,例如快递
假如你不依赖快递,那么当你需要把一个物品交给一个与你相隔很远的朋友那时,你需要坐车坐个几天几夜才能到达他那里,最终把物品交给他。
而当你使用快递时,你只需要到达附近的快递站点然后把物品交给快递站点,然后你的工作就已经做完了,换句话来说当你把物品交给快递站点时,你已经认为自己的东西会到你朋友的手里。
而关于为什么要有缓冲区,我们可以换一个问法,为什么需要有快递呢?
因为快递提高了使用者的效率,以前没有快递的时候,你必须千里迢迢的跑到你朋友那,有了快递以后你只需要到附近最近的快递站点即可
需要有缓冲区的第一个原因:缓冲区提高了使用者的效率
那么思考一下,当你把自己需要寄送的东西给快递站点以后,难道快递站点的工作人员立马就拿着你的东西赶到你朋友那交到它手中吗?
显然不是,快递站点一般会尽量等到较多的要寄送的物品以后,快递员才会开始寄送,如果真的是拿到快递立马就开始送的话,那么快递站点收到100件物品就需要寄送100趟,而等到100件物品再开始寄送时,只需要寄送一趟即可,这样就提高了整体效率
所以需要有缓冲区的第二个原因:缓冲区可以聚集数据之后一起拷贝,减少了拷贝次数,提高了整体效率。
总的来说为什么需要缓冲区,因为缓冲区提高了效率
而缓冲区聚集数据之后一起拷贝的过程我们称之为刷新
而我们前面说过缓冲区本质上就是一块内存空间,而缓冲区还提高了效率,这两句话合在一起,缓冲区的设计策略是一种以空间换时间的策略
从技术角度理解为什么需要用户级缓冲区
我前面说的缓冲区与快递站点类似,是从日常生活中开始切入,接下来让我们从技术角度理解一下什么是缓冲区
首先,先给出一个结论,我们一直说的缓冲区并不是操作系统内核中的缓冲区,而是语言层面的缓冲区。当然,内核中也有缓冲区,但现在不是重点。
语言层面的缓冲区,我们以C语言为例,C语言其实自带了缓冲区,如图
实际上,语言层的缓冲区也就是C语言自己定义的一个数组
我们之前说过C语言的文件操作接口实际上是封装的系统调用,但还需要注意的一个点是,使用系统调用是有成本的,成本体现在空间和时间
也正是由于系统调用的成本,所以语言设计者就要考虑尽量少的使用系统调用,宁愿一次拷贝大量数据,也不要把大量数据分批拷贝,为了实现一次拷贝大量的数据,所以C语言在语言层定义了一个缓冲区buffer。
数据如何从用户级缓冲区到内核?
当你使用C接口进行写文件时,你可能以为是直接调用系统调用接口写入到内核的,但实际上,C的文件接口是把数据先写入到这个buffer缓冲区,然后根据一定的刷新策略,进行刷新缓冲区的。
所以调用C接口进行写入时,顺序应该如下图
而当数据已经使用系统调用接口刷新到内核后,就与语言没啥关系了!可以理解为只要使用了系统调用写入后,那么数据就直接被写到磁盘上了
所以,当C语言这样设计以后,如果用户使用fwrite,fwrite不会直接去调用write系统调用,而是等到满足某种刷新策略以后再调用write,当不满足刷新条件时,fwrite把数据写到语言层的缓冲区后直接返回即可,而这一系列操作就减少了调用系统调用的次数,由于调用系统调用是有成本的,所以减少了调用系统调用次数就意味着提高了整体效率,并且由于C语言接口不需要把数据放到内核缓冲区,直接把数据放到语言缓冲区就意味着写入完成,这样子就提高了C接口的使用效率
用户级缓冲区的刷新策略
接下来,我们要聊的是关于缓冲区刷新的问题,也就是C语言提供的缓冲区默认什么时候刷新呢?
首先,第一种:无刷新(无缓冲)
这一种刷新策略较为简单,也就是说当我们调用语言的接口时直接强制要求使用系统调用接口,也就没有了缓冲区的概念
第二种:行刷新(行缓冲)
这一种刷新策略实际上我们也用过,也就是当你写入的文件是显示器时,一般采取的就是这个刷新策略,它的策略是当遇到\n时,就会自动刷新缓冲区
第三种:全刷新(全缓冲)
这一种刷新策略是我们最常用的,一般的普通文件都是采取这个刷新策略,它的策略是当缓冲区写满才刷新
当然,上面所说的刷新策略都是默认的,也就是我们不强制刷新时的刷新策略
除此之外,当进程退出的时候,缓冲区也要进行刷新
用户级缓冲区在哪?
实际上,这个缓冲区是被维护在C语言中的FILE结构体当中的,也就是说一个结构体一个缓冲区
接下来,光有理论还不够,我们还需要证明一下这个语言层缓冲区的存在
如下代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>int main()
{//使用系统调用写入const char* s1 = "Hello,Write\n";write(1,s1,strlen(s1));//使用C库接口写入const char* s2 = "Hello,fprintf\n";fprintf(stdout,"%s",s2);const char* s3 = "Hello,fwrite\n";fwrite(s3,strlen(s3),1,stdout);fork();return 0;
}
运行结果:
[yyf@VM-24-5-centos 24.06.28]$ ./test
Hello,Write
Hello,fprintf
Hello,fwrite
[yyf@VM-24-5-centos 24.06.28]$ ./test > log.txt
[yyf@VM-24-5-centos 24.06.28]$ cat log.txt
Hello,Write
Hello,fprintf
Hello,fwrite
Hello,fprintf
Hello,fwrite
关于这一段代码的运行结果的疑问,我猜测大概率分为两条
1、为什么进程输出内容重定向到一个新的普通文件时是5条
2、为什么进程输出内容到显示器上时是3条
首先,先回答第二点,由于显示器文件的缓冲区刷新策略是行缓冲,而我进行写入的三个字符串都带有\n,所以是三条
接下来回答第一点,当进程把输出内容重定向到一个新的普通文件时,这个普通文件的缓冲区策略是全缓冲,也就是说当进程退出时或者缓冲区满时才会刷新,而这么一点数据显然不会造成缓冲区满了,所以只能是进程退出时才把缓冲区进行刷新,但在刷新之前我们使用fork接口创建了一个子进程,子进程继承了父进程的缓冲区内容,而当子进程退出时,它需要把自己的缓冲区内容刷新,实际上所谓的刷新也就是把自己的缓冲区进行清空,而这也是对数据的一种修改,所以父子进程需要发生写时拷贝,发生写时拷贝以后子进程的缓冲区刷新不会影响父进程,所以后面父进程还会把自己的缓冲区进行刷新,这也就导致了f系列文件接口被重复写入了两次
那么为什么write系统调用接口没有打印两次呢?
实际上,也正是因为这一点,恰恰证明了在语言层有一个缓冲区,这一段缓冲区与系统无关,因为这一段缓冲区如果被内置到内核当中,那么write接口也应该打印两次