当前位置: 首页 > news >正文

Linux -- I/O接口,文件标识符fd、file结构体、缓冲区、重定向、简单封装C文件接口

一、理解文件

狭隘理解(传统视角)

  • 聚焦物理存储:文件特指存储在磁盘等外存设备上的二进制数据集合
  • 输入输出特性:
    • 写入文件:CPU 通过总线将数据输出到磁盘
    • 读取文件:磁盘通过 DMA 将数据输入到内存
      (DMA(Direct Memory Access)即直接存储器访问,它允许某些硬件子系统可以独立地直接读写计算机内存,而不需要经过中央处理器(CPU),从而提高数据传输的效率。例如在硬盘与内存之间的数据传输中,可以使用 DMA 来减少 CPU 的负担,加快数据传输速度。)
  • 典型操作:open/read/write/close 等标准文件操作接口

Linux 广义抽象

  • 设备文件化:
    • 硬件设备映射为 /dev 目录下的特殊文件(如 /dev/sda 代表磁盘)
    • 进程间通信管道也被视为文件(如命名管道文件)
  • 统一操作接口:
    • 所有设备都通过文件描述符(fd)操作
    • 实现方式:驱动程序提供 file_operations 结构体
  • 典型案例:
    • 键盘输入:/dev/console
    • 网络通信:/dev/net/tun
    • 随机数生成:/dev/urandom

这种设计的核心优势在于:

  • 实现 "一处代码,多处复用" 的设备无关性
  • 进程可以用相同的系统调用操作不同设备
  • 极大简化了设备管理和驱动开发的复杂度

例如,当我们使用 cat 命令读取文件时,实际上是在读取磁盘设备文件;而当使用 cat > /dev/tty 时,数据就会直接输出到终端设备,两者使用的都是 read/write 系统调用。

归类认知:

  • 文件 = 文件内容 + 文件属性
  • 对于0KB的空文件是占用磁盘空间的
  • 所有的文件操作本质是文件内容操作和文件属性操作
  • 对文件的操作本质是进程对文件的操作
  • 磁盘的管理者是操作系统
  • 文件的读写本质不是通过C/C++的库函数来操作的,而是通过文件相关的系统调用接口来实现的,C/C++库函数对系统调用进行了封装。

二、C文件接口 

fopen:打开文件

  • #include <stdio.h>
    ​​​​​​​FILE *fopen(const char *filename, const char *mode);
  • 功能:打开指定名称的文件,并返回一个指向该文件的 FILE 指针。如果打开失败,返回 NULL
  • 参数
    • filename:要打开的文件的名称,可以包含路径。
    • mode:指定文件的打开模式,常见的模式有:
      • "r":(read)以只读模式打开文本文件。
      • "w":(write)以写入模式打开文本文件,如果文件不存在则创建,如果文件已存在则清空内容,shell的输出重定向就是这个原理。
      • "a":(append)以追加模式打开文本文件,如果文件不存在则创建;shell的追加重定向就是这个原理。
      • "rb""wb""ab":分别对应二进制文件的只读、写入和追加模式
    • 例子:
      #include <stdio.h>
      
      int main() {
          FILE *fp = fopen("test.txt", "w");
          if (fp == NULL) {
              perror("Failed to open file");
              return 1;
          }
          // 文件操作代码
          fclose(fp);
          return 0;
      }

fclose:关闭文件

  • #include <stdio.h>
    int fclose(FILE *stream);
  • 功能:关闭指定的文件流。如果关闭成功,返回 0;否则返回 EOF。同时会刷新用户态标准I/O库缓冲区
  • 参数
    • stream:指向要关闭的文件的 FILE 指针。

fwrite: 二进制写入文件(wb)

size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
  • ptr:指向要写入的数据的指针,const void * 类型表示可以指向任意类型的数据。
  • size:每个数据项的大小(以字节为单位)。
  • nmemb:要写入的数据项的数量。
  • stream:指向目标文件的 FILE 指针,该文件通常以写入模式(如 "wb" 用于二进制写入)打开。
  • 返回实际成功写入的数据项的数量。如果返回值小于 nmemb,可能表示发生了错误或者到达了文件末尾。

fread:二进制读取文件(rb)

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
  • ptr:指向用于存储读取数据的缓冲区的指针。
  • size:每个数据项的大小(以字节为单位)。
  • nmemb:要读取的数据项的数量。
  • stream:指向源文件的 FILE 指针,该文件通常以读取模式(如 "rb" 用于二进制读取)打开。
  • 返回实际成功读取的数据项的数量。如果返回值小于 nmemb,可能表示发生了错误或者到达了文件末尾。

