目录
一. 文件描述符
1.1 什么是文件描述符
1.2 OS如何通过文件描述符找到指定文件
1.3 文件描述符的分配规则
二. 重定向
2.1 重定向的现象和底层原理
2.2 通过系统接口dup2实现重定向
三. 总结
一. 文件描述符
1.1 什么是文件描述符
Liunx操作系统为用户提供了四个用于读写的系统接口,它们分别为:
- int open(const char* file, int flag, mode_t mode) -- 打开文件。
- int close(int fd) -- 关闭文件。
- ssize_t write(int fd, const void* ptr, size_t size) -- 向指定文件中写内容。
- ssize_t read(int fd, void* ptr, size_t size) -- 从指定文件中读取内容。
上面的open函数,返回的就是新打开的文件的描述符,而close、write、read函数,都是通过文件描述符fd来找到文件,对文件进行相应的操作。
综上,凭借感性认识,我们可以认为文件描述符是Linux操作系统下,进程用于找到指定文件的一种标识,就像在语言层面上,C语言通过FILE*指针,找到指定的文件来进行操作一样,FILE的功能,也是确定进行操作的文件。
操作系统并不认识FILE*,如果想要直接通过系统接口进行IO操作,就必须要指定文件描述符。语言层面上的IO函数其底层一定封装了系统IO接口。因此,我们可以断定,C语言FILE的本质是结构体类型,里面有一个成员变量就是文件描述符fd,C语言的IO函数会拿着FILE内部的文件描述符成员去调用系统的IO接口。
代码1.1先后使用open打开了4个文件(省略检查打开是否成功),这四个文件的文件描述符分别为fd1~fd4,我们使用printf函数,打印出这四个文件描述符,他们的值为3~6。
代码1.1:open打开文件,输出文件描述符
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/fcntl.h>
int main()
{
int fd1 = open("log1.txt", O_WRONLY|O_CREAT, 0666);
int fd2 = open("log2.txt", O_WRONLY|O_CREAT, 0666);
int fd3 = open("log2.txt", O_WRONLY|O_CREAT, 0666);
int fd4 = open("log2.txt", O_WRONLY|O_CREAT, 0666);
printf("fd1:%d\n", fd1);
printf("fd2:%d\n", fd2);
printf("fd3:%d\n", fd3);
printf("fd4:%d\n", fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
提问:用户使用open打开的文件所对应的文件描述符为3/4/5...,那么,0/1/2去哪里了呢?
当一个C/C++可执行程序载入到内存,成为进程开始被执行时,会自动打开三个流,它们分别为:stdin -- 标准输入流、stdout -- 标准输出流、stderr -- 标准错误流。
一般情况下,标准输入流stdin就是键盘,标准输出流stdout和标准错误流stderr就是显示器,而在Linux下,我们认为一切能够进行IO操作的设备都是文件,那么C/C++进程默认打开这三个流,就相当于打开了三个文件。
既然是打开了文件,就要对文件进行管理,也就需要为它们分配一个文件描述符。在Linux下,文件描述符0、1、2的意义为:
- 0 -- 标准输入流,stdin
- 1 -- 标准输出流,stdout
- 2 -- 标准错误流,stderr
代码1.2对上面的结论进行了证明,stdin/stdout/stderr的类型都是FILE*,既然是FILE*,其内部必然要存储文件描述符。通过stdin->_fileno操作,打印stdin/stdout/stderr对应的文件描述符,结果分别为0/1/2。同时,定义const char* s = "hello world",使用write接口,指定文件描述符1和2输出s的内容,可以看到hello world被两次输出在显示屏上。
代码1.2:验证文件描述符0、1、2的意义
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/fcntl.h>
int main()
{
printf("stdin fd:%d\n", stdin->_fileno);
printf("stdout fd:%d\n", stdout->_fileno);
printf("stderr fd:%d\n", stderr->_fileno);
const char* s = "hello world\n";
write(1, s, strlen(s));
write(2, s, strlen(s));
return 0;
}
1.2 OS如何通过文件描述符找到指定文件
如果一个进程要打开多个文件,那么OS就一定要将这些文件组织管理起来。同时,我们还知道,OS会为每个进程都创建一个进程控制块PCB,即task_struct,task_struct中会记录一个进程的全部属性信息,当然也包括这个进程所打开的文件。task_struct中会有一个files_struct*类型的指针成员变量fs,指向存储当前进程文件信息的结构体files_struct。
files_struct要记录进程打开的多个文件的信息,其内部有fd_array数组,数组元素的类型为file*,fd_array每个下标位置对应的元素都会指向一个文件对象file,因此,文件描述符的本质就是元素类型为指向文件对象的指针的数组的下标。
当用户传递给系统接口文件描述符fd希望操作特定文件时,系统所进行的操作的流程为:找到进程PCB -> 找到其内部的files_struct* fs成员变量 -> 通过fs找到管理进程打开的文件信息的结构体file_struct -> 通过fd_array[fd],找到指定文件进行操作。
图1.2为task_struct -> fd_array[fd]的结构示意图。
通过上面的分析,我们可以分析出,C/C++的IO函数,访问文件的底层执行流程为:拿到FILE中的fd成员 -> 将拿到的fd作为参数调用系统接口 -> 通过task_struct找到file_struct -> fd_array[fd]找到文件对象file -> 执行相应操作。
1.3 文件描述符的分配规则
我们自己打开的文件,其实并不一定从3开始分配文件描述符。在代码1.3中,我们通过close(0),将标准输出流stdin关闭,然后在先后通过open函数打开两个文件,此时输出这两个文件对应的文件描述符,分别为0和3。
结论:新打开文件的文件描述符为最小的、还没有被占用的文件描述符。
代码1.3:文件描述符分配规则测试
int main()
{
close(0); //关闭标准输入流
int fd1 = open("log1.txt", O_WRONLY|O_CREAT, 0666);
if(fd1 < 0)
{
perror("open log1.txt");
return 1;
}
int fd2 = open("log2.txt", O_WRONLY|O_CREAT, 0666);
if(fd2 < 0)
{
perror("open log2.txt");
return 2;
}
printf("fd1:%d\n", fd1); // fd1:1
printf("fd2:%d\n", fd2); // fd2:3
return 0;
}
二. 重定向
2.1 重定向的现象和底层原理
看代码2.1,我们使用printf,将标准输出流stdout关闭,然后使用open,以写文件的方式打开文件log.txt,使用printf函数,将打开文件成功后的文件描述符和const char* s = "hello world\n"输出出来。但是运行代码,我们发现,进程并没有向屏幕上处任何内容。但是,如果cat log.txt查看log.txt文件的内容,我们发现,本该输出到屏幕上的内容被输出到了log.txt文件中。
上面提到的现象就是输出重定向,将close(1)关闭了标准输出流,新打开的文件log.txt的文件描述符被赋予了1,而stdout为C/C++语言层已经定义并初始化的FILE*对象,其内部记录的文件描述符为1。
我们通过close(1)将标准输出流关闭,并不会影响stdout->_fileno的值,stdout->_fileno永远为1。然而,此时文件描述符1已经不再对应屏幕,而是被新打开的文件log.txt占用,操作系统只认识文件描述符,只能通过文件描述符来查找进行读写操作的文件,因此,printf由于在底层实现为向stdout输出内容,即向fd=1的文件输出内容,此时log.txt的fd为1,printf就向log.txt输出内容了。
代码2.1:输出重定向
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/fcntl.h>
int main()
{
close(1); //关闭标准输出流
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666); //打开文件,fd=1
if(fd < 0)
{
perror("open");
return 1;
}
printf("open success, fd:%d\n", fd);
const char* s = "hello world\n";
printf("%s", s);
printf("%s", s);
return 0;
}
同样,如果我们通过close(0)将标准输入流关闭,然后再open打开文件,这时新打开的文件的文件描述符就是0,而FILE* stdin中的_fileno成员的值为0,这样标准输入就会被重定向为新打开的文件。此时scanf、fgets( ... , stdin)等操作,都是从open打开的文件中读取数据,这样就实现了输入重定向。
代码2.1:输入重定向
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/fcntl.h>
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY); //打开文件log.txt,里面的内容为hello world
if(fd < 0)
{
perror("open");
return 1;
}
char buff[20];
memset(buff, 0, 20);
fgets(buff, sizeof(buff), stdin);
printf("%s", buff); //输出hello world
close(fd);
return 0;
}
2.2 通过系统接口dup2实现重定向
- 函数原型:int dup2(int oldfd, int newfd)
- 功能:将文件描述符oldfd指向的文件对象,更改为指向newfd指向的文件对象,释放newfd原来指向的文件对象(fd_array[newfd] = NULL)
- 返回值:如果重定向成功,返回新的文件描述符newfd,如果失败,返回-1。
使用dup2接口,必须包含头文件#include<unistd.h>
有了dup2,我们在实现重定向时,就不需要再通过close(0)、close(1)来实现重定向了,只需要在open文件之后,通过dup2(fd, 1)/dup2(fd, 0)实现输出/输入重定向。
代码2.2使用dup2接口,将标准输出重定向到文件log.txt中,这样,原本该输出到显示屏上的内容,就被重定向到了log.txt中。
代码2.2:使用dup2接口进行输出重定向
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
fd = dup2(fd, 1); //输出重定向
printf("fd:%d\n", fd);
const char* s = "hello world";
printf("%s\n", s);
printf("%s\n", s);
printf("%s\n", s);
fflush(stdout);
close(fd);
return 0;
}
三. 总结
- 文件描述符的本质为数组下标,每个进程PCB中会有一个指向struct_files的指针,而struct_files中有存在有fd_array数组指向文件对象。系统IO接口通过拿到的文件描述符fd,根据fd_array[fd]找到指定的文件操作。
- 一个C/C++程序运行起来,默认打开三个流:标准输入stdin、标准输出stdout、标准错误stderr,它们对应的文件描述符为0、1、2。
- 重定向的底层原理是改变某个文件描述符fd所指向的文件,这样通过fd进行读写操作时,被操作的文件就发生了改变。