> 文章列表 > 【Linux】文件描述符

【Linux】文件描述符

【Linux】文件描述符

文章目录

  • 📕 预备知识
  • 📕 文件操作的接口
    • open() 接口
    • write() 接口
    • read() 接口
    • close() 接口
  • 📕 文件描述符
    • 三个标准
    • ★ 理解文件描述符 ★
    • ★ Linux下一切皆文件 ★
    • 文件描述符分配规则
  • 📕 重定向的本质
  • 📕 dup2() 函数

📕 预备知识

在了解文件描述符之前,可以先对下面的问题有一个概念,这样子方便我们更好地理解文件。

  1. 你真的理解文件原理和操作了吗?不是语言层面,是系统层面。
  2. 是不是只有 C/C++ 有文件操作?java、python、go……这些语言的文件操作方法是不一样的吗?如何理解这种现象?可不可以有一种统一的视角,去看待所有语言的文件操作呢?
  3. 操作文件的适合,第一件事情是打开文件,打开文件是在做什么?
  4. 当文件没有被操作的适合,一般而言会在什么位置。(磁盘)
  5. 当我们对文件进行操作的时候,文件需要被加载到内存,那么加载的是文件的内容还是属性?只有这一个文件在内存里面吗?

对于上述问题,如果对部分内容没有很明确的认知,那么恭喜你,本文就是为你准备的!
打开文件的本质就是将需要的文件属性加载到内存中,OS内部一定会存在大量的被打开的文件,那么OS就要管理这些被打开的文件——先描述,再组织!

先描述,要用结构体来描述被打开的文件。构建在内存中的结构体 struct file 来描述被打开的文件
再组织,每一个被打开的文件,在操作系统的内存里都有对应的 struct file 结构体描述,可以将所有的 struct file 结构体对象用链表串联起来。这样子之后,在操作系统的内部,对被打开的文件进行的管理,就转换成了对链表的增删查改

文件被打开,是被操作系统打开,但是本质上是用户(进程)让操作系统打开的。所以,我们所进行的文件操作,都是 进程被打开的文件 之间的关系。

📕 文件操作的接口

对于C语言而言,文件操作有 fopen、fclose、fwrite、fread、fscanf、fprintf 等等函数,其主要依靠的是 FILE 文件流。

但是对于 Linux 操作系统而言,对文件进行操作,是使用 open、close、read、write 这四个系统调用。

open() 接口

如下,这是 man 手册中的 read 接口,通过查看其返回值的说明,可以看出,成功之后返回文件描述符,发生错误返回 -1 。
其第一个参数,是文件名;第二个参数是打开文件的方式,是位图的结构。
【Linux】文件描述符
如何理解位图?在我们写程序的时候,时常用到标志位,一般而言是设置一个变量 flag,其值为 1 或者 0 代表两种状态。如果要设置多个标志位,就要设置多个 flag 变量。但是,位图却可以用一个变量实现多个标志位的操作。
例如 int x; 变量 x 是 int 类型,占4个字节,有32个比特位,用一个比特位表示一个标志位(一个比特位可以有 0 、1 两种状态),这样一个int类型的变量,就可以传递32个标志位!
比如,第一个标志位表示创建文件、第二个标志位表示只读、第三个标志位表示可写……(以文件操作为例)。现在 x=3; 那么用二进制来表示x就是 0000 0000 0000 0000 0000 0000 0000 0011 ,其第一个和第二个标志位是1,代表创建文件并且以只读的方式打开。


当然了,操作系统不会让我们去记住第几个操作位对应的什么标志位,而是更加形象地表示,如下。O_CREAT 表示创建文件、O_APPEND 表示追加 …… 使用的时候需要多个标志位,只需要按位或即可

【Linux】文件描述符

要以只读方式打开 log.txt 文件,如果没有则创建。

int fd = open(“log.txt”,O_CREAT | O_RDONLY);

