「前言」本节大致内容是Linux的文件操作以及解释文件描述符。

一、重谈文件

文件的基本知识:

  1. 文件由文件内容和文件属性两部分构成
  2. 空文件只是文件内容为空,但文件依旧存在属性,所以空文件也要占用磁盘空间
  3. 因为文件由内容和属性构成,所以对文件的操作 = 对内容操作 + 对属性操作 or 对内容和属性操作
  4. 标识一个文件:文件路径 + 文件名(唯一性)
  5. 如果没有指明路径,默认在当前路径下进行文件访问,当前路径是指当前进程的工作路径
  6. 在C语言中,对包含 fopen、fclose、fwrite、fread 等函数接口的程序进行编译链接形成可执行程序之后,如果不运行该可执行程序,相应的文件接口就没有被调用,则对应的文件操作也不会被执行。所以对文件的操作只有进程运行了才会对文件进行操作,运行起来的程序就是一个进程,所以对文件的操作本质上就是进程对文件的操作
  7. 一个文件没有打开,不能直接对文件进行访问;一个文件要被访问,就必须先被打开。怎么打开?用户进程 + OS(操作系统),进程负责调用文件接口,OS 负责打开相应的文件
  8. 一个文件被打开,并不是磁盘上所有的这些文件都被打开了,只是打开了相应的文件。所以磁盘上的文件分为 被打开的文件 + 未打开的文件。所以,文件操作的本质:进程和被打开的文件的关系
  9. 而 没有被打开的文件 与文件系统相关,文件系统也是 基础IO 重点学的对象

二、C语言文件操作

2.1 重谈C语言文件操作

在学习文件操作之前,我们先来回顾一下C语言的文件操作。

C语言文件操作部分接口:

C语言文件操作函数功能
fopen打开文件
fclose关闭文件
fputc写入一个字符
fgetc读取一个字符
fputs写入一个字符串
fgets读取一个字符串
fprintf格式化写入数据
fscanf格式化读取数据
fwrite向二进制文件写入数据
fread从二进制文件读取数据
fseek设置文件指针的位置
ftell计算当前文件指针相对于起始位置的偏移量
rewind设置文件指针到文件的起始位置
ferror判断文件操作过程中是否发生错误
feof判断文件指针是否读取到文件末尾

C语言文件打开的几种方式(部分):

文件打开方式含义如果指定文件不存在
“r”(只读)为了输入数据,打开一个已经存在的文本文件出错
“w”(只写)为了输出数据,打开一个文本文件建立一个新的文件
“a”(追加)向文本文件尾添加数据建立一个新的文件
“r+”(读写)为了读和写,打开一个文本文件出错
“w+”(读写)为了读和写,建议一个新的文件建立一个新的文件
“a+”(读写)打开一个文件,在文件尾进行读写建立一个新的文件
“rb”(只读)为了输入数据,打开一个二进制文件出错
“wb”(只写)为了输出数据,打开一个二进制文建立一个新的文件
“ab”(追加)向一个二进制文件尾添加数据出错

C语言文件操作的例子在 C语言专栏的文件操作里面,这里不再演示。

默认打开的三个流,任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流,对应到C语言当中就是 stdinstdout 以及 stderr

随着不断学习,接触的语言逐渐增多,比如面向过程语言C、面向对象语言 C++/Java、静态编译语言 go、解释型语言 python 等等语言,它们的都有文件操作,并且接口都不一样,学习成本高。

但是它们底层调用的接口都一样。因为文件是在磁盘上,磁盘归 OS 管理,而所有人想访问磁盘都绕不开操作系统,想访问磁盘就必须使用 OS 提供的接口,所以想要访问文件,OS 就必须提供相关文件级别的系统接口,所以想访问文件就必须直接或间接使用操作系统提供的接口。

所以无论上层的语言(C/C++/Java..)怎么变化,库函数的底层必须调用系统接口。库函数可以千变万化,但是底层是不变的,要降低学习成本,我们只要学习不变的东西即可!!

