首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Linux基础IO

Linux基础IO

作者头像
夜雨声烦1413
发布2026-01-12 18:17:32
发布2026-01-12 18:17:32
1120
举报

1:C语言文件IO

C语言中的文件操作函数如下:

文件操作函数

功能

fopen

打开文件

fclose

关闭文件

fputc

写入一个字符

fgetc

读取一个字符

fputs

写入一个字符串

fgets

读取一个字符串

fprintf

格式化写入数据

fscanf

格式化读取数据

fwrite

向二进制文件写入数据

fread

从二进制文件读取数据

fseek

设置文件指针的位置

ftell

计算当前文件指针相对于起始位置的偏移量

rewind

设置文件指针到文件的起始位置

ferror

判断文件操作过程中是否发生错误

feof

判断文件指针是否读取到文件末尾

下面只会选择性对C语言的部分文件操作函数进行使用,若想详细了解其余文件操作函数的使用方法,请跳转到博主的其它博客:文件处理不再难:带你轻松攻克C语言文件操作_c语言大文件处理-CSDN博客

代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>

int main()
{
   FILE * fp = fopen("log.txt","w");
   if(NULL == fp)
   {
      perror("fopen");
      exit(-1);
   }
   fclose(fp);
   fp = NULL;
   return 0;
}

运行程序后,在当前路径下就会生成对应文件.

那么我们也可以通过输出重定向向文件里面写入新的内容.

1.1:对文件的初步理解

那么有了上面的简单例子我们可以对文件有一个简单的初步理解

  • 打开文件,本质是进程在打开文件.
  • 文件在没有被打开的时候,在什么地方上------->在磁盘上头.
  • 进程能够同时打开很多文件.

系统中可以存在很多进程------------>很多情况下,OS内部,一定存在大量的被打开的文件---------->那么OS就要对这些被打开的文件进行处理,先描述,再组织------->因此可以推测,每一个被打开的文件在OS内部,一定要存在对应的描述文件属性的结构体,类似与PCB.

  • 操作文件的本质:进程在操作文件------>因此探讨的是进程与文件的关系.

1.2:默认打开的三个流

我们都知道在Linux下一切皆文件,也就是说Linux下的任何东西都可以看作是文件,那么显示器和键盘当然也可以看作是文件.我们能看到显示器上面的相关数据,是因为我们向"显示器文件”写入了数据,电脑能获取到我们敲击键盘时对应的字符,是因为电脑从"键盘文件"读取了数据.

有的uu就会有疑问,为什么我们向“显示器文件”写入数据以及从“键盘文件”读取数据前,不需要进行打开“显示器文件”和“键盘文件”的相应操作?

  • 因为打开文件一定是进程运行的时候打开的,而任何进行在运行的时候会默认打开三个输入输出流,即标准输入流,标准输出流以及标准错误流,对应到C语言当中就是stdin,stdout以及stderr
  • 其中,标准输入流对应的设备就是键盘标准输出流标准错误流对应的设备都是显示器

查看man手册我们就可以发现,stdin、stdout以及stderr这三个家伙实际上都是FILE*类型的。

  • 当我们的C程序被运行起来时,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于scanf和printf之类的函数向键盘和显示器进行相应的输入输出操作。
  • 也就是说,stdin、stdout以及stderr与我们打开某一文件时获取到的文件指针是同一个概念.

那么可以试想一个场景,当我们使用fputs函数时,将第二个参数设置为stdout,此时fputs函数会不会将数据显示在显示器上呢.

代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>

int main()
{
   fputs("hello stdin\n",stdout);
   fputs("hello stdout\n",stdout);
   fputs("hello stderr\n",stdout);
   
}

不止是C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。

2:系统文件I/O

操作文件,除了上述 C 接口(当然, C++ 也有接口,其他语言也有),我们还可以采用系统接口来进行文件访问.

我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发.

2.1:open函数

open函数的第一个参数是pathname,表示要打开或创建的目标文件.

  1. 若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建.
  2. 若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建.

open函数的第二个参数是flags,表示打开文件的方式。

参数选项

含义

O_RDONLY

以只读的方式打开

O_WRONLY

以只写的方式打开文件

O_APPEND

以追加的方式打开

O_RDWR

以读写的方式打开

O_CREAT

当目标文件不存在时,创建文件

2.1.1:代码1
代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>

int main()
{
   int fd =open("test.txt",O_WRONLY | O_CREAT);
   if(fd < 0 )
   {
      perror("open");
      exit(-1);
   }    
   return 0;   
}