以写方式打开 log.txt 文件(覆盖写),如果没有则创建,设置文件权限为 0666。

int fd = open(“log.txt”,O_CREAT | O_WRONLY,0666); // 默认不会对原始文件进行清空

如下,代码的意思是:打开文件之后写入内容。执行一次程序就会写入一次。
在这里我们用到了有三个参数的的 open 调用,第三个参数是设置文件的权限,但是要注意文件掩码的存在,所以可以先设置文件掩码为0。

  1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #define LOG "log.txt"9 10 int main()11 {12   umask(0);13   int fd=open(LOG,O_CREAT | O_WRONLY,0666);                                                                                  14   if(fd == -1)15   {16     printf("open error\\n");17     exit(-1);18   }19   char buf[128]="hello linux!\\n";20   write(fd,buf,strlen(buf));21 22   close(fd);23 }

如下运行结果,确实创建了 log.txt 文件并向里面写入内容。同时,多次写入后,文件里的内容依然是一行,并没有增加,所以这是覆盖写。

【Linux】文件描述符
如果想要实现追加写,就要按位与上 O_APPEND,这里就不过多展示了,其实和C语言的文件操作是类似的,只不过C语言的文件操作函数,是对 write 、read 、open 、close 这几个系统调用进行封装的,为了方便我们使用。

int fd = open(“log.txt”,O_CREAT | O_WRONLY | O_APPEND,0666);

write() 接口

如下是 man 手册中的 write 系统调用,如果写入成功,那么返回写入的字节数,否则返回-1。
其三个参数分别是 文件描述符、指针(指向要写入的数据)、要写入的字节数。
【Linux】文件描述符

如下,是上面测试 open 的代码,这里面也有 write 系统调用,使用起来并不困难。

  1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #define LOG "log.txt"9 10 int main()11 {12   umask(0);13   int fd=open(LOG,O_CREAT | O_WRONLY,0666);                                                                                  14   if(fd == -1)15   {16     printf("open error\\n");17     exit(-1);18   }19   char buf[128]="hello linux!\\n";20   write(fd,buf,strlen(buf));21 22   close(fd);23 }

read() 接口

如下是 man 手册中的 read 接口,其返回值和 write 类似,读取成功返回读取的字节数,读取失败返回-1。
三个参数,分别是文件描述符、要读取到哪个流中、读取的字节数。

【Linux】文件描述符

如下是对 read 系统调用的使用,由于其是系统层面的读取,不会自动加上 ‘\\0’ 表示字符串的结束,所以要我们手动加。

  1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #define LOG "log.txt"9 10 int main()11 {12   umask(0);13   int fd=open(LOG,O_CREAT |O_RDONLY,0666);14   if(fd == -1)15   {16     printf("open error\\n");17     exit(-1);18   }19   char buf[128];20   int n=read(fd,buf,sizeof(buf)-1);21   buf[n]='\\0';                                                                                                               22   printf("%s",buf);23 24   close(fd);25 }

【Linux】文件描述符

close() 接口

关闭打开的文件。如下是 man 手册中的 close 系统调用,只需要传入文件描述符即可。关闭成功返回0,否则返回 -1 。

【Linux】文件描述符

对于上述四个系统调用,都是操作系统提供的,各种高级语言(C/C++、JAVA、Python等等)对这四个系统调用进行封装,就有了语言层面的文件操作。文件操作必然是要涉及到硬件的,因为要存储数据等待,但是高级语言无法直接绕过操作系统对硬件进行操作,操作系统可以,所以必须要使用操作系统提供的系统调用,将其进行封装,来更方便地使用。

📕 文件描述符

open 系统调用返回值是文件描述符,那么什么是文件描述符呢?它代表什么含义呢?

先用下面一段代码进行引入,其运行结果如下,发现 fd 这个文件描述符的值是3。
运行 test 之后,这就是一个单独的进程,该进程打开的第一个文件 返回的文件描述符却是3?这是为什么?怎么不是从0开始呢?