其他相关C文件操作的库函数还有:
fputc:写入字符;原型:int fputc(int c, FILE *stream);
fgetc:读取字符;原型:int fgetc(FILE *stream);
fputs:写入字符串;原型:int fputs(const char *s, FILE *stream);
fgets:读取字符串;原型:char *fgets(char *s, int size, FILE *stream);
fprintf:写入格式化数据;原型:int fprintf(FILE *stream, const char *format, ...);
fscanf:读取格式化数据;原型:int fscanf(FILE *stream, const char *format, ...);
ftell:返回当前文件指针的位置。如果发生错误,返回 -1L
fseek:将文件指针移动到指定的位置。如果成功,返回 0;否则返回非零值; 原型:

int fseek(FILE *stream, long offset, int whence);

stream:指向目标文件的 FILE 指针。
offset:偏移量,以字节为单位。
whence:指定偏移的起始位置,有三个可选值:
SEEK_SET:从文件开头开始偏移。
SEEK_CUR:从当前文件指针位置开始偏移。
SEEK_END:从文件末尾开始偏移。

本文并非对这些接口进行详细讲解,如有需要可以跳转之前的博客:文件与文件操作_1文件-CSDN博客 

编译并执行proc:

#include <stdio.h>
#include <stdlib.h>

int main()
{
  FILE *file = fopen("myfile","w");
  if(!file){
    perror("myfile open");
    exit(1);
  }

  fputs("hello world\n",file);
  fclose(file);

  return 0;
}

myfile在proc执行之前是不存在的,打开文件使用"w"模式,如果打开文件不存在就会在当前工作路径下创建这个文件。
cwd:当前工作路径,指的是当前进程所在的路径,会随着进程在不同的路径而发生改变;(可使用系统调用chdir来改变当前工作路径;使用系统调用getcwd来获取当前工作路径)
exe:指的是启动该进程的路径,在启动进程时就确定了
打开文件,本质是进程打开的,进程通过当前工作路径知道自己在哪里,所以文件不存在且文件不带路径,进程通过当前工作路径也能够在该路径下创建文件。

实现简单cat命令:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
  if(argc != 2){
    printf("argv error\n");
    exit(1);
  }
  FILE *file = fopen(argv[1],"r");
  if(!file){
    perror("file fopen");
    exit(1);
  }

  char buf[1024];
  while(1){
    int size = fread(buf,sizeof(char),sizeof(buf),file);
    if(size > 0){
      buf[size] = '\0';
      printf("%s",buf);
    }
    if(feof(file)){
      break;
    }
  }
  printf("\n");
  fclose(file);

  return 0;
}

三、C语言的输入输出流

C会默认打开三个输入输出流,分别是stdin、stdout、stderr,这三个流的类型都是FILE* 文件指针。

  • stdin:标准输入,对应的外设:键盘
  • stdout:标准输出,对应的外设:显示器
  • stderr:标准错误,对应的外设:显示器

既然stdout是标准输出流,类型是FILE*,对应的外设是显示器,我们说Linux下一切皆为文件,那么显示器也可以当成文件,所以可以通过文件操作函数,将信息输出到显示器:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
  char s[] = "hello linux\n";
  fwrite(s,sizeof(char),sizeof(s),stdout);

  printf("hello world\n");

  fprintf(stdout,"hello hhhh\n");
  return 0;
}

stdout和stderr对应的外设都是显示器,但它们在机制上存在差异:

缓冲机制

  • stdout
    • stdout 通常是行缓冲(line-buffered)或全缓冲(fully-buffered)的。
    • 行缓冲:在遇到换行符(\n)时,缓冲区中的内容会被刷新并输出到终端。例如,使用 printf("Hello, World!\n"); 时,Hello, World! 会立即输出。
    • 全缓冲:只有当缓冲区满或者调用 fflush(stdout) 函数时,缓冲区中的内容才会被输出。在某些情况下,例如程序重定向输出到文件时,stdout 可能会变成全缓冲模式。
    • 这里使用标准I/O库函数进行文件操作,刷新的是用户态标准 I/O 库缓冲区;使用系统调用进行文件操作,刷新的是内核缓冲区。
  • stderr
    • stderr 是无缓冲(unbuffered)的。这意味着一旦有数据写入 stderr,数据会立即被输出到终端,不会进行缓冲等待。这种设计确保了错误信息能够及时显示,避免因缓冲导致错误信息显示不及时而影响调试。

输出重定向的影响

  • stdout
    • 可以很方便地将 stdout 的输出重定向到文件或其他设备。在类 Unix 系统的命令行中,可以使用 > 符号进行重定向。例如,./a.out > output.txt 会将程序 a.out 的 stdout 输出重定向到 output.txt 文件中。
  • stderr
    • stderr 的输出不会受到 stdout 重定向的影响。如果只对 stdout 进行重定向,stderr 的信息仍然会输出到终端。不过,也可以单独对 stderr 进行重定向,在类 Unix 系统中,可以使用 2> 符号,例如 ./a.out 2> error.txt 会将程序 a.out 的 stderr 输出重定向到 error.txt 文件中。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
  printf("hello\n");
  fprintf(stderr,"hello world\n");

  return 0;
}

 
在C++中,默认打开的三个流为cin、cout、cerr,分别对应标准输入、标准输出、标准错误。

四、系统文件I/O