相信通过上面的代码uu们对open函数有了一个基础的了解,但是会存下以下两个疑问

  1. 第二个参数为什么能传入多个选项.
  2. 创建出来的文件具有的权限跟正常创建出来的文件所具有的权限不一样.
2.1.1.1:问题1
  • 打开文件时,可以传入多个参数选项,当有多个选项传入时,将这些选项用“或”运算符隔开。
  • 第二个参数的本质是位图,因为整型占32个比特位,用比特位来进行标志位的传递------->这个是OS设计很多系统调用接口的常见方法.
  • 实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的:

这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是1(O_RDONLY选项的二进制序列为全0,表示O_RDONLY选项为默认选项),且为1的比特位是各不相同的,这样一来,在open函数内部就可以通过使用“与”运算来判断是否设置了某一选项。

代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
//0000 0001
#define One  1
//0000 0010
#define Two (1 << 1)
//0000 0100
#define Three (1 << 2)
//0000 1000
#define Four (1 << 3)

void Print(int Flag)
{
   if (Flag & One)
     printf("Function One\n");
   if (Flag & Two)
     printf("Function Two\n");
   if (Flag & Three)
     printf("Function Three\n");
   if (Flag & Four)
     printf("Function Four\n");
   
}
int main()
{ 
   Print(One);
   printf("\n");
   Print(One | Two);
   printf("\n");
   Print(One | Two | Three);
   printf("\n");
   Print(One | Four);
   printf("\n");
   return 0;
}
2.1.1.2:问题2

创建出来的文件具有的权限跟正常创建出来的文件所具有的权限不一样,这就要牵扯到open函数的第三个参数了

open函数的第三个参数是mode,表示创建文件的默认权限。

例如,将mode设置为0666,则文件创建出来的权限如下:

但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。

若想创建出来文件的权限值不受umask的影响,则需要在创建文件前使用**umask**函数将文件默认掩码设置为0。

2.1.2:代码2
代码语言:javascript
复制
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
int main()
{
   //将umask码设置成0
   umask(0);
   int fd = open("test.txt",O_WRONLY|O_CREAT,0666);
   if(fd < 0)
   {
      perror("open");
      exit(-1);
   }
   return 0;   
}

open函数的返回值是新打开文件的文件描述符。

我们可以尝试一次打开多个文件,然后分别打印它们的文件描述符。

2.1.3:代码3
代码语言:javascript
复制
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
	umask(0);
	int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
	int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
	int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
	int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
	int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);
	printf("fd1:%d\n", fd1);
	printf("fd2:%d\n", fd2);
	printf("fd3:%d\n", fd3);
	printf("fd4:%d\n", fd4);
	printf("fd5:%d\n", fd5);
	return 0;
}

运行程序后可以看到,打开文件的文件描述符是从3开始连续且递增的,那么为什么是从3开始递增的呢

2.1.4:代码4

我们再尝试打开一个根本不存在的文件,也就是open函数打开文件失败。

代码语言:javascript
复制
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
   int fd = open("test.txt",O_RDONLY);
   printf("%d\n",fd);
	return 0;
}

  • 实际上,文件描述符的本质是一个指针数组的下标,指针数组中的每一个下标都指向一个被打开文件的文件信息,通过对应文件的文件描述符就可以找到对应的文件信息.
  • 当open函数打开文件成功时,数组当中的指针个数增加,然后将该指针在数组当中的下标返回,而当文件打开失败时直接返回-1,因此成功打开多个文件时的文件描述符师连续且递增的.
  • 而Linux进程默认情况下会有3个缺省打开的文件描述符,分别就是标准输入0、标准输出1、标准错误2,这就是为什么成功打开文件时所得到的文件描述符是从3开始进程分配的。

2.2:close函数

使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。

2.2.1:代码1
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
   int fd = open("test.txt",O_WRONLY | O_CREAT,0666);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   printf("%d\n",fd);
   close(fd);
	return 0;
}

2.3:write函数

系统接口中使用write函数向文件写入信息,write函数的函数原型如下:

代码语言:javascript
复制
ssize_t write(int fd, const void *buf, size_t count);

使用write函数,将buf位置开始向后count字节的数据写入文件描述符为fd的文件中.

  • 如果数据写入成功,实际写入数据的字节个数被返回.
  • 如果数据写入失败,-1被返回.