【Linux】文件描述符

 1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #define LOG "log.txt"9 
10 int main()
11 {
12   umask(0);
13   int fd=open(LOG,O_CREAT |O_RDONLY,0666);
14   if(fd == -1)
15   {
16     printf("open error\\n");
17     exit(-1);
18   }
19   printf("the value of fd is:%d\\n",fd);                                                                                      
20   close(fd);
21 }

三个标准流

实际上,一个进程会默认打开三个文件,分别是:标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。这三个文件用文件描述符表示依次是: 0、1、2。这就不难理解为什么代码打开的第一个文件,其文件描述符是3了。

标准输出、标准错误都是输出文件,并且都是向显示器输出,这两者有什么区别呢?

标准输入 – 设备文件 --> 键盘
标准输出 – 设备文件 --> 显示器
标准错误 – 设备文件 --> 显示器

通过下面的代码,我么可以先略微了解标准输出和标准错误的区别。

  1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #include<iostream>                                                                                                           9 #define LOG "log.txt"10 11 int main()12 {13   fprintf(stdout,"fprintf->stdout\\n");14   fprintf(stderr,"fprintf->stderr\\n");15 16   std::cout<<"cout->stdout"<<std::endl;17   std::cerr<<"cout->stderr"<<std::endl;18 }

这段代码的运行结果如下,可以发现,运行 test 之后,标准输出和标准错误都是可以打印出来的。但是,如果使用输出重定向,将 test 的输出结果重定向到 out.txt ,就会发现只有标准输出成功重定向了,标准错误没有。目前而言观测到的两者区别如是。
【Linux】文件描述符

★ 理解文件描述符 ★

文件描述符对应的 0、1、2、3、4 …… 本质上就是数组下标

如下,当我们运行可执行程序时,它就变成了一个进程。进程需要在操作系统内核创建pcb(也可以叫做 task_struct ),而一个进程会默认打开三个文件:标准输入、标准输出、标准错误。打开的文件是要加载到内存的,为了方便操作系统管理文件,就需要创建对象来描述加载到内存的文件——即 struct file ,这里面包含了文件的大部分属性,就好像 pcb 包含了进程的大部分属性。并且也可以用某种数据结构组织好 struct file。“先描述、再组织”。
【Linux】文件描述符

文件是用户让操作系统打开的,但是用户也是通过调用系统调用接口打开的,系统调用也需要通过进程来执行,所以 进程 和 被打开的文件 之间的关系是我们必须要考虑的

首先,一个进程可以打开 n 个文件,就要维护 n 个映射关系。
如下图,操作系统内部定义了一个结构体 struct files_struct ,这里面最主要的字段是 struct file* fd_array[] 这个数组,这个数组里存储的数据类型是 struct file* ,那么,就可以通过这个数组里存储的指针,找到内存中对应的 struct file 对象,找到了某个文件的 struct file 对象,也就可以找到该文件所有的属性和内容

进程在初始化的时候,其 pcb 内部也会有一个 struct files_struct* 类型的指针,指向内核中的对应结构体。所以进程就可以通过该指针,找到该进程对应的 files_struct 结构体,进而找到 fd_array[] 这个数组,只要是数组,就一定有下标,这个下标就是 open 返回的文件描述符!!通过下标,就可以访问到 struct file,进而找到文件!
【Linux】文件描述符

所以,当我们打开文件的时候,内存中就要创建一个新的 struct file 对象,里面存储文件相关的属性,再从操作系统内核中的 struct files_struct 结构体中的 fd_array[] 数组里面,找到一个未被使用的下标,然后将新的 struct file 对象的地址填进去,并返回文件描述符(数组下标)。

当然了上述的内容只不过是简单理解,这还远远不够。


