读者必看!Linux文件描述符深度解析

目录

前言

预备知识 

复习C语言的文件接口

写方式打开文件

追加方式打开文件

读方式打开文件

系统的文件接口

open

close 

write

read

文件描述符

0 & 1 & 2 

理解文件描述符

文件描述符的分配规则

重定向的本质

dup2

理解Linux下一切皆文件

缓冲区

认识缓冲区 

缓冲区的体现


前言

本篇文章内容有点多,但是非常的重要,,希望你可以坚持看下去,看会你将会对Linux下一切皆文件有一个全新的理解。

如果内容有错或者不足,还望你能指出,让我们共同进步

预备知识 

知识点1 

当我们创建了一个空文件时,这个空文件是否会占空间呢?

答案是:当然啦,因为文件 = 内容 + 属性,文件的属性也是数据当然会占空间啦

因此我们对文件的操作无外乎是对文件的内容或者文件的属性做操作。

知识点2

文件会被加载到磁盘中,我们在C语言或者其它语言中通过编写代码并运行来访问文件,那么这本质是谁在访问文件呢?

答:是进程通过C语言或者其它语言给我们提供的文件接口来访问的。

知识点3 

我们的磁盘是属于硬件,我们普通用户是没有权利向硬件中写入的,只有操作系统才有这个权利,而我们也想要向硬件中写入的话必须通过操作系统提供给我们的接口才能向磁盘中写入。

知识点4

显示器也是属于硬件,我们在C语言中使用printf向显示器中打印时,其实和向磁盘中写入是一样的,没有本质的区别。 

知识点5

所有的语言给我们都提供了有文件访问接口,其实在底层实现都封装的是系统调用的接口,那么这些语言为什么要封装其接口呢?

封装的原因是系统调用的接口比较难,为了能让这些接口更好的给用户使用,所以在语言层面上对这些接口进行了封装。并且这些语言为了实现跨平台性,在底层把所用平台提供的文件接口都实现了一遍,通过条件编译的方式将这些代码进行动态裁剪(就跟多态才不多),这样就能实现在不同平台调用的文件访问接口都是一样的。

知识点6

Linux下一切皆文件,在这里先有个感性的认识,站在你写代码的角度,你会认为加载到内存中的就是文件,但站在系统的角度,能够被读取或者能够被写入的设备就叫做文件。

狭义上理解的文件:普通的磁盘文件

广义上理解的文件:几乎所有外设都可被称为文件。

复习C语言的文件接口

文件打开的方式

写方式打开文件

 先来看看下面的代码

#include <stdio.h>

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


  fclose(fp);
  return 0;
}

我们都知道当我们执行这份代码时,如果当前路径下没有log.txt文件,w方式会在当前路径下创建log.txt文件,那么什么叫做当前路径呢?

所以当前路径是指当你的进程运行起来时所处的工作路径

在来看看下面的代码

#include <stdio.h>
#include <string.h>

int main()
{
  FILE *fp = fopen("log.txt", "w");
  if(fp == NULL)
  {
    perror("fopen");
    return 1;
  }
  
  const char *s1 = "hello man\n";
  fwrite(s1, strlen(s1), 1, fp);


  fclose(fp);
  return 0;
}

我们知道字符串的末尾是会加上一个'\0',那么这里的strlen(s1)是否要加1呢?

答案肯定是不要的,因为'\0'结尾是C语言的规定,文件不需要遵守,并且文件中要保存的是有效数据,'\0'不是有效数据。如果你加上1的话就会出现以下乱码的情况。

当我们以w方式写入时,是先清空文件中的内容再写入。

把上个图片中的hello man和乱码清除再写入hello Linux

这个就和输出重定向很像

追加方式打开文件

#include <stdio.h>
#include <string.h>

int main()
{
  FILE *fp = fopen("log.txt", "a");
  if(fp == NULL)
  {
    perror("fopen");
    return 1;
  }
  
  const char *s1 = "hello Linux\n";
  fwrite(s1, strlen(s1), 1, fp);


  fclose(fp);
  return 0;
}

 

读方式打开文件

#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])//命令行参数
{
  if(argc != 2)
  {
    printf("argv error");
    return 1;
  }
  FILE *fp = fopen(argv[1], "r");
  if(fp == NULL)
  {
    perror("fopen");
    return 2;
  }

  char line[64];
  while(fgets(line, sizeof(line), fp) != NULL)
  {
    fprintf(stdout, "%s", line);//向显示器输出
  }

  fclose(fp);
  return 0;
}

