文件操作的底层原理(文件描述符与缓冲区)

举报
卖寂寞的小男孩 发表于 2022/10/16 09:15:07 2022/10/16
【摘要】 本文主要介绍文件描述符与缓冲区,帮助更好理解Linux的文件系统

@[toc]

零.前言

在C语言和C++中都存在文件操作,通常是以读或者写的方式打开文件,然后进行读写,最后关闭文件。但其实文件操作的底层并没有这样简单。
文件操作的底层原理分为两部分,分别某一进程找到它打开的文件,某一进程对该文件进行操作,要理解这两部分,就需要理解文件描述符和缓冲区。

一、C/C++语言的文件操作回顾

1.C语言文件操作

#include<stdio.h>    
#include<stdlib.h>    
int main()    
{    
  FILE* fp1=fopen("./log.txt","w");    
  if(fp1==NULL)    
  {    
    perror("fopen");    
    return -1;    
  }    
  int cnt=10;    
  while(cnt--)    
  {    
    const char* msg="hello file!\n";    
    fputs(msg,fp1);//写操作    
  }    
  fclose(fp1);    
  FILE* fp2=fopen("./log.txt","r");    
  char buffer[64];    
  while(fgets(buffer,sizeof(buffer),fp2))    
  {    
    printf("%s\n",buffer);//读操作    
  }    
  if(feof(fp2))//判断是否正常退出    
  {    
        printf("fgets quit normal!\n");    
  }    
  else     
  {    
    printf("fgets quit not normal");                                                                                                                     
  }                                       
  fclose(fp2);    
  return 0;
}    

2.C++文件操作

#include<iostream>    
#include<fstream>    
#include<string>    
using namespace std;    
int main()    
{    
   ofstream out("./log.txt",std::ios::out|std::ios::binary);    
   if(!out.is_open())    
   {    
     std::cerr<<"open error"<<std::endl;    
     return 1;    
   }    
   string msg="hello world!\n";    
   int cnt=10;    
   while(cnt--)    
   {    
     out.write(msg.c_str(),msg.size());    
   }    
   out.close();                                                                                                                                          
}    

我们发现两种语言处理的方式都是一样的,以读或者写的操作打开文件,对文件进行操作之后关闭文件。

二、三种输入输出流

1.读写的本质

当进行文件读写操作时,其本质上是向硬件上进行读写。当C语言程序运行时,会默认打开三个标准输入输出流:分别是标准输入,标准输出,标准错误。其中标准输入是键盘,标准输出和标准错误都是显示器,它们在C语言中是以文件的形式而存在(stdin,stdout,stderror),在C++中以对象的形式存在(cin,cout,cerror):
在这里插入图片描述
当向标准输出文件中写入内容时,其实就是将内容打印在显示器上。

  const char* msg="I am the king of Asgard\n";      
  fputs(msg,stdout);    

此时运行程序,msg的内容被显示到显示器上。
在这里插入图片描述

2.stdout和stderr区别

stdout和stderr都代表显示器,两者是有区别的。标准输出stdout是可以进行输出重定向的,但是stderr是不能进行输出重定向的。
依然使用上文的代码举例:

./mytest1>log.txt

此时再打开log.txt就会发现重定向完成:
在这里插入图片描述
我们使用的是输入重定向,如果不希望覆盖的话还可以使用追加重定向。
但是将fputs中的参数stdout改为stderr后,就无法完成重定向。

三、系统调用接口

无论在键盘还是显示器还是磁盘,当我们在语言层面进行读写操作的时候,本质上都是在访问硬件。而OS又是硬件的管理者,因此在语言上对文件进行操作都需要贯穿操作系统。再由操作系统来进行对硬件的读写。
不同的语言实际上使用的是操作系统读写文件的同一套接口。语言中文件操作的函数都是对这些接口的封装。

1.open和close的概念

在系统调用接口中的打开文件和关闭文件的函数就是open和close。
我们可以通过man手册来查询open和close的使用,由于是系统调用接口,所以使用man2来进行查询:

man 2 open

open:
在这里插入图片描述
close:
在这里插入图片描述
在open中

返回值是-1或者文件描述符。
pathname指的是文件名。
flag指的是文件的打开方式。它的值可以是
O_RDONLY:只读模式
O_WRONLY:只写模式
O_RDWR:可读可写
O_CREAT:创建
mode指的是文件的权限信息。
在close中
当打开成功则返回1。打开失败则返回0。
传入的参数fd是文件描述符。

2.open和close的使用

  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);
  if(fd<0)
  {
     printf("open error!\n");
  }
  else
  {
    close(fd);
  }

