前言
本次主要讲解的就是文件fd--文件描述符,会带着重定向和缓冲区的知识。
一、预备知识
1.文件 = 内容 + 属性,对文件操作不是修改内容就是修改属性!
2.文件分为打开的文件和没有打开的文件
3.打开的文件:由进程来打开,本质是研究进程和文件的关系!
4.没打开的文件,在哪里放着呢?在磁盘上,没有被打开的文件非常多,
文件如何被分门别类的放置好--我们要进行快速的增删查改---快速
找到文件
文件被打开,必须先被加载到内存!
OS如何管理被打开的大量文件呢?先描述再组织,在内核中,一个被打开
的文件都必须有自己的文件打开对象,包含文件的很多属性
struct file {} ---这个在后面还会提到。
Linux中一切皆文件,如何理解?
不是说所有东西在物理上都是文件,磁盘、显示器、网卡等是硬件不是文件,但是所有的资源都通过文件接口和文件描述符来操作!底层通过内核的VFS(虚拟文件系统)来屏蔽差异,把这些资源抽象成了文件的概念。底层也是统一的,比如
统一的标识:所有资源都通过 inode唯一标识,一个文件一个inode。
统一的接口:对所有资源的操作,都通过 open()/read()/write()/close() 等标准系统调用,不用为不同资源学习不同接口,比如打开目录的接口底层也一定封装了open ! 那我们C语言学的fopen等也是封装了open吗? 对!
统一的管理:进程通过文件描述符fd,如 0 = 标准输入、1 = 标准输出、2 = 标准错误来引用资源,内核用文件描述符表管理进程打开的所有文件。
这里先简单理解即可,随着后面的学习这种理解会越来越深刻,VFS后面讲。
二、C语言中的文件操作
三个标准输入输出流
我们打开一个文件需要fopen,关闭需要close,那我们printf向显示器上打印,怎么没有打开显示器文件呢??显示器也是文件 ---一切皆文件! C程序在启动的时候,默认会打开三个标准输入输出流!stdin,stdout,stderr

FILE*是C库自己封装的结构体,关于它后面还会再谈,一定会和后面操作系统的管理能够相吻合!
这个东西C++里面也有---cin,cout,cerr,Java中有System.in,System.out,
System.err, 所以这是操作系统的特性,进程会默认打开键盘,显示器,显示器。
读写文件
文件其实是在键盘上的,键盘是外部设备,访问文件其实是访问硬件!
几乎所有的库只要是访问硬件设备,必定要封装系统调用,之前提到的那一套体系,用户 库函数/lib/指令 系统调用接口 操作系统 驱动 硬件,C语言中的读写文件接口也一定封装了系统调用接口。后面再提open/read/write/close
写操作
cpp
#include<bits/stdc++.h>
#include<unistd.h>
#include<sys/types.h>
#include<cstdio>
using namespace std;
int main()
{
FILE* fp = fopen("t.txt","w");
if(fp == nullptr) {
perror("open fail");
exit(2);
}
const char*p1 = "hello fwrite!\n";
fwrite(p1,strlen(p1),1,fp);
const char*p2 = "hello fprintf!\n";
fprintf(fp,"%s",p2);
const char*p3 = "hello fputs!\n";
fputs(p3,fp);
fclose(fp);
return 0;
}
"w"是覆盖写,每次都会重新覆盖内容,"a"是追加写,每次都会在文件末尾接着写。--在open中都可以体现。
strlen(p1)中不需要加一,字符串末尾以'\0'结尾是C语言的规定,文件中不需要。
fwrite 、fprintf 、fputs底层都一定做了相同的封装!