这样就实现了和cat一样的功能

在上面的代码中用到了一个stdout,这个stdout为标准输出

在C语言中会默认打开三个标准输入输出流:stdin、stdout、stderr

并且这三个流的类型都是FILE*的(解释在后面)

系统的文件接口

在上面已经说到了C语言标准库中给我们提供的文件操作的函数在底层其实是调用的系统提供的文件接口,那么系统给我们提供了哪些文件接口呢?下面让我们来认识一下这些文件接口

open

打开成功返回的是一个文件描述符,失败则返回-1

参数介绍

pathname:要打开的目标文件

flags:打开文件的方式。提供了多个选项,可传入多个选项共同构成这个参数

下面三个选项必须指定一个且只能指定一个

  • O_RDONLY:只读
  • O_WRONLY:只写
  • O_RDWR:可读可写

上面选项配合其它选项一起使用,如

  • O_APPEND 表示追加方式的写入文件
  • O_CREAT 表示如果指定文件不存在,则创建这个文件
  • O_TRUNC 表示截断,如果文件存在,并且以只写、读写方式打开,则将其长度截断为0。
  • ……

这些选项还有好多这里就不一一列举了,大家可以通过man进行查看

可以观察到这些选项都是大写,一般这种大写的基本上是宏定义,这里也不例外。

我们通过或(|)运算就可以将这些选项结合在一起使用了,例如:O_WRONLY|O_CREAT

为什么使用或运算就可以将这些选项结合在一起使用了呢?

其实这里是用到了一个位图的思想,看了下面代码想必你应该就会明白了

#include <stdio.h>
#include <string.h>

//用不重复的位就可以标识一个状态
#define ONE 0x1 //0000 0001
#define TWO 0x2 //0000 0010
#define THREE 0x4 //0000 0100

void print(int flags)
{
  //&运算:有0则为0,全1才为1
  if(flags & ONE) printf("I am ONE\n");
  if(flags & TWO) printf("I am TWO\n");
  if(flags & THREE) printf("I am THREE\n");
}

int main()
{
  print(ONE);
  printf("--------------------------------\n");
  print(TWO);
  printf("--------------------------------\n");
  print(THREE);
  printf("--------------------------------\n");
  print(ONE | TWO | THREE);//0000 0001 | 0000 0010 | 0000 0100 = 0000 0111
  printf("--------------------------------\n");
  print(ONE | TWO);//0000 0001 | 0000 0010 = 0000 0011
  printf("--------------------------------\n");
  print(ONE | THREE);//0000 0001 | 0000 0100 = 0000 0101
  printf("--------------------------------\n");
  return 0;
}

库中也确实是这样干的

mode:为设置文件访问的权限

红色区域即为文件的权限,新建文件夹的默认权限是0666,但实际看到的权限不是这个值,因为还会受到umask的影响,umask默认是0002

使用

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

int main()
{
  int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  printf("open success, fd:%d\n", fd);

  return 0;
}

close 

关闭一个文件描述符,成功返回0,失败返回-1并且设置对应的错误码

和fclose的用法就差不多

write

返回值为实际写入的有效数据,ssize_t为有符号整形

参数介绍

fd:为文件描述符

buf:为要写入的数据

count:为写入数据的大小

使用演示

#include <stdio.h>
#include <string.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 success, fd:%d\n", fd);

  const char *s1 = "hello write\n";
  write(fd, s1, strlen(s1));
    
  close(fd);
  return 0;
}

 

read

 

返回值的读取到的有效数据 

参数和write一样就不过多介绍了

使用演示

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

int main()
{
  int fd = open("log.txt", O_RDONLY);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  printf("open success, fd:%d\n", fd);
  
  char buffer[64];
  memset(buffer, '\0', sizeof(buffer));
  read(fd, buffer, sizeof(buffer));
  printf("%s\n", buffer);
  
  close(fd);
  return 0;
}

这里系统接口可不会给我们在末尾加上\0,所以我们需要手动在末尾加上'\0'。 

文件描述符

0 & 1 & 2 

先来看一下下面的代码 

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

int main()
{
  int fd1 = open("log1.txt", O_WRONLY|O_CREAT);
  int fd2 = open("log2.txt", O_WRONLY|O_CREAT);
  int fd3 = open("log3.txt", O_WRONLY|O_CREAT);
  int fd4 = open("log4.txt", O_WRONLY|O_CREAT);

  printf("open success, fd1:%d\n", fd1);
  printf("open success, fd2:%d\n", fd2);
  printf("open success, fd3:%d\n", fd3);
  printf("open success, fd4:%d\n", fd4);
    
  close(fd1);
  close(fd2);
  close(fd3);
  close(fd4);
  return 0;
}