fopen、ifstream等流式属于语言层的方案,对系统调用进行了封装,系统调用才是打开文件最底层的方案。

介绍一种传递标志位的方法:

假设设计一个函数,根据传递的参数不同而实现不同的功能,这就可以使用位图的结构,将每一位设置为每一种功能的标志位。使用&可以鉴别各个功能的存在,使用 | 可以使一个位图传递多个功能。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define ONE 1  //0001
#define TWO 2  //0010
#define THREE 4 //0100

void func(int flag){
  if(flag & ONE) printf("func of ONE\n");
  if(flag & TWO) printf("func of TWO\n");
  if(flag & THREE) printf("func of THREE\n");
  printf("\n");
}

int main()
{
  func(ONE);
  func(TWO);
  func(THREE);
  func(ONE | TWO);
  func(TWO | THREE);
  func(ONE | TWO | THREE);

  return 0;
}

系统调用open:

  • 函数原型int open(const char *pathname, int flags, mode_t mode);
  • 参数解析
    • pathname:要打开或创建的文件的路径名,可以是绝对路径(如/home/user/test.txt)或相对路径(如test.txt,相对当前工作目录)。
    • flags:指定打开文件的方式和选项,常用取值如下:
      • O_RDONLY:以只读方式打开文件。
      • O_WRONLY:以只写方式打开文件。
      • O_RDWR:以可读可写方式打开文件。
      • O_CREAT:如果文件不存在,则创建新文件。
      • O_EXCL:与O_CREAT一起使用,确保文件创建时不存在,否则返回错误。
      • O_APPEND:以追加模式打开文件,每次写入数据时,数据将追加到文件末尾。
      • 这些选项都是每个二进制位表示一个功能,与传递标志位的方法一样。
      • 头文件:<fcntl.h>
    • mode:当O_CREAT标志被指定时,用于指定新文件的访问权限。取值通常使用八进制表示,如0644表示所有者可读可写,组用户和其他用户只读;
      需要注意,每个启动的进程都设置有文件权限掩码(例如为0002),实际文件权限 = 设置的文件权限值 - 文件权限掩码(0644 - 0002 = 0642);
      每个进程的权限掩码互不影响,可以通过调用umask(掩码值),来设置当前进程的文件权限掩码。
      文件权限这部分博主有过介绍,这里不再赘述,需要可移步:Linux权限管理_linux没有root用户怎么办-CSDN博客
  • 返回值:成功时返回一个非负的文件描述符fd,用于后续的文件操作;失败时返回 - 1,并设置errno变量来指示错误原因。

系统调用read:

  • 函数原型ssize_t read(int fd, void *buf, size_t count);
  • 参数解析
    • fd:要读取的文件的描述符,由open函数返回。
    • buf:用于存储读取数据的缓冲区,是一个指向字符数组或其他数据类型的指针。
    • count:指定要读取的字节数。
  • 返回值:成功时返回实际读取的字节数。如果到达文件末尾,返回 0。出错时返回 - 1,并设置errno变量。

系统调用write:

  • 函数原型ssize_t write(int fd, const void *buf, size_t count);
  • 参数解析
    • fd:要写入的文件的描述符。
    • buf:指向要写入数据的缓冲区的指针,数据类型通常为const char *,但也可以是其他数据类型,取决于实际需求。
    • count:指定要写入的字节数。
  • 返回值:成功时返回实际写入的字节数。出错时返回 - 1,并设置errno变量。

系统调用close:

  • 函数原型int close(int fd);
  • 参数解析fd为要关闭的文件的描述符。
  • 返回值:成功时返回 0,失败时返回 - 1,并设置errno变量。关闭文件后,相应的文件描述符将被释放,可以被其他文件重新使用。同时,系统会将与该文件描述符相关的内核缓冲区数据刷新到磁盘,确保数据的完整性。

五、内核缓冲区、用户态标准I/O库缓冲区、缓冲区刷新

内核缓冲区:
当使用系统调用函数(如 openreadwriteclose)进行文件操作时,涉及到内核缓冲区。在这种情况下,系统调用close 函数会刷新内核缓冲区close 函数在关闭文件描述符时,会确保将内核缓冲区中与该文件描述符相关的所有待写入数据刷新到磁盘。这是为了保证数据的完整性,避免数据丢失。例如,当你使用 write 函数将数据写入文件时,数据可能只是被暂时存储在内核缓冲区中,而不是立即写入磁盘。当调用 close 函数关闭文件描述符时,内核会将这些缓冲的数据写入磁盘。


内核缓冲区的刷新机制:

  • 延迟写入策略:内核为了提高文件 I/O 性能,采用延迟写入策略。当应用程序调用 write 系统调用将数据写入文件时,数据并不会立即被写入磁盘,而是先被复制到内核缓冲区。内核会在合适的时机(如缓冲区满、达到一定时间间隔、系统空闲等)将缓冲区中的数据批量写入磁盘,这样可以减少磁盘的 I/O 次数,提高整体性能。
  • 数据一致性保证:内核会确保在某些关键操作(如关闭文件close、系统关机等)时,将内核缓冲区中的数据刷新到磁盘,以保证数据的一致性和持久性。

