Linux基础 文件描述符,重定向及缓冲区理解

🏙️正文

1、文件描述符

在使用 C语言 相关文件操作函数时,可以经常看到 FILE 这种类型,不同的 FILE* 表示不同的文件,实际进行读写时,根据 FILE* 进行操作即可。

cpp 复制代码
#include<iostream>
#include <cstdio>

using namespace std;

int main()
{
    //分别打开三个 FILE 对象
    FILE* fp1 = fopen("test1.txt", "w");
    FILE* fp2 = fopen("test2.txt", "w");
    FILE* fp3 = fopen("test3.txt", "w");

    //对不同的 FILE* 进行操作
    //......

    //关闭
    fclose(fp1);
    fclose(fp2);
    fclose(fp3);
    fp1 = fp2 = fp3 = NULL;

    return 0;
}

那么在 C语言 中,OS 是如何根据不同的 FILE* 指针,对不同的 FILE 对象进行操作的呢?

  • 答案是文件描述符 fd,这是系统层面的标识符,FILE 类型中必然包含了这个成员。

注**:stdin,stdout,stderr 等标准流在 C语言 中被覆写为 FILE 类型,它们都是封装了文件标识符。**

cpp 复制代码
//标准文件流
cout << "stdin->fd: " << stdin->_fileno << endl;
cout << "stout->fd: " << stdout->_fileno << endl;
cout << "stderr->fd: " << stderr->_fileno << endl;
cout << "===================================" << endl;
cout << "此时标准流的类型为:" << typeid(stdin).name() << endl;
cout << "此时文件流的类型为:" << typeid(fp1).name() << endl;
cout << "===================================" << endl;
//自己打开的文件流
cout << "fp1->fd: " << fp1->_fileno << endl;
cout << "fp2->fd: " << fp2->_fileno << endl;
cout << "fp3->fd: " << fp3->_fileno << endl;

可以看出,FILE 类型中确实有 fd 的存在。那么文件标识符的本质到底是什么呢?


2.文件标识符的本质
2.1 操作系统文件管理设计:先描述、再组织

操作系统(OS)是现代计算机系统的核心,它承担着对计算机硬件资源的管理和调度工作,包括 CPU、内存、磁盘、网络等。操作系统的设计目标之一是提高效率,确保资源的高效利用。然而,资源数量庞大,任务种类繁多,若不合理分配或管理这些资源,可能会导致效率低下、系统崩溃等问题。因此,在设计操作系统时,必须关注如何高效地管理这些资源,尤其是文件系统。

以文件系统为例,如果操作系统不进行合理的设计,进行文件输入输出(IO)操作时,系统将不得不扫描所有文件,找到目标文件后再进行操作,这显然是低效且不合理的。为了提高效率,操作系统需要采取一种结构化、合理的方式来管理文件。

