当前位置: 首页 > ai >正文

【文件IO】认识文件描述符和内核缓冲区

前言

hello各位佬,本篇文章我们将聚焦于文件IO的知识,在介绍这部分内容之前,我想先和大家回顾一下我们在C语言部分学习的操作文件的知识,以这个知识为基础我将引出文件描述符的概念,基于文件描述符的概念我们将来学习文件重定向,最后我们来学习文件缓冲区的概念。
那么废话不多说,我们直接开始,如果觉得本篇文章对您有所帮助的话希望留下点赞、评论加关注,您的支持就是我创作的最大动力

文件打开的过程

我们在前面的学习当中了解到一个文件等于文件内容+文件属性,当我们想要对一个文件进行相关操作的时候,我们需要把文件打开,而我们打开一个文件就是把文件内容加载到内存;如果一个文件没有被打开,那么它就在磁盘上。

由于我们对文件的操作都需要把文件打开,即:将文件内容加载到内存,那么一个文件是如何被加载到内存中的呢?

答案是通过操作系统将文件内容加载到内存,因为操作系统是软硬件资源的管理者

那么操作系统是如何将一个文件加载到内存的呢?

答案是通过进程将文件加载到内存,即:操作系统通过bash启动进程,进程通过操作系统的系统调用函数,将文件内容从磁盘加载进入内存

那么为什么操作系统可以通过系统调用将硬件资源加载进入内存呢?

因为操作系统是软硬件资源的管理者,对下管理硬件设备,对上暴露自己的系统调用接口,方便用户完成操作

到这里我们就可以推测,操作系统里面一定同时存在大量被打开的文件,那么操作系统是如何管理这些被打开的文件的呢?

答案是先描述再组织,因此操作系统内部一定存在一种结构体,这个结构体里面一定包含了文件的各种属性,可以用来描述被打开的文件

到这里我们就深入的理解了文件等于文件内容+文件属性的概念

回顾C语言对文件的操作

从上面的讨论当中我们知道,当我们想要对一个文件进行操作的时候,我们就必须把这个文件打开,然后再进行读写操作,当我们操作结束的时候,为了防止内存泄漏,我们就必须把这个文件关闭,而C语言当中我们如果想要对一个文件进行操作都需要指定操作权限,常见到的文件操作权限如下所示:

w:写权限,如果当前文件不存在则新建文件,如果当前文件存在则对该文件做清空后再写入
r:读权限,如果当前文件不存在则出错,如果当前文件存在则对该文件进行读操作
a:追加,如果当前文件不存在则新建,如果当前文件存在,则对该文件进行追加写入
w+:读写权限,如果该文件不存在则新建文件,如果该文件存在,则对该文件进行读写操作
r+:为了读和写,打开一个文件,如果该文件不存在则出错
a+:打开一个文件进行读和写,如果该文件不存在,则新建文件

打开文件

C语言当中我们想要打开一个文件有下面几个接口:

这里是引用
因此我们可以用上面的接口打开一个文件,我们以fopen函数为例,来看下面的代码:

 1 #include<stdio.h>2 #include<stdlib.h>3 4 int main()5 {6   FILE*fp=fopen("main.txt","w");7   if(!fp)8   {9     perror("fopen error\n");10     exit(1);11   }12 13   while(1);                                                                                                                                                                                                   14   fclose(fp);15   return 0;16 }

运行结果如下所示:
在这里插入图片描述
从上面的代码我们可以看到,当我们需要打开一个文件,如果我们不指定路径,那么就默认在当前路径下新建文件,这个进程是如何知道当前路径是什么呢?

答案是通过cwd找到当前进程的工作路径,这就是一个进程要有cwd的原因之一

写文件

C语言中为我们提供了如下函数,让我们对文件进行写入:

这里是引用
我们以fwrite函数为例:

  1 #include<stdio.h>2 #include<stdlib.h>3 #include<string.h>                                                                                                                                                                                            4 5 int main()6 {7   FILE*fp=fopen("main.txt","w");8   if(!fp)9   {10     perror("fopen error\n");11     exit(1);12   }13 14   const char*str="hello world\n";15   fwrite(str,strlen(str),1,fp);16 17   fclose(fp);18 19   return 0;20 }

运行结果如下所示:
在这里插入图片描述

读文件

C语言中,我们读文件的接口函数是fopen函数,而在Linux当中,cat函数也可以用来读一个文件当中的内容,所以,我们可以根据fopen函数自己实现一个cat指令,代码如下所示:

  1 #include<stdio.h>2 #include<stdlib.h>3 4 int main(int argc,char*argv[])5 {6   if(argc!=2)7   {8     printf("指令输入错误,请重新输入\n");9     exit(1);10   }11 12   FILE*fp=fopen(argv[1],"r"); //打开当前文件,以读的形式13   if(!fp)14   {15     perror("打开文件失败\n");16     exit(1);17   }18 19   char buffer[1024]; //将读取出来的文件信息放进该数组当中20   while(1)21   {22     size_t n=fread(buffer,1,sizeof(buffer),fp); //按一个字符为单位向后读取文件中的内容23     if(n>0)24     {25       printf("%s",buffer);26       buffer[n]=0;                                                                                                                                                                                            27     }28     else{29       break;30     }31   }32 33   fclose(fp);34 35   return 0;36 }

如上就是C语言当中为我们提供的读写文件的函数使用方法,但是到目前为止,我们并不知道这些函数底层是如何运行的,接下来我将从系统文件I/O的角度来深入讨论一个被打开的文件在系统当中的存在形式

stdin/stdout/stderr

我们在C语言的学习中知道,stdin是标准输入流,一般指键盘,C语言是从这个标准输入流拿到数据,将其写入文件当中的;stdout是标准输出流,一般指显示器,C语言从标准输出流中读取数据并将其打印到显示器;stderr是标准错误流,保存运行错误的数据。

如果我们想要将信息输出到显示器,我们有哪些方法呢?C语言为我们提供了很多函数接口,如printf、fputs、fwrite、fprintf等,它们的使用方法如下代码所示:

	1 #include<stdio.h>2 #include<stdlib.h>3 #include<string.h>4 5 int main()                                                                                                                                                                                                  6 {7   const char*str1="hello printf\n";8   printf(str1);9 10   const char*str2="hello printf\n";11   fprintf(stdout,str2);12 13   const char*str3="hello printf\n";14   fputs(str3,stdout);15 16   const char*str4="hello printf\n";17   fwrite(str4,strlen(str4),1,stdout);18 19   return 0;20 }

运行结果如下所示:

这里是引用

我们可以看到,这些函数可以帮助我们完成向显示器打印字符,那么如果我们输入的是123456呢,此时我们向显示器打印的是字符还是整数呢?

答案显而易见,我们向显示器打印的都是字符,因此可以这么说,显示器是一个字符设备;与此同时,当我们通过键盘输入的12345也都是字符,因此键盘也是一个字符设备

但是在我们以往编写代码的过程当中,我们从键盘输入的数字,经scanf函数后在内存当中是以整数的形式存在,这是为什么呢?

实际上,scanf函数会将键盘上输入的字符串转换成整数存入内存,因此scanf称为格式化输入函数;同理,我们在内存中储存的整数经printf函数打印到显示器当中时转换成了字符串,因此printf函数称为格式化输出函数;而将字符转化成整数的方式是通过assci码表进行转换的

这里是引用

如上图我们可以看到,fprintf函数的第一个参数是FILE类型的,也就是说,我们可以向任意文件输出信息,然而上述代码我们可以向显示器输出信息,因此我们可以得出结论:

stdin stdout stderr实际上是文件,C会默认打开这三个输入输出流,我们向显示器写入实际上是向stdout文件写入,因此键盘显示器我们可以叫作文本文件;而不用做格式化工作的叫二进制文件,一个文件是文本文件还是二进制文件是由文件本身的属性决定,文件属性决定了调用的接口