刷新方式:

  • close函数:当调用 close 函数关闭文件描述符fd时,内核会检查该文件描述符对应的内核缓冲区,将其中尚未写入磁盘的数据刷新到磁盘,然后释放相关的内核资源。
  • fsync函数:fsync 函数会强制指定文件描述符fd对应的文件的所有数据和元数据(如文件权限、修改时间等)从内核缓冲区同步到磁盘。它会阻塞调用进程,直到数据完全写入磁盘,确保数据的持久性。
  • fdatasync函数:fdatasync 函数与 fsync 类似,但它只保证文件的数据部分被写入磁盘,而不强制刷新文件的元数据,因此在性能上可能会比 fsync 稍好。

写入流程:打开文件 --> 写入数据 --> 刷新内核缓冲区(可选) --> 关闭文件

用户态标准I/O库缓冲区 :
当使用标准 I/O 库函数(如 fopenfreadfwritefclose)进行文件操作时,会使用用户态的标准 I/O 库缓冲区。在这种情况下:close 函数不会刷新标准 I/O 库缓冲区close 函数是系统调用,它只能处理文件描述符相关的内核缓冲区,而无法直接操作标准 I/O 库缓冲区。如果你使用 fopen 打开文件,然后使用 fwrite 写入数据,数据会先被存储在标准 I/O 库缓冲区中。如果想要确保数据被写入磁盘,需要使用 fflush 函数刷新标准 I/O 库缓冲区,或者使用 fclose 函数,因为 fclose 函数在关闭文件流时会自动刷新标准 I/O 库缓冲区。


标准I/O库缓冲区的刷新机制:

  • 缓冲类型:标准 I/O 库提供了三种缓冲类型,分别是全缓冲、行缓冲和无缓冲。
    • 全缓冲:当缓冲区满时才会将数据刷新到内核缓冲区。通常用于对磁盘文件的操作。
    • 行缓冲:当遇到换行符 \n 或者缓冲区满时,将数据刷新到内核缓冲区。标准输入和标准输出默认采用行缓冲。
    • 无缓冲:数据会立即被写入内核缓冲区,不会进行缓冲。标准错误输出通常采用无缓冲。
  • 自动刷新:在某些情况下,标准 I/O 库会自动刷新缓冲区,如程序正常结束时。

刷新方式:

  • fflush 函数,fflush 函数用于将指定 FILE 指针对应的标准 I/O 库缓冲区中的数据刷新到内核缓冲区。如果传入 NULL 作为参数,则会刷新所有打开的输出流的缓冲区。
  • fclose 函数,调用 fclose 函数关闭文件流时,它会自动调用 fflush 函数将标准 I/O 库缓冲区中的数据刷新到内核缓冲区,然后再关闭底层的文件描述符。

写入流程:打开文件 --> 写入数据 --> 刷新标准I/O库缓冲区(可选) --> 关闭文件

六、文件描述符fd、重定向

通过上面对open的介绍,知道open会返回一个int类型的值,这个返回值称之为文件描述符(File Descriptor),即为fd。

在 Linux 系统里,一切皆文件,诸如普通文件、目录、设备文件(像键盘、鼠标、磁盘等)都可以通过文件描述符来进行操作。当进程打开或创建一个文件时,内核会为该操作分配一个唯一的文件描述符,进程后续对该文件的读写、定位等操作都通过这个文件描述符来进行。
Linux进程默认情况下会有三个缺省打开的文件描述符fd:

  • 标准输入(stdin):0,对应的物理设备:键盘
  • 标准输出(stdout):1,对应的物理设备:显示器
  • 标准错误(stderr):2,对应的物理设备:显示器
  • 通过文件操作和fd的结合使用,从键盘输入到文件,再从文件输入到显示器:
    #include <stdio.h>
    #include <string.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    
    int main()
    {
      char buf[1024];
      ssize_t size = read(0,buf,sizeof(buf));//input buf from fd:0
      if(size > 0){
        buf[size] = '\0';
        write(1,buf,strlen(buf));
        write(2,buf,strlen(buf));
      }
      return 0;
    }
    

当进程后续打开新的文件时,系统会从最小的未使用的非负整数开始分配文件描述符。例如,若 0、1、2 已被占用,新打开文件时会分配 3 作为文件描述符。 

那么为什么fd是int类型?为什么fd是0、1、2、3...线性分配的?

当我们打开一个文件时,OS在内存中创建相应的数据结构 file结构体 来描述这个打开的文件属性,使用OS对打开的文件进行管理;进程执行open调用,就需要让进程和文件关联起来,所以在每个进程的PCB中,都有一个file*字段,指向一张表file_struct(文件描述符表),表内存在一个指针数组,每个元素都是一个指向进程打开的文件的指针,所以,文件描述符就是数组的下标,一个进程只需要拿着文件描述符,就可以找到对应的文件。 

