目录
- 什么是文件描述符
- 标准输入、输出、错误的返回值类型FILE*的理解
- 进程中文件描述符的分配规则
- 重定向的原理
- 重定向的实际使用方法dup2
- 如何理解缓冲区
什么是文件描述符
在基础IO的上一篇博客里有提到过,系统调用open与close的返回值问题:
成功返回文件描述符;失败返回-1;
写一段简单的代码来打印文件描述符:
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define LOG "log.txt"int main()
{umask(0);int fd = open(LOG, O_CREAT | O_WRONLY, 0666);if(fd == -1){printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));}else{printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));}close(fd);return 0;
}
运行结果:
发现返回值文件描述符fd = 3,这是为什么呢?
——因为任何一个进程,在启动的时候,默认会打开三个文件:标准输入、标准输出、标准错误。
若在C、C++中,这三者的对应关系如下:
语言 | 标准输入 | 标准输出 | 标准错误 |
---|---|---|---|
C | stdin | stdout | stderr |
C++ | cin | cout | cerr |
标准输入、标准输出、标准错误其实都是文件。
而stdin、stdout、stderr都是文件在语言层的表现。(cin、cout、cerr也一样,但是是一个类)
(查询可以看到,其实这三者数据类型都是文件类型)
而标准输入、标准输出、标准错误对应的文件分别是:
标准输入——设备文件:键盘文件
标准输出——设备文件:显示器文件
标准错误——设备文件:显示器文件
所以打开文件的时候,文件描述符是3的原因其实就是:
0、1、2默认对应的是标准输入、标准输出、标准错误!
因为012已经默认被操作系统占用了,所以我们自己打开文件的文件描述符就是3!
文件描述符 | 文件 |
---|---|
0 | 标准输入 |
1 | 标准输出 |
2 | 标准错误 |
文件描述符对应的数字0、1、2、3…到底是什么?
——先说结论:
结论:文件描述符本质就是数组的下标。
解析:
文件存储在磁盘当中,当打开一个文件的时候,OS都会在内核中创建一个struct file
结构体来描述该文件,充当这个打开的文件。struct file结构体中包含着文件的大部分属性,就好像struct task_struct结构体中包含着进程的大部分舒心。对文件的增删查改就转换成了对链表的增删查改。只要找到了file,就可以得到所有文件属性和内容。
而文件是用户通过进程(调用系统调用接口)让OS打开的,我们在执行代码的时候,能最直接找到的就是进程的PCB,所以我们要维护进程和被打开文件之间的对应关系,即要维护好进程和被打开文件的映射关系:
一个进程可以打开多个文件——进程 : 被打开的文件 == 1 : n,OS为了能让进程快速找到对应的文件,在内核中定义了一个数据结构struct files_struct
,在该结构体中包含着一个数组struct file* fd_array[]
,其中包含的类型都是file*。当从磁盘加载一个文件时OS就要创建为该文件创建一个对应的对象,然后OS就要在当前执行打开操作的进程的struct files_struct内部的数组中从上到下进行遍历,找到一个没有被使用位置,将新加载的文件的结构体对象的地址填入到数组中,这样就建立了struct files_struct与被打开文件之间的对应关系。同时在进程的结构体当中,又包含了一个struct file_struct *files;
指针,在进程初始化的时候,会为该进程创建struct files_struct对象,并将地址存在struct file_struct *files指针内,这样就建立了进程与被打开文件之间的映射关系。
最后在应用层返回的文件描述符就是上面说的数组的下标,所以当使用read、write、close这些系统调用接口的时候,必须在参数列表传入文件对应的文件描述符,通过结构体对象的映射关系来找到对应的文件进行操作。
以上就是对文件描述符本质是数组的下标的理解!
对应关系如下图所示:
对文件写入、读取操作的进一步理解:
每个文件都要匹配一个缓冲区,比如当我们使用write(3, buffer, xxx)向缓冲区写入操作时,所做的工作过程是:我们调用了write函数 = => OS识别到了系统调用 = => 找到进程对应的pcb = => 找到file_struct结构体 = => 在file_struct结构体中找到fd_array[]数组,通过传入的文件描述符找到索引 = => 找到匹配的文件结构体file,找到后从用户层将对应的数据拷贝到缓冲区。调用结束后返回写了多少字节。而拷贝的数据什么时候由缓冲区刷新到外设磁盘,由OS自主决定。(过程如下如所示)
所以我们的IO类read、write函数本质是拷贝函数!(用户空间与内核空间进行数据的来回拷贝)
如何理解"一切皆文件"?
计算机有很多的硬件外设,比如键盘、显示器、网卡、磁盘等,并且这些硬件都有与之匹配的驱动程序来实现与硬件的交互,比如读写操作(read、write)。
以键盘为例:读取方法(read_keyboard();
)站在内存的视角来看,就是要将数据从外设键盘读取到内存中;而键盘这个外设不需要写入方法(write_keyboard();
),因为总不能从内存写入到键盘让键盘自己动,即让键盘驱动中的写入操作只有声明,函数体是空的、什么都不做即可。
与键盘类似的,计算机所有的外设都有IO操作,即驱动都有read与write功能。
再举个例子:显示器设备,在显示器显示数据,就是将数据写入到了显示器外设,所以显示器也有写入方法(write_screen();
),而读取方法(read_screen();
)则不需要,因为计算机是从键盘读取的数据,只是回显到了显示器上。
所有的硬件设备都是不同的,那么在Linux下一切皆文件是怎么做到的呢,是如何将不同的硬件设备都看做是文件的呢?——当打开一个硬件设备的时候,在OS内部也同样要创建一个struct file结构体对象,来描述这个硬件文件,其中包含了该文件的权限、大小、以及各种其他属性,除此之外还包含两个函数指针int (*readp)(int fd, char buffer, int size);
和int (*writep)(int fd, char buffer, int size);
,同时这个文件的struct file结构体对象中也有一小块缓冲区。读写的两个函数指针就指向属于自己的硬件驱动中的读写方法,并且每一个硬件外设在被打开时都有一个这样的结构体对象,其中都会将属于自己的读写方法的地址填入自己的函数指针中。
在进程的struct files_struct中的数组中保存着使用的外设文件对应的结构体地址,在进程通过该数组(文件描述符)访问对应的外设时,在进程看来,所有的外设都是struct file文件结构体对象。数据拷贝放入struct file的缓冲区中,在需要进行硬件设备的读写时,只需要通过函数指针调用对应底层驱动中的方法来完成读写即可,根本不用关心底层实现的差异。而OS也有读写操作,它的读写操作本质就是拷贝,只需要将上层应用层的数据拷贝到缓冲区中,然后通过指针调用底层不同的读写方法就可以将数据放到对应的外设中,所以在文件对象struct file以上来看,就实现了Linux下一切皆文件!不用关心底层的差异化。
我们都是通过进程的方式来进行OS的访问的!而进程只能看到文件(struct file),所以才有了一切皆文件。
这其实就是使用C语言来设计面向对象的思想(多态特性)。
标准输入、输出、错误的返回值类型FILE*的理解
标准输入输出错误、fopen的类型都是FILE*,那么FILE是什么?
——FILE是一个结构体。
FILE是谁提供的?
——是C语言提供的。可以进入/usr/include/stdio.h
文件中进行查看:
查看的结果:发现是_IO_FILE,类型后续会被重命名:typedef struct _IO_FILE FILE;
位置也在/usr/include/stdio.h
FILE与struct file有关系吗?FILE结构体中封装了什么?
——没有关系。并且这个结构体中必定封装了fd(系统调用的文件描述符),因为返回值为FILE*类型的例如fopen、fclose、fwrite、fread这些C的文件操作接口底层都调用了open、close、write、read这样的系统调用接口。
在/usr/include/libio.h
有一句:int _fileno; //封装的文件描述符
。
总结:
- 因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的,所以C库当中的FILE结构体内部,必定封装了fd。
并且既然返回值的FILE*是一个指针,我就可以通过指针来查看_fileno,并且结果与前面说的一致,012描述符被stdin、stdout、strerr占用,我们自己打开的文件描述符为3:
//头文件略
#define LOG "log.txt"
int main()
{printf("%d\n",stdin->_fileno);printf("%d\n",stdout->_fileno);printf("%d\n",stderr->_fileno);FILE* fp = fopen(LOG, "w");printf("%d\n", fp->_fileno);return 0;
}
进程中文件描述符的分配规则
在进程中,文件描述符的分配规则:
在文件描述符表中,会将最小的、没有被使用的数组元素分配给新文件。
重定向的原理
首先写一段测试代码:
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define LOG "log.txt"int main()
{close(1);//关闭标准输出int fd = open(LOG, O_CREAT | O_WRONLY | O_TRUNC, 0666);printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");printf("hello world!\n");return 0;
}
结果:
运行后发现没有在命令行打印,并且在生成的log.txt文件中发现了5行hello world!
解析:
首先因为012号文件描述符分别对应标准输入输出和错误,我们关闭1号描述符即关闭了stdout;
又因为文件描述符的分配规则:在文件描述符表中,会将最小的、没有被使用的数组元素分配给新文件。
所以当我们打开新的文件log.txt的时候,会将现在空出来的1号文件描述符分配给log.txt文件;
而printf默认向显示器打印,即默认输出到准输出流stdout,但是printf是根据数组下标来打印的,它并不知道对应数组下标的fd实际指向的是谁,此时1号文件描述符指向的是log.txt,所以就会发生打印输出到log.txt文件的现象,这不就是输出重定向吗?
对输入重定向,同样的方法,以O_RDONLY(只读模式)打开文件,如果关闭0号文件描述符标准输入stdin,打开文件后会将文件地址填入0号下标,此时在进程中如果使用scanf输入,就不会从默认的标准输入stdin——键盘输入,而是直接从打开的文件中读取数据并输入,这不就是输入重定向吗?
而对于追加重定向,将输出重定向中打开文件的方式由O_TRUNC(对文件内容做清空)改为O_APPEND(追加)即可,所以输出重定向与追加重定向唯一的区别就是文件打开的方式不同。
重定向的原理:
在上层无法感知的情况下,在OS内部,更改进程对应的文件描述符表中特定下标的指向!
输出重定向只会修改1号文件描述符对应的文件。下面写一段C++测试代码,分别向标准输出与标准错误打印,然后再输出重定向到一个文件log.txt,观察现象并分析:
#include <iostream>
#include <cstdio>//c++风格的c语言头文件int main()
{//cprintf("hello printf->stdout\n");fprintf(stdout, "hello fprintf->stdout\n");fprintf(stderr, "hello fprintf->stderr\n");//c++std::cout << "hello cout->cout" << std::endl;std::cerr << "hello cerr->cerr" << std::endl;return 0;
}
结果:
发现输出重定向后,只有fprintf指定文件流stdout的与cout的打印到了文件log.txt中,而stderr与cerr还依旧向显示器文件打印。
分析:
这是因为fprintf指定文件流stdout与cout对应的是1号文件描述符,我们输出重定向的时候修改的是1号文件描述符指向的文件,从指向stdout修改成了指向log.txt,所以这两者打印的时候虽然传入的是stdout,但是实际上只传入了1号文件描述符,1号文件描述符的指向它们并不知道,所以完成了输出重定向;而fprntf指定文件流stderr与cerr对应的2号文件描述符并没有进行修改,所以不会收到输出重定向的影响,依旧向显示器文件打印。
如果关闭2号文件描述符,再打开一个新的文件logError.txt那么此时就可以将标准错误打印到这个文件内。
以后文件量大的话,可以使用这种方法将错误消息都筛选到一个文件中,便于观察。
补充:输出重定向命令行操作
- 如果想在输出重定向的时候将标准输出和标准错误都输出到一个文件内,可以使用
./文件名 > log.txt 2>&1
(log.txt是输出的文件名,根据自己的文件名输入),意思是将1文件描述符指向的内容给2下标。- 如果想分别把标准输出和标准错误分别放在两个文件内,可以使用
./文件名 1>nor.txt 2>err.txt
(nor.txt、err.txt是输出的文件名,根据自己的文件名输入),意思是将1号文件描述符的结果重定向到nor,将2号文件描述符对应的结果重定向到err。
重定向的实际使用方法dup2
前面讲原理时所用的方法:关闭相关的文件描述符指向的文件,然后再打开文件来实现重定向。这种方法平时不会使用,只是讲原理时使用。下面介绍重定向的实际使用方法:
头文件:
#include <unistd.h>
函数:
int dup2(int oldfd, int newfd);
参数:
newfd与oldfd都是文件描述符,让newfd下标对应的内容拷贝oldfd下标的内容(相当于最后保留oldfd),比如fup2(fd, 1)
就是输出重定向,1原来指向标准输出,修改为指向fd指向的文件。
下面写一段测试代码来通过dup2函数,将标准输出和标准错误分别重定向到不同的文件中。
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>#define LOG_NORMAL "logNormal.txt"
#define LOG_ERROR "logError.txt"int main()
{int fd1 = open(LOG_NORMAL, O_CREAT | O_WRONLY | O_TRUNC, 0666);int fd2 = open(LOG_ERROR, O_CREAT | O_WRONLY | O_TRUNC, 0666);if(fd1 < 0){perror("open");return 1;}else if(fd2 < 0){perror("open");return 1;}dup2(fd1, 1);//1号文件描述符重定向到fd1dup2(fd2, 2);//2号文件描述符重定向到fd2fprintf(stdout, "hello world!\n");fprintf(stdout, "hello world!\n");fprintf(stdout, "hello world!\n");fprintf(stderr, "hello world!\n");fprintf(stderr, "hello world!\n");fprintf(stderr, "hello world!\n");close(fd1);close(fd2);
}
结果:
如何理解缓冲区
我们以前说的输出缓冲区、输入缓冲区还有内核结构体对象的缓冲区是一样的吗,位置在哪里,为什么要存在缓冲区。
1.刷新策略有哪几种?
——行缓冲、全缓冲、无缓冲。
C库会结合刷新策略,将缓冲区中的数据写入给OS。
2.显示器与普通文件的刷新策略分别是?
显示器采用的刷新策略:行缓冲
普通文件采用的刷新策略:全缓冲
3.存在缓冲区的意义是?
——存在缓冲区的意义是节省调用时间,因为如果直接使用系统调用,用一次数据刷新一次,调用系统调用然后数据刷新也是要消耗时间的,但是如果存在缓冲区,那么可以将多次的数据存储在一起,然后一次刷新,减少刷新频率,可以节省调用者的时间。
4.缓冲区的位置在哪里?
——在进行fopen打开文件的时候,会为我们生成一个FILE结构体,malloc一块空间,缓冲区就在FILE结构体中。
5.分析下面这段测试代码的结果,理解缓冲区。
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int main()
{fprintf(stdout, "hello fprintf\n");const char* str = "hello write\n";write(1, str, strlen(str));fork();return 0;
}
结果:
直接运行结果正常输出,但是当输出重定向到log.txt文件的时候,为什么hello fprintf会多打印一次?下面进行解析理解。
解析:
当调用write这样的系统调用的时候,不经过C库,不存在缓冲区,直接调用write写给OS;
当调用fprintf这样的C库函数的时候,会经过C库,调用函数在C库中运行fprintf的时候会有缓冲区,数据先存在缓冲区中,又因为显示器的刷新策略是行缓冲,所以C库再以行缓冲的形式将数据刷新到标准输出屏幕。
所以当直接运行上面的程序的时候,会刷新两个字符串。
但是当重定向的时候,write依旧会直接写入给操作系统;fprintf则会将数据先写入到缓冲区中,又因为是重定向,不是向显示器写入,所以缓冲区的刷新策略会由行缓冲变为全缓冲,当缓冲区写满时才会刷新,但是我们只有一句hello fprintf\n,无法将缓冲区写满,所以在fork创建子进程的时候,write已经写入到了OS,而fprintf还在缓冲区中,缓冲区的这部分数据虽然在C库中,但是也属于父进程的数据,所以后续父子进程都要对缓冲区做刷新,因为刷新有先后顺序,在刷新的时候就会发生写时拷贝,所以缓冲区的数据就会刷新两次,自然会打印两条fprintf了。