那么为什么要打开这三个输入输出流呢?

因为数据是需要通过进程写入cpu的,而操作系统需要拿到输入输出结果,因此需要进程默认打开对应的文件

文件打开的方式

stdin stdout stderr这三个输入输出流是需要被打开的,那么它们是如何被打开的呢?答案是通过系统调用打开;那么为什么要通过系统调用打开呢?因为操作系统是软硬件资源的管理者,对外暴露自己的系统调用接口。

但是从上面的代码中我们知道,我们并没有用什么系统调用啊,我们用的是C语言给我们提供的函数啊,那系统调用在哪里呢?

答:系统调用被封装进了C语言提供的函数当中,即:C语言的函数封装了系统调用的接口

那么为什么要封装系统调用的接口呢?

答:因为系统调用使用起来十分麻烦,会给使用者带来很多的学习成本;为了代码的跨平台性和可移植性

显而易见,几乎所有的操作系统的教材都会告诉你是因为上述原因,但是它们都没有从底层的角度告诉你为什么,所以,我将通过系统调用函数open的使用,带大家理解上面的原因

首先我们来看系统调用函数open的使用方法:
在这里插入图片描述
首先我们可以看到,open函数的第一个参数是文件名,表示我们打开的文件,第二个参数是flags标志位,这里的标志位表示打开文件的方法,常见的标志位如下所示:

O_APPEND:以追加的方式打开文件
O_CREAT:若文件不存在,则创建它
O_TRUNC:创建文件时将原本文件中的内容覆盖
O_RDONLY:只读的方式打开文件
O_WRONLY:只写的方式打开文件
O_RDWR:以读写的方式打开文件

我们可以看到,常见的六个标志位都是以宏的形式进行传参,但是对于普通的传参方式来说,我们一次只能传递一个参数,如果我们想一次传递多个参数,我们应该怎么办呢?

我们可以以位图的形式传参,我们来看下面的例子:

  1 #include<stdio.h>2 3 #define BITMAP1 14 #define BITMAP2 25 #define BITMAP3 46 #define BITMAP4 87 #define BITMAP5 168 9 void ShowBitmap(int flags)10 {11   if(flags&BITMAP1)12   {                                                                                                                                                                                                           13     printf("BITMAP1\n");14   }15   if(flags&BITMAP2)16   {17     printf("BITMAP2\n");18   }19   if(flags&BITMAP3)20   {21     printf("BITMAP3\n");22   }23   if(flags&BITMAP4)24   {25     printf("BITMAP4\n");26   }27   if(flags&BITMAP5)28   {29     printf("BITMAP5\n");30   }31 }32 33 int main()34 {35   ShowBitmap(BITMAP1);36   printf("--------------------\n");37   ShowBitmap(BITMAP1|BITMAP2);38   printf("--------------------\n");39   ShowBitmap(BITMAP1|BITMAP2|BITMAP3);40   printf("--------------------\n");41   ShowBitmap(BITMAP1|BITMAP2|BITMAP3|BITMAP4);42   printf("--------------------\n");43   ShowBitmap(BITMAP1|BITMAP2|BITMAP3|BITMAP4|BITMAP5);44   printf("--------------------\n");45   return 0;46 }

运行结果如下所示:

这里是引用
上述代码就是位图传参的全部过程,因此我们可以通过位图传参一次传递多个不同的参数

基于上面的认识,我们来使用open系统调用函数,代码如下所示:

  1 #include<stdio.h>2 #include <sys/types.h>3 #include <sys/stat.h>4 #include <fcntl.h>5 6 int main()7 {  8   //以写的方式打开文件,若文件不存在则创建并清空文件当中的内容                                                                                                                                                9   int fd=open("text.txt",O_WRONLY | O_CREAT | O_TRUNC);10   if(fd<0)                     11   {                              12       perror("fopen error!");  13   }                            14                                 15   close(fd);                    16                                 17   return 0;                     18 }            