2.3.1:代码1
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   //O_WRONLY表示只写打开,O_CREAT表示若文件不存在,则创建,并且要提供对应的权限码
   int fd = open("test.txt",O_WRONLY | O_CREAT,0666);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   const char * message = "cwd world";
   write(fd,message,strlen(message));
   close(fd);
	return 0;
}
2.3.2:代码2
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   //写方式打开,不存在文件则创建文件,存在文件的话会先刷新文件的内容
   int fd = open("test.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   const char * message = "hello Linux";
   write(fd,message,strlen(message));
   close(fd);
	return 0;
}
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   int fd = open("test.txt",O_RDONLY);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   char ch;
   while (1)
   {
      //从文件描述符为fd的文件读取1个字节的数据到Message中
      ssize_t Message = read(fd,&ch,1);
      if(Message <=0)   
         break;
       printf("%c",ch);
   }
   printf("\n");
   close(fd);
   return 0;
   
}

2.4:read函数

系统接口中使用read函数从文件读取信息,read函数的函数原型如下.

代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   int fd = open("test.txt",O_RDONLY);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   char ch;
   while (1)
   {
      //从文件描述符为fd的文件读取1个字节的数据到Message中
      ssize_t Message = read(fd,&ch,1);
      if(Message <=0)   
         break;
       printf("%c",ch);
   }
   printf("\n");
   close(fd);
   return 0;
   
}

我们可以使用read函数,从文件描述符为fd的文件读取count字节的数据到buf位置当中。

  • 如果数据读取成功,实际读取数据的字节个数被返回。
  • 如果数据读取失败,-1被返回。
2.4.1:代码1
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   int fd = open("test.txt",O_RDONLY);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   char ch;
   while (1)
   {
      //从文件描述符为fd的文件读取1个字节的数据到Message中
      ssize_t Message = read(fd,&ch,1);
      if(Message <=0)   
         break;
       printf("%c",ch);
   }
   printf("\n");
   close(fd);
   return 0;
   
}

3:文件描述符fd

  • 通过前面的学习,我们对文件有了一些基础的了解,我们知道文件是由进程在运行的时候打开的,一个进程可以同时打开多个文件,而系统中又存在大量的进程,也就是说,在系统中任何时候都可能存在大量已经打开的文件.
  • 因此,操作系统务必要对这些文件进行管理,那么就要先描述,再组织,操作系统会为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双向循环链表的方式链接起来,之后对文件的管理就变成了对双向循环链表的增删改查等操作.
  • 但是为了区分已经打开的文件哪些属于特定的某个进程,因此就还需要建立进程和文件之间的对应关系.

进程和文件之间的关系是如何建立的. 我们知道当一个程序运行起来的时候,操作系统会将该程序的代码和数据加载到内存当中,然后创建对应的task_struct与mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存与物理内存之间的对应关系.

而task_struct当中有一个指针,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组,该数组的下标就是我们所谓的文件描述符,因此一个进程想要找到对应的文件,只需要在对应在指针数组struct file * fdN中的下标,将其返回给上层,就能够访问文件,因此文件描述符fd的本质是:内核的进程:文件映射关系的数组的下标.

  • 当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file链入到双向循环链表中,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给被调用的进程即可.
  • 因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件一系列输入输出操作.

3.1:知识补充

什么叫做进程创建的时候会默认打开0、1、2?

  • 0就是标准输入流,对应键盘;1就是标准输出流,对应显示器;2就是标准错误流,也是对应显示器。
  • 而键盘和显示器均属于硬件,属于硬件说明就能够被操作系统识别到,当某一进程创建的时候,操作系统就会根据键盘、显示器形成各自的struct file,将这3个struct file连入文件的双向循环链表当中,并将这个3个struct file的地址分别填入fd_array数组下标为0 1 2的位置,至此就默认打开了标准输入流、标准输出流、标准错误流.

磁盘文件VS内存文件.

  • 当文件存储在磁盘中,将其称之为磁盘文件,而当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件.磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便形成了进程,而当磁盘文件加载到内存当中以后便成了内存文件.
  • 磁盘文件由两部分构成,分别是文件内容与文件属性.文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
  • 文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再延后式的加载文件数据。