所以我们学习文件操作只需要学习操作系统系统调用有关文件操作方面的接口即可,以后我们再学习语言的文件操作时只需要学习一些新的特性即可,总体层面上是不变的(因为底层接口都一致),大大降低了学习成本。

2.2 补充细节

测试代码

#include<stdio.h>      

#define FILE_NAME "log.txt"      

int main()
{
    FILE* fp = fopen(FILE_NAME, "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    int cnt = 5;
    while (cnt)
    {
        fprintf(fp, "%s, %d\n", "hello", cnt--);
    }

    fclose(fp);

    return 0;
}

运行结果

log.txt文件创建出来的权限为664,普通文件的默认权限为0666,Linux 下普通用户默认的umask0002,而文件最终权限等于默认权限 & ~umask,所以 log.txt 的权限为 0664

C语言以w方式打开文件时,无论是否写入新数据,都会清空之前文件中的数据。

三、操作系统文件操作(系统文件I/O)

3.1 文件相关系统调用:close 

close:关闭一个文件

man 2 close 进行查看

3.2 文件相关系统调用:open

open:打开或者创建一个文件 

man 2 open 进行查看

#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);

pathname: 要打开或创建的目标文件

flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags
参数:
    O_RDONLY: 只读打开
    O_WRONLY: 只写打开
    O_RDWR : 读,写打开
        这三个常量,必须指定一个且只能指定一个
    O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
    O_APPEND: 追加写

mode: 指定创建新文件时文件的默认权限(文件最终权限还要受umask的影响)

返回值:
    成功:新打开的文件描述符
    失败:-1

3.2.1 open 的第二个参数 flags

open函数的第二个参数是 flags,表示打开文件的方式

系统接口open的第二个参数flags是整型,有 32比特位,若将一个比特位作为一个标志位,则理论上flags可以传递32种不同的标志位。实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的。

参数选项:

上述这些宏表示不同的文件打开方式,其原理是通过比特位传递选项

什么是比特位传递选项?

#include<stdio.h>    

//每一个宏只占用一个比特位,该比特位为1说明该选项成立,且各个宏的位置不重叠    
#define ONE (1<<0)    
#define TWO (1<<1)    
#define THREE (1<<2)    
#define FOUR (1<<3)    

void show(int flags)
{
    //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(ONE | TWO);
    printf("--------------\n");
    show(ONE | TWO | THREE);
    printf("--------------\n");
    show(ONE | TWO | THREE | FOUR);

    return 0;
}

 运行结果

下面进行测试使用

#include<stdio.h>    
#include<sys/types.h>    
#include<sys/stat.h>    
#include<fcntl.h>    
#include<unistd.h>

#define FILE_NAME "log.txt"               

int main()
{
    int fp = open(FILE_NAME, O_WRONLY);//以写的形式打开    
    if (fp < 0)
    {
        perror("open");
        return 1;
    }

    close(fp);

    return 0;
}

运行结果

文件没有创建成功是因为 没有传创建参数 O_CREAT,系统接口默认是不会自动创建的,必须传创建参数

修改一下代码

运行结果,文件创建成功了

但是,这个文件有点不对劲,它的权限是乱码?这就跟第三个参数有关了

3.2.2 open 的第三个参数 mode

open函数的第三个参数是mode,表示创建文件的默认权限

但实际上创建出来文件的权限值还会受到 umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)umask的默认值普通用户为 0002,当我们设置 mode 值为 0666 时实际创建出来文件的权限为 0664 

再次修改代码

运行结果,文件就可以正常创建了

3.2.3 open 的返回值

open函数的返回值是新打开文件的文件描述符

文件描述符下面讲解。

3.3 文件相关系统调用:write

write 系统接口的作用是向文件写入信息

man 2 write 查看

ssize_t write(int fd, const void *buf, size_t count);

使用 write 系统调用,将 buf位置开始向后 count 字节的数据写入文件描述符为 fd 的文件当中。

  • 如果数据写入成功,实际写入数据的字节个数被返回
  • 如果数据写入失败,-1被返回

测试代码