从上面的的代码可以看出fd都是从3开始的,那么前面的0,1,2去哪里了呢

在上面复习C语言文件接口时,提到过C语言中会默认打开三个标准输入输出流:stdin、stdout、stderr,这三个标准的输入输出流对应的就是0,1,2。

不信的话我们可以验证一下

stdout 

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

int main()
{
  fprintf(stdout, "hello stdout\n");
  const char *s1 = "hello 1\n";
  write(1, s1, strlen(s1));
  return 0;
}

stdin 

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

int main()
{
  char buf[64];
  ssize_t s = read(0, buf, sizeof(buf));
  if(s > 0)
  {
    buf[s] = '\0';//在读取到的有效数据的末尾添加\0
    printf("%s\n",buf);
  }

  return 0;
}

我们再回到C语言中,我们在使用C语言的文件函数时都见过FILE*,这个FILE*是一个指针,那么这个FILE是什么呢?

FILE其实是一个结构体,是C语言对底层做出的一个封装,里面包含了很多成员其中就有这个fd(文件描述符)。

#include <stdio.h>

int main()
{
  printf("stdin:%d\n", stdin->_fileno);
  printf("stdout:%d\n", stdout->_fileno);
  printf("stderr:%d\n", stderr->_fileno);

  return 0;
}

理解文件描述符

进程要访问文件,必须先要打开文件,并且一个进程可以打开多个文件,那么如果多个进程都打开自己的文件,系统中就会存在大量被打开的文件,面对如此之多的文件,操作系统肯定要管理起来,而管理的本质就是先描述,再组织。所以在操作系统的内部为了管理每一个被打开的文件,会构建一个结构体,这个结构体中包含了一个被打开的文件的所有内容,然后再将这一个个的结构体用双链表组织起来。

当我们调用open时,必须让进程和文件关联起来,才能打开对应文件,那么我们的进程又是如何和文件关联起来的呢?

在每个进程的PCB中都有一个指针*files,指向一张表files_struct,该表中会包含一个指针数组,数组中的每个元素都是一个指向打开文件的指针,这就实现了和文件进行关联。

那么既然是数组就会有下标,并且是从0开始的,所以文件描述符的本质就是该指针数组的下标。所以只要拿着文件描述符,就可找到对应的文件。

文件描述符的分配规则

从上面的演示中我们就知道了0,1,2已经被三个标准输入输出流给占了,所以我们调用open打开文件时文件描述符(后面就简称fd了)只能从3以后开始,那么我们尝试close关闭0或者2看一下会发生什么现象?

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


int main()
{
  //close(0);
  close(2);
  int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  close(fd);
  return 0;
}

所以fd的分配规则是:当前没有被使用的最小的一个下标

重定向的本质

当我们close关闭1又会发生什么现象呢?

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

int main()
{
  close(1);
  int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd); 
  printf("fd: %d\n", fd);

  fflush(stdout);
  close(fd);
  return 0;
}

本该往显示器打印的内容结果打印到文件中去了。

这种现象是不是就和输出重定向是一样的了

本质是在操作系统内部更改了fd对应的内容指向

输入重定向

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


int main()
{
  close(0);
  int fd = open("log.txt", O_RDONLY, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  printf("fd: %d\n", fd); 
  char buffer[64];
  fgets(buffer, sizeof buffer, stdin);
  printf("%s\n", buffer);

  fflush(stdout);
  close(fd);
  return 0;
}

追加重定向

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


int main()
{
  close(1);
  int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }

  fprintf(stdout, "hello hjx\n");

  fflush(stdout);//刷新缓冲区
  close(fd);
  return 0;
}

但事实上重定向并不是这样实现的,我们上面的实现只是利用了fd的分配规则,而在操作系统中早就给我们准备了实现重定向的接口。

dup2

 

dup2的使用描述

这里的意思就是将旧的文件描述符所对应的内容拷贝的新的文件描述符中,最后两个文件描述符是和旧的文件描述符保持一致,如果必要时把新的文件描述符关掉。

dup2的使用

#include <stdio.h>
#include <string.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_APPEND, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  dup2(fd, 1);
  fprintf(stdout, "hello dup2\n");

  close(fd);
  return 0;
}