基于"先描述、再组织"的设计原则,操作系统通过以下方式来优化文件的管理:

  • 文件抽象与组织 :操作系统将每个文件抽象为一个**file** 对象,并为每个文件分配一个 file* 类型的指针。这些文件指针会被存储在一个指针数组 file* fd_array[] 中。数组下标即为文件描述符(fd),它充当了文件的唯一标识符。

  • 标准文件描述符 :当程序启动时,操作系统默认打开三个文件流,即标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)。 这些文件的 file* 指针会存入 fd_array[] 数组的前 3 个位置,分别对应文件描述符 0、1 和 2。

  • 动态文件描述符分配 :在程序运行期间,当程序打开更多文件时,操作系统会将新文件的 file* 指针存入 fd_array[] 数组中的第一个空闲位置,因此用户自己打开的文件描述符通常从 3 开始。(如果你关闭了标准输出流1,那么你再次打开一个文件,它的标识符会变成1,而不是4了)

    cpp 复制代码
    //关闭 显示器 写入数据
    close(1);
    
    int fd = open("file.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    assert(fd != -1);   //存在打开失败的情况
    
    cout << "单纯打开文件 fd: " << fd << endl;
    cout << "you can see me! not on screen" << endl;
    
    close(fd);  //记得关闭

这种设计确保了文件操作的高效性,操作系统通过文件描述符实现对文件的快速随机访问和管理。文件描述符和文件指针的结合,避免了每次文件操作时都要遍历所有文件,提高了操作效率

2.2 文件描述符与 files_struct 结构体

files_struct 结构体是操作系统中用于描述已打开文件的关键数据结构之一**,它包含了文件的多个属性。每个进程都有一个与之相关的 files_struct 结构体** ,操作系统通过该结构体管理与该进程相关的文件及文件描述符。files_struct 中的关键成员之一就是文件描述符 fd

files_struct 结构体

files_struct 结构体包含了一个****文件描述符数组,用于存储进程打开的所有文件的 file* 指针。它记录了文件的各种属性,如:

  • 文件描述符 :每个文件描述符对应一个 file* 指针,通过该指针,操作系统可以访问文件的各种信息。

  • 文件权限:定义进程对文件的访问权限,如读、写权限等。

  • 文件大小:记录文件的大小。

  • 文件路径:文件在系统中的完整路径。

  • 引用计数:记录文件被多少进程打开,反映文件的使用情况。

  • 挂载数:文件系统挂载的次数。

这些属性共同描述了一个文件的状态,使得操作系统能够管理文件的生命周期并控制对文件的访问。

在每个进程的 task_struct 中,都包含一个指向 files_struct 的指针,这使得操作系统能够通过进程的 task_struct 快速获取进程打开的文件信息。

2.3一切皆文件

如何理解 Linux 中一切皆文件这个概念?

现象:即使是标准输入(键盘)、标准输出(显示器) 在 OS 看来,不过是一个 file 对象

原理:无论是硬件(外设),还是软件(文件),对于 OS 来说,只需要提供相应的 读方法 和 写方法 就可以对其进行驱动,打开文件流后,将 file* 存入 fd_array 中管理即可,因此在 Linux 中,一切皆文件

3 .重定向是什么?

重定向就是将本来该输入到一个文件的数据,转而向另一个文件中输入。

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;
}

利用指令重定向

下面直接在命令行中实现输出重定向,将数据输出至指定文件中,而非屏幕中

cpp 复制代码
echo you can see me > file.txt

可以看到数据直接输出至文件 file.txt

当然也可以 file.txt 中读取数据,而非键盘

cpp 复制代码
cat < file.txt

现在可以理解了,> 可以起到将标准输出重定向为指定文件流的效果,>> 则是追加写入
< 则是从指定文件流中,标准输入式的读取出数据

此时,我们发现,本来应该输出到显示器上的内容输出到了文件 myfile 当中 ,其中,fd=1。这种现象叫做输出重定向 。常见的重定向有:>, >>, <。


3 .1重定向的本质

在操作系统中,标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)分别对应文件描述符 0、1 和 2,这些标准文件描述符是系统级别的接口,用于与程序交互。操作系统通过这些文件描述符来进行输入输出操作,而不需要关心底层文件的具体执行流。这使得操作系统能够实现与用户和其他程序的交互。

文件描述符与标准流

  • 标准输入(stdin,文件描述符 0):通常与键盘输入相关,用户通过键盘输入数据时,操作系统通过文件描述符 0 来接收这些数据。

  • 标准输出(stdout,文件描述符 1):通常用于输出到屏幕或终端,程序的输出通常通过文件描述符 1 传递到显示设备。

  • 标准错误输出(stderr,文件描述符 2):用于输出错误信息,通常也指向屏幕或终端,用于错误报告。

操作系统通过这些标准文件描述符来进行输入输出操作,而程序本身并不需要关心具体的数据流向。

重定向的原理

**重定向的本质就是通过改变文件描述符的指向,从而"偷梁换柱"地改变数据流的方向。**通过重定向,程序可以改变标准输入、标准输出和标准错误输出的目标,使得程序的输入输出不再依赖于默认的终端或控制台,而是可以转向文件、管道、网络等其他资源。

  • 标准输出重定向 :通过将标准输出(stdout)的文件描述符 1 指向一个文件,可以将程序的输出保存到文件中,而不是显示在屏幕上。

  • 标准输入重定向 :通过将标准输入(stdin)的文件描述符 0 指向一个文件,可以使程序从文件中读取输入,而不是从键盘获取。

  • 标准错误输出重定向 :通过将标准错误输出(stderr)的文件描述符 2 指向文件或其他流,可以将错误信息保存到文件,或者通过管道将错误输出传输给其他程序。