#include<stdio.h>             
#include<sys/types.h>              
#include<sys/stat.h>                  
#include<fcntl.h>                
#include<unistd.h>                 
#include<string.h>                       

#define FILE_NAME "log.txt"                        

int main()
{
    int fp = open(FILE_NAME, O_WRONLY | O_CREAT, 0666);//以写的形式打开,不存在创建,权限设置为 0666                                                    
    if (fp < 0)
    {
        perror("open");
        return 1;
    }

    int cnt = 5;
    char outBuffer[64];
    while (cnt)
    {
        sprintf(outBuffer, "%s:%d\n", "hello", cnt--);
        write(fp, outBuffer, strlen(outBuffer));//strlen 之后不用+1,系统接口是以有效数据结尾,不是C语言,C语言 strlen+1代表添加'\0',因为C语言的字符串是以'\0'结尾       
    }

    close(fp);

    return 0;
}

运行结果

修改一下写入文件的内容

 

再次运行,cat 查看文件内容

文件里面怎么有上一次的内容?怎么没有清理干净??

这是因为我们没有传清空文件内容的参数 O_TRUNC,修改代码

再次运行,cat 查看,上一次的内容就被清空了

C语言的 w则是自动清空上一次文件的数据,当你C语言在以 w的方式写入时,实际上操作系统在底层给你传了 O_WRONLY|O_CREAT|O_TRUNC, 0666,这就是系统接口与库函数的区别

3.4 文件相关系统调用:read

read 系统接口的作用是从文件读取信息

man 2 read进行查看

ssize_t read(int fd, void *buf, size_t count);

 使用read函数,从文件描述符为fd的文件读取count字节的数据到buf位置当中。

  • 如果数据读取成功,实际读取数据的字节个数被返回
  • 如果数据读取失败,-1被返回

测试代码

 #include<stdio.h>    
  #include<sys/types.h>    
  #include<sys/stat.h>    
  #include<fcntl.h>    
  #include<unistd.h>    
  #include<string.h>    
      
  #define FILE_NAME "log.txt"    
      
  int main()    
  {    
      int fp = open(FILE_NAME, O_RDWR);//O_RDWR读    
      if(fp < 0)    
      {    
          perror("open");    
          return 1;    
      }    
      
      char buffer[512];    
      ssize_t num = read(fp, buffer, sizeof(buffer)-1);    
      if(num>0)    
      {    
          buffer[num] = 0;//C语言的字符串以'\0'结尾    
      }    
      printf("%s", buffer);
      
      close(fp);    
      
      return 0;    
  }    

运行结果

由于C语言字符串以 \0结尾,而文件中的字符串数据并不包含\0,所以这里我们需要预留一个位置,便于在数据量大于等于512字节情况下buf中仍有空间来放置\0 

总结:

  • C语言的库函数接口fwrite fread fclose fopen ..,类比系统调用接口write read close open ...,实际上就是 C语言对系统调用接口进行封装形成库函数

四、文件描述符

4.1 如何理解文件

上面已经说过:

  • 文件操作本质上是进程与被打开文件之间的关系

  • 一个进程可以打开多个文件,且操作系统同时运行着许多个进程,那么操作系统中就一定存在着大量被打开的文件 

那这些被打开的文件要不要被操作系统管理起来呢?肯定要的

怎么管理?

先描述,在组织

操作系统为了管理被打开的文件,必定要为文件创建对应的内核数据结构用于标识文件,在 Linux 中,这个数据结构是 struct file {}这个结构体包含了文件的大部分属性(这个结构体与C语言的 FILE 没有任何关系)

4.2 什么是文件描述符

open系统调用的返回值是新打开文件的文件描述符

测试代码

C语言 # 在宏当中的作用 –- 将参数插入到字符串中 

#include<stdio.h>    
#include<sys/types.h>    
#include<sys/stat.h>    
#include<fcntl.h>    
#include<unistd.h>    
#include<string.h>    

#define FILE_NAME(number) "log.txt"#number    