理解Linux下一切皆文件

 Linux下一切皆文件时Linux的一个设计哲学,它是体现在操作系统的软件设计层面的。

我们都知道Linux是用C语言写的,那么你知道怎么用C语言来实现面向对象,甚至是运行时多态吗?

在C++或者其它语言中实现面向对象我们都知道是要用类,类中包含了成员属性和成员方法,但是C语言中只有结构体的概念,而结构体中只能包含成员属性,包含不了成员方法,那该怎么办呢?

没事,我们可以用到一个函数指针来实现其效果。

我们知道操作系统再往下就是硬件(比如磁盘、网卡、键盘、显示器等等)了,这些不同的硬件对应的一定是不同的驱动方法(你访问磁盘和访问键盘是不一样的),但是这些硬件都是外设,所以这每一个设备的核心驱动程序都可以是read/write——>I/O,根据冯诺依曼所有的外设无非都是I/O,所以所有的外设都可以有自己的read和write,但是代码实现一定是不一样的。

这种设计方案就叫做虚拟文件系统(VFS)。 

在Linux内核中也确实是这么干的。 

内核源码

struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*aio_read) (struct kiocb *, char __user *, size_t, loff_t);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*aio_write) (struct kiocb *, const char __user *, size_t, loff_t);
	int (*readdir) (struct file *, void *, filldir_t);
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, struct dentry *, int datasync);
	int (*aio_fsync) (struct kiocb *, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
	ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
	ssize_t (*sendfile) (struct file *, loff_t *, size_t, read_actor_t, void *);
	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*dir_notify)(struct file *filp, unsigned long arg);
	int (*flock) (struct file *, int, struct file_lock *);
};

缓冲区

认识缓冲区 

缓冲区其实是一段内存空间,就跟你寄快递一样,你把快递交给菜鸟,而菜鸟收到你要寄的快递并不是马上就帮你寄过去,而是先在菜鸟店里暂存等要寄的快递达到一定量时,才一起将这些快递寄出去。而这个例子中的菜鸟就起到了一个缓冲区的作用。

缓冲区的刷新策略分为:1、立即刷新    2、行刷新(行缓冲)   3、满刷新(全缓冲)

但也有特殊情况:1、用户强置刷新(fflush)   2、进程退出

为什么要有缓冲区呢?

缓冲区的出现是为了提高整机的效率,提高用户的响应速度。就比如说你寄快递是你自己亲自送到对方手中好,还是交给菜鸟由菜鸟帮你送好呢?

缓冲区的体现

 再来看一下下面的代码

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

int main()
{
  //c语言接口
  printf("I am printf\n");
  fprintf(stdout, "I am fprintf\n");
  const char *s = "I am s\n";
  fputs(s, stdout);

  //操作系统接口
  const char *s1 = "I am s1\n";
  write(1, s1, strlen(s1));

  fork();
  return 0;
}

 注意:fork()之前,fork上面的代码已经是执行完了

我们运行一下往显示器上打印

结果是正常的,没有任何问题,那么我们用输出重定向往文件中打印看看会发生什么呢

我们会发现凡是用了C语言接口的都打印了两次这是为什么呢?

解释:一般而言,我们的显示器采用的都是行刷新策略,而我们的磁盘文件采用的是满刷新策略,并且所有的设备都倾向于满刷新策略,因为缓冲区满了才刷新,可以减少I/O操作以及对外设的访问,从而提高效率。而显示器是给用户看的,为了照顾用户又考虑到效率问题所以采用行刷新策略。所以在上面的代码中,我们向显示器中打印,代码中的 \n 就会起作用——直接刷新到显示器上,而当我们向磁盘文件中打印时,磁盘文件采用的是满刷新,所以 \n 就不会起作用,我们的数据是被放在了缓冲区中,而且从上面的结果来看,缓冲区是语言层面给我们提供的不是系统给我们提供的。不要忘了我们执行完fork后是立即退出了(进程退出了),所以缓冲区中的数据要给我们刷新出来,并且fork创建子进程时,会有写时拷贝的发生。

因此综上所述,写时拷贝+缓冲区是我们的C语言数据打印了两次。

在上面解释中所说的缓冲区是用户级缓冲区,其实操作系统也会提供内核级别的缓冲区(不在写时拷贝的范围内),但是这里就不在讨论了,有兴趣的话可以去内核源码中找一找。

C语言提供的缓冲区

在/usr/include/stdio.h中


struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

  • 22
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值