欢迎来到博主的专栏:从0开始linux
博主ID:代码小豪
文章目录
- 设备文件
- 文件缓冲区
- 重新认识文件描述符
- 重定向
设备文件
在前一篇文章博主提到,当一个c/c++进程运行时,会默认打开三个文件流,分别是stdin,stdout,stderr。分别对应键盘,显示器,显示器,我们将这三个文件流称为标准文件流。但是大家有没有思考过这么一个问题?那就是为什么外部输入设备,竟然可以用文件打开?文件不是一个数据吗?
不知道大家有没有听过这么一句话,叫做linux下,一切皆文件。比如我们的指令,它们是文件,保存在/usr/bin目录下,而设备也是文件,保存在/dev目录下。
如果我们仅拿这个论证,证明设备是文件,其实是倒果为因了,因为/dev中存在设备文件,是linux将设备设计成文件的结果之一。因此我们想搞清楚linux是如何将设备描述成文件?又是为什么要将其设计成文件?这才是真正重要的东西。
为了搞清楚这点,我们需要回到系统内核本身来看,在前面的章节中,我们了解到linux内核的主要功能有:管理进程,管理文件,外设交互等等的功能。这里博主就要抛出一个概念了,叫做linux对于管理对象,总是先描述在组织
。
如何理解先描述在组织呢?简单来说,就是linux会先设计出管理的对象的数据结构,接着通过这些数据结构,再去进行对象的行为操作。比如linux为了管理进程,会创建出task_struct来描述进程,接着在通过task_struct来管理进程,为了管理文件,就创建了struct file来描述文件,接着在设计出针对这些对象的方法,比如关闭进程,打开文件,关闭文件等。
linux则是将设备描述成struct device结构体,以我们常见的pc为例,外部输入设备有键盘,显示器,磁盘,网卡等。而每个设备,其对应的功能又不同,(从系统的角度上看)比如显示器可写不可读,键盘可读不可写。因此不同的设备,其对应的struct device的具体属性是不同的。
但是由于不同的设备,存在差异,对于系统来说,与外设进行交互实在是太频繁了,而且设备的数量随着时代的发展,也会越来越多,如果系统与外设交互时,都需要创建出特殊的对象,那实在是太麻烦了。
那么linux的设计者想了想,发现了这么一个特性:对于设备来说,种类很多,但是系统的交互无外乎就是读和写两件事,而文件呢?每个文件在结构上基本都是相同的,而且也能进行读和写的操作。那么如果我们借助文件的结构,来描述设备,是不是就能完成统一了?
于是有趣的地方来了,我们的进程,需要与外设进行交互(比如printf),为了让用户的使用方便,在struct device和task_struct之间,建立了一个struct file,作为两者沟通桥梁。于是乎进程对于外设的写入,就变成对文件的写入,而对外设的读取,就变成了文件的读取,方便性得到了大大的提升。
在上图中,博主将表现这种关系的代码写成了c++风格的代码,但是实际上linux是由C语言写成的,实际上struct
file当中read和write在源码当中其实是函数指针,指向对应设备的write的read函数,这么写只是方便理解罢了。
这里博主为了验证这个观点,放出linux当中的源代码:
可以看到,在struct file当中,存在一个名叫file_operations的成员,该成员当中保存着各种各样的与文件操作相关的函数指针,因此,如果我们的进程想要与外设进行交互,系统就会将该外设对应的文件打开,然后对文件执行,对应的读写操作。由于存在函数指针这一层的封装,对文件的读写操作,就会转变成,对于外设的读写操作。
文件缓冲区
当我们读写普通文件或者设备文件时,并非是直接向对应的文件直接进行读写的,而是在文件与cpu之间,创建一个文件缓冲区,该缓冲区存在在内存当中,当我们写入文件时,实际上是向文件缓冲区进行写入,读取文件时,首先将文件当中的数据写入到文件缓冲区当中,接着再向文件缓冲区当中读取我们想要的数据。
首先我们要搞清楚一点,就是打开文件的是进程,读写文件的也是进程,操作文件的行为,都是进程执行的。比如我们想要像显示器写入数据,是不是要在进程当中调用printf()?我们想要从键盘当中读取数据,是不是要写scanf?因此操作文件的并非用户,而是进程,或者说是用户在进程当中对文件进行操作。
以普通文件log.txt为例,当我们打开文件时,首先操作系统会在内存当中生成对应的struct file结构。在struct file当中会存在一个指针,该指针指向文件缓冲区(buffer)。
此时进程1调用write()函数,向文件写入"hello world",实际上并不是向磁盘当中的log.txt文件写入“hello world”,而是向buffer当中写入"hello world".
此时,磁盘当中的log.txt其实并没有被写入数据,只有当操作系统刷新缓冲区时,这个数据才会被写入到磁盘当中的log.txt里面,比如我们在写word文档时,实际上并不是将文本写入到磁盘当中的word文档,而是写在其对应的文件缓冲区当中。只有我们点击了保存以后,才算是真正将数据写入到磁盘当中的word文档,否则写的文本在下次打开word文档时就会消失(不过现在有自动保存,这个现象很少能见到了)。
那么当我们读取文件的数据时,也并不是直接读取磁盘当中的内容,而是操作系统会先将文件的数据加载到文件缓冲区当中,然后进程在文件缓冲区当中读取,比如我们现在运行进程2,让进程2读取log.txt文件的数据(“hello world”)。
为什么要创建一个文件缓冲区而不是直接读取磁盘当中的数据呢?其实还是效率问题,首先我们要知道,cpu的计算速度是很快的,而外设与内存之间的交互是很慢的,cpu与内存的交互速度远快于内存与外设的交互速度。因此如果频繁的让内存与磁盘进行交互,运行速度会非常慢,因此有了文件缓冲区,让内存一次性获取磁盘文件的大量数据,这样就能减少内存与磁盘交互的次数。运行效率会快上不少。(我们的读写操作,从cpu->内存->磁盘,变成cpu->内存,当必要时才让内存与磁盘交互,这样就减少了内存与磁盘的交互次数。)
重新认识文件描述符
我们在前一篇章节当中重点讲解了文件描述符,但是有一个细节博主只是简单带过了,因为这个细节与文件的重定向操作有关,因此博主将其放在这篇文章当中讲述,因为后面就要讲解与重定向相关的内容了。
首先,文件是由进程打开的,因此在进程的pcb(task_struct)当中,存在一个指针,指向当前已被打开的文件。由于一个进程可以打开多个文件,因此只有一个指针是不够的(要指向多个文件),因此pcb当中管理被打开文件是一个文件指针数组fd_array[N]。
一个进程被运行,会创建一个对应的task_struct结构,而每一个打开的文件,都有一个对应struct file结构,而一个进程可以打开多个文件,因此就有多个struct file结构被创建。这个fd_array,保存的是指向struct file的指针。
在上一篇博客中。博主简单了的提了一下,open函数会返回一个文件的文件描述符fd,如果我们要在进程中使用write,read函数对文件进行读写操作,首先是要告诉write,read函数,我们要操作的文件的fd是多少,因此fd对于文件来说,是起一个指向作用的,就好比进程的pid一样。
当我们使用open函数时,会打开一个在磁盘当中的文件,这个打开文件的操作,实际上是分成一下几步的:
(1)创建描述该文件的struct file
(2)生成该文件的对应的文件缓冲区
(3)将该文件对应的struct file,记录在task_struct的fd_array当中
(4)该文件的fd,实际上是在fd_array数组的下标。
如何验证这一点呢?还记得博主在上一篇文章提到的吗?每一个c/c++程序运行,都会默认打开三个文件流,分别是stdin,stdout,stderr。因此最先加载到fd_array的文件就是这三个。因此我们每一个进程,最初的fd_array都是这样的:
而被打开的文件对应的fd,实际上是它们在fd_array[N]的数组下标,因此stdin的fd为0,stdout的fd为1,stderr的fd为2,如果此时我们再让进程,打开log.txt文件,此时它就会被放在fd_array数组的3号下标处。因此其fd就是3.
不信?不信我们就来写一份代码验证一下。
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>int main()
{int fd1=open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0666);//open的返回值,是被打开的文件的fdprintf("stdin fd:%d\n",stdin->_fileno);//stdin->_fileno是stdin的fd,后者同理printf("stdout fd:%d\n",stdout->_fileno);printf("stderr fd:%d\n",stderr->_fileno);printf("log.txt fd:%d\n",fd1);
}
接着我们运行该程序,并且查看结果。
从打印的结果来看,证明了我们上面所言非虚,fd其实是被打开的文件在fd_array当中的数组下标。
重定向
这里给大家补充一个知识点,是关于printf()函数的,这printf谁不会啊?从刚开始学C语言的时候就用过了。不就是向显示器打印字符串嘛。
这里大家有没有想过,stdout是显示器的文件流,而stdout的fd是1,而printf函数是向显示器输出数据,也就是向stdout输出数据,实际上也就是向fd等于1的文件输出数据,如果我们先用close函数关闭fd为1的文件(stdout),接着打开log.txt,此时log.txt就会顺位变成fd为1的文件。那么此时printf会向什么文件进行写入呢?
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>int main()
{close(1);//关闭stdoutopen("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);//log.txt会顺位变成fd为1printf("hello world\n");printf("hello world\n");printf("hello world\n");printf("hello world\n");printf("hello world\n");fflush(stdout);//将stdout的缓冲区刷新return 0;}
接着我们运行该程序,此时我们惊讶的发现,printf竟然没有向屏幕打印"hello world",接着我们查看一下log.txt文件。可以发现printf将数据都输出到了log.txt当中。
这是因为,stdout其实就是fd为1的文件,如果我们先将fd为1的文件关闭,也就是将进程与显示器之间的输出流关闭,接着我们打开log.txt,而此时log.txt会顺位进入fd_array的1数组下标的位置,即fd为1的文件变成了log.txt。此时,stdout就从显示器,变成了log.txt(stdout并非显示器,而是fd为1的文件!!!只是默认是显示器)。而printf是向stdout写入数据,由于stdout流向了log.txt,于是printf就变成向log.txt写入数据了。
我们将这种文件流进行修改的操作,叫做文件重定向。而文件重定向当中又分为输出重定向,输入重定向,追加重定向,但是它们的原理都是差不多的,就是将文件的fd进行替换。详细的内容我们后面再讲。、
文件重定向的系统叫做dup系列,分别为duo,dup2,dup3。
#include <unistd.h>int dup(int oldfd);
int dup2(int oldfd, int newfd);#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <fcntl.h> /* Obtain O_* constant definitions */
#include <unistd.h>int dup3(int oldfd, int newfd, int flags);
这里博主重点介绍dup2。dup2又两个参数,叫做newfd和oldfd,其中,newfd是被替换的文件的fd,oldfd是替换的文件的fd。比如我们想让log.txt替换掉fd为1的文件,我们就应该这么写:
#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);dup2(fd,1);//fd替换掉1的文件printf("hello world\n");//向stdout写入fprintf(stdout,"hello byte\n");//向stdout写入const char str[]="hello code\n";fwrite(str,sizeof(str),1,stdout);//向stdout写入return 0;
}
接着我们运行该程序,并且查看log.txt文件的内容。
这里我们谈谈dup2函数的原理是什么,首先我们都知道,文件的fd其实就是被打开的struct file在fd_array的数组下标。而dup2函数则是将oldfd中的struct file,拷贝到newfd当中,其中newfd的原结构可能会被删除,而oldfd的原结构则会被保留。
从上图可以看到,其实替换的实质,是一个赋值操作,即将fd(3)号下标的struct file对象,赋值给fd_array的1号下标。原来的三号下标的指向的文件依然不变。但是如果某个文件在fd_array当中的指针个数变为了0,那么这个文件流就会被关闭。