在 struct file 中,最重要的是 每一个 struct file 对象,都要匹配一个缓冲区(也就是说每一个文件都有一个缓冲区),当我们尝试在应用层调用 write 向文件写入内容的时候,实际上是把这个内容写进了对应文件的缓冲区(一定是某个进程调用 write ,所以可以知道进程的pcb,那么就可以找到该 pcb 对应的 struct files_struct ,write 里面传入了文件描述符,就可以通过 fd_array[] 数组来找到内存中的 struct file,继而找到缓冲区)。

同样地,当我们调用 read 函数的时候,磁盘中文件的数据会被放到对应的 struct file 结构体对象的缓冲区里面,然后根据 read 传入的文件描述符参数 可以找到缓冲区,将缓冲区里面的数据拷贝到 read 的第二个参数指向的地方。

所以, IO 中的 read 、write 函数,本质是拷贝函数!至于被拷贝到了缓冲区的数据,什么时候刷新到磁盘中的对应位置,是由操作系统来刷新的,涉及到多种不同的算法。

【Linux】文件描述符

★ Linux下一切皆文件 ★

如下,底层有各种各样的硬件资源,这些东西在冯诺依曼体系中都叫外设。每一种外设都有对应的驱动程序。
对硬件资源的操作有两种:读、写。 拿键盘为例,对于读,站在内存的角度,是把数据从键盘读到内存中;可是在内存的角度,键盘不需要 写 方法。而显示器也好理解,对于内存而言,显示器的write 就是把数据从内存写到显示器;但是显示器不需要 read 方法。

所有的外设都有读、写两种方法,可是很显然,每一种硬件的 读、写 方法都是不同的,那么是怎么做到 “一切皆文件”的呢?
要理解这个概念,就需要 struct file 的介入,该结构体对象里面有两个函数指针,指向对应的硬件的 read 、write 方法。这样子在管理的时候,就不需要去关心每一种硬件的 读、写 方法的差异,因为是使用指针,直接调用即可
【Linux】文件描述符

如下,进程对文件进行操作的时候,先从 pcb 找到 struct files_struct ,根据里面的指针找到 struct file,每一个 struct file 在进程看来都是文件对象,当进程要向文件 写入数据,本质上就是把数据拷贝到 struct file 里面的缓冲区,然后调用底层对应设备的 读、写 方法。进程不需要去关心底层的读、写是如何实现的、有什么不同,它只需要无脑调用即可。

而操作系统提供的 read 、write ,本质上就是拷贝,将数据拷贝到 struct file 的缓冲区,或者将该缓冲区的数据拷贝到对于的空间。

至此,从文件对象这一层往上看,就是所谓的 “linux 下一切皆文件” !!
我们访问文件的时候,在我们看来访问到的所有东西都是 struct file 这个文件对象,而底层不同类型文件的读、写方法的差异,被文件对象里面的 指针 给屏蔽掉了。对于进程而言,它只认文件对象,而不认底层的实现。
【Linux】文件描述符
当然了,这种思想非常接近C++里面的多态,这里其实就是使用C语言来设计面向对象。


到这里,我们已经知道:

  • 从操作系统层面看,我们必须知道 fd,才能访问文件。
  • 任何 语言 要访问外设、文件,必须经过操作系统。

拿C语言为例,其文件操作是使用 FILE* 指针,C语言必须要经过操作系统,而操作系统必须要知道 fd 才可以访问文件,所以 FILE 结构体里面必然封装了 fd 。

所以,我们可以通过下面的代码来验证一下,结果确实如此。

【Linux】文件描述符

  1 #include<sys/types.h>                                                     2 #include<sys/stat.h>                                                      3 #include<fcntl.h>                                                         4 #include<stdio.h>                                                         5 #include<stdlib.h>                                                        6 #include<string.h>                                                        7 #include<unistd.h>                                                        8 #include<iostream>                                                        9 #define LOG "log.txt"                                                     10                                                                           11 int main()                                                                12 {                                                                         13   printf("%d\\n",stdin->_fileno);                                          14   printf("%d\\n",stdout->_fileno);                                         15   printf("%d\\n",stderr->_fileno);                                         16   FILE* fp=fopen(LOG,"r");                                                                                                   17   18   printf("%d\\n",fp->_fileno);  19 }   