如上就是我们用系统调用打开一个文件的方法,运行结果如下所示:

这里是引用

我们可以看到,当前目录下多了一个文件text.txt,但是它的权限是乱码的形式,这是为什么呢?

实际上,用open打开一个文件,如果文件不存在,我们创建文件的时候需要传递三个参数,即:文件的权限,所以我们需要指定打开文件的权限,我们修改上述代码:

  1 #include<stdio.h>2 #include <sys/types.h>3 #include <sys/stat.h>4 #include <fcntl.h>5 6 int main()7 {  8   //以写的方式打开文件,若文件不存在则创建并清空文件当中的内容                                                                                                                                                9   int fd=open("text.txt",O_WRONLY | O_CREAT | O_TRUNC,0666); //设置权限为可读可写10   if(fd<0)                     11   {                              12       perror("fopen error!");  13   }                            14                                 15   close(fd);                    16                                 17   return 0;                     18 }            

注:这里的权限传递的是八进制数字,将0666这个八进制数字转化成二进制数字为110110110,从左往右分别代表拥有者所属组以及其他的权限,我们在这里将它们的权限全部设置成可读可写的

运行结果如下所示:

这里是引用

我们可以看到,text.txt这个文件的预期权限应该是-rw-rw-rw-,但是实际运行结果与我们于其并不相符合,这是为什么呢?

这是因为操作系统默认设置了一个权限掩码umask,对于普通用户来说,权限掩码默认为0002,实际权限需要与权限掩码做于运算才能得到,即:
最终权限=起始权限&(~umask)

根据上面的代码,我们再来回顾上面我们所讲的为什么要封装系统调用接口:

此时我们就可以看到,系统调用使用起来是非常麻烦的,不仅需要用户了解操作系统权限掩码的概念,还要学会位图传参,因此我们需要对系统调用进行封装,所以诞生了C语言/C++/Python等编程语言,这些语言的库函数毫无疑问都封装了系统调用的各个接口,但是,市面上会有许多不同的操作系统,这些编程语言将这些系统调用都封装了吗?答案是肯定的,这些编程语言都需要兼容市面上所有的操作系统,正是因为它们的库兼容了所有的操作系统,所以才能让同一份代码在不同的环境下运行,由此保证了代码的跨平台性和可移植性

到这里我们就能理解了,fwrite底层是封装了write、fopen底层封装了open、fclose底层封装了close这些系统调用接口,那么文件打开的权限是如何封装的呢?

r权限=O_RDONLY
w权限=O_WRONLY | O_APPEND
a权限=O_WRONLY | O_CREAT | O_TRUNC
注:O_RDONLY、 O_WRONLY、O_RDWR这三个选项一次有且仅有一个,不能同时指定多个

FILE文件结构体

我们仔细观察stdin stdout stderr的返回值类型,我们可以看到,这三个文件的返回值类型都是FILE*

这里是引用
那么FILE*是什么呢?我们带着这个问题回到上面我们所讲的文件打开的过程,我们得出了一个结论:

操作系统为了管理这些大量被打开的文件一定定义了一个结构体,这个结构体当中为我们定义了文件的各种信息,用来描述这个被打开的文件

所以我们可以知道,FILE是一个结构体指针,指向被打开的文件,而FILE这个结构体里面包含了一个文件的各种信息,而文件是进程通过系统调用打开的,那么操作系统是如何知道这个文件是否被成功打开呢?答案是通过FILE这个结构体来管理被打开的文件。因此我们可以猜测,进程的PCB结构体当中一定存在一个结构体指针,指向这些被打开的文件,从而将进程与文件关联起来,但是操作系统内部同时存在大量被打开的文件,一个进程操作的文件也有多个,因此我们不能让一个进程指向单个文件,而是需要让一个进程指向多个被打开的文件,所以操作系统内核为我们提供了一个指针数组,这个指针数组保存了当前进程需要的FILE文件指针,这些FILE*指针指向被打开的文件。