文件描述符的分配规则和重定向:

通过两段代码看现象:

clude <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>

int main()
{
  int fd = open("myfile",O_CREAT | O_RDONLY, 0664);
  if(fd < 0){
    perror("myfile open");
    exit(1);
  }
  printf("myfile fd:%d\n",fd);
  close(fd);

  return 0;
}

输出:
前面说过,fd的0、1、2这三个是默认打开的,如果新打开的文件,fd就会分配当前没有被使用的最小的一个下标作为新的文件描述符。

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>

int main()
{
  int fd1 = open("myfile1",O_CREAT | O_RDWR, 0664);
  close(1);
  int fd2 = open("myfile2",O_CREAT | O_RDWR, 0664);
  if(fd1 < 0 && fd2 < 0){
    perror("myfile1 or myfile2 open");
    exit(1);
  }
  printf("myfile1 fd:%d\n",fd1);
  printf("myfile2 fd:%d\n",fd2);
  fflush(stdout);
  close(fd1);
  close(fd2);

  return 0;
}


close(1)后,fd为1的输出流被关闭了,所以当前没有被使用的最小下标就是1,所以新打开的myfile2分配的fd就是1;因为输出流被关闭了,所以printf语句没有输出到显示器上,而是输出到当前fd为1的myfile2,通过cat就能发现输出内容。
(注意:这里需要调用fflush(stdout))刷新一下缓存区才能将内容重定向到myfile2中,因为 printf 是标准 I/O 库函数,它使用标准 I/O 缓冲区。在标准输出未重定向时,标准输出通常采用行缓冲模式,即遇到换行符 \n 时会自动刷新缓冲区。但当标准输出被重定向到文件时,标准 I/O 库会将缓冲模式切换为全缓冲模式,此时遇到换行符 \n 不会自动刷新缓冲区,只有当缓冲区满或者手动调用 fflush 函数时,缓冲区中的数据才会被写入文件。
 

原本应该输出到显示器的内容,被输出到其他文件中,fd=1指向其他file结构体,这样的现象就叫做输出重定向。常见的输出重定向有shell的:> ,>> ,<,。

可以通过系统调用 dup2 来完成fd的指向,完成重定向操作。

函数原型

#include <unistd.h>
int dup2(int oldfd, int newfd);

参数

  • oldfd:旧的文件描述符,也就是要被复制的文件描述符。它必须是一个已经打开的有效的文件描述符。
  • newfd:新的文件描述符,复制操作将把 oldfd 所指向的文件对象关联到 newfd 上。如果 newfd 已经打开,那么在复制之前会先关闭 newfd

返回值

  • 成功:返回新的文件描述符 newfd
  • 失败:返回 -1,并设置 errno 来指示具体的错误原因。常见的错误包括 oldfd 无效、newfd 是一个无效的文件描述符编号等。

功能

dup2 函数的主要功能是将 oldfd 对应的文件描述符复制到 newfd 上。复制完成后,oldfd 和 newfd 都会指向同一个文件对象,它们共享文件的偏移量、文件状态标志等信息。也就是说,对 oldfd 或 newfd 进行的读写操作都会影响同一个文件。


使用场景

  • 输入输出重定向:在 shell 脚本中,经常会使用 dup2 来实现输入输出的重定向。例如,将标准输出重定向到一个文件,这样程序的所有输出都会被写入该文件而不是显示在终端上。
  • 多进程编程:在创建子进程时,子进程可以使用 dup2 来复制父进程的文件描述符,从而实现父子进程之间共享文件资源。

示例:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
  int fd = open("myfile",O_CREAT | O_RDWR, 0664);
  if(fd < 0){
    perror("myfile open");
    exit(1);
  }
  if(dup2(fd,1) == -1){
    perror("dup2");
    close(fd);
    exit(1);
  }
  printf("This output will be redirected to the file\n");
  close(fd);

  return 0;
}

七、file结构体

上面谈到的file结构体,是一个关键的数据结构,它用于表示一个打开的文件。当进程打开一个文件时,内核会为这个打开的文件实例创建一个 file 结构体通过该结构体对文件进行管理和操作。file 结构体定义在内核源码的 <linux/fs.h> 头文件中,其定义较为复杂,下面给出一个简化版本,展示一些主要成员:

struct file {
    union {
        struct llist_node    fu_llist;
        struct rcu_head     fu_rcuhead;
    } f_u;
    struct path             f_path;
    struct inode            *f_inode;       /* cached value */
    const struct file_operations    *f_op;

    /*
     * Protects f_ep_links, f_flags.
     * Must not be taken from IRQ context.
     */
    spinlock_t              f_lock;
    atomic_long_t           f_count;
    unsigned int            f_flags;
    fmode_t                 f_mode;
    struct mutex            f_pos_lock;
    loff_t                  f_pos;
    struct fown_struct      f_owner;
    const struct cred       *f_cred;
    struct file_ra_state    f_ra;