Linux一切皆文件的原因.

  • 有的uu会好奇,0就是标准输入流,对应键盘;1就是标准输出流,对应显示器;2就是标准错误流,也是对应显示器。但是键盘和和显示器都是硬件啊,而在Linux中一切皆文件,这是为什么呢
  • 因为文件从磁盘加载到内存的时候,会形成对应的struct file,从struct file的视角向上看,此时就不用再关心底层硬件的差异了,因为我们所看到的所有读写外设文件的方法都是通过struct file中的函数指针去进行调用的,因此从上层看到的一切皆文件.

3.2:文件描述符的分配规则

代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   int fd = open("test.txt",O_RDONLY);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   printf("fd:> %d\n",fd);
   close(fd);
   return 0;
   
}

输出发现是 fd: 3,关闭0或者2,再看

代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
   close(0);
   int fd = open("test.txt",O_RDONLY);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   printf("fd:> %d\n",fd);
   close(fd);
   return 0;
   
}

发现是结果是:fd:0,文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符.

4:重定向

4.1:重定向的原理

  • 在明确了文件描述符的概念及其分配规则后,现在我们已经具备理解重定向原理的能力了。看完下面三个例子后,你会发现重定向的本质就是修改文件描述符下标对应的struct file*的内容。
  • 输出重定向就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。

我们来看一段代码

代码1
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>

const char * FileName = "log.txt";

int main()
{
   umask(0);
   close(1);
   int fd = open(FileName,O_CREAT | O_WRONLY | O_TRUNC,0666);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   printf("fd:> %d\n",fd);
   fprintf(stdout,"fprintf,fd:>%d\n",fd);
   fflush(stdout);
   close(fd);
   return 0;
   
}

我们可以看到此时显示器上并没有输出数据,对应数据输出到了log.txt文件当中,那么这是为什么呢?

printf与fprintf,他们只认stdout,但stdout里头封装的文件描述符依旧是1,但在内核当中,1号下标已经不是指向之前的显示器了,而是指向一个新打开的文件了,printf与fprintf默认打印的时候,本质就是往显示器上打印,本应该打印到显示器上的内容,却打印到了log.txt里头,这种技术就叫做重定向.

为什么log.txt的文件描述符会变成1呢

根据文件描述符的分配规则,由于创建了文件并关闭了1号下标,所以导致1号下标并为被使用,因此原本1号下标指向标准输出(显示器)最终成为了log.txt的文件描述符.

那么因此我们可以知道,重定向的本质:是在内核中改变文件描述符表特定下标的内容,与上层无关.

代码2
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>

const char * FileName = "log.txt";

int main()
{
   umask(0);
   close(1);
   int fd = open(FileName,O_CREAT | O_WRONLY | O_TRUNC,0666);
   if(fd < 0)
   {
      perror("open");
      return 1;
   }
   printf("fd:> %d\n",fd);
   fprintf(stdout,"fprintf,fd:>%d\n",fd);
   //fflush(stdout);
   close(fd);
   return 0;
   
}

我们可以看到,当将fflush这段代码屏蔽掉了以后,此时再运行程序,会发现log.txt啥都没有,那么这是为什么呢

  • 首先我们要知道fflush(stdout)根本不是把底层的内核级的文件缓冲区刷新到外设上,而是将语言级别的缓冲区通过文件描述符写入到对应的内核级的文件缓冲区.
  • 在刷新数据前,先将文件描述符关闭了,那么此时数据就遗留在了语言级别的文件缓冲区,由于文件描述符被关闭了,那么此时根本无法通过文件描述符fd将数据刷新到内核级的文件缓冲区,进而再刷新到磁盘里头.

4.2:dup2

要完成重定向我们只需进行fd_array数组当中元素的拷贝即可。例如,我们若是将fd_array3当中的内容拷贝到fd_array1当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。

在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。dup2的函数原型如下:

  • dup2会将fd_arrayold的内容拷贝到fd_arraynewfd当中,如果有必要的话,我们需要先关闭文件描述为newfd的文件.
  • dup2如果调用成功,返回newfd,否则返回-1.

使用dpu2,我们需要注意以下两点

  1. 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭.
  2. 如果oldfd是有效的文件描述符,但是newfd和oldfd是指向相同的文件,那么dup2不做任何操作,并返回newfd
代码1
代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>

const char * FileName = "log.txt";

int main()
{
   int fd = open(FileName,O_CREAT | O_WRONLY | O_TRUNC,0666);
   //fd指向的文件描述符复制到文件描述符1,即让1成为fd的一个拷贝,使得两者指向相同的文件即log.txt
   dup2(fd,1);
   printf("hello world\n");
   fprintf(stdout,"hello Linux\n");
   return 0;
}