int main()
{

    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("fd0:%d\n", fd0);
    printf("fd1:%d\n", fd1);
    printf("fd2:%d\n", fd2);
    printf("fd3:%d\n", fd3);
    printf("fd4:%d\n", fd4);

    close(fd0);
    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);

    return 0;
}

运行结果

从运行结果可以看到,文件描述符是连续分配且依次增大的,这也很合理,因为文件描述符本质上是数组下标,而连续增长正好是数组下标的特性;但是这里有一个很奇怪的地方 – 文件描述符是从3开始的,但是0、1、2呢? 

这是由三个默认打开的标准流引起的:标准输入stdin、标准输出流stdout 与 标准错误流stderr

man stdin 查看,这三个流都是 FILE* 的指针,FILE 结构体里面就一定会封装一个变量来表示文件描述,这个这个变量是 _fileno

在C语言中,打开一个文件:FILE* fp = open(),这个 FILE 实际上也是一个结构体,它底层封装采用的是系统调用,所以必定有一个字段叫做:文件描述符

所以,我们进行打印一下这三个值

  #include<stdio.h>    
  #include<sys/types.h>    
  #include<sys/stat.h>    
  #include<fcntl.h>
  #include<unistd.h>    
  #include<string.h>    
      
  #define FILE_NAME(number) "log.txt"#number    
      
  int main()    
  {    
      printf("stdin->fd:%d\n", stdin->_fileno);    
      printf("stdout->fd:%d\n", stdout->_fileno);    
      printf("stderr->fd:%d\n", stderr->_fileno);    
      
      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("fd0:%d\n", fd0);    
      printf("fd1:%d\n", fd1);    
      printf("fd2:%d\n", fd2);    
      printf("fd3:%d\n", fd3);    
      printf("fd4:%d\n", fd4);    
      

      close(fd0);
      close(fd1);
      close(fd2);
      close(fd3);
      close(fd4);
  
      return 0;
  }

 运行结果

从运行结果就可以知道, 因为三个标准流是默认打开的,所以 0、1、2 是默认被占用的,所以文件描述符默认是从 3 开始的

  • 而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体,表示一个已经打开的文件对象。
  • 而进程执行 open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!
  • 所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。

4.3 文件描述符的分配规则

测试代码

#include<stdio.h>    
#include<sys/types.h>    
#include<sys/stat.h>    
#include<fcntl.h>
#include<unistd.h>    

int main()
{
    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;
}

运行结果,是3,没毛病,0、1、2 默认被占用

若我们先关闭文件描述符为0的文件,此后文件描述符的分配又会是怎样的呢? 

修改一下代码

再次运行,可以看到,打开的文件获取到的文件描述符变成了0 

再次修改代码

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>   
#include<fcntl.h>
#include<unistd.h>   

int main()
{
    close(0);//关闭文件描述符0
    close(2);

    int fd1 = open("log.txt1", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd2 = open("log.txt2", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd3 = open("log.txt3", O_WRONLY | O_CREAT | O_TRUNC, 0666);

    printf("open fd1: %d\n", fd1);
    printf("open fd2: %d\n", fd2);
    printf("open fd3: %d\n", fd3);

    close(fd1);
    close(fd2);
    close(fd3);

    return 0;
}

 运行结果

  

可以看到,当 0 和 2 文件描述符被关闭以后,系统将其分配给了新打开的文件

close 关闭文件并不是将 fd 指向的文件对象释放掉,而仅仅是让当前进程文件描述符表中的对应下标不再指向该文件对象,因为同一个文件可能会被多个进程访问

其底层采用 count “引用计数” 的方式来实现,即当有指向该文件的进程关闭时文件计数减1,有指向该文件的进程打开时文件计数加1,当 count 为 0 时操作系统才释放该文件的内核数据结构,即真正意义上的关闭文件

所以,文件描述符的分配规则是:在 files_struct 数组当中,从小到大依次搜寻,找到当前没有被使用的最小的一个下标,作为新的文件描述符

--------------- END ---------------

「 作者 」 枫叶先生
「 更新 」 2023.1.13
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。