此时以O_WRONLY|O_CREAT的方式创建了一个文件log.txt,表示的是以写的方式打开,如果文件不存在则创建它。
权限为0644,转换为二进制表示为110 100 100。
我们可以得知,当C语言使用fopen来对open进行封装时,没有让我们自己规定权限,说明在封装的过程中权限已经被规定了。
此时我们可以观察到log.txt的权限:
在这里插入图片描述
最后通过close关闭文件。

2.系统调用接口的参数和返回值

文件名没有什么可以介绍的,下面主要来介绍标志位:
通过man手册查询可知,flags的类型为int类型,而显然O_RDONLY,O_WRONLY等都是宏,因此,标志位是由int型所定义的宏。
在代码中使用或操作符O_WRONLY|O_CREAT来满足实现两者中的一种操作。因此我们可以大概可以猜到这些宏是怎么定义的,即:这些宏都是只有一个比特位为1的数据,并且不重复。
我们可以通过查询这些宏的定义来验证这一猜想:

grep -ER 'O_CREAT|O_RDONLY|WRONLY' /usr/include/

在这里插入图片描述
可以发现O_RDONLY被定义为00,O_WRONLY被定义为01,O_CREAT被定义为0100。
open返回的是一个文件描述符,在下面会进行详细的介绍。

四、文件描述符

1.文件描述符的值

每当操作系统打开一个文件的时候,会给他一个编号,这个编号就叫做文件描述符。当打开文件失败,则文件描述符为-1。
其中标准输入,标准输出,标准错误的文件描述符分别为0,1,2(因为在操作系统眼中,它们都是以文件的形式来存在的。)
首先我们可以来接收一下open的返回值,即文件描述符:

  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);    
  printf("%d\n",fd);    
  if(fd<0)                                                                                                                                               
  {    
     printf("open error!\n");    
  }    
  else    
  {    
    close(fd);    
  }    

最终得到open的返回值,即文件描述符的值是:3,这是因为在C语言中0,1,2号文件是默认被打开的。
在这里插入图片描述
显然文件描述符的值是与我们创建的文件log.txt是有关的。这也说明3之前的0,1,2是已经被占用了的。

2.操作系统对文件的管理

我们对文件进行操作就需要打开文件,而打开文件是由某一个进程来完成的,打开文件的本质是将文件信息加载到内存中,而一个进程可以打开很多个文件,如果有很多进程,那么内存中就存在很多的已经打开的文件的信息。因此操作系统是需要对这些打开的文件来进行管理的。
操作系统的管理方法是:先描述,再组织。而对文件的描述是在一个名为file的结构体中进行的。
一个文件包括内容和属性两个部分(比如创建一个空文件,在磁盘中也会占据空间的,这是因为需要存储该空文件的属性)。因此在file结构体中有文件的内容和属性两个内容。同时,操作系统将所有打开的文件通过数据结构组织了起来(即将各个file结构体组织了起来),每一个进程需要知道自己打开的文件在哪一个位置,因此在PCB中需要一个来描述该进程打开哪些文件的结构体files,而结构体files中存在一个指针数组array_file,它的每个元素指向的就是该进程打开的每一个文件对应的file,下面用一张图来说明几者之间的关系:
在这里插入图片描述
因此我们在使用系统调用接口open和write的时候,会通过该进程PCB中指向files的结构体的指针找到files结构体,通过该结构体中的数组编号找到对应的文件指针,然后找到对应的文件。
因此当我们进行文件操作时,只需要传入该文件对应的数组下标(即文件描述符)即可。

3.父子进程的文件关系

子进程在创建之初是父进程的拷贝,它的files结构体与父进程是相同的,因此父进程打开的文件子进程也会进行打开。而我们在创建进程打开的标准输入,标准输出以及标准错误其实是由bash打开的,由各个进程继承下来的。

4.不同外设的读写

注意,系统调用接口只有一套,但是显然我们对不同外设的读写方式是不一样的,比如对磁盘,显示器的读写,对标准输入甚至不需要写操作。那么一套系统调用接口如何实现对不同外设的读写呢?
这和C++的多态有些相似,不同外设的读写方式被写入了对应的驱动中,但调用读写操作的时候,实际上调用的是该外设的驱动上的读写操作,从而完成读写的。外设都有I/O接口,但不一定都要被实现。

五、文件描述符的分配规则以及输入重定向

1.文件描述符的分配规则

标准输入,标准输出以及标准错误的文件描述符分别为0,1,2,并且会在进程打开的时候自动进行打开,从而导致后序创建的文件的文件描述符的值为3,4,5…如果我们在进程中将标准输入或输出关闭呢?后序文件的文件描述符的值是否会发生变化呢?
答案是会的,我们可以通过下面的例子来总结一些规律:

  close(0);                                                                                                                                              
  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);                       
  printf("%d\n",fd);     