    u64                     f_version;
#ifdef CONFIG_SECURITY
    void                    *f_security;
#endif
    /* needed for tty driver, and maybe others */
    void                    *private_data;

#ifdef CONFIG_EPOLL
    /* Used by fs/eventpoll.c to link all the hooks to this file */
    struct list_head        f_ep_links;
    struct list_head        f_tfile_llink;
#endif
    struct address_space    *f_mapping;
};
1. 文件路径和索引节点信息
  • struct path f_path:包含文件的挂载点和目录项信息,用于确定文件在文件系统中的位置。
  • struct inode *f_inode:指向文件对应的 inode 结构体。inode 存储了文件的元数据,如文件大小、权限、创建时间等,以及文件数据块在磁盘上的存储位置信息。
2. 文件操作函数集
  • const struct file_operations *f_op:指向一个 file_operations 结构体,该结构体包含了一系列用于操作文件的函数指针,如 readwriteopenrelease 等。不同类型的文件(如普通文件、字符设备文件、块设备文件)会有不同的 file_operations 实现,通过 f_op 可以调用相应的操作函数来完成文件的读写等操作。
3. 文件状态和标志
  • unsigned int f_flags:存储文件的打开标志,如 O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)等,这些标志在调用 open 系统调用时指定。
  • fmode_t f_mode:表示文件的访问模式,如读、写、执行等权限信息。
4. 文件偏移量
  • loff_t f_pos:记录文件当前的读写位置,即文件偏移量。每次进行读写操作时,会根据操作的字节数更新这个偏移量。
5. 引用计数
  • atomic_long_t f_count:用于记录对该 file 结构体的引用计数。当有新的文件描述符指向这个 file 结构体时,引用计数加 1;当关闭文件描述符时,引用计数减 1。当引用计数为 0 时,内核会释放该 file 结构体。
6.file 结构体的作用
  • 管理打开的文件:内核通过 file 结构体对每个打开的文件进行管理,记录文件的状态、位置、操作函数等信息,方便进行文件的读写、定位等操作。
  • 实现文件共享:多个文件描述符可以指向同一个 file 结构体,从而实现文件的共享访问。不同的进程或同一个进程的不同文件描述符可以共享同一个 file 结构体,它们共享文件的偏移量和状态信息。
  • 抽象文件操作file 结构体中的 f_op 成员提供了统一的文件操作接口,使得内核可以以相同的方式处理不同类型的文件,提高了系统的可扩展性和兼容性。
7.相关操作
  • 创建 file 结构体:当进程调用 open 系统调用打开一个文件时,内核会分配一个新的 file 结构体,并初始化其各个成员。
  • 释放 file 结构体:当所有引用该 file 结构体的文件描述符都被关闭后,内核会释放该 file 结构体,回收相关资源。
  • 文件操作:通过 file 结构体中的 f_op 成员调用相应的操作函数,实现文件的读写、定位等操作。
8.指向问题(多个进程或者同一个进程里的多个文件描述符指向同一个文件时,是否需要创建多个 file 结构体)

同一个进程内多个文件描述符指向同一文件

  • 使用 dup 或 dup2 复制文件描述符:当在同一个进程里使用 dup 或者 dup2 系统调用复制文件描述符时,不会创建新的 file 结构体。这两个系统调用的作用是复制已有的文件描述符,让新的文件描述符和原文件描述符指向同一个 file 结构体。多个文件描述符共享同一个 file 结构体意味着它们共享文件的偏移量、状态标志等信息。例如,若一个文件描述符改变了文件的偏移量,其他指向同一 file 结构体的文件描述符也会受到影响。
  • 多次调用 open 打开同一文件:如果在同一个进程里多次调用 open 函数打开同一个文件,每次调用都会创建一个新的 file 结构体。这是因为每次 open 调用都被视为一个独立的打开操作每个 file 结构体有自己独立的文件偏移量和状态标志不同的 file 结构体虽然对应同一个文件(即共享同一个 inode 结构体),但它们之间的操作是相互独立的。

不同进程指向同一文件

  • 父子进程通过 fork 继承文件描述符:当父进程打开一个文件后调用 fork 创建子进程,子进程会继承父进程的文件描述符。这些继承的文件描述符会指向和父进程相同的 file 结构体。也就是说,父子进程共享同一个 file 结构体,它们对文件的操作会相互影响,例如文件偏移量的改变会被双方感知。
  • 不同进程分别调用 open 打开同一文件:如果不同的进程分别调用 open 函数打开同一个文件,每个进程都会创建自己的 file 结构体。不同进程的 file 结构体是相互独立的,它们有各自的文件偏移量和状态标志,彼此之间的操作不会直接相互影响。
9.重新理解“一切皆文件”