将打开文件log.txt时获取到的文件描述符和1传入dup2函数,那么dup2将会把fd_arryafd的内容拷贝到fd_array1中,在代码中我们向stdout输出数据,而stdout是向文件描述符为1的文件输出数据,因此,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。

5:FILE

5.1:FILE当中的文件描述符

  • 因为IO相关函数与系统调用接口对应,并且库函数封装了系统调用,所以本质上,访问文件都是通过fd访问的.
  • 所以C库当中的FILE结构体内部,必定封装了fd.

首先,我们在**/usr/include/stdio.h**头文件中可以看到下面这句代码,也就是说FILE实际上就是**struct _IO_FILE**结构体的一个别名。

代码语言:javascript
复制
typedef struct _IO_FILE FILE;

而我们在**/usr/include/libio.h**头文件中可以找到**struct _IO_FILE**结构体的定义,在该结构体的众多成员当中,我们可以看到一个名为**_fileno**的成员,这个成员实际上就是封装的文件描述符。

代码语言:javascript
复制
在/usr/include/libio.h
struct _IO_FILE {
 int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
 //缓冲区相关
 /* The following pointers correspond to the C++ streambuf protocol. */
 /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
 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; //封装的文件描述符
#if 0
 int _blksize;
#else
 int _flags2;
#endif
 _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
 /* 1+column number of pbase(); 0 is unknown. */
 unsigned short _cur_column;
 signed char _vtable_offset;
 char _shortbuf[1];
 /* char* _save_gptr; char* _save_egptr; */
 _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

现在再来理解一下C语言当中的fopen函数究竟在做什么?

  • fopen函数在上层为用户申请FILE结构体变量,并返回该结构体的地址(FILE*),在底层通过系统接口open打开对应的文件,得到文件描述符fd,并把fd填充到FILE结构体当中的_fileno变量中,至此便完成了文件的打开操作.
  • 而C语言当中的其他文件操作函数,比如fread、fwrite、fputs、fgets等,都是先根据我们传入的文件指针找到对应的FILE结构体,然后在FILE结构体当中找到文件描述符,最后通过文件描述符对文件进行的一系列操作.

5.2:FILE当中的缓冲区

5.2.1:缓冲区的概念

什么是缓冲区.

缓冲区就是一段内存空间,由语言维护就叫做语言级的缓冲区,由操作系统内核维护就叫做内核级的缓冲区.

为什么存在缓冲区.

给上层提供良好的IO体验,间接提高整体的效率.

缓冲区的分类以及作用

缓冲区主要分为用户级缓冲区与内核级缓冲区,二者的作用在于

  1. 解耦------>将数据交给缓冲区,底层如何做的就不用再顾及了(类比寄快递给远方的好朋友)
  2. 提供使用者的效率(调用系统调用,是有成本的,尽量少调用,效率就高了)------>提供刷新IO的效率.

缓冲区的刷新策略.

  1. 立即刷新---->语言级:fflush(stdout) 系统级:int fsync(int fd).
  2. 行刷新----->显示器采用行刷新.
  3. 全缓冲----->缓冲写满了才进行刷新,普通文件.

缓冲区刷新的特殊情况.

  1. 进程强制退出,缓冲区会自动刷新.
  2. 强制刷新缓冲区

我们来看一段代码.

代码语言:javascript
复制
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>


int main()
{
   const char * message0 = "hello printf\n";
   const char * message1 = "hello fwrite\n";
   const char * message2 = "hello write\n";
   printf("%s",message0);
   fwrite(message1,strlen(message1),1,stdout);
   write(1,message2,strlen(message2));

   fork();
   return 0;
}

通过观察我们可以发现,对进程实现了输出重定向之后,printf和fwrite函数都输出了两次,而write这个系统调用函数只输出了一次.为什么呢?

  • 重定向向普通文件写入,刷新策略发生了变化,那么此时采用的是全缓冲(即缓冲区满了以后才会刷新)而系统调用write是直接写入到了内核级的文件缓冲区,而语言级 + 全缓冲,导致了其数据会先保存在语言级别的缓冲区.
  • 因此fork以后,由于进程间具有独立性,而之后当父进程或者子进程要刷新缓冲区内容时,本质是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的.
  • write已经写入到了内核当中,printf和fprintf写入的数据依旧在语言级别的缓冲区(缓冲区没写满),所以重定向到log.txt文件当中printf和fprintf函数打印的数据就有两份。但由于write函数是系统接口,我们可以将write函数看作是没有缓冲区的,因此write函数打印的数据就只打印了一份.

6:文件系统

我们知道文件可以分为磁盘文件和内存文件,内存文件前面我们已经谈过了,接下来谈谈磁盘文件。

6.1:初识inode

磁盘文件由两部分组成,分别是文件内容和文件属性.文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息.

在命令行当中输入**ls -l**,即可显示当前目录下各文件的属性信息。

其中,各列信息所对应的文件属性如下:

  • 在Linux操作系统中,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为inode,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性集起一个唯一的编号,即inode号.
  • 也就是说,inode是一个文件的属性集合,Linux中几乎每个文件都有一个inode,为了区分系统当中大量的inode,我们为每个inode设置了inode编号.

在命令行当中输入**ls -i**,即可显示当前目录下各文件的inode编号。

PS:无论是文件内容还是文件属性,都是存储在Inode当中的.

6.2:磁盘的概念

什么是磁盘?

  • 磁盘是一种永久性存储介质,在计算机中,磁盘几乎是唯一的机械设备.与磁盘相对应的是内存,内存是掉电易失存储介质,目前所有的普通文件都是在磁盘中存储的.
  • 磁盘在冯诺依曼体系结构当中既可以充当输入设备,又可以充当输出设备.

磁盘的基本概念.

磁盘的寻找方案.

对磁盘进行读写操作时,一般有以下几个步骤:

  1. 确定读写信息在磁盘的哪个盘面.
  2. 确定读写信息在磁盘的哪个柱面.
  3. 确定读写信息在磁盘的哪个扇区.

通过上面的三个步骤,最终确定信息在磁盘的读写位置.

6.3:磁盘分区域格式化

理解文件系统,首先我们需要将磁盘想象成一个线性的存储介质,联想一下磁带,当磁带被卷起来时,其就像磁盘一样是原形的,但当我们把磁带拉直以后,其就是线性的.

磁盘通常被称作为块设备,一般以扇区为单位,一个扇区的大小通常为512字节,我们若以大小为512GB的磁盘为例,该磁盘可被分为10亿多个扇区.

计算机为了更好的管理磁盘,于是对磁盘进行了分区。磁盘分区就是使用分区编辑器在磁盘上划分几个逻辑部分,盘片一旦划分成数个分区,不同的目录与文件就可以存储进不同的分区,分区越多,就可以将文件的性质区分得越细,按照更为细分的性质,存储在不同的地方以管理文件,例如在Windows下磁盘一般被分为C盘和D盘两个区域。

在Linux操作系统中,我们也可以通过以下命令查看我们磁盘的分区信息:

磁盘格式化

  • 当磁盘完成分区后,我们还需要对磁盘进行格式化。磁盘格式化就是对磁盘中的分区进行初始化的一种操作,这种操作通常会导致现有的磁盘或分区中所有的文件被清除。简单来说,磁盘格式化就是对分区后的各个区域写入对应的管理信息。

其中,写入的管理信息是什么是由文件系统决定的,不同的文件系统格式化时写入的管理信息是不同的,常见的文件系统有EXT2、EXT3、XFS、NTFS等.

6.4:EXT2文件系统的存储方案

计算机为了更好的管理磁盘,会对磁盘进行分区。而对于每一个分区来说,分区的头部会包括一个启动块(Boot Block),对于该分区的其余区域,EXT2文件系统会根据分区的大小将其划分为一个个的块组(Block Group)。

PS:启动块的大小是确定的,而块组的大小是由格式化的时候确定的,并且不可以更改。

其次,每个组块都有相同的组成结构,每个组块都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、Inode位图(inode Bitmap)、inode表(inode Table)以及数据表(Data Block)组成.

  1. Super Block:存放文件系统本身的结构信息.记录的信息主要有:Data Block和inode的总量、未使用的DataBlock和inode的数量、一个Data Block和inode的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息.Super Block的信息被破坏,可以说整个文件系统结构就被破坏了.
  2. Group Descriptor Table:块组描述符表,描述该分区当中块组的属性信息.
  3. Block Bitmap:块位图当中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用.
  4. inode Bitmap:inode位图当中记录着每个inode是否空闲可用.
  5. inode Table:存放文件属性,即每个文件的inode.
  6. Data Blocks:存放文件内容.

PS:

  1. 其他块组当中可能会存在冗余的Super Block,当某一Super Block被破坏后可以通过其他Super Block进行恢复。
  2. 其他块组当中可能会存在冗余的Super Block,当某一Super Block被破坏后可以通过其他Super Block进行恢复。

将属性和数据分开存放的想法看起来很简单,但实际上是如何工作的呢?我们通过 touch 一个新文件来看看如何工作。

为了说明问题,我们将上图简化:

创建一个新文件主要有以下4个操作.

  1. 存储属性:内核先找到一个空闲的节点,这里是392578.内核把文件信息记录到其中.
  2. 存储数据:该文件需要存储在三个磁盘块,内核找到了三个空闲块:300、500、800.将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推.
  3. 记录分配情况:文件内容按照顺序300,500,800存放.内核在Inode上的磁盘分布区记录了上述块列表.
  4. 添加文件名到目录:新的文件名Data.txt,Linux如何在当前的目录中记录这个文件,内核将入口(392578,Data.txt)添加到目录文件,文件名和inode之间的对应关系将文件名和文件内容及属性链接起来.

如何理解对文件写入信息.

  1. 通过文件的inode编号找到对应的inode结构.
  2. 通过inode结构找到存储该文件内容的数据块,并将数据写入数据块.
  3. 若不存在数据块或申请的数据块已经被写满,则通过遍历块位图的方式找到一个空闲的块号,并在数据区当找到对应的空闲块,再将数据写入数据块,最后还需要建立数据块和inode结构的对应关系.

PS:一个文件使用的数据块和inode结构的对应关系,是通过一个数组进行维护的,该数组一般可以存储15个元素,其中前12个元素分别对应该文件使用的12个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充。 如何理解删除一个文件

  1. 将该文件对应的inode在inode位图当中置为无效。
  2. 将该文件申请过的数据块在块位图当中置为无效。

由于此操作并不会真正将文件对应的信息删除,而只是将其inode号和数据块号置为了无效,所以当我们删除文件后短时间内是可以恢复的。为什么说是短时间内呢,因为该文件对应的inode号和数据块号已经被置为了无效,因此后续创建其他文件或是对其他文件进行写入操作申请inode号和数据块号时,可能会将该置为无效了的inode号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了. 为什么拷贝文件很慢,而删除文件却很快.

  • 因为拷贝文件首先需要创建文件,再对文件进行写入操作,该过程中
  • 先申请inode号并填入文件的属性信息,之后还需要再申请数据块号,最后才能进行文件内容的数据拷贝,而删除文件只需将对应文件的inode号和数据号置为无效即可,无需真正的删除文件,因此拷贝文件是很慢的,而删除文件是很快的

PS:每个文件的文件名并没有存储在自己的inode结构当中,而是存储在该文件所处目录文件的文件内容当中。因为计算机并不关注文件的文件名,计算机只关注文件的inode号,而文件名和文件的inode指针存储在其目录文件的文件内容当中后,目录通过文件名和文件的inode指针即可将文件名和文件内容及其属性连接起来。

7:软硬链接

那么有了目录以及目录项的概念之后,那么我们其中便可以引入软链接的概念

7.1:软链接

那么我们在解释其原理之前,我们先来看看现象.

代码语言:javascript
复制
ln -s [目标文件或目录]  [软链接名称]

  • 那么创建完之后软链接之后,我们再来使用ls -li 指令来查看一下当前目录下的所有子目录以及文件的属性.
  • 我们可以清晰地发现,软链接本质上也就是一个文件,因为它有独立的inode,既然有独立的inode,说明它有独立的inode结构体以及其相关联的数据块,其独立的inode结构体存储着相关的属性,那么其相关联的数据块那么存储的是什么内容呢?我们来做个小实验.

  • 我们在Test目录下创建三个目录:bin目录 conf目录 log目录.
  • 然后在bin目录下创建a目录,a目录下创建b目录,b目录下创建c目录.
  • 然后将usr/bin/ls拷贝到c目录下并且重命名为myls.

我们可以看到,./run.link所执行出来的效果跟ls指令一样.那么这是怎么做到的呢?

