1.文件描述符
在对于文件的认识中,文件就等于内容+属性,磁盘上面存储的也就是文件的内容加上属性。
文件分为被打开的文件和未打开的文件。
文件一般被谁打开呢-->进程,一个进程可以打开多个文件,进程通过调用系统接口来打开文件,在这之前操作系统也会检查你的权限是否支持该操作,返回一个文件描述符 给你,来支持对文件的操作,打开了之后,操作系统会创建一个结构体 来描述该文件的相关属性。同时打开文件,文件的内容不会一下子全部被加载到内存,而是通过缺页中断 和惰性加载的方式。
有了上面的认识之后,简单认识运行C语言fopen函数:
cpp
#include<stdio.h>
FILE *fopen(const char *filename, const char *mode);
1.第一个参数表示要打开的文件路径。
2.第二个参数表示要打开方式。

r:文本只读方式,如果文件不存在,返回NULL。
w:文本只写方式,如果文件不存在,会自动创建文件,并且清空文件。
a:文本只写追加方式,如果文件不存在,会自动创建文件,不会清空文件,写入会追加到文件末尾。
系统调用接口open函数:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
1.第一个参数:表示要打开的文件路径。
2.第二个参数:控制文件的打开方式,访问权限和写为特性,多个标志位通过|组合而成。
- O_RDONLY :文件只能读取,不能写入,且文件必须存在(否则失败)。
- O_WRONLY :文件只能写入,不能读取,文件不存在则失败(除非配合 O_CREAT )。
- O_RDWR :读写模式,文件可同时读写,文件不存在则失败(除非配合 O_CREAT )。O_CREAT :文件不存在时自动创建。必须配合第三个参数 mode (指定新文件权限,如 0644 ),否则编译报错。
- O_TRUNC :文件存在且以写方式( O_WRONLY / O_RDWR )打开时,清空文件内容。O_APPEND :所有写入操作强制追加到文件末尾。
- O_EXCL :与 O_CREAT 连用,确保文件是"全新创建"的。如果文件已存在, open 直接失败(避免覆盖已有文件)。
fopen的a对应flags:O_RDONLYfopen的w对应对应 flags:O_WRONLY | O_CREAT | O_TRUNC
fopen的r对应flags:O_WRONLY | O_CREAT | O_APPEND
3.第三个参数:创建文件时设置文件的权限,这个设置的权限同样会受到umask权限掩码的影响,可以通过系统调用接口去把umask设置为0,这样子输入的权限是多少就是多少。
可以看到open的返回值是返回一个整数。这个整数是什么呢?
文件描述符
open是系统调用也就是最接近底层的接口了,所以fopen返回的FILE*指针里面肯定也包含了这个整数。
stdin(键盘文件),stdout(显示器文件),stderr(显示器)文件等标准流本质就是指向C语言的FILE结构体的指针,这3个标准流对应的文件描述符分别对应0,1,2。
cpp
22 int main()
23 {
24 //标准文件流
25 //分别打开三个 FILE 对象
26 FILE* fp1 = fopen("test1.txt", "w");
27 FILE* fp2 = fopen("test2.txt", "w");
28 FILE* fp3 = fopen("test3.txt", "w");
29
30 cout << "stdin->fd: " << stdin->_fileno << endl;
31 cout << "stout->fd: " << stdout->_fileno << endl;
32 cout << "stderr->fd: " << stderr->_fileno << endl;
33 //自己打开的文件流
34 cout << "fp1->fd: " << fp1->_fileno << endl;
35 cout << "fp2->fd: " << fp2->_fileno << endl;
36 cout << "fp3->fd: " << fp3->_fileno << endl;
37 fclose(fp1);
38 fclose(fp2);
39 fclose(fp3);
40 return 0;
41 }
运行结果:

可以发现我们创建的3个文件的描述符是3,4,5 stdin,stdout,stderr的文件描述符是0,1,2。说明C语言会自动给我们打开这3个输入输出流。
如果想要在显示器上面输出,就可以把接口的fd写为1即可
cpp
43 int main()
44 {
45 const char*mes="xxxxxxxxxxxxxx";
46 write(1,mes,strlen(mes));
47 close(1);
48 return 0;
49 }
运行结果:

2.进程和文件描述符
操作系统的管理方式:先描述,再组织。