读操作
用fgets一行一行读取,读到空为止即可。也可以用fread直接全部读出来,fscanf需要控制格式,当然这三种都可以
cpp
int main()
{
FILE* fp = fopen("t.txt","r");
if(fp == nullptr) {
perror("open fail");
exit(2);
}
char buff[1024];
bzero(buff,sizeof(buff));
// fread(buff,sizeof(buff),1,fp);
// cout << buff << endl;
char line[64];
while(fgets(line,sizeof(line),fp) != nullptr) {
strcat(buff,line);
}
cout << buff;
fclose(fp);
return 0;
}
这些接口需要的文件流类型都是FILE*,三个标准输入输出流也是FILE*,用它们会有什么结果呢?也比较好理解,比如fprintf()如果向stdout里打印,就会打印在显示屏上,fscanf从文件中获取,从stdin里面获取就是等待键盘输入。
理解当前路径
FILE* fp = fopen("t.txt","w");这个打开文件没有加路径,是在当前路径创建的文件,怎么理解当前路径呢??这里复习一遍,启动进程,打开/proc/pid看一眼.
比如我在用户目录下启动1_2linux下面的这个可执行。

cwd是进程运行时用户所处的路径,exe是可执行所在的路径,所以这里的当前路径指的是进程运行时用户所在的路径(cwd),不是可执行在的路径!
三、Linux中的文件接口
如果C的接口用的很明白的话,这些接口看几眼手册就能上手用了。
open和close

上面提到的fopen就是封装了open,来看open的三个参数。
pathname: 要打开文件的路径,不带路径默认当前路径,也可以带绝对或者相对路径。
flags:
O_RDONLY: 只读打开 ---对应fopen中的r
O_WRONLY: 只写打开---对应fopen中的w
O_RDWR : 可读可写打开。
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限,配合第三个参数使用--文件权限问题。
O_APPEND: 追加写
O_TRUNC: 覆盖写

truncate也是mysql和linux下的命令,在Linux下作用就是修改文件大小到指定,可以截断或者加长。
一次是可以带多个选型的! 比如O_WRONLY | O_APPEND | O_CREAT,就是没有就创建文件,只能写,追加写。实现也比较好理解,这三个本质上是宏,其实就是具体的数字,0x0001,0x0010这种,或起来就能执行对应的选项了。
第三个参数:mode,上面说了含义,配合创建文件使用。
返回值:成功打开返回文件描述符fd,失败返回-1,错误码被设置.
close:

比较好理解,关闭文件描述符。和fclose底层就是封装了close,这个翻译比较有内容。第二段话比较有内容:
若 fd 是指向底层 "打开文件描述"(参见 open(2))的最后一个文件描述符,则与该打开文件描述关联的资源会被释放;若该文件描述符是已通过 unlink(2) 标记移除的文件的最后一个引用,则该文件会被彻底删除。
这两句话很有意义,后面还会再提。
read和write


这俩接口参数和返回值都相同,第一个参数是文件描述符,第二个参数就是要读/写的缓冲区,第三个参数是要写入的长度。返回值是ssize_t 本质就是size_t表示成功写入/读了多少。
cpp
int main()
{
int fd = open("t.txt",O_CREAT | O_TRUNC | O_WRONLY);
if(fd < 0)
{
perror("open fail");
exit(errno);
}
const char* p = "hello world!\n";
write(fd,p,strlen(p));
write(fd,p,strlen(p));
write(fd,p,strlen(p));
close(fd);
return 0;
}

我们可以看到确实是覆盖写,返回值方面这里想判断就判断,就是个简单的实验代码。read不演示了,同理。
四、fd的认识和理解
概念
上面多次提到fd,fd到底是啥,为啥拿着这个数字就可以进行wrtie等操作。
多创建几个文件看看

fd从3开始逐渐递增。0、1、2去哪了呢? 标准输入、标准输出、标准错误!
怎么验证?read、write第一个下标传的是fd!我如果传的是0、1会有对应的效果!比如write往1号里面写就会写到显示器上!

下面正式讲一下这个fd
前面提到,操作系统一定要管理打开的文件,先描述再组织!task_struct中有一个指针指向files_struct结构体,里面有一个struct file* fd[],这是个指针数组,每一个存放一个指针,指针指向特定的文件,这个数组的下标就是文件描述符了!
strucf file里面包含了很多属性:磁盘的什么位置,基本属性,权限,大小,读写位置,文件的内核缓冲区信息,struct file* next指针...

具体一点就是:

open打开了一个文件,从这个角度简单来说就是创建了一个struct file对象,找到fd_array中最小空闲下标放入,增加该struct file的引用计数,链入到struct file* next中.至于引用计数是啥后面再谈。
这里我们也能看出文件描述符的分配规则:
从0下标开始,寻找最小的没有被使用的数组位置,他的下标就是新文件
的文件描述符. 所以0,1,2也可以使用,先close()就可以!
FILE 和 fd
FILE具体是什么不重要,但是它里面一定含有fd!!
无论是谁都要经过系统调用!FILE结构体里面有一个_fileno就代表着fd!

四、重定向
回忆一下重定向
什么是重定向?比如echo hello world > t.txt本来应该打印到显示屏文件上的hello world但是写入了t.txt中,这就是重定向!
又包含输入重定向: cat < t.txt
输出重定向:echo hello > t.txt
追加重定向: echo hello >> t.txt
别忘了命令也是编译好的可执行程序!
输出重定向
有了前面还是比较好理解的了,比如我们echo hello > t.txt,就是先关掉标准输出,close(1),然后open t.txt这个文件获取到fd,这个fd就会分配1了,那我们原来对显示屏文件的操作都变成了对t.txt的操作!因为无论是stdout还是cout,对于操作系统来说只用fd统一处理!
简单一份代码演示一下
cpp
int main()
{
close(1);
int fd = open("t.txt",O_TRUNC | O_RDWR | O_CREAT,0666);
cout << "fd:" << fd << endl;
const char* str = "hello fd!\n";
write(1,str,strlen(str));
fputs(str,stdout);
fprintf(stdout,str);
fflush(stdout);
close(fd);
return 0;
}

结果也是跟想的一样,这里需要刷新一下缓冲区,涉及到行缓冲和全缓冲的问题,后面再说。
追加重定向
有了输出重定向追加重定向就非常好理解了。就是把O_TRUNC换成O_APPEND,即可,代码不演示了。
输入重定向
理解了前两个这个也是比较好理解的,就是close(0) -> open -> read
cpp
int main()
{
close(0);
int fd = open("t.txt",O_RDONLY);
char buff[1024];
cin >> buff;
cout << buff << endl;
close(fd);
return 0;
}

dup2
先close再打开肯定很麻烦了,操作系统提供了重定向接口

dup2(int oldfd,int newfd),注意看描述,newfd be the copy of oldfd,所以注意好是谁是谁的拷贝,比如要把1重定向到fd,应该是dup2(fd,1),
dup2实际上就是:两个 fd 指向同一个 file 结构体,但是下面几种情况特别:
若 newfd 已打开,会关闭 newfd,再完成绑定;
若 oldfd 无效(未打开),则 dup2 失败,newfd 不会被关闭;
若 oldfd == newfd,则 dup2 直接返回 newfd。
简单演示一下。
cpp
int main()
{
int fd = open("t.txt",O_CREAT | O_TRUNC | O_WRONLY,0666);
//判断是否打开成功
dup2(fd,1);
cout << "hello dup2!\n";
close(fd);
return 0;
}

stdout stderr
这里谈一下重定向方面的一些差异
cpp
int main()
{
fprintf(stdout,"fprintf stdout!\n");
fprintf(stdout,"fprintf stdout!\n");
fprintf(stdout,"fprintf stdout!\n");
fprintf(stdout,"fprintf stdout!\n");
fprintf(stderr,"fprintf stderr!\n");
fprintf(stderr,"fprintf stderr!\n");
fprintf(stderr,"fprintf stderr!\n");
fprintf(stderr,"fprintf stderr!\n");
return 0;
}

重定向到log.txt只有stdout重定向了,stderr还是输出到了显示屏幕上,因为重定向的是1号下标不是2号!
可以这样重定向 ./main 1>log.txt 2>err.log 输出到两个文件!
就想输出到一个文件呢?
./a.out 1>all.log 2>&1
可以省略1,默认就是stdout,./a.out >all.log 2>&1
五、缓冲区
四个现象
前面一直在提到缓冲区的问题,这里正式谈一谈缓冲区,先来看一份代码。
cpp
int main()
{
const char* str1 = "hello write!\n";
const char* str2 = "hello fwrite!\n";
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
fwrite(str2,strlen(str2),1,stdout);
write(1,str1,strlen(str1));
return 0;
}

打印到屏幕和重定向到文件竟然不是一个顺序!这是第一个现象
另外我没有close(1),如果close(1):

这是第二个现象,发现重定向到t.txt中只有write写进去了!如果我fork,但是不close(1)呢。

发现t.txt中除了write其他都被写了两次!这是第三个现象,如果我把所有的换行都去掉,再执行上面的代码呢?

此时重不重定向的结果一样!并且write是一个,只写了一次,其他都写了两次,这是第四个现象。
下面就结合这四个现象来讲解缓冲区
理解
先谈谈缓冲区刷新问题---结合之前写的进度条代码,写进度条的时候我们知道C语言给我们提供了一个缓冲区,并且是遇到'\n'刷新缓冲区。
缓冲区刷新策略一共有三种:
1.无缓冲-----直接刷新(一般标准错误默认无缓冲)
2.行缓冲----不刷新,遇到'\n'才刷新--往显示器中写的策略
3.全缓冲----只有缓冲区满了才刷新----普通文件的写入
另外,进程结束的时候也会刷新缓冲区。
再来谈用户级缓冲区和内核级缓冲区,exit和_exit中我们提到C语言提供的缓冲区一定不在内核中,因为_exit打印不出来。
C语言为我们提供了一个用户级缓冲区,同时OS内还有内核级缓冲区,printf、fwrite、fprintf是先把内容拷贝到用户级缓冲区中!满足了刷新条件再调用对应的write() 将数据拷贝到内核级的缓冲区上 ,我们现在暂时忽略数据从内核级缓冲区到硬件的过程,直接就认为到了内核级缓冲区就到了硬件。
用户刷新的本质就是调用write()将数据写到了内核中!
通过这个我们就能理解前两个现象了,
1.为什么输出顺序不同?? 往显示器中打印的时候,采用行缓冲策略,我们要打印的每一个字符串都有换行!每次调用一个fprintf/printf/fwrite直接满足了刷新条件,刷新到了内核中显示出来了,所以顺序和执行流的顺序一样。
但是如果重定向到普通文件,此时刷新策略变成全缓冲策略,每次调用一个fprintf/printf/fwrite都不满足刷新条件,无法写入内核,而write直接向内核中写,所以文件中第一行对应的是write,当进程结束时,用户级缓冲区也会刷新,所以后三句也能看得到。
2.close(1)之后,往显示器中打印没有变化,这个很好理解,和上面一样的原因,往普通文件里写为什么只剩了个write呢?因为close(1)之后,在用户级缓冲区中调用write(1)就失效了!1号都被关了,怎么可能往内核中写呢?所以只有write留下来了。
fork又是什么情况呢?我们知道fork之后子进程的数据和父进程采用写时拷贝,子进程和父进程指向同一块用户级缓冲区。
当父进程 / 子进程首次尝试刷新缓冲(调用 write 等等),会触发对共享内存页的写时拷贝------ 内核为写入方复制一份新的物理内存页,父子进程的用户缓冲从此分离,两个进程都有了一份数据!
父子各自刷新,写入内核:
父子进程各自将自己的用户级缓冲数据(hello printf hello fprintf hello fwrite! )调用write写入内核,最终输出两份;而 fork 前write的hello write! 已经在内核缓冲区,只会输出一份。
这就完美解释了第三种现象的原因!
第四种现象也好解释了,因为此时没有换行,无论写入到普通文件还是显示器都不会刷新,进程结束了才会刷新,父子进程各写入内核一份,出现了两次。
C语言中提供的缓冲区其实在FILE结构体中,不是说内存在这里,内存当然在用户态地址空间中,而是FILE结构体通过指针指向了这块缓冲,并且管理了这块缓冲。
glibc:
cpp
typedef struct _IO_FILE {
char* _IO_buf_base; // 指向用户级缓冲区的起始地址
char* _IO_buf_end; // 缓冲区的结束地址
char* _IO_ptr; // 当前写入/读取的位置指针
int _IO_buf_mode; // 缓冲类型
int _flags; // 包含缓冲相关标记
int _fileno; // 绑定的文件描述符
//.....
} FILE;
内核中处理不一样,是通过inode来搞的,后面再提。