文件描述符分配规则

在文件描述符表中,最小的、没有被使用的数组元素,分配给新文件。通过下列代码和运行结果就可以看出。

【Linux】文件描述符

  1 #include<sys/types.h>2 #include<sys/stat.h>                                                                                                         3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #include<iostream>9 #define LOG "log.txt"10 11 int main()12 {13   close(0);14   close(2);15 16   int fd1=open(LOG,O_WRONLY | O_CREAT);17   int fd2=open(LOG,O_WRONLY | O_CREAT);18   int fd3=open(LOG,O_WRONLY | O_CREAT);19                                        20   printf("%d\\n",fd1);21   printf("%d\\n",fd2);                    22   printf("%d\\n",fd3);                    23 }  

📕 重定向的本质

测试如下代码,其运行结果如图。
代码中,将关闭文件描述符 1,也就是关闭标准输出,然后打开文件,那么 fd 就会被分配到文件描述符1。此时,调用 printf 函数,本该向屏幕打印的内容,现在打印到了 log.txt 里面。

【Linux】文件描述符

    1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #include<iostream>9 #define LOG "log.txt"10 11 int main()12 {13   close(1);14 15   int fd=open(LOG,O_WRONLY | O_CREAT);16 17   printf("I am here!\\n");18   printf("I am here!\\n");19   printf("I am here!\\n");20   printf("I am here!\\n");21   printf("I am here!\\n");                                                                                                  22 }

如下,上述代码可以这样解释:
printf 函数是依赖 stdout 的,而 stdout 的文件描述符是1。我在操作系统内部,更改当前进程的 fd_array[] ,使其 1 号元素所指向的内容 从标准输出变成 log.txt ,这个操作 C 语言是感知不到的,那么 printf 依赖的还是文件描述符1,所以,向 1 号里面去写,实际上就写到了 log.txt 。

所以,重定向的原理就是:在上层无法感应的情况下,在操作系统中更改进程对应的文件描述符表中,特定的下标指向。
【Linux】文件描述符

到这里,我们就能理解之前的一个问题了:下图中, ./test > out.txt 之后,只有标准输出重定向到了文件中,标准错误却没有。

cout、stdout 都是依赖于 1 号文件描述符,cerr、stderr 都是依赖于 2 号文件描述符。./test > out.txt 是把 1 号文件描述符的指向改成 out.txt ,没有改变 2 号的指向,所以才会出现如下情况。
【Linux】文件描述符

📕 dup2() 函数

如下,dup2 实际上是将 fd_array[] 数组的对应下标中的内容进行拷贝,将 newfd 下标的内容拷贝成为 oldfd 下标的内容
【Linux】文件描述符

可以用下列代码来测试, dup2(1,fd); 将 fd 指向的内容 改成 1 指向的内容,也就是说,原本 fd 下标里面的数组元素是指向 log.txt 的,调用 dup2(1,fd); 之后, fd 指向标准输出。

  1 #include<sys/types.h>2 #include<sys/stat.h>3 #include<fcntl.h>4 #include<stdio.h>5 #include<stdlib.h>6 #include<string.h>7 #include<unistd.h>8 #include<iostream>9 #define LOG "log.txt"10 11 int main()12 {13   int fd=open(LOG,O_WRONLY | O_CREAT);14 15   dup2(1,fd);16 17   char arr[]="hello linux\\n";18   write(fd,arr,sizeof arr);                                                                                                  19 }

执行结果如下,本来是向 log.txt 写入的,结果却输出到屏幕了,这就是 dup2(1,fd); 起的作用。
【Linux】文件描述符
关于文件描述符的理解,就暂时到这里结束,如果有什么问题欢迎在评论区讨论哦!