  • 软链接本身有一个独立的inode结构体以及相关联的数据块,软链接本身的数据块存储的内容便是目标文件的路径字符串.
  • 所以当我们使用 ./ 来访问到软链接的时候,那么首先会得到软链接的inode编号并且识别到该文件的类型.
  • 然后从inode编号中获取其关联的数据块,而数据块存储的内容便是目标文件的路径.
  • 那么接着系统会解析这个路径得到目标文件的inode结构体从而间接访问到目标文件的数据块.
  • 然后最终执行myls命令.
  • 所以我们的软链接和我们c语言的指针其实非常的像,那么指针的本质其实就是一个变量,只不过该变量的内容就是指向的变量的地址,而同理我们的软链接,也是一个文件,只不过内容保存的是指向的目标文件的路径字符串.

PS:软链接指向的目标文件一定要存在,如果不存在,那么软链接保存的内容是无效的,那么此时该软链接的状态就是悬浮的.

7.1.1:应用场景

那么在我们Windows下我们的一个可执行程序的成功执行需要编译各种配置的源文件,那么可执行文件和其源文件会封装到一个文件夹在特定的盘的特定路径下保存,那么我们运行这个可执行文件,那么我们就得知道其路径,但是我们用户通常不需要记住每一个可执行文件它保存的路径在哪里,而是通过桌面的快捷方式点击即可运行,那么快捷方式本质上就是一个软链接,那么它内部记录了可执行文件的路径,那么打开该快捷方式其实本质上就是解析其路径然后运行目标的可执行文件即可.

7.2:硬链接

那么有软连接,我们Linux还有硬链接的存在,那么它的作用其实和软链接是差不多的.

代码语言:javascript
复制
ln 目标文件名 硬链接

那么我们在解释其硬链接的原理之前,我们先来看看现象.

输入ls -li来查看该该目录下的文件的inode编号,我们发现硬链接文件以及指向的目标文件的inode编号是相同的,那么因此可以说明硬链接不是一个独立的文件,因为没有独立的inode number,使用的是目标文件的inode number.

我们可以看到,当使用cat命令打印硬链接的文件的内容时,硬链接打印的内容是指向目标文件的File_Target.txt的文件内容,那么这是怎么做到的呢?

  • 我们知道硬链接文件是和其指向的目标文件的inode编号相同,那么说明其共享一个inode结构体,并其硬链接和指向的目标文件是共享inode结构体和其关联的数据块.
  • 那么当我们创建一个硬链接的时候,那么就会在该硬链接所处的目录中添加一个新的映射,也就是该硬链接的文件名到inode编号的映射.
  • 因此,当我们访问该硬链接的时候,那么其实本质上就是访问了其指向的目标文件的inode结构体,所以打印的内容就是目标文件的内容.

  • 有了硬链接的概念之后,那么我们输入ls -l指令所展示的文件的属性中,其中拥有者以及所属组和其他后面那个数字便是该文件的硬链接数.
  • 而创建一个普通文件,该文件的硬链接数是1,因为它所处的目录文件的目录项会指向它,而对于目录文件来说,则是2,是因为它所处的目录文件会有一个指向其子目录的目录项,并且对于该目录的目录项来说,那么它也有一个目录项,也就是文件名"."指向自己的inode的映射,同时还有一个文件名为".."指向其上级目录的映射,而对于根目录来说,其"."和".."都是指向自己
7.1.2:用处
  • 构建Linux的路径结构,让我们可以使用 .和..来进行路径定位.
  • 一般使用硬链接来进行文件备份.
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2026-01-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1:C语言文件IO
    • 1.1:对文件的初步理解
    • 1.2:默认打开的三个流
  • 2:系统文件I/O
    • 2.1:open函数
      • 2.1.1:代码1
      • 2.1.2:代码2
      • 2.1.3:代码3
      • 2.1.4:代码4
    • 2.2:close函数
      • 2.2.1:代码1
    • 2.3:write函数
      • 2.3.1:代码1
      • 2.3.2:代码2
    • 2.4:read函数
      • 2.4.1:代码1
  • 3:文件描述符fd
    • 3.1:知识补充
    • 3.2:文件描述符的分配规则
  • 4:重定向
    • 4.1:重定向的原理
      • 代码1
      • 代码2
    • 4.2:dup2
      • 代码1
  • 5:FILE
    • 5.1:FILE当中的文件描述符
    • 5.2:FILE当中的缓冲区
      • 5.2.1:缓冲区的概念
  • 6:文件系统
    • 6.1:初识inode
    • 6.2:磁盘的概念
    • 6.3:磁盘分区域格式化
    • 6.4:EXT2文件系统的存储方案
  • 7:软硬链接
    • 7.1:软链接
      • 7.1.1:应用场景
    • 7.2:硬链接
      • 7.1.2:用处
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档