例如,通过命令行重定向的例子:

cpp 复制代码
$ echo "Hello, World!" > output.txt  # 将标准输出重定向到 output.txt 文件
$ cat < input.txt  # 将标准输入重定向到 input.txt 文件
$ ./my_program 2> error.log  # 将标准错误重定向到 error.log 文件

在这些例子中,重定向的过程就是将文件描述符 0、1 或 2 修改为指向指定的文件或设备,从而改变程序的输入输出行为。

重定向的实现机制

在操作系统内部,重定向的实现是通过修改文件描述符指向的文件表项来完成的。具体而言,当一个程序进行重定向时,操作系统会将对应的文件描述符(如 0、1、2)指向一个新的文件或设备,而不是默认的终端或标准流。这个过程在内核中通过以下方式完成:

  • 打开一个新的文件或设备。

  • 获取新文件的文件描述符。

  • 将标准文件描述符(0、1、2)指向新打开的文件描述符。

  • 接下来,程序的输入输出操作就会通过新的文件描述符进行,而不是通过默认的标准输入输出。

通过这种方式,操作系统实现了对标准流的重定向,允许程序与外部资源进行灵活的数据交互。

3.2、利用函数重定向

系统级接口 int dup2(int oldfd, int newfd)

函数解读:将老的fd 重定向为新的fd ,参数1 oldfd 表示新的 fd ,而newfd 则表示老的 fd ,重定向完成后,只剩下oldfd ,因为 newfd 已被覆写为oldfd 了;如果重定向成功后,返回 newfd,失败返回 -1

参数设计比较奇怪,估计作者认为 newfd表示重定向后,新的 fd

下面来直接使用,模拟实现报错场景,将正常信息输出至 log.normal,错误信息输出至 log.error 中

cpp 复制代码
int main()
{
  int fd = open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  dup2(fd,1);
  printf("hello linux\n");
  
  return 0;
}
3.3 重定向的特殊写法

bash中,需要将脚本demo.sh的标准输出和标准错误输出重定向至文件demo.log,以下哪些用法是正确的?
1. demo.sh >demo.log 2>&1

首先将demo.sh 1>demo.log ,将demo.sh要输出到标准输出流的内容(1)输入到demo.log 文件。然后 2&1 是将标准错误流重定向到标准输出流中,而1已经指向demo.log 文件了,所以标准错误流也指向了demo.log。
2. demo.sh 2>demo.log 1>demo.log

  1. 2>demo.log 会将 demo.sh 脚本的错误输出(例如运行时错误、警告信息等)写入 demo.log 文件。

  2. 1>demo.log 会将 demo.sh 脚本的正常输出(例如 echo 输出的内容等)也写入 demo.log 文件。

需要注意的是,由于 2>demo.log1>demo.log 都将输出重定向到同一个 demo.log 文件,所以错误信息和正常输出会都写入同一个日志文件中。

4.缓冲区
4.1 缓冲区的概念

缓冲区(Buffer)是一段内存区域 ,用来临时存储数据,通常用于I/O操作中,目的是在不同速度的硬件或程序之间传递数据。例如,操作系统通过缓冲区来提高输入输出操作的效率,使得数据能够顺畅地在设备和程序之间传输。

4.2 为什么要使用缓冲区?

缓冲区的主要目的是提高I/O操作的效率,具体原因包括:

  1. 高效的I/O体验 :在没有缓冲区的情况下,数据从硬件设备(如磁盘、网络)读取或写入时,可能需要频繁的进行低效的读写操作。而缓冲区通过先将数据暂存起来,再统一进行读写操作,可以大幅减少这种频繁操作的开销。

  2. 提高整体效率:缓冲区的使用允许程序和设备在速度上不匹配的情况下仍然能够高效地工作。例如,CPU和硬盘的读写速度差异较大,缓冲区有助于减少CPU等待硬盘I/O操作的时间。

4.3 缓冲区的刷新策略

缓冲区的刷新策略决定了数据什么时候从缓冲区中转移到实际的目标(比如文件或屏幕)。不同的刷新策略有不同的使用场景,常见的刷新策略包括:

正常情况:

  1. 立即刷新: 在调用 fflush(stdout) 时,标准输出的缓冲区内容会立即刷新到目标(通常是终端)。对于文件系统,可以使用 fsync(int fd) 来强制将文件的内存中数据同步到存储设备。

  2. 行刷新标准输出(通常用于终端)的缓冲区是按行刷新(例如,按行输出到屏幕)。这种策略可以为用户提供更好的体验,因为每次按下回车键时,输出会立即显示在屏幕上。

  3. 全缓冲对于普通文件,缓冲区内容会在缓冲区填满时才会刷新到文件中。这样可以减少频繁的磁盘操作,提高效率。直到缓冲区被填满或者关闭文件时,数据才会被写入磁盘。

特殊情况:

  1. 进程退出时自动刷新:当进程结束时,系统会自动刷新所有打开文件的缓冲区,以确保所有数据都被写入文件。

  2. 强制刷新 :有些情况下可以通过程序中的操作强制刷新缓冲区。比如使用 fflush(stdout) 强制刷新标准输出缓冲区。

缓冲区可以根据其所在的位置分为不同级别:

  1. 用户级缓冲区 :由程序在用户空间中管理的缓冲区。比如使用 C 语言的 stdio.h 库时,标准输入输出流(如 stdout)就有一个用户级缓冲区。程序可以通过操作库函数来控制这些缓冲区,如 fflush()

  2. 内核级缓冲区:由操作系统内核管理的缓冲区。内核级缓冲区通常用于硬件设备的 I/O 操作,比如磁盘、网络等设备的读写缓存。内核会将 I/O 数据先暂存到内核级缓冲区中,再进行进一步的操作。

在进程结束时,缓冲区的刷新顺序通常是先刷新用户级缓冲区 (如标准输入输出的缓冲区),然后再刷新内核级缓冲区。这是因为操作系统希望确保在进程结束前,所有的输出数据已经被写入到目标位置,而这通常涉及到用户级缓冲区的内容。

在进程结束时,刷新过程的具体顺序:

  1. 用户级缓冲区刷新

    • 在进程退出时,操作系统会首先确保所有用户级缓冲区 (例如标准输入、标准输出、标准错误输出)中的数据已经被刷新到文件或终端。对于标准输出(stdout)和标准错误(stderr),如果它们的缓冲区中仍然有未写入的数据,操作系统会通过相应的刷新操作(如调用 fflush() 或者进程退出时自动刷新)将数据写入目标设备或文件。
  2. 内核级缓冲区刷新

    • 在刷新完用户级缓冲区后,操作系统会进一步刷新内核级缓冲区。这些缓冲区通常与硬件设备(如磁盘、网络、文件系统等)相关。在文件关闭或者进程退出时,操作系统会将内核级缓冲区中的数据(例如磁盘缓冲区中的数据)同步到实际存储设备中,以确保数据持久化到磁盘上。

具体实现:

  • 文件描述符的刷新 :当进程结束时,操作系统会对所有打开的文件进行刷新。具体的行为是,操作系统会首先刷新用户级缓冲区中的内容,然后同步内核级缓冲区中的数据。对于文件的操作,操作系统通过调用 fsync() 或类似的系统调用,确保文件的数据同步到存储设备。

  • 内存与磁盘同步:内核会将内存中的文件数据(内核级缓冲区)写入到磁盘,确保进程退出后,所有的数据都已经保存。

在进程结束时,操作系统先进行用户级缓冲区的刷新(如标准输出),然后再刷新内核级缓冲区。这样做的目的是确保用户的输出数据能够及时写入目标位置,而内核级缓冲区的刷新则确保文件数据最终被正确保存到磁盘或其他存储设备中。

在一个进程都拥有自己的用户级缓冲区,而内核级缓冲区是由操作系统直接管理的,是大家共享的,用户级缓冲区的数据是

4.4 缓冲区的意义
  1. 解耦:缓冲区帮助解耦了生产者(如程序)和消费者(如硬件设备)之间的速度差异,使得数据流可以更加平滑地传递,不会因为速度不匹配而导致频繁的阻塞和等待。

  2. 提高效率

    • 提高用户体验:通过使用缓冲区,程序能够更高效地进行输入输出操作,减少了不必要的阻塞时间,提升了响应速度。

    • 提高I/O效率:通过减少频繁的 I/O 操作(比如磁盘写入),缓冲区能够提升文件读写等操作的效率。