通过close将标准输入关闭,此时我们运行程序会发现,log.txt的文件操作符变成了0。正好填补了空出的0号位置。
在这里插入图片描述
当我们关闭标准输出,此时显示器上不会显示任何内容,但是log.txt中出现了本该在显示器上所打印的内容:

  close(1);                                                                                                                                              
  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);                       
  printf("%d\n",fd);
  printf("I am here!\n");      

在这里插入图片描述
这是什么原因呢?
在C语言中的标准输出是stdout也就是显示器,在操作系统看来,它就是一个文件。
我们可以通过man手册查询到stdout的类型是FILE类型,而FILE类型是一个结构体,它其中有一个名为_fileno的整型,每一个FILE类型的文件的文件描述符就存放在这里。
我们可以通过代码来验证一下:

  printf("stdin->_fileno:%d",stdin->_fileno);    
  printf("stdout->_fileno:%d",stdout->_fileno);    
  printf("stderr->_fileno:%d",stderr->_fileno);     

打印的结果是:
在这里插入图片描述
也就是说printf向stdout文件进行打印的本质是它获得了stdout文件的文件操作符1,通过这个1来找到对应的文件并向其中写入内容。
而在代码的开始我们将已经打开的显示器文件进行了关闭,此时log.txt的文件描述符变成了1,此时printf依然通过1来找到对应的文件并向其中写入数据,因此最终写入的数据进入log.txt中。并且由于系统调用接口类似多态,调用的是不同的底层输入函数,因此可以将内容写入log.txt中。
这一过程也是输入重定向的原理。
在这里插入图片描述
同时我们还可以得出一个结论:当为一个文件分配文件描述符时,从数组下标0的开始遍历,如果某一位置的指针为空,则将指针指向该文件,并将数组下标作为该文件的文件描述符。

2.重定向

(1)输出重定向

echo "hello world">log.txt

即将本来要打印在显示器上的内容打印在了文件log.txt中,它的原理就是先将显示器关闭,使得log.txt的文件描述符为1,然后调用系统调用函数来向描述符为1的文件,即log.txt进行写入。
注意输出重定向是将标准输出重定向,而不是标准错误。在标准错误打印的内容不会被重定向,因为它的文件描述符是2。

(2)追加重定向

追加重定向指的是重定向的内容不覆盖文件中原有的内容。只是在open函数中的flag多加了一个APPEND的参数,其他和输出重定向相同。

(3)输入重定向

输入重定向即将标准输入关闭,将某一个文件来作为输入。

    close(0);    
    int fd=open("./log.txt",O_RDONLY,0644);    
    char line[128];    
    while(fgets(line,sizeof(line)-1,stdin))    
    {    
         printf("%s\n",line);                                                                                                                            
    }   

此时fgets拿到的就是文件log.txt中的内容,而不是标准输入的,说明它本质是通过stdin的文件描述符来寻找文件的。

(4)直接重定向

我们会发现,如果想完成重定向操作需要进行关闭标准输入输出等操作,是比较麻烦的,因此系统提供了一个dup2函数来直接进行重定向的操作:
在这里插入图片描述
它的参数有两个,即oldfd与newfd,其中下标为newfd中的元素是下标为oldfd的元素的拷贝。

  int fd=open("./log.txt",O_WRONLY,0644);    
  dup2(fd,1);    
  printf("hello dup2!\n");  

此时将数组中的1下标中的元素替换为fd下标的元素,上层对文件描述符为1的文件进行操作就是对之前的fd对应的文件进行操作。

六、缓冲区

1.缓冲区的分类

在C语言中我们在学习getchar函数的时候就提到过缓冲区,只不过只是浅尝则止。其实缓冲区分为两类,分别是用户缓冲区和系统缓冲区。
用户缓冲区的数据最终刷新到系统中,系统缓冲区的数据最终刷新到硬件上。在C语言中我们写入的数据会首先保存在用户缓冲区中,该用户缓冲区是由C语言提供的。
在C语言的结构体FILE中,不仅仅封装了文件描述符,还封装了缓冲区。
我们可以在usr/include/libio.h找到关于FILE的定义:
在这里插入图片描述
其中_fileno指的就是文件描述符,而上面的char*内容就有些是与缓冲区相关的。
因此用户缓冲区属于用户层,而不属于操作系统层,在C语言进行读写文件时,会先将数据放入FILE结构体中的缓冲区中,然后再刷新到操作系统中。操作系统中的系统中缓冲区也同理,这里我们主要了解用户层的缓冲区。
在这里插入图片描述

2.缓冲区的刷新策略