那么进程是如何拿到任意一个被打开的文件的呢?

答案是通过文件描述符fd,因此我们可以猜测,FILE结构体当中一定定义了一个文件描述符,操作系统是通过文件描述符来操作一个文件的

文件描述符fd

我们来看open系统调用函数的返回值:

这里是引用
我们可以看到,这个返回值是一个整数

那么我们不禁思考,这个整数有什么意义呢?

我们在上面的讨论中知道,操作系统是通过文件描述符来操作文件的,而open函数是用来打开一个文件的,所以我们就可以知道,open函数的返回值是文件描述符

那么文件描述符是什么呢?我们知道它是一个整数,可以用来表示一个文件的,那么为什么它可以表示一个文件呢?

我们前面的讨论中发现,进程和文件结构体之间存在一个指针数组,这个数组里面保存了各个文件结构体的指针,指向各个文件,这样设计的好处就可以将对文件的操作转化成对数组的操作,而对数组的操作就是对数组下标的操作,而文件描述符可以表示一个文件,因此我们可以得出一个结论:文件描述符实际上就是文件描述符表的下标

所以我们可以知道,FILE结构体里面必定包含了文件描述符fd,那么我们写一段代码,来看一下文件描述符:

  1 #include<stdio.h>2 #include <sys/types.h>3 #include <sys/stat.h>4 #include <fcntl.h>5 6 7 int main()8 {9   int fd1=open("test1.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);10   int fd2=open("test2.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);11   int fd3=open("test3.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);12 13   printf("test1.txt:%d\n",fd1);14   printf("test2.txt:%d\n",fd2);15   printf("test3.txt:%d\n",fd3);                                                                                                                                                                               16   return 0;17 }

运行结果如下所示:

这里是引用
我们可以看到,open函数的返回值从3开始,分别是3 4 5

由于open函数的返回值是文件描述符,文件描述符是数组下标,一般而言,数组下标都是从0开始,但是为什么是从3开始向后递增的呢?

回答这个问题之前,我想带着大家回顾一下stdin stdout stderr这三个输入输出流,我们在前面的讨论当中知道,这三个输入输出流实际上也是文件,它们被默认打开,由于文件是进程通过系统调用打开的,因此这三个输入输出流也是被进程默认打开的,那么是哪一个进程打开的呢?答案是父进程bash打开的,而我们在命令行运行的程序实际上都是bash的子进程,子进程会继承父进程的代码和数据,所以子进程是通过bash默认打开这三个输入输出流的,而操作系统为了管理这些被打开的文件所以在进程结构体内部存在一个结构体指针指向文件结构体,这个文件结构体里面包含了一个文件描述符表,这个文件描述符表指向这些被打开的文件。
所以stdin stdout stderr这三个输入输出流默认占据这个文件描述符表的前三位,也就是0、1、2,因此我们自己打开的文件都是从3开始编码的

文件描述符的分配规则

我们知道,stdin stdout stderr这三个输入输出流默认占据文件描述符表的前三位

0号位:表示stdin
1号位:表示stdout
2号位:表示stderr

那么文件描述符的分配规则是什么呢?我们来看下面的代码:

    1 #include<stdio.h>2 #include <sys/types.h>3 #include <sys/stat.h>4 #include <fcntl.h>5 6 7 int main()8 {9   close(0);10   int fd1=open("test1.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);11   int fd2=open("test2.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);                                                                                                                                              12   int fd3=open("test3.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);13 14   printf("test1.txt:%d\n",fd1);15   printf("test2.txt:%d\n",fd2);16   printf("test3.txt:%d\n",fd3);17 18   close(fd1);19   close(fd2);20   close(fd3);21   return 0;22 }~

当我们把stdin关闭后,我们看一下运行结果:

这里是引用

我们可以看见,当我们把stdin关闭后,再打开其他文件,后续打开的文件就会占据这个被关闭文件的下标,也就是说,文件描述符fd的分配规则是:

找到当前没有被使用的最小的一个下标,作为新的文件描述符

