![](https://img-blog.csdnimg.cn/ea40e8b946b54f209dbf74d5f27de258.png)
我们曾经学过语言层面的文件操作,是不是感觉都不知道在干啥?因为在语言层面上,是肯定解释不清楚原因的,必须要结合OS的知识才可以。今天我们来探索一下它的底层原理。
回顾C语言文件操作知识
C程序运行起来,会默认打开三个输入输出流:stdin、stdout、stderr。
stdin:键盘
stdout:显示器
stderr:显示器
我们可以先来一段C文件操作的代码:
#include<stdio.h>
int main()
{
FILE* fp = fopen("./myfile", "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
const char* msg = "hello world\n";
fputs(msg, fp);
return 0;
}
运行结果:
可能大家对上面的C函数接口都见过, 我们有没有好奇过FILE*这个类型?
我们来看几个文件操作接口:
通过上面的接口,我们发现都要传一个FILE的指针,那这个FILE的指针到底是什么呢?
我们来看C语言默认给我们打开的三个流的类型是什么:
我们发现这三个流的类型竟然也是FILE*,那我们是不是也可以传stdout来打印内容到显示器上呢?
我们对上面的代码进行简单修改测试:
int main()
{
const char* msg = "hello world\n";
fputs(msg, stdout);
return 0;
}
测试结果:
我们发现使用stdout也能向显示器上打印。
上面例子说明了什么?fputs向一般文件或者硬件都可以写入!!那么站在OS的角度就是,一切皆文件。
系统文件IO
系统接口和语言层IO接口的关系
语言层文件操作的接口实际上就是对系统调用接口的封装,它必须遵守OS系统调用的规则。
系统调用接口
open
**pathname: **要打开或创建的目标文件
**flags: **打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
参数:
**O_RDONLY: **只读打开
**O_WRONLY: **只写打开
**O_RDWR : **读,写打开
这三个常量,必须指定一个且只能指定一个
**O_CREAT : **若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限通常是传mode参数,如果是普通文件,比如我们给0644(-rw-r--r--)。
**O_APPEND: **追加写返回值:
成功:新打开的文件描述符
失败:-1
close
fd:文件描述符(后面再讲)
read
read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.
功能:把从fd指向的文件中读到的内容写到buf缓冲区中,最多读取count字节。
write
write() writes up to count bytes from the buffer pointed buf to the file referred to by the file descriptor fd.
功能:把buf缓冲区里面的内容写到fd指向的文件中。
lseek
通过对比上面的系统调用接口,我们可以发现他们传参时都必须要有一个fd整型。这个整型是什么呢?我们接着往下思考:
fd
open返回值
我们通过下面的代码实验,创建文件时,open的返回值是多少:
int main()
{
int fd0 = open("./myfile0", O_WRONLY | O_CREAT, 0644);
int fd1 = open("./myfile1", O_WRONLY | O_CREAT, 0644);
int fd2 = open("./myfile2", O_WRONLY | O_CREAT, 0644);
int fd3 = open("./myfile3", O_WRONLY | O_CREAT, 0644);
printf("fd: %d\n", fd0);
printf("fd: %d\n", fd1);
printf("fd: %d\n", fd2);
printf("fd: %d\n", fd3);
return 0;
}
运行结果:
我们发现当我们创建多个文件时,他们会从3开始依次往后增。
那么 0 1 2呢??实际上0 1 2就是OS默认帮我们打开的输入输出三个流。
那么我们就有了很重要的发现,fd的顺序是0,1,2,3,4,5...看到这个我们能想起什么??
有点抽象?那我再画个图更清晰一些:
**我们就能惊奇发现,这不就是数组吗?答案是,是的! **
struct_file* fd_array[]
那么如何描述呢?
实际上OS创建了一个struct file的一个结构体,来保存文件相关的属性信息、连接信息等。
那么如何管理呢?
我们知道,进程中可能存在多个文件,那么多个文件就会被struct file的结构体描述起来,同时用一定的数据结构(比如双向链表)组织起来。同时,OS会创建一个struct files_struct这样的结构体,里面有一个struct file* fd_arrey[]这样的指针数组,它里面存放的是对应描述文件的结构体(struct file)的地址。
0下标对应的struct file*是描述标准输入的结构体的地址。
1下标对应的struct file*是描述标准输出的结构体的地址。
2下标对应的struct file*是描述标准错误的结构体的地址。
这时候我们就明白了,为什么我们常说的0、1、2对应的输入输出流了。
另外,PCB(struct task_struct)里面要存放struct files_struct的地址,这样OS就能通过struct file_struct*找到结构体,然后就可以找到**对应的下标:fd**。然后就可以找到对应的struct file的文件,进行各自的读写操作了。
OS角度 --- 一切皆文件
** 每个struct file里面都有自己的函数指针,可以找到对应的驱动层上的读写方法!**这样struct file上层看来,所有的文件,读,写,根本就不关心你到底是什么文件。因为每个struct file会调用自己对应的函数指针,执行不同硬件的读写方法,这种特性和C++的多态很类似。
所以,在vfs层看来,就是一切皆文件,对硬件的读写,也不过就是调用不同的函数而已。
总结:
文件在系统层面有一个vfs(虚拟文件系统),里面包含了每一个文件的struct file结构体,它里面有一批函数指针,这批函数指针帮我们指向底层方法的。在vfs看待(统一视角看待文件读写),一切皆文件。(通过fd来找到对应的struct file)。
文件描述符的分配规则
我们先用两组代码测试一下:
我们关闭文件描述符0,然后新创建一个文件,来看看,新创建文件的文件描述符是多少:
int main()
{
close(0);
int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
return 0;
}
运行结果:
我们发现这时候fd就不是从3开始了,而是fd变成了0!
我们接下来关闭文件描述符2,再来试试?
int main()
{
close(2);
int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
return 0;
}
运行结果:
这时候我们发现,fd也不是3,而是2!
结论:
在files_struct里的数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
重定向
输出重定向
我们在上面知道了,通过文件描述符可以找到对应的struct file。再根据文件描述符的分配规则,我们是不是可以这么做:
创建一个文件描述符为1的文件,我们再向stdout里面写内容,那这时候写的内容是在文件里,还是在标准输出上?
代码测试:
int main()
{
close(1);
int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
if(fd < 0)
{
perror("open");
return 1;
}
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
return 0;
}
运行结果:
我们发现,当文件描述符1里面对应的指针改成新创建文件struct file的地址之后,我们再向stdout上打印,就不行了,而是变成了向文件写入!
不难理解,我们画个图展示这个过程:
当我们接着向标准输出打印内容时,根据文件描述符分配规则,这时候通过文件描述符1就找到的不是标准输出的struct file,而是创建的新文件的struct file,,这时候再写内容时,就变成了向myfile的文件里写内容了。
这就是完后才能重定向的过程!
追加重定向
我们要实现追加重定向的功能,也很简单,就是在传flags参数时,或上O_APPEND,就表示追加字符串的意思:
代码和上面改动如下:
运行结果:
总结
我们这时候再来分析一下C语言中的FILE*指针。
通过FILE*可以找到一个结构体FILE,FILE实际上就是C语言层面上的结构体!
语言无论怎么封装,底层一定是调用了系统接口,并且,语言层就一定有表示该文件的fd!
1、因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。
2、所以C库当中的FILE结构体内部,必定封装了fd。
dup2系统调用
功能:old->new,把old(文件描述符)里面指向的地址拷贝给new(文件描述符)对应的指针。
怎么用呢?举个栗子:
int main()
{
int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 1);// fd 拷贝给 1
printf("hello wmm\n");
printf("hello wmm\n");
printf("hello wmm\n");
printf("hello wmm\n");
return 0;
}
运行结果:
这时候我们发现,经过dup2(fd, 1);操作,把标准输出的指向改成了myfile的文件,我们再往stdout打印时,就变成了往文件里写内容了,实际上,这也是完成重定向的过程!
缓冲区
stderr和stdout区别
我们发现stderr和stdout对应的硬件设备都是显示器,那么他们之间有什么差别呢?
我们先用一段代码来演示:
int main()
{
fprintf(stdout,"%s\n","hello 标准输出");
fprintf(stderr, "%s\n", "hello 标准错误");
return 0;
}
测试:
我们通过测试结果可以发现,当我们直接向显示器上打印时,标准输出和标准错误都打印出来了。 但是我们把打印结果重定向到文件中时,这时候我们发现标准错误还在显示器上打印,标准输出被写入了文件中。
我们发现标准错误不能被重定向到文件中,它是直接打印到显示器上,stderr中间是没有缓冲区的。
./tset > myfile 2>&1
如果我们想让标准错误也写入文件中,可以拷贝fd:
[cyq@VM-0-7-centos 文件描述符]$ ./test > myfile 2>&1
实验结果:
2>&1:把1号文件描述符拷贝给2号文件描述符,类似于dup2(1,2)。
这样stderr对应的地址,就是标准输出的struct file了。
现象一
我们来看一段代码:
int main()
{
close(1);
int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
if(fd < 0)
{
perror("open");
return 1;
}
fprintf(stdout, "hello wmm\n");
fprintf(stdout, "hello wmm\n");
fprintf(stdout, "hello wmm\n");
fprintf(stdout, "hello wmm\n");
fprintf(stdout, "hello wmm\n");
close(fd);
return 0;
}
这段代码和之前写的差别就是,在最后加了close(fd)
我们来看一下运行结果:
我们发现我们close(fd)后,我们往myfile文件里没有写入成功??是我们出现幻觉了吗?这是怎么回事?
原因
我们先了解一下缓冲区刷新策略:
结合语言层接着分析:
我们的代码中实现了重定向的功能,也就是本来向显示器上打印,变成了向文件中写入。这时候,就会发生刷新策略的改变!**由行缓冲变成全缓冲! **
这时候我们向缓冲区里面写内容时,这时候并没有写满,并且在进程退出前,我们关闭了fd,那么缓冲区里面的数据就找不到要刷新的地方了,没办法通过系统调用把数据刷新到OS层面的缓冲区中。所以,这时候我们就发现并没有往文件中写入数据。
进一步理解FILE
这个语言层面的缓冲区是在哪呢?在FILE中维护!
也就是说,FILE里面不仅封装了一个fd,而且维护了与C缓冲区相关的内容。
解决方法
在关闭fd前,使用fflush刷新用户层缓冲区的数据到系统文件缓冲区中。
实验结果:
现象二
int main()
{
const char* str = "hello write\n";
write(1, str, strlen(str));
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
fputs("hello fputs\n", stdout);
fork();
return 0;
}
实现结果:
我们对比发现,当打印到文件中时,printf、fprintf、fputs的打印多了一份。这是为什么?
原因
我们通过之前的知识知道,从往显示器上打印,转换到往文件中打印后,缓冲区的刷新策略会从行缓冲变成全缓冲,所以在进程退出前,FILE维护的缓冲区里面是数据的!
fork创建子进程后,子进程会共享父进程的代码和数据,包括FILE维护的缓冲区里面的数据。刚开始时父子进程是共享的,如果父进程先退出,这时候父进程的缓冲区里面的数据就会被刷新到指定的文件中。**就在数据被刷新前的时候,子进程会发生写时拷贝,**当子进程退出的时候,它也会把自己进程里面缓冲区的数据刷新到文件中,这时候就出现了(printf、fprintf、fputs)打印两遍的情况。
那write接口为什么只打印了一遍呢?
因为write是系统接口,是没有用户层缓冲区的,它是直接把数据刷新到指定的文件中。
系统接口是不受影响的!
解决方法:
实验结果:
** 看到这里,支持博主一下吧~**
![](https://img-blog.csdnimg.cn/e642d041802c4fddaca1d3dc5f513d62.png)
版权归原作者 暴走的橙子~ 所有, 如有侵权,请联系我们删除。