1.立即刷新(不缓冲)
2.行刷新(行缓冲):遇到换行操作,则进行缓冲区的刷新。比如向显示器的刷新。
3.全缓冲:缓冲区满了即进行刷新,比如向磁盘文件中写入,这样也解决了缓冲区的溢出问题。

3.对缓冲区刷新的理解

首先我们来验证缓冲区的刷新策略。

    const char* msg="hello 标准输出!\n";      
    write(1,msg,strlen(msg));                                                                                                                            
    printf("hello printf!\n");                                           
    fprintf(stdout,"hello fprintf!\n");                                          
    fputs("hello fputs!\n",stdout);  

这段代码的打印结果很简单:
在这里插入图片描述
下面来解释打印的原理,其中printf,fprintf,fputs是C语言的函数,因此它们所打印的内容要先存放在用户区,由于是向显示器打印,因此是行刷新,每打印完一行就会刷新到系统中,再向硬件上显示。而write是系统调用接口,是直接写入系统缓冲区的,再向硬件上显示。
如果我们对其进行重定向操作呢?
在这里插入图片描述
我们发现文件中的内容和显示器上显示的是一模一样的,但是性质却发生了变化,由于是将显示器中的内容重定向到log.txt中,log.txt是磁盘中的一个文件,刷新方式也就由原来的行刷新转换为了全刷新。
其中write中内容依然是直接写入系统缓冲区中,然后再显示在硬件上。而printf,fprintf,fputs中的内容会先存入用户缓冲区(不过用户缓冲区没有被填满,不会刷新到系统缓冲区中),待进程运行结束之后再刷新到系统缓冲区,最终显示在硬件上。
我们可以再通过一个更直观的方式来验证:

    const char* msg="hello 标准输出!\n";    
    write(1,msg,strlen(msg));    
    printf("hello printf!\n");    
    fprintf(stdout,"hello fprintf!\n");    
    fputs("hello fputs!\n",stdout);    
    close(1);     

当我们在代码的末尾将文件1关闭的时候,再进行重定向:

./mytest1>log.txt

此时我们在文件中看到的结果是:
在这里插入图片描述
只有标准输出一个内容,这是因为在三个C语言字符串内容刚写入缓冲区,就把文件log.txt关闭了(重定向后文件描述符1代表了重定向文件),缓冲区中的内容还没来得及刷新到系统缓冲区中文件就已经被关闭了,因此不会写到硬件。而write是系统调用接口,它的内容是直接向系统缓冲区中进行写入了,因此会写到硬件上。
这个例子也说明了,用户缓冲区是存在在用户层的,而不是存在在系统层的。
我们如果一定要关闭文件,我们可以用fflush函数来帮助刷新到系统缓冲区:

    const char* msg="hello 标准输出!\n";    
    write(1,msg,strlen(msg));    
    printf("hello printf!\n");    
    fprintf(stdout,"hello fprintf!\n");    
    fputs("hello fputs!\n",stdout);    
    fflush(stdout)
    close(1);     

在这里插入图片描述
此时硬件就被成功刷新了。
我们可以使用子进程来测试一下是否理解了:

    const char* msg="hello 标准输出!\n";    
    write(1,msg,strlen(msg));    
    printf("hello printf!\n");    
    fprintf(stdout,"hello fprintf!\n");    
    fputs("hello fputs!\n",stdout);    
    fork();

当我们在代码结尾创建一个子进程的时候:
在这里插入图片描述
由于write是系统调用接口,因此其中的内容会被直接存放在系统缓冲区中,在子进程创建之前就已经执行完该过程了。而C语言函数中的字符串会首先被存放在用户缓冲区中,此时进程还没有结束,因此没有被刷新到系统缓冲区中,子进程被创建,继承了父进程的缓冲区中的内容,进程结束,父子进程的内容被刷新到系统缓冲区最终显示在硬件上。因此是C字符串是两份。

七、总结

本文阐述了数据被写入硬件的完整过程,首先数据是在一个进程中完成写入操作的。通过该进程的PCB中的file指针,会找到该进程的files结构体,在files结构体中有一个数组,它的元素是该进程打开的文件的指针(指向用来描述文件的file结构体),它的下标是各个文件的文件描述符。
在用户层面,对某一个文件进行写入只需要知道它的文件描述符即可,在写入的过程中,如果是用户层面的写入需要先将内容写入用户层的缓冲区,然后根据要写入的不同硬件的刷新策略,将数据刷新到系统缓冲区,最终再写到硬件中。如果是系统层面的写入,则直接在系统缓冲区写入,然后再写入硬件。
总体来说,文件操作分为两个部分,分别是找文件和写文件,对应的分别为对文件操作符的理解,和对缓冲区的理解。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。