重定向

前面的一段代码,我们将标准输入流关闭,然后我们发现后续打开的文件会占据这个被关闭文件的下标,由于stdin是标准输入流,我们将它关闭后,scanf函数会从哪里拿数据呢?我们来看下面的代码:

    1 #include <stdio.h>2 #include <sys/types.h>3 #include <sys/stat.h>4 #include <fcntl.h>5 6 7 int main()8 {9   close(0);10 11   int fd=open("test1.txt",O_RDONLY);                                                                                                                                                                        12   int a,b,c;13   scanf("%d %d %d",&a,&b,&c);14   printf("%d %d %d\n",a,b,c);15 16   close(fd);17 18   return 0;19 }

注:test1.txt文件里面包含了10 20 30这三个整数

我们来看运行结果:

这里是引用
我们可以看到,我们并没有从键盘上面输入数据,但是scanf竟然可以打出test1.txt文件当中的信息,这是为什么呢?我们接下来分析

我们把标准输入流关闭后打开了test1.txt这个文件,那么这个文件默认占据了stdin的文件描述符的下标,但是操作系统并不知道这个文件发生了变化,操作系统只能根据文件描述符找到0号位置然后将这个位置的信息作为标准输入输入到显示器当中,我们把这个现象称之为重定向

那么为什么会发生重定向呢?

因为操作系统作为上层,不关心底层如何实现,它只会根据fd去找对应的文件,当我们把相应的文件关闭后,操作系统还会根据文件描述符去找对应的文件,而子进程会继承父进程的代码和数据,所以父子进程会指向同一个文件,区分两个进程的方式是通过引用计数

小结

由此我们便了解了一个文件在操作系统中被打开的过程,后面一篇文章我将根据重定向原理编写一个重定向代码,然后带着大家理解一切皆文件以及缓冲区的概念。

http://www.xdnf.cn/news/15793.html

相关文章:

  • SSH开启Socks5服务
  • C++ STL容器
  • 金融大前端中的 AI 应用:智能投资顾问与风险评估
  • 【Nature Communications】GaN外延层中位错辅助的电子和空穴输运
  • 0401聚类-机器学习-人工智能
  • nvm、npm、pnpm、cnpm、yarn
  • 《深入C++多态机制:从虚函数表到运行时类型识别》​
  • 数据并表技术全面指南:从基础JOIN到分布式数据融合
  • Spring Boot 自动装配用法
  • Materials Studio学习笔记(二十九)——尿素的几何优化
  • 树同构(Tree Isomorphism)
  • [特殊字符] 小程序 vs 智能体:下一代应用开发,谁主沉浮?
  • 【Java项目安全基石】登录认证实战:Session/Token/JWT用户校验机制深度解析
  • 基于自定义数据集微调SigLIP2-分类任务
  • PDF 编辑器:多文件合并 拆分 旋转 顺序随便调 加水印 密码锁 页码背景
  • [学习] 深入理解傅里叶变换:从时域到频域的桥梁
  • vscode环境下c++的常用快捷键和插件
  • 嵌入式通信DQ单总线协议及UART(一)
  • Linux练习二
  • 鸿蒙蓝牙通信
  • [AI风堇]基于ChatGPT3.5+科大讯飞录音转文字API+GPT-SOVITS的模拟情感实时语音对话项目
  • 字节跳动开源Seed-X 7B多语言翻译模型:28语种全覆盖,性能超越GPT-4、Gemini-2.5与Claude-3.5
  • 关于Vuex
  • GeoPandas 城市规划:Python 空间数据初学者指南
  • 零基础 “入坑” Java--- 十二、抽象类和接口
  • ndexedDB 与 LocalStorage:全面对比分析
  • aosp15实现SurfaceFlinger的dump输出带上Layer详细信息踩坑笔记
  • EP01:【Python 第一弹】基础入门知识
  • Vue rem回顾
  • 文档表格标题跑到表格下方,或标题跟表格空隔太大如何处理