代码思考:
cpp 复制代码
int main()
{
  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
 
  const char* msg = "hello write\n";
  write(1,msg,strlen(msg));
  fork();
  return 0;
}

1.显示器刷新策略为 行缓冲,而普通文件为 全缓冲

2.直接运行程序时:此时是向 显示器 中打印内容,因为有 \n,所以两条语句都直接进行了冲刷

3.进行重定向后:此时是向 普通文件 中打印内容,因为普通文件是写满后才能刷新,并且 fprintf 有属于自己的缓冲区,这就导致 fork() 创建子进程后,父子进程的 fprintf 缓冲区中都有内容,当程序运行结束后,统一刷新,于是就是打印了两次 hello fprintf

1.在操作系统中,每个进程都有自己的文件描述符表。当一个进程打开一个文件或流时,操作系统会分配一个文件描述符,指向内核管理的文件对象。用户级缓冲区通常是与这个文件描述符绑定的,进程通过标准库函数(如 fopen()fwrite()fread() 等)来与文件描述符对应的缓冲区交互。

对于标准流(stdinstdoutstderr),每个进程都有自己的缓冲区。然而,在父子进程关系中,父进程的缓冲区和子进程的缓冲区可以通过文件描述符共享,从而使得缓冲区中的数据可以被子进程访问。

  1. 文件描述符在父子进程之间的共享

当父进程通过 fork() 创建一个子进程时,子进程会继承父进程的文件描述符表。这意味着:

  • 父进程打开的文件或流(如标准输出 stdout)会被复制到子进程中,子进程拥有对这些文件描述符的访问权限。

  • 文件描述符表中的条目是指向同一个文件对象的指针(file*)。这些文件对象包含了缓冲区的地址,因此,父子进程共享同一个文件对象的缓冲区。

这就是为什么子进程能够访问父进程的缓冲区数据的原因。

  1. 缓冲区的继承

在父子进程中,文件描述符表和缓冲区都由内核进行管理。当父进程调用 fork() 时,操作系统并没有为子进程创建全新的缓冲区,而是将父进程的文件描述符表和相关的缓冲区一起复制到子进程。也就是说,父子进程共享同一块缓冲区区域。

如果父进程已经向缓冲区写入了数据,那么这些数据会留在缓冲区中,子进程可以继续从这个缓冲区中读取数据。反之,如果子进程向缓冲区写入数据,父进程也能读取到更新后的数据,前提是缓冲区尚未被刷新。

  1. 缓冲区刷新与进程退出

在进程退出时,通常会通过标准库的 fflush() 或操作系统的 exit() 来刷新缓冲区内容。这是因为进程的缓冲区数据需要被写入到文件或其他外部设备。如果父子进程共享缓冲区数据,父进程的缓冲区数据可能会影响子进程的数据。当一个父进程尝试修改缓冲区时,操作系统会将缓冲区的数据复制到子进程的私有内存中。这样,父子进程就不再共享同一个缓冲区,而是各自拥有独立的缓冲区副本。

相关推荐
Dovis(誓平步青云)1 小时前
C++ Vector算法精讲与底层探秘:从经典例题到性能优化全解析
开发语言·c++·经验分享·笔记·算法
Logan Lie3 小时前
Linux运维笔记:服务器感染 netools 病毒案例
linux·运维·服务器·安全
学游戏开发的3 小时前
Lyra学习笔记 Experience流程梳理
笔记·unreal engine
Huazzi.4 小时前
【Vim】高效编辑技巧全解析
linux·编辑器·vim
碎梦归途5 小时前
Linux_T(Sticky Bit)粘滞位详解
linux·运维·服务器
HHBon5 小时前
判断用户输入昵称是否存在(Python)
linux·开发语言·python
Paper_Love5 小时前
Linux-pcie ranges介绍
linux
DjangoJason6 小时前
计算机网络 : 应用层自定义协议与序列化
linux·服务器·计算机网络
小杜-coding7 小时前
天机学堂(初始项目)
java·linux·运维·服务器·spring boot·spring·spring cloud
保持学习ing8 小时前
黑马Java面试笔记之框架篇(Spring、SpringMvc、Springboot)
java·笔记·spring·面试·mvc·mybatis·springboot