目录
九.文件描述符
9.1 文件描述符概念
9.2 文件描述符的分配规则
9.3 重定向
9.3.1 常见的重定向操作
9.3.2 重定向的原理
9.4 缓冲区
9.4.1 缓冲区概念
9.4.2 缓冲区刷新策略
9.4.3 C语言的缓冲区在哪里?
九.文件描述符
9.1 文件描述符概念
在上一篇讲到基础IO时,我们说到open函数在打开一个文件后有一个int类型的返回值.我们将之称为文件描述符. 那么到底什么是文件描述符?它有什么作用呢?
概念:
文件描述符(File Descriptor,简称fd),是一个用于标识和操作文件或输入/输出设备的整数。在Unix-like操作系统中,包括Linux,文件描述符是对打开文件或I/O设备的引用.每个进程都有一个文件描述符表,它是一个数组,其中包含了该进程打开的文件或I/O设备的引用
也就是说:每一个进程都会维护一个独立的文件描述符表(File Descriptor Table)用来管理自己打开的文件
这个文件描述符表存储在哪里?
这里我们可以结合之前的知识猜到,文件描述表应该存储在进程的PCB结构中.
事实上,也确实如此.如上图所示,在进程对应的的task_struct结构中会存在一个files指针指向指向一个files_struct结构体
每个进程都会有单独维护的files_struct结构体,其专门用于跟踪对应进程与文件相关的信息.
files_struct有两个地方来管理所有打开的文件结构,即有两个数组来管理所有打开的文件结构
- files_struct结构体中会包含一个struct fdtable *fdt,其指向fdtable(文件描述符表),fdtable同样也是一个结构体,其中包含** struct file fd,这个数组存储了每个文件描述符对应的文件指针
- files_struct结构体中同样直接包含了一个fd_array用来直接存储了每个文件描述符对应的文件指针
在这两个数组中都储存着指向file结构体的指针,也就是说进程打开的文件在内核中会被描述为struct file结构体的实例,这个结构体包含了有关打开文件的各种信息,包括文件的状态、位置、访问模式等
总的来说,通过这种方式,内核能够更灵活地管理文件,而进程则通过文件描述符引用这些文件实例,而不必直接关心底层的文件结构和操作.这提供了一种高层次的抽象,简化了用户空间程序对文件的操作
在这里我们可以通过一步一步讲解fwrite()的运行过程,自上而下的梳理一遍
当我们在调用了fwrite()时,首先需要接收一个FILE *指针,表示要写入的文件的FILE结构,而对应的FILE结构体中存有文件描述符(fd),这个时候fwrite()会底层调用write(fd,.....),来执行操作系统内部的write(),它能找到进程的task_struct,再通过里面的files指针再找到files_struct,而files_struct中的fd_array[fd]便指向打开的文件的file结构体,这时候便内存文件便找到了,也就可以后续操作.
注意:
- 文件的fd实际上是fd_array数组的对应下标
- C语言中的FILE结构体和内核中的file结构体是两个不同的概念,需要区分
9.2 文件描述符的分配规则
在上文讲到基础IO时,我们讲到C语言的程序会默认打开三个流,分别是stdin,stdout,stderr.
这时候结合Linux系统的设计哲学:一切皆文件,我们可以知道,在C语言编写的程序运行后,会默认打开对应的三个文件
这时候,我们可以编写下面这个简单的程序来验证.
也就是说fd_array数组当中下标为0、1、2的位置已经被占用了,之后打开的文件都是重3之后开始
那么如果我们在进行文件操作之前将默认打开的文件关掉会怎么样呢?
这里我们将下标为1的文件先关掉进行验证,给程序先加上这样一句代码:
close(0);
这时候我们再此运行观察
这时我们可以得出 文件描述符的分配规则:
顺序分配: 一般情况下,文件描述符会按照顺序分配,即从最小的未使用的整数开始分配
9.3 重定向
9.3.1 常见的重定向操作
重定向是指将一个文件描述符与另一个文件描述符或文件关联起来,从而改变输入或输出的方向。在 Unix-like 操作系统中,包括 Linux,重定向是一种强大的机制,允许在命令行中灵活地管理输入和输出。
以下是常见的重定向操作符:
1.>
:输出重定向示例:
command > output.txt
描述:将命令的标准输出重定向到文件
output.txt
中。如果文件不存在,则会创建文件;如果文件已存在,将会覆盖其中的内容。
2.>>
:追加输出重定向示例:
command >> output.txt
描述:将命令的标准输出追加到文件
output.txt
中。如果文件不存在,则会创建文件;如果文件已存在,内容将会被追加到文件末尾。
3.<
:输入重定向示例:
command < input.txt
描述:将文件
input.txt
中的内容作为命令的标准输入。命令将读取文件的内容而不是从键盘读取。
4.|
:管道示例:
command1 | command2
描述:将
command1
的标准输出通过管道传递给command2
的标准输入。这使得两个命令可以协作处理数据。
5.2>
:错误输出重定向示例:
command 2> error.txt
描述:将命令的错误输出重定向到文件
error.txt
中。类似于>
,但是针对错误输出。
6.&>
或&>>
:合并输出和错误输出重定向示例:
command &> output_and_error.txt
或command &>> output_and_error.txt
描述:将命令的标准输出和错误输出合并,并重定向到文件
output_and_error.txt
中。
9.3.2 重定向的原理
重定向的本质是通过操作系统提供的文件描述符机制,动态地改变进程的输入和输出源
dup2:
#include<unistd.h>
int dup2(int oldfd, int newfd);
函数返的作用:dup2的作用是将 newfd 指向的文件描述符关闭,然后将 oldfd 复制到 newfd,使得它们指向同一个打开的文件、套接字或管道。如果 newfd已经打开,dup2 会首先关闭它
这个系统调用的主要应用场景之一是在重定向中,将一个文件描述符重定向到另一个文件描述符。例如,在 shell 命令中,使用 >
这样的操作符进行输出重定向时,实际上就是使用了dup2
函数返回值: dup2如果调用成功,返回newfd,否则返回-1。
使用dup2时,我们需要注意以下两点:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
借助dup2(),我们可以试着模拟一下重定向的过程
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {int fd = open("log.txt", O_CREAT | O_RDWR,0666);if (fd < 0) {perror("open");return 1;}close(1);dup2(fd, 1);for (;;) {char buf[1024] = { 0 };ssize_t read_size = read(0, buf, sizeof(buf) - 1);if (read_size < 0) {perror("read");break;}printf("%s", buf);fflush(stdout);}return 0;
}
通过这样一个简单的程序我们便可以将标准输出(文件描述符1)重定向到一个文件("log.txt"),并不断地从标准输入(文件描述符0)读取数据,然后将其写入到文件中.
9.4 缓冲区
9.4.1 缓冲区概念
缓冲区(Buffer)是计算机系统中常见的概念,它是一块用于临时存储数据的内存区域。缓冲区在各种计算机应用中都起着关键作用,从文件输入输出到网络通信,都涉及到对数据的缓冲和管理
下面是缓冲区的常见作用:
提高效率: 缓冲区的存在可以减少对底层资源的频繁访问,从而提高数据传输的效率。通过按块读写数据,而不是逐个字节进行操作,可以减少读写次数,降低系统开销
平滑数据流: 缓冲区可以平滑数据流,使得数据以块的形式传输。这对于文件输入输出、网络通信等场景特别有用,有助于减少频繁的小规模数据传输,提高整体性能
优化磁盘和网络访问: 在文件系统中,文件缓冲区可以优化磁盘访问,减少磁盘I/O的次数。在网络通信中,网络缓冲区可以优化数据在网络上传输的效率
应对不同速度的设备: 缓冲区可以协调不同速度的设备之间的数据传输,确保数据能够以适当的速率流动,避免了生产者和消费者之间的速度不匹配问题
提高用户体验: 输入缓冲区允许用户按块输入数据,提高了用户体验。输出缓冲区允许程序按块输出数据,减少对屏幕或文件的频繁写入,提高了响应速度
支持流式处理: 缓冲区使得数据能够以流式的方式进行处理,而不是一次性处理整个数据集。这对于处理大数据或实时数据流非常重要
适应不同工作负载: 缓冲区的存在使得系统能够更好地适应不同的工作负载。它可以根据需要动态调整缓冲策略,以满足不同场景下的性能需求
错误处理: 缓冲区可以提供一定程度的错误处理机制。例如,在网络通信中,如果无法立即发送所有数据,缓冲区可以保存部分数据,等待合适的时机重新发送
9.4.2 缓冲区刷新策略
C语言中一般有以下几种缓冲区刷新策略:
行缓冲(Line Buffering):在行缓冲模式下,缓冲区在遇到换行符
\n
时自动刷新。也就是说,当遇到换行符时,缓冲区中的数据会被立即写入文件全缓冲(Fully Buffered):在全缓冲模式下,缓冲区满时会触发刷新,此时缓冲区中的数据才会被写入文件
手动刷新缓冲区:使用 fflush() 函数手动刷新缓冲区。这对于确保数据在特定时刻被写入文件很有用
关闭文件时刷新:当文件关闭时,C库会自动刷新缓冲区
一般而言,行缓冲的设备文件 --- 显示器
全缓冲的设备文件 --- 磁盘文件
极端情况,你是可以自定义规则的 --- 这时候我们可以自己使用fflush()来手动刷新
但所有的设备,永远倾向于全缓冲 --> 缓冲区满了再刷新 --> 需要更少次数的IO操作 -->更少次数的外设访问(相当于提高了整机效率).
有同学可能有疑问,比如10行数据,每一行有100个字节,虽然10行最后再一起刷新,只进行了一次的外设访问,但是数据量很多啊,1000个字节,而按行刷新虽然刷新了10次,但每次数据量少啊,那为什么外设访问次数越少越好呢?
这是因为和外部设备IO的时候,数据量的大小不是主要矛盾,你和外设预备IO的过程是最耗费时间的.
9.4.3 C语言的缓冲区在哪里?
缓冲区在哪里?我们所说的这个缓冲区是由操作系统维护还是语言维护?
为了回答这个问题,我们可以先感受下面这段代码
#include<stdio.h>
#include<fcntl.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<unistd.h>
#include<string.h>
int main()
{//C语言提供的接口 printf("hello,printf\n");fprintf(stdout, "hello,fprintf\n");const char* s = "hello,fputs\n";fputs(s, stdout);//系统接口 const char* ss = "hello,write\n";write(1, ss, strlen(s));//创建子进程fork();
}
当我们把代码编译运行后:
此时当我们将程序的输出 重定向到另外的文件时
这时候我们可以先预测一下输出的结果
最后我们会惊讶的发现一个现象:所有C语言提供的函数都输出了两次 .而系统接口的只输出了一次
这时候我们改一下我们原本的代码,在write()与fork()之间加上一句fflush()
这时候再进行一次上述操作
这次我们发现结果又发生了变化,这是为什么呢?
这时候我们得回到最初的问题:缓冲区是由操作系统维护还是语言维护?
这时大家也许会猜到 我们所说的这个 缓冲区其实是由C语言自己维护的
在源代码中,你可以找到stdio.h 头文件以及其中包含的FILE结构体的定义,而FILE结构体里面不仅封装了fd,而且包含了该文件对应的语言层的缓冲区结构_IO_FILE。
_IO_FILE的内部结构大致如下:
/* In glibc, FILE is a typedef, defined in FILE struct and definedelsewhere. */
struct _IO_FILE {int _flags; /* High-order word is _IO_MAGIC; rest is flags. *//* The following pointers correspond to the C++ streambuf protocol. */char *_IO_read_ptr; /* Current read pointer */char *_IO_read_end; /* End of get area. */char *_IO_read_base; /* Start of putback+get area. */char *_IO_write_base; /* Start of put area. */char *_IO_write_ptr; /* Current put pointer. */char *_IO_write_end; /* End of put area. */char *_IO_buf_base; /* Start of reserve area. */char *_IO_buf_end; /* End of reserve area. *//* The following fields are used to support backing up and undo. */char *_IO_save_base; /* Pointer to start of non-current get area. */char *_IO_backup_base;/* Pointer to first valid character of backup area */char *_IO_save_end; /* Pointer to end of non-current get area. */struct _IO_marker *_markers;struct _IO_FILE *_chain;int _fileno;int _flags2;__off_t _old_offset; /* This used to be _offset but it's too small. *//* 1+column number of pbase(); 0 is unknown. */unsigned short _cur_column;signed char _vtable_offset;char _shortbuf[1];_IO_lock_t *_lock;__off64_t _offset;/* Wide character stream stuff. */struct _IO_codecvt *_codecvt;struct _IO_wide_data *_wide_data;struct _IO_FILE *_freeres_list;void *_freeres_buf;size_t __pad5;int _mode;/* Make sure we don't get into trouble again. */char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
};
回到我们的最初的代码
我们直接运行程序是向显示器中打印,采用的是行刷新策略,而我们重定向到文件中,向文件中打印,便成了全缓冲策略.
1.如果是向显示器中打印,那么采用的是行刷新策略,那么最后执行fork的时候,所有的数据都已经刷新完成了,此时再执行fork就没有意义了.
2.如果对程序进行了重定向,即此时要向文件中打印,此时刷新策略便隐式的变成了全缓冲,
遇到\n换行符便不会再触发刷新.
fork的时候,一定是函数已经执行完了,但是数据还没有刷新! 这些数据在当前进程对应的C标准库中的缓冲区里. 而这些数据是父进程的.
fork之后子进程和父进程执行return 0前,父进程和子进程实际上是共享相同的缓冲区的.但是,当子进程执行 return 0 后,由于任一方进程退出时触发了关闭文件时刷新策略,此时可能触发了对标准输出缓冲区的刷新.这时,写时复制拷贝机制会起作用,确保子进程得到自己的缓冲区副本,从而避免对父进程缓冲区的影响。这样就有了两份数据,然后分别输出到文件中。
所以就出现了C语言标准库输出的函数打印了两次,而系统接口打印了一次。
因为系统接口是直接写入到了文件中,而不用经过缓冲区。
而在我们更改之后的代码中,fflush已经强制将缓冲区的内容刷新了出来,此时缓冲区已经是空的了,然后再执行fork,父子进程缓冲区都是空的,所以也没有数据刷新了,这样才各自只打印了一条语句
此时,更加让我们确信了一个事实:缓冲区一定不是操作系统提供的!而是由C语言标准库提供的!因为如果是操作系统提供的,那么这个系统接口也应该输出两次,而不是只有一次
注意:
缓冲区的概念并不是特定于 C 语言,而是一种广泛应用于计算机科学和编程的概念。缓冲区是一块用于临时存储数据的内存区域,其目的是提高数据传输的效率。在不同的编程语言和操作系统中,都存在类似的概念