struct files_struct:包含一个数组,里面存放文件描述符,每一个进程都拥有一份。
struct file:描述被打开文件的信息。
一个进程在操作系统会为它创建PCB结构体,里面有一个files指针会指向files_struct结构体,里面包含存放文件描述符的数组,这使得进程可以快速获取打开文件信息对文件进行操作。操作系统将每个文件抽象为一个 file 对象,并为每个文件分配一个 file* 类型的指针。这些文件指针会被存储在一个指针数组 file* fd_array[] 中。数组下标即为文件描述符(fd),它充当了文件的唯一标识符。当程序启动时,操作系统默认打开三个文件流,即标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)。这些文件的 file* 指针会存入 fd_array[] 数组的前 3 个位置,分别对应文件描述符 0、1 和 2。在程序运行期间,当程序打开更多文件时,操作系统会将新文件的 file* 指针存入 fd_array[] 数组中的第一个空闲位置,因此用户自己打开的文件描述符通常从 3 开始。(如果你关闭了标准输出流1,那么你再次打开一个文件,它的标识符会变成1,而不是4了)。
cpp
int main()
{
close(1);
//关闭了,标准输出流
int fd = open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n",fd);
close(fd);
return 0;
}

很多的进程了们都会使用到显示器文件,那么一个进程或者如果把显示器文件关闭了,或者一个进程里面有多个文件描述符指向一个文件,关闭一个文件描述符,另一个就使用不了了吗,其他进程就使用不了了吗?
答案显然不是的,struct_file里面会使用到引用计数的方法,有一个文件描述符指向时,就把引用计数++,当关闭时,就对引用计数--,只有引用计数为0的时候,才会真正的释放,引用计数的过程加减过程是原子的,不会任何调度打断,只有完成和未完成态。
2.1 open和close函数
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
open() 是 Linux 中最核心的文件操作系统调用之一,用于打开一个已存在的文件或创建一个新文件,并返回一个用于后续 I/O 操作的文件描述符。
open函数:
1.根据用户给与的路径,去寻找,没找到一层路径,都会查看是否有权限。
2.根据flags设置的标志位,查看是否有权限执行对应的操作,有权限根据flags去执行对应操作,包含O_CREAT,如果文件不存在,则创建新文件,如果文件已存在且 flags 包含 O_EXCL ,则 open() 失败并返回 -1 , errno 设为 EEXIST 。
3.创建结构体描述文件的基本信息。
4.找到最小的未使用的整数作为新的文件描述符,创建struct file指针指向struct file对象。
cpp
#include <unistd.h>
int close(int fd);
1.参数fd表示要关闭的文件描述符
2.返回值:成功返回0,失败返回-1。
现在我们来真正理解close函数到底做了一些什么东西。
在 Linux 中, close() 是一个系统调用,用于关闭一个已打开的文件描述符(file descriptor)。它的主要作用是通知内核,当前进程不再需要通过该文件描述符访问对应的文件或设备,从而允许内核释放相关资源。
首先会根据struct files_struct对应的文件描述符的位置里面的struct file指针(指向struct file对象)置为空。检查strcut file对象,查看引用计数是否为空,为空才会调用操作系统的方法去释放对应的资源。
3.linux下一切皆文件
现象:即使是标准输入(键盘)、标准输出(显示器) 在 OS 看来,不过是一个 file 对象。
原理:无论是硬件(外设),还是软件(文件),对于 OS 来说,只需要提供相应的 读方法 和 写方法 就可以对其进行驱动,打开文件流后,将 file* 存入 fd_array 中管理即可,操作系统分配一个文件描述符给你,就可以通过文件描述符找到你并且去使用你了,因此在 Linux 中,一切皆文件
4.重定向
Linux 重定向是一种将命令的输入或输出从默认位置(如键盘、屏幕)改变到其他位置(如文件、设备或另一个命令)的机制。
使用指令重定向
cpp
echo hello world > test1.txt

把原本要打印到屏幕上面的信息打印到test1文件中。

同样也可以把test1文件里面的信息读取出来。
现在可以理解了,> 可以起到将标准输出重定向为指定文件流的效果,>> 则是追加写入,而<则是从指定文件流中,标准输入式的读取出数据
此时,我们发现,本来应该输出到显示器上的内容,输出到了文件 myfile 当中,其中,fd=1。这种现象叫做输出重定向。常见的重定向有:>, >>, <。
4.1 重定向的原理
重定向的本质就是通过改变文件描述符的指向,从而"偷梁换柱"地改变数据流的方向。通过重定向,程序可以改变标准输入、标准输出和标准错误输出的目标,使得程序的输入输出不再依赖于默认的终端或控制台,而是可以转向文件、管道、网络等其他资源。
cpp
$ echo "Hello, World!" > output.txt # 将标准输出重定向到 output.txt 文件
$ cat < input.txt # 将标准输入重定向到 input.txt 文件
$ ./my_program 2> error.log # 将标准错误重定向到 error.log 文件
在这些例子中,重定向的过程就是将文件描述符 0、1 或 2 修改为指向指定的文件或设备,从而改变程序的输入输出行为。
4.1.1 dup2函数
dup2函数是实现重定向的关键。
cpp
#include <unistd.h>
int dup2(int oldfd, int newfd);
newfd和oldfd都表示文件描述符,newfd会被覆盖未oldfd,最后只剩下oldfd。
可以理解为newfd最后因为被覆盖了,变化了,指向了新的fd里面的内容这个意思。
cpp
9 int main()
10 {
11 int fd=open("log.txt",O_CREAT|O_WRONLY|O_APPEND,0666);
12 if(fd<0)
13 {
14 perror("open");
15 return 1;
16 }
17 dup2(fd,1);
18 printf("fd:%d\n",fd);
19 printf("hello printf!\n");
20 fprintf(stdout,"hello fprintf\n");
21 close(fd);
22 return 0;
23 }
运行结果:

发现原本应该打印在显示器文件上面的文件打印到了log.txt文件。
dup2工作原理
1.dup2会检查oldfd和newfd是否为有效文件描述符,如果nwefd打开,关闭nwefd,把nwefd指向的struct file里面的引用计数--。
2.dup2会把oldfd的文件描述符里面的struct file*指针复制给newfd的struct file*,因为newf和old放到都指向应该struct file,struct file里面的引用计数++。
3.成功返回newfd,失败返回-1。
4.2 重定向的特殊写法
cpp
./mytest > log.txt
mytest程序里面需要打印到显示屏里面的打印到log.txt文件里面。
cpp
./mytest 1>log1.txt 2>log2.txt
mytest程序中把标准输出(stdout)打印到log1.txt,标准错误(stderr)打印到log2.txt。
cpp
./mytest >log3.txt 2>&1
mytset把标准输出(stdout)打印到log3.txt,标准错误(stderr)打印到标准输出所指的地方。
5.缓存区
5.1 缓冲区的概念
缓冲区(Buffer)是一段内存区域,用来临时存储数据,通常用于I/O操作中,目的是在不同速度的硬件或程序之间传递数据。例如,操作系统通过缓冲区来提高输入输出操作的效率,使得数据能够顺畅地在设备和程序之间传输。
5.2 缓冲区的作用
缓冲区的主要目的是提高I/O操作的效率,具体原因包括:
- 高效的I/O体验:在没有缓冲区的情况下,数据从硬件设备(如磁盘、网络)读取或写入时,可能需要频繁的进行低效的读写操作。而缓冲区通过先将数据暂存起来,再统一进行读写操作,可以大幅减少这种频繁操作的开销。
- 提高整体效率:缓冲区的使用允许程序和设备在速度上不匹配的情况下仍然能够高效地工作。例如,CPU和硬盘的读写速度差异较大,缓冲区有助于减少CPU等待硬盘I/O操作的时间。
5.3 缓冲区的刷新
c语言是有自己的缓冲区的,语言层的缓冲区,在我们调用c语言的接口,比如printf,fprintf,fwrite中,数据会被存储到c语言层面的缓冲区里面,这些接口的返回值都是FILE*,所以这个缓冲区就存在于FILE*所指的结构体里面。
那么什么情况下语言层的缓冲区会被刷新到内核级的缓冲区呢,操作系统是会为每一个文件都分配一个缓冲区的。
缓冲区的刷新策略决定了数据什么时候从缓冲区中转移到实际的目标(比如文件或屏幕)。不同的刷新策略有不同的使用场景,常见的刷新策略包括:
cpp
#include<stdio.h>
int fflush(FILE *stream);
其中,stream 是要刷新的流的指针。如果 stream 为 NULL,则刷新所有流的缓冲区。
成功返回0,失败返回EOF,设置errno。
fflush会通过调用write接口来实现把数据刷新到内核缓冲区。
- 立即刷新:在调用 fflush(stdout) 时,标准输出的缓冲区内容会立即刷新到目标(通常是终端)。
- 行刷新:标准输出(通常用于终端)的缓冲区是按行刷新(例如,按行输出到屏幕)。这种策略可以为用户提供更好的体验,因为每次按下回车键时,输出会立即显示在屏幕上。
- 全缓冲:对于普通文件,缓冲区内容会在缓冲区填满时才会刷新到文件中。这样可以减少频繁的磁盘操作,提高效率。直到缓冲区被填满或者关闭文件时,数据才会被写入磁盘。
- 在每次进程结束的时候,会刷新到内核级缓冲区。
5.3.1 终端采用行缓冲
cpp
24 int main()
25 {
26 const char *str="hello";
27 printf("hello,printf");
28 fprintf(stdout,"hello,fprintf");
29 fwrite(str,strlen(str),1,stdout);
30 close(1);
31 return 0;
32 }
不加close函数的打印结果。

加close函数的打印结果。

通过结果可以看出来,我们向显示器上面打印的数据没有被打印出来,不加close可以打印出来,正因为对于1号显示器文件采用的是行刷新,如果没有加\n,都会被存储到语言层的缓冲区,当进程结束要刷新到内核级的缓冲区的时候,发现已经把1号文件关闭了,就刷新不了了,也就不会被打印出来,要打印出来,要么在后面加上\n,要么不关闭1号文件描述符,进程结束刷新到内核级缓冲区。
cpp
24 int main()
25 {
26 const char *str="hello";
27 printf("hello,printf");
28 fprintf(stdout,"hello,fprintf");
29 fwrite(str,strlen(str),1,stdout);
30 write(1,str,strlen(str));
31 close(1);
32 return 0;
33 }

使用write系统接口可以打印出来,因为write是系统调用接口,会直接存储在内核级缓冲区,也就会被打印出来了。
5.3.2 普通文件采用全缓存
cpp
24 int main()
25 {
26 const char *str="hello\n";
27 printf("hello,printf\n");
28 fprintf(stdout,"hello,fprintf\n");
29 fwrite(str,strlen(str),1,stdout);
30 write(1,str,strlen(str));
31 // close(1);
32 sleep(5);
33 return 0;
34 }
把程序的运行结果打印到普通文件里面。

使用监控脚本一直向显示器上面打印log.txt的内容。
cpp
while :; do cat log.txt;sleep 1;echo"-------------------------------";done

代码中不能把文件描述符给关闭掉,负责就无法刷新到内核缓冲区里面,因为普通文件的刷新方式是全缓存,表示行刷新,所以只有等待进程结束后才会打印到普通文件里面,所以在前5秒里面,只有write接口的被打印进去了,其他c语言接口的没有被打印。
5.4 父子进程的缓冲区
cpp
24 int main()
25 {
26 const char *str="hello\n";
27 printf("hello,printf\n");
28 fprintf(stdout,"hello,fprintf\n");
29 fwrite(str,strlen(str),1,stdout);
30 write(1,str,strlen(str));
31 // close(1);
32 sleep(5);
fork();
33 return 0;
34 }
打印到普通文件:

进程具有独立性,在父子进程中,文件描述符表和缓冲区都由内核进行管理。当父进程调用 fork() 时,操作系统会把父进程的文件描述符表和语言层的缓冲区复制一份,对于内核级的缓冲区进行共享。所以进程过5秒退出时,会把父进程和子进程的语言缓冲区都刷新到内核=缓冲区,内核级的缓冲区就有了两份c接口打印和一份write接口的数据。
打印到显示器文件:

如果父进程已经向语言层缓冲区写入了数据,那么这些数据会留在缓冲区中,子进程可以继续从这个缓冲区中读取数据。反之,如果子进程向缓冲区写入数据,父进程也能读取到更新后的数据,前提是缓冲区尚未被刷新,因为是打印到显示器文件,所以fork()时,数据已经被刷新到内核级缓冲区,最后打印出来只有一份c接口打印和一份write接口的数据。
5.5 缓冲区的作用
5.5.1 语言层缓冲区
1.减少IO次数
2.减少从内核态到用户态的性能消耗。
3.提供多种刷新策略,适配不同场景。
4.将用户层和内核进行进一步的解耦,用户无需关心数据和内核的交互,只需要调用标准库函数,缓冲区的管理由语言库自主管理。
5.5.2 内核层缓冲区
1.减少内核和磁盘的IO次数。
2.解决组件的速度不匹配问题,比如内存和磁盘的速度,如果每次IO都直接访问磁盘,CPU大部分都会处于等待状态,效率低下。
3.提高磁盘等设备的吞吐量,每次传输的数据量更多。