在Linux中,进程、磁盘、显示器、键盘等都被抽象成了文件,可以使用访问文件的方法访问它们获取信息;这样做的好处就是,开发者只需要一套API和开发工具,即可调取Linux系统中绝大部分的资源。举个简单例子,Linux中几乎所有读操作(读文件,读系统状态,读PIPE)都可以用read函数进行;几乎所有更改操作(更改文件、更改系统参数、写PIPE)都可以用write函数来进行。
在结构体struct file中的 f_op 指针指向一个 file_operations 结构体,这个结构体中的成员除了 struct module *owner 其余都是函数指针。

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    int (*iterate) (struct file *, struct dir_context *);
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    int (*release) (struct inode *, struct file *);
    int (*fsync) (struct file *, loff_t, loff_t, int datasync);
    int (*aio_fsync) (struct kiocb *, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *);
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
    int (*check_flags)(int);
    int (*flock) (struct file *, int, struct file_lock *);
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
    int (*setlease)(struct file *, long, struct file_lock **, void **);
    long (*fallocate)(struct file *file, int mode, loff_t offset,
                      loff_t len);
    void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
    unsigned (*mmap_capabilities)(struct file *);
#endif
};

 file_operator 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用,读取 file_operator 中相应的函数指针,然后把控制权交给函数,从而完成Linux设备驱动程序的工作。每个设备都有自己的read、write,但一定是对应着不同的操作方法(实现不同),但通过struct file下 file_operation 中的各种函数回调,让开发者只用file便可以调取Linux系统中绝大部分的资源,这就是“一切皆文件”的核心。

八、FILE与fd

重新梳理一下FILE和fd的区别和关系:​​​​​​​

抽象层次的区别:

  • fd(文件描述符):属于操作系统内核层面的概念。它是一个非负整数,是内核为了管理进程打开的文件而分配的索引。进程通过文件描述符与内核中的文件对象交互,是一种底层的文件操作方式。
  • FILE:是标准 C 库层面的抽象。FILE 是一个结构体类型,它封装了文件描述、系统调用以及其他与文件操作相关的信息,如缓冲区状态、错误标志等。

接口的区别:

  • fd:使用系统调用函数进行操作,例如 openreadwriteclose 等。这些系统调用直接与内核交互,绕过了标准 C 库的缓冲区,数据直接在用户空间和内核空间之间传输。
  • FILE:使用标准 C 库函数进行操作,如 fopenfreadfwritefclose 等。这些函数在内部会调用相应的系统调用,同时还处理了缓冲区管理、错误检查等额外的工作。

缓冲区管理:

  • fd:不涉及标准 C 库的缓冲区。使用 read 和 write 系统调用时,数据直接在用户空间和内核缓冲区之间复制。内核会根据自身的策略(如延迟写入)来决定何时将内核缓冲区中的数据写入磁盘。
  • FILEFILE 结构体管理着标准 C 库的缓冲区,有全缓冲、行缓冲和无缓冲三种模式。全缓冲模式下,缓冲区满时才会将数据刷新到内核缓冲区;行缓冲模式下,遇到换行符或缓冲区满时刷新;无缓冲模式下,数据立即写入内核缓冲区(比如标准错误)。可以使用 fflush 函数手动刷新缓冲区。

FILE和fd相互转换:

  • 从 fd 到 FILE:可以使用 fdopen 函数将一个文件描述符转换为 FILE 指针。这样就可以使用标准 C 库函数对该文件进行操作。
  • 从 FILE 到 fd:可以使用 fileno 函数从 FILE 指针获取对应的文件描述符。这在需要使用系统调用对 FILE 指针指向的文件进行操作时非常有用。

FILE 结构体在内部会持有一个文件描述符,通过这个文件描述符与内核中的文件对象进行交互。也就是说,标准 C 库函数在实现文件操作时,最终还是会调用系统调用,借助文件描述符来完成实际的文件读写等操作。

看如下代码:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main()
{
  const char *s1 = "Hello printf\n";
  const char *s2 = "Hello fwrite\n";
  const char *s3 = "Hello write\n";

  printf("%s",s1);
  fwrite(s2,strlen(s2),1,stdout);
  write(1,s3,strlen(s3));
  fork();

  return 0;
}

 
为什么同一个代码,输出到显示器和输出到文件的结构不同呢?

  1. 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲
  2. printf fwrite库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲,所以不会遇到\n就刷新了,而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后,也没有刷新缓冲区;fork的时候,父子数据会发生写时拷贝,子进程也有一份同样的数据,在父进程的缓冲区的数据在子进程也有一份,进程退出之后,会统一刷新,父子进程的缓冲区的数据写入,所以同样的一份数据,随即产生两份数据。
  3. 然而write 没有变化,说明没有所谓printf和write的缓冲。

所以,printf和fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这
里所说的缓冲区,都是用户级缓冲区(标准库I/O缓冲区)。为了提升整机性能,OS也会提供相关内核级缓冲区。

那这个缓冲区谁提供呢?
printf和fwrite是库函数,write是系统调用,库函数在系统调用的“上层”,是对系统调用的“封装”,但是 write 没有缓冲区,而 printf和fwrite 有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。

glibc 中的 FILE 结构体:

struct _IO_FILE {
    int _flags;        /* 文件状态标志,如是否为只读、是否已到达文件末尾等 */
    char *_IO_read_ptr; /* 当前读指针位置 */
    char *_IO_read_end; /* 读缓冲区的结束位置 */
    char *_IO_read_base; /* 读缓冲区的起始位置 */
    char *_IO_write_base; /* 写缓冲区的起始位置 */
    char *_IO_write_ptr; /* 当前写指针位置 */
    char *_IO_write_end; /* 写缓冲区的结束位置 */
    char *_IO_buf_base; /* 缓冲区的起始位置 */
    char *_IO_buf_end; /* 缓冲区的结束位置 */
    int _fileno;       /* 文件描述符 */
    /* 其他成员,用于处理错误状态、锁机制、流的方向等 */
};

typedef struct _IO_FILE FILE;

可以看出FILE结构体封装了fd,和缓冲区。

模拟简单封装FILE和操作函数:

//my_stdio.h

#pragma once

#define SIZE 2048
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

typedef struct IO_FILE{
  int flag;
  int fileno;
  char outbuffer[SIZE];
  int cap;
  int size;
}myFILE;

myFILE *myfopen(const char *filename,const char *mode);
int myfwrite(const void *ptr,int num,myFILE *stream);
void myfflush(myFILE *stream);
void myclose(myFILE *stream);
//my_stdio.c

#include "my_stdio.h"
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

myFILE*myfopen(const char *filename, const char *mode){
  int fd = -1;
  if(strcmp(mode,"r") == 0)
    fd = open(filename,O_RDONLY);
  else if(strcmp(mode,"w") == 0)
    fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC, 0666);
  else if(strcmp(mode,"a") == 0)
    fd = open(filename,O_CREAT | O_WRONLY | O_APPEND, 0666);
  else{
    perror("myfopen mode");
    exit(1);
  }

  if(fd < 0) return NULL;
  myFILE *mf = (myFILE*)malloc(sizeof(myFILE));
  if(!mf){
    close(fd);
    return NULL;
  }

  mf->fileno = fd;
  mf->flag = FLUSH_LINE;
  mf->size = 0;
  mf->cap = SIZE;

  return mf;
}

int myfwrite(const void *ptr,int num,myFILE *stream){
  memcpy(stream->outbuffer+stream->size,ptr,num);
  stream->size += num;

  //flush
  if(stream->flag == FLUSH_LINE && stream->size > 0 && stream->outbuffer[stream->size - 1] == '\n')
    myfflush(stream);

  return num;
}

void myfflush(myFILE *stream){
  if(stream->size > 0){
    write(stream->fileno,stream->outbuffer,stream->size);
    fsync(stream->fileno);
    stream->size = 0;
  }
}

void myclose(myFILE *stream){
  if(stream->size > 0)
    myfflush(stream);

  close(stream->fileno);
}

//main.c

#include "my_stdio.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main()
{
  myFILE *fp = myfopen("myfile","w");
  if(fp == NULL)
    return 1;

  int i;
  for(i = 1; i <= 10; i++){
    printf("write %d\n",i);
    char buffer[64];
    snprintf(buffer,sizeof(buffer),"hello, num is %d",i);
    myfwrite(buffer,strlen(buffer),fp);
    myfflush(fp);
    sleep(1);
  }
  myclose(fp);

  return 0;
}

相关文章:

  • MySQL实现文档全文搜索,分词匹配多段落重排展示,知识库搜索原理分享
  • Android 系统开发的指导文档
  • 前端知识一
  • 【Linux】配置hosts
  • C# IEquatable<T> 使用详解
  • kali_Linux_2024安装frida==12.8.0
  • Xcode 运行真机失败
  • 前端面试题---.onChange() 事件与焦点机制解析
  • python爬虫:python中使用多进程、多线程和协程对比和采集实践
  • pyside6学习专栏(九):在PySide6中使用PySide6.QtCharts绘制6种不同的图表的示例代码
  • jenkins流程概述
  • Vue 调用摄像头扫描条码
  • 【零基础到精通Java合集】第二十三集:G1收集器深度解析
  • Git 强制同步远程仓库:如何彻底放弃本地更改并同步远程数据?
  • printf 与前置++、后置++、前置--、后置-- 的关系
  • 数据库设计理论与实践
  • 软件试用 防破解 防软件调试(C# )
  • 2025前端岗位技术需求统计+前端进阶抗AI取代详解
  • 458. 可怜的小猪
  • iOS安全和逆向系列教程 第3篇:搭建iOS逆向开发环境 (上) - 工具链与基础配置
  • 常州 做网站/企业网站首页
  • 免费拿货的代理商/嘉兴优化公司
  • 做商城网站在哪里注册营业执照/凡科建站怎么用
  • 郑州 公司网站制作/谷歌手机版下载安装
  • 网页版传奇制作教程/怎样优化网站排名
  • 做网站的组要具备哪些素质/做网站建设公司