🪐🪐🪐欢迎来到程序员餐厅💫💫💫
主厨的主页:Chef‘s blog
很好,佬们博客都写一百多甚至于两百多篇了,而且都是优质博客,我得加把劲了,之前我一个朋友还和我说他一天足足写了四篇,我就试试今天双更一把好了
一切皆文件
开宗明义:Linux下所有都是文件,你自己写的程序、bash命令行解释器、众多指令、都是文件,连你的显示器、键盘这些硬件也是文件!
-
我们自己要写代码第一步是创建一个test.c,他显然是文件。
-
命令行解释器也是一段代码,我们之前已经尝试它的代码实现了,所以她也是文件
-
指令本质就是可执行程序,我们之前还展示过他们所处的文件夹,所以他们也是文件
可是,硬件这东西怎么会是文件呢?
请注意,这里所谓的“硬件是文件”指的是把硬件当作文件来描述、处理、操控,而不是说硬件真的就是那些存在磁盘里的文件 。
就像计算机有很多进程、有很多文件一样、他也同样有很多外设,例如鼠标、键盘、网卡、显卡、磁盘、麦克风等等,我们之前对操作系统下过定义:
操作系统时一款帮助用户进行软硬件资源管理的软件。
好了好了、谈到了管理,已经可以梅开四度了:
经过前面那么多次的重复,相信观众朋友已经可以抢答了,
怎么描述?
定义一个struct device结构体类型,里面包含了外设的种种信息,
struct device{ char name[50];//设备名称 int status; //当前状态char vender[50]; //厂商//.............. };
上面这些用来描述硬件的成员变量很好理解,但是我们知道硬件的价值在于去使用他,所以接下来要考虑的是如何通过软件(代码)使用硬件。
vfs(虚拟文件系统)
可以理解的是不同的外设(鼠标、键盘、显示器)都可以进行read(读取信息)的操作
-
从键盘读取就是接受你敲了那些键位
-
从鼠标读取就是接受你是按了左键还是右键,点击的哪里这些信息
-
从显示器读取就是读取显示器呈现的字母数字等符号
毫无疑问,他们具体的读取方法是不一样的,但是他们都可以读取信息,
同样的,我们直到有些外设(显示器、磁盘、网卡)可以接受信息,他们的接受方法不一样但却是都可以接受。
此时类比上面的成员变量,
- 比如name,所有外设都有名字,只是名字不一样,
- 比如状态,所有外设都有当前状态(开启、关闭、故障),只是可能相互之间不同。
我们所提到的读写操作也是啊,所有硬件都有读写操作,只是具体操作方法不一样,于是我们就可以在struct device中加入各种函数指针类型的变量来代表对硬件的种种操作。
那键盘的write函数怎么写呢?你不可能向键盘写入啊,诶只需要写一个空函数就好了。
此时我们就可以以统一的操作对外设进行操作了,想读取显示器?直接调用read,想读取磁盘?也是直接read,他们具体实现不一样,但是接口却是一样,如此一来就很方便统一管理了。
如此一来,一个外设的相关信息和使用方法都被统一的封装成了一个结构体,
这套操作被称为vfs(虚拟文件系统)
此时我们就会发现,磁盘里的文件被以结构体的形式描述和操作,而我们的外设也已经被描述为了结构体,所以我们就可以把操作文件的那套函数(read,open、write)套用在外设上了,例如我们要对显示器进行写操作,过程如下。
向系统接口write传入fd等参数,write函数会先通过显示器的fd找到描述他的那个结构体,再调用这个结构体中的函数指针write,就可以对显示器写入了。所以这些系统文件接口write和read等本质也是对外设结构体中的函数指针的封装调用,而非直接自己实现。
与外设相同,磁盘中的文件在被加载进来时也会有对应的结构体struct file
他有一个成员变量
struct file_operations *f_op;
我们可以看一下这个成员变量里是什么
显而易见,就像外设有write这些函数指针一样,文件也是有的,上面这一大堆就是文件的函数指针,至于他的实现,在创建file结构体时,OS会搞定的 ,我们不用管
既然对所谓外设的管理与文件一样,那我们不就可以认为外设也是文件吗,那不就是一切皆文件了吗?
重定向
概念:
每个进程在启动时都会自动打开三个输入输出流:
- 标准输入(stdin)指向键盘,用于接收用户输入的数据;
- 标准输出(stdout)指向屏幕,用于显示程序的输出结果;
- 标准错误(stderr)也指向屏幕,用于显示程序的错误信息。
重定向是一种操作,它改变了数据的默认流向。在计算机系统中,重定向就是将这些输入输出流从它们的默认设备(如键盘和屏幕)引导到其他地方,比如文件或者其他进程。
重定向的方法
手动close
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main(){close(1);int fd1=open("./test1.txt",O_WRONLY|O_CREAT,0666);int fd2=open("./test2.txt",O_WRONLY|O_CREAT,0666);printf("test1.txt:%d\n",fd1);printf("test2.txt:%d\n",fd2);close(fd1);close(fd2);
}
我们先是关闭了标准输出流stdout,接着又打开了两个文件,则可以得知test.txt的fd会是1,test2.txt的fd会是3,最后我们用printf输出一些内容。
推理如下:
首先stdout被关闭了,所以不可能在显示器上打印结果
其次,test1.txt文件fd是1,
最后,printf的封装就是向系统文件接口write传入fd=1从而在显示器打印的
结论:最后会把内容打印在test1.txt
结果如下:
我们推理错了吗?为什么没有打印,数据跑哪了,显示器也没有、test1.txt也没有。
好了,我知道你很急,但是你先别急,我们再来看一段代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main(){close(1);int fd1=open("./test1.txt",O_WRONLY|O_CREAT,0666);int fd2=open("./test2.txt",O_WRONLY|O_CREAT,0666);printf("test1.txt:%d\n",fd1);printf("test2.txt:%d\n",fd2);fflush(stdout);close(fd1);close(fd2);
}
这段代码和上一段代码相比唯一的区别就是这次我最后加了一个刷新缓冲区。
结果也是非常Amazing啊,确实在test1.txt上面打印出来了 。原理你先别管,我们后面说,现在大家都看到了重定向的具体表现了,但是这样写代码有可能写出问题,因为你需要先关闭stdout再打开你要重定向到的另一个文件A,如果你先打开A,那就不能重定向了。所以我们换个优雅一点的。
dup2
#include <unistd.h>int dup2(int oldfd, int newfd);
返回值
- 函数成功将返回修改前的newfd的值,
- 若oldfd和newfd一样则dup2什么也不做,且返回newfd
- 若newfd对应文件不存在则dup2正常运行
- 失败返回-1(例如oldfd不存在)
它的基本功能是将
oldfd
(旧的文件描述符)所代表的文件关联(或者说 “复制”)到newfd
(新的文件描述符)上。这意味着,在调用dup2
之后,newfd
和oldfd
将指向相同的文件
原本的newfd所指向的文件则会被自动关闭,我们也发现原来不同的fd可以指向同一个文件
不用担心对他们两个都是用close会不会报错,因为close采用的是引用计数,我们设一个文件对应的fd有n个,每次close都会让n--,只有当n等于0,才会关闭该文件。
缓冲区
文件内核级缓冲区
我们在调用write接口向文件写入,本质就是CPU和磁盘做IO,这个过程很耗时间,于是OS的佬们给文件设计了一个文件内核级缓冲区。这块区域属于内存,放在结构体struct file中,更详细的我们以后再说。
在调用write进行写入时CPU不会直接去和磁盘做IO,而是先把数据写到该文件的缓冲区中,等到缓冲区满了再一次性刷新到磁盘的文件中。
在调用read进行读取时CPU可以提前把一大段数据预加载到该文件的缓冲区中,等到了需要读取数据时就不用再去磁盘,而是可以去缓冲区读取了。这样一来,缓冲区的设置就提高了CPU与文件的IO效率。
fsync
刷新fd所对应的文件级缓冲区
#include <unistd.h>int fsync(int fd);
语言级缓冲区
我们之前说了,为了加快系统接口write、read和磁盘文件的IO速率,我们创建了文件级缓冲区,但是不只是CPU和外设之间的IO有时间开销,我们平时所使用的c语言的文件接口是封装了系统接口了,那么你在使用c语言接口时就会调用系统接口,系统接口也是函数,只要函数调用就会有时间开销,为了降低这份开销,佬们又创建了语言级缓冲区。在调用c语言接口时,先不直接调用对应的系统接口,而是把数据写到语言级缓冲区(这个过程很快),等到语言级缓冲区快满了再调用系统接口把数据一次性都传递过去。这个缓冲区就存放在FILE结构体类型中。
在了解了这些东西后,我们就可以回答下面的问题了
为什么我加了fflush就可以把结果输出在t2.txt上面,不加fflush就什么也不会输出
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
int main(){close(1);int fd=open("t2.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);printf("t2.txt:%d",fd);//fflush(stdout);close(fd);return 0;
}
因为printf本质是对write系统接口的封装,他会先把数据输出到语言级缓冲区中,等到缓冲区满了才会把该缓冲区的内容通过write一次输出到文件级缓冲区。注意这个文件级缓冲区是在结构体file中的(不是FILE!!!)我们调用了close就会把对应的file对象从内存中清理掉,这样的话虽然在进程结束前一刻,会统一刷新所有的文件级缓冲区和语言级缓冲区,但是t2.txt的file对像已经没了,原本在语言级缓冲区的数据就不可能再进入文件级缓冲区,自然的就没有输出了。
在close前面加入fflush,就可以刷新t2.txt的语言级缓冲区,把数据传入对应的文件级缓冲区,等到进程结束的前一刻,会自动刷新所有缓冲区,于是文件缓冲区的数据就会被刷新到文件中,于是我们的t2.txt中就有内容了。
总结一下:
-
显示器文件的语言级缓冲区设定为行刷新,即遇到换行符就耍新
-
其他文件的语言级缓冲区设定为满刷新,即缓冲区满了再刷新
-
进程结束前一刻会刷新所有语言级缓冲区和文件级缓冲区
-
close会刷新文件级缓冲区缓冲区,fclose会刷新语言级缓冲区,且他是对close的封装
现在请看这段代码,你可以猜猜结果
#include<fcntl.h>
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
int main(){int fd=open("t1.txt",O_CREAT |O_WRONLY|O_TRUNC,0666);dup2(fd,1);write(fd,"write\n",6);printf("printf\n");fprintf(stdout,"fprintf\n");fwrite("fwrite\n",1,7,stdout);fork();return 0;
}
可以看到除了write,其他三个接口都被执行了两次。
这可以用我们小学二年级就学过的写时拷贝来解释。
显然,另外三个都是语言接口,而该文件是满刷新,所以他们的内容会先都保留在文件的语言级缓冲区,注意语言级缓冲区是在FILE里的,而fork创建子进程时所需的代码和数据来自父进程的放在FILE中的代码和数据,这里的数据是包括这个语言级缓冲区,等进程结束父进程或子进程会有一个先结束,并且自动刷新缓冲区,刷新缓冲区造成数据的修改触发写时拷贝,于是另一个进程的缓冲区就不再是原来的共享,而是拷贝,该进程结束也会刷新缓冲区,于是这三个语言接口看起来就像被执行了两次。
至于write,他本来就是系统接口,内容也是放到了文件级缓冲区,文件级缓冲区在结构体file中,与FILE是区别开的,这个file结构体是由OS负责管理创建、销毁的,fork属于上层的语言函数,自然无法像创建FILE一样也创建一个file,因此write只有一条语句。