【Linux】基础I/O和文件系统

发布于:2025-09-01 ⋅ 阅读:(14) ⋅ 点赞:(0)

一、感性理解文件

狭义理解:

• ⽂件在磁盘⾥
• 磁盘是永久性存储介质,因此⽂件在磁盘上的存储是永久性的
• 磁盘是外设(即是输出设备也是输⼊设备)
• 磁盘上的⽂件 本质是对⽂件的所有操作,都是对外设的输⼊和输出 简称 IO

⼴义理解:

• Linux 下⼀切皆⽂件(键盘、显⽰器、⽹卡、磁盘…… 这些都是抽象化的过程)(后⾯会讲如何去理解)

对于 0KB 的空⽂件要不要占用磁盘空间?
答:要。⽂件是⽂件属性(元数据)和⽂件内容的集合。即,⽂件 = 属性(元数据)+ 内容。
所有的⽂件操作本质是⽂件内容操作和⽂件属性操作

系统⻆度理解文件:

• 对⽂件的操作本质是进程对⽂件的操作。

• 磁盘的管理者是操作系统。

• ⽂件的读写本质不是通过 C 语⾔ / C++ 的库函数来操作的(这些库函数只是为⽤⼾提供⽅便),⽽是通过⽂件相关的系统调⽤接⼝来实现的。

一个进程能否打开多个文件?
答:肯定能。若进程打开多个文件,系统中便会存在大量被打开的文件,而这些文件需要由操作系统进行管理。

操作系统如何管理这些文件呢?它采用的方法是“先描述,再组织”。

具体而言,系统为了管理文件,会创建内核数据结构来标识文件,在 Linux 操作系统中,这个内核数据结构被定义为 struct file{ … } 结构体,其中包含了文件的大部分属性。

每个被打开的文件都对应一个 struct file 结构体。在操作系统内部,所有的 struct file(即被打开的文件)通过链表结构链接起来。如此一来,操作系统只需找到链表的起始地址,对打开文件的管理就转化为对链表的增删查改操作,这种管理思路与进程管理是类似的。

二、回顾 C 语言文件接口

1.写文件

#include <stdio.h>
#include <string.h>
int main()
{
	FILE *fp = fopen("myfile", "w");
	if(!fp){
		printf("fopen error!\n");
	} 
	
	const char *msg = "hello bit!\n";
	int count = 5;
	while(count--){
		fwrite(msg, strlen(msg), 1, fp);
	}
	
	fclose(fp);
	return 0;
}

函数原型

size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

参数解析
在这里插入图片描述

返回值
返回 size_t 类型,表示 实际成功写入的数据项数量(而非字节数!字节数 = 返回值 × size)

2.读文件

代码示例:

#include <stdio.h>
#include <string.h>
int main()
{
	FILE *fp = fopen("myfile", "r");
	if(!fp){
		printf("fopen error!\n");
		return 1;
	} 
	char buf[1024];
	const char *msg = "hello bit!\n";
	while(1){
		//注意返回值和参数,此处有坑,仔细查看man⼿册关于该函数的说明
		
		//ssize_t s = fread(buf, 1, sizeof(buffer) - 1, fp);
		ssize_t s = fread(buf, 1, strlen(msg), fp);
		if(s > 0){
			buf[s] = 0;
			printf("%s", buf);
		} 
		if(feof(fp)){
			break;
		}
	} 
	
	fclose(fp);
	return 0;
}

函数原型

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

参数解析
在这里插入图片描述
返回值

返回 size_t 类型,表示 实际成功读取的数据项数量(而非字节数!字节数 = 返回值 × size)

3. stdin & stdout & stderr

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

为什么我们向“显示器文件”写入数据以及从“键盘文件”读取数据前,不需要进行打开“显示器文件”和“键盘文件”的相应操作?
答:任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流,对应到C语言当中就是stdin、stdout以及stderr。
其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。

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

#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

也就是说,stdin、stdout以及stderr与我们打开某一文件时获取到的文件指针是同一个概念,试想我们使用fputs函数时,将其第二个参数设置为stdout,此时fputs函数会不会之间将数据显示到显示器上呢?

#include <stdio.h>
int main()
{
	fputs("hello stdin\n", stdout);
	fputs("hello stdout\n", stdout);
	fputs("hello stderr\n", stdout);
	return 0;
}

答案是肯定的,此时我们相当于使用fputs函数向“显示器文件”写入数据,也就是显示到显示器上。
在这里插入图片描述
注意: 不止是C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。

4. 总结

打开文件的方式:
在这里插入图片描述
如上,是我们之前学的文件相关操作,除此之外还有 fseek、ftell、rewind 的函数。

三、系统文件I/O

操作文件除了C语言接口、C++接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。
相比于C库函数或其他语言的库函数而言,系统调用接口更贴近底层,实际上这些语言的库函数都是对系统接口进行了封装。
在这里插入图片描述
我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。

1. 打开

open() 是 C 语言中用于打开或创建文件的系统调用,属于 UNIX/Linux 系统的标准库函数。

函数原型

#include <fcntl.h>

int open(const char *pathname, int flags, mode_t mode);

参数解析

pathname

  • 类型:const char *
  • 含义:要打开或创建的文件路径(绝对路径或相对路径)。
  • 示例:“/home/user/file.txt” 或 “data.csv”。

flags

  • 类型:int
  • 含义:指定文件的打开方式和行为,必须包含以下三个标志之一:
    • O_RDONLY:只读模式。
    • O_WRONLY:只写模式。
    • O_RDWR:读写模式。
  • 可选的附加标志(可通过按位或 | 组合):
    • O_CREAT:若文件不存在则创建它,需同时指定 mode 参数。
    • O_TRUNC:若文件存在且为写模式,将其长度截断为 0(清空文件)。
    • O_APPEND:每次写操作都追加到文件末尾。
    • O_NONBLOCK:以非阻塞模式打开(用于设备文件或管道)。

系统接口open的第二个参数flags是整型,有32比特位,若将一个比特位作为一个标志位,则理论上flags可以传递32种不同的标志位。

实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的:
在这里插入图片描述
这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是1(O_RDONLY选项的二进制序列为全0,表示O_RDONLY选项为默认选项),且为1的比特位是各不相同的,这样一来,在open函数内部就可以通过使用“与”运算来判断是否设置了某一选项。

int open(arg1, arg2, arg3){
	if (arg2&O_RDONLY){
		//设置了O_RDONLY选项
	}
	if (arg2&O_WRONLY){
		//设置了O_WRONLY选项
	}
	if (arg2&O_RDWR){
		//设置了O_RDWR选项
	}
	if (arg2&O_CREAT){
		//设置了O_CREAT选项
	}
	//...
}

mode

  • 类型:mode_t(通常为八进制数)
  • 含义:当使用 O_CREAT 标志创建文件时,指定文件的访问权限。
  • 权限位通过按位或组合,例如:

将mode设置为0666,则文件创建出来的权限如下:
在这里插入图片描述

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

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

umask(0); //将文件默认掩码设置为0

注意:open 函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要 open 创建,则第三个参数表示创建文件的默认权限,否则,使用两个参数的 open。

返回值

  • 成功:返回一个非负整数,表示文件描述符(file descriptor,简称 fd)。
  • 失败:返回 -1,并设置 errno 以指示具体错误(如 ENOENT 文件不存在、EACCES 权限拒绝等)。

open的代码实现:

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

#define FILE_NAME "log.txt"

int main()
{
    int fd = open(FILE_NAME, O_WRONLY | O_CREAT, 0666); // 以只写方式打开
    if (fd < 0)
    {
        perror("open");
        return 1;
    }

    // 
    close(fd);
    return 0;
}

运行结果:
在这里插入图片描述

2. 写入

函数原型

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数说明:

  • fd:件描述符,指向已打开的文件、设备或管道。
  • buf:指向要写入数据的缓冲区,必须是有效的内存地址,否则会导致段错误。
  • count:要写入的字节数。

返回值

  • 如果数据写入成功,实际写入数据的字节个数被返回。
  • 如果数据写入失败,-1被返回。

代码示例:

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

#define FILE_NAME "log.txt"

int main()
{
    int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666); // 以只写方式打开
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
      
    // 写入
    int cnt = 5;
    char outBuffer[64];
    while (cnt)
    {
        sprintf(outBuffer, "%s:%d\n", "hello edison", cnt--); // 用于将格式化的数据写入到一个字符数组中
        // 不需要 +1。因为 strlen(outBuffer) 返回的是字符串长度(不包含 \0),
        // 而 write 是二进制安全的,它会写入指定长度的字节。
        // 如果 +1,会把字符串结束符 \0 也写入文件,可能导致文件末尾多出一个不可见字符。
        write(fd, outBuffer, strlen(outBuffer)); 
    }
    
    
    close(fd);
    return 0;
}

运行结果:
在这里插入图片描述

3. 读取

函数原型

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

参数说明

  • fd:文件描述符,指向已打开的文件、设备或管道。
  • buf:指向用于存储读取数据的缓冲区,必须是有效的可写内存地址,否则会导致段错误。
  • count:最多读取的字节数。

返回值

  • 如果数据读取成功,实际读取数据的字节个数被返回。
  • 如果数据读取失败,-1被返回。

代码示例:

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

#define FILE_NAME "log.txt"

int main()
{
    //int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_APPEND, 0666); // 以只写方式打开
    int fd = open(FILE_NAME, O_RDONLY); // 以只读方式打开
    if (fd < 0)
    {
        perror("open");
        return 1;
    }
      
    // 读取
    char buffer[1024];
    ssize_t num = read(fd, buffer, sizeof(buffer) - 1);
    if (num > 0)
    {
        buffer[num] = 0; // 末尾加上/0
    }
    printf("%s", buffer); // 将读取的字节流转换为 C 风格的字符串(以 \0 结尾的字符数组)

    close(fd);
    return 0;
}

运行结果:
在这里插入图片描述

4.总结

下面是库函数接口和系统调用接口:
在这里插入图片描述
回忆一下我们谈操作系统概念时,画的一张图:
在这里插入图片描述

在操作系统中,应用程序对文件的访问最终都需要通过系统调用接口来完成,因为只有操作系统内核具备直接操作硬件设备的权限。

C/C++ 语言提供了丰富的标准库函数(如fopen、fread等)来简化文件操作,但这些库函数在底层实现上均依赖于操作系统提供的系统调用(如open、read)。

这种设计形成了明确的层次关系:用户空间的库函数通过封装系统调用接口,向上层提供统一且跨平台的编程接口,同时将底层实现细节(如文件描述符管理、权限检查等)隐藏起来。

因此,当应用程序调用C/C++库函数进行文件操作时,实际上会触发一系列的上下文切换,最终由内核执行对应的系统调用并返回操作结果。

四、⽂件描述符fd

1.文件描述符的真面目

文件是由进程运行时打开的,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件。

因此,操作系统务必要对这些已经打开的文件进行管理,操作系统会为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。

而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。

进程和文件之间的对应关系是如何建立的?

答: 我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。
在这里插入图片描述
而task_struct当中还有有一个指针,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组,该数组的下标就是我们所谓的文件描述符

当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。

在这里插入图片描述

因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件进行一系列输入输出操作。

注意: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中。

我们先来看一段代码:

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

#define FILE_NAME(number) "log.txt"#number

int main()
{
    int fd0 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd1 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd2 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd3 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd4 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 0666);
        
    printf("fd: %d\n", fd0);
    printf("fd: %d\n", fd1);
    printf("fd: %d\n", fd2);
    printf("fd: %d\n", fd3);
    printf("fd: %d\n", fd4);

    close(fd0);
    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);
    return 0;
}

运行结果:
在这里插入图片描述

思考一下:

1.为什么文件描述符是从 3 开始的?0、1、2呢?

答:在 Linux 系统中,每个进程启动时会自动打开三个标准文件描述符:
0 (STDIN_FILENO):标准输入(通常对应键盘)
1 (STDOUT_FILENO):标准输出(通常对应终端屏幕)
2 (STDERR_FILENO):标准错误输出(通常也对应终端屏幕)
当你调用 open() 创建或打开文件时,系统会分配最小的未被使用的文件描述符。由于 0、1、2 已经被占用,因此第一个新打开的文件会分配到 3,后续依次递增。

代码证明一下:

printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);

运行结果如下:
在这里插入图片描述

2.文件描述符的分配规则

为什么这个文件描述符是连续的数字呢?
答:这和文件描述符的分配规则有关。

⽂件描述符的分配规则:每一次在内核中打开文件时,系统都会创建一个文件对象,并将其与当前进程关联起来。这个关联过程需要在进程的文件描述符表中找到一个未被使用的下标。文件描述符的分配默认从0开始,依次递增,但0、1、2这三个位置已经被标准输入、标准输出和标准错误占用,因此新打开的文件描述符通常从3开始分配。

文件描述符之所以是连续的数字,是由数组的特性所决定的。数组的下标是从 0 开始的连续整数,而文件描述符实际上就是这个数组的下标。进程在访问文件时,会通过系统调用传入文件描述符,操作系统依据这个描述符在 fd_array 中定位到对应的 struct file,进而实现对文件的操作。

对1、2点的小总结

  • 文件描述符的本质就是文件描述符表(fd_array)的数组下标。默认情况下,0、1、2 这三个下标分别被分配给了标准输入、标准输出和标准错误。

  • 新打开的文件会被分配后续最小的可用数字,这样就保证了文件描述符的连续性。这种设计十分巧妙,它将进程和文件之间的关系转化为简单的数组索引操作,既高效又简洁。

3.重定向

重定向的原理

(输出)重定向就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。常见的重定向有:>、>>、<

例如,如果我们想让本应该输出到“显示器文件”的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是1。

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

int main()
{
    close(1);

    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 1;
    }

    // 操作
    printf("open fd: %d\n", fd);

    // 关闭文件
    fflush(stdout); // 刷新缓冲区
    close(fd);
    
    return 0;
}

运行结果后,我们发现显示器上并没有输出数据,对应数据输出到了log.txt文件当中。
在这里插入图片描述
说明一下:

  • printf函数是默认向stdout输出数据的,而stdout指向的是一个struct FILE类型的结构体,该结构体当中有一个存储文件描述符的变量,而stdout指向的FILE结构体中存储的文件描述符就是1,因此printf实际上就是向文件描述符为1的文件输出数据。
  • C语言的数据并不是立马写到了内存操作系统里面,而是写到了C语言的缓冲区当中,所以使用printf打印完后需要使用fflush将C语言缓冲区当中的数据刷新到文件中。

那重定向的本质是什么呢?我们来分析一下,如下图所示
在这里插入图片描述
在操作系统中,每个进程都有一个文件描述符表,用于关联该进程打开的文件。文件描述符是一个非负整数,默认从0开始分配,其中 0、1、2 分别预留给标准输入、标准输出和标准错误。当我们执行 close(1) 操作时,实际上是关闭了标准输出对应的文件描述符 1,使其不再指向显示器设备。

随后,当我们使用 open() 函数打开一个新文件(如 myfile )时,系统会创建一个文件对象(在 Linux 内核中表现为 struct file 结构体),并在进程的文件描述符表中寻找一个最小的未使用索引。由于之前关闭了文件描述符 1,此时它成为可用的最小索引,因此新文件对象的指针会被分配到索引 1 的位置。

需要注意的是,printf() 等标准库函数默认向标准输出(文件描述符 1 )写入数据。在 C 语言中,标准输出流 stdout 内部封装的文件描述符正是 1。在重定向前,文件描述符 1 指向显示器设备,因此数据会显示在屏幕上;而重定向后,虽然 stdout 对应的文件描述符仍为 1,但内核中该索引指向的文件对象已变为新打开的文件(如 myfile ),因此数据会被写入文件而非显示在屏幕上。

这一过程的本质是:上层应用程序使用的文件描述符(如 stdout 关联的 1)保持不变,但内核通过修改该描述符对应的文件对象指针,改变了数据的流向。这种机制被称为 “重定向”,它允许我们在不修改应用程序代码的情况下,动态改变输入输出的目标设备。

例如,当我们执行以下操作:

  • 关闭文件描述符 1(标准输出)

  • 打开一个新文件(如 log.txt )

此时,新文件会被分配到文件描述符 1,导致后续所有向标准输出的写入操作(如 printf() )都会被重定向到该文件中。这就是为什么代码运行后,原本应该显示在屏幕上的内容会出现在指定文件中的原因。

追加重定向原理

追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。

例如,如果我们想让本应该输出到“显示器文件”的数据追加式输出到log.txt文件当中,那么我们应该先将文件描述符为1的文件关闭,然后再以追加式写入的方式打开log.txt文件,这样一来,我们就将数据追加重定向到了文件log.txt当中。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(1);
	umask(0);
	int fd = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0666);
	if(fd < 0){
		perror("open");
		return 1;
	}
	printf("hello Linux\n");
	printf("hello Linux\n");
	printf("hello Linux\n");
	printf("hello Linux\n");
	printf("hello Linux\n");
	fflush(stdout);
	close(fd);
	return 0;
}

运行结果后,我们发现对应数据便追加式输出到了log.txt文件当中。
在这里插入图片描述

输入重定向原理

输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。

例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改为从log.txt文件当中读取数据,那么我们可以在打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是0。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
	close(0);
	int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	char str[40];
	while (scanf("%s", str) != EOF){
		printf("%s\n", str);
	}
	close(fd);
	return 0;
}

运行结果后,我们发现scanf函数将log.txt文件当中的数据都读取出来了。
在这里插入图片描述
说明一下:

scanf函数是默认从stdin读取数据的,而stdin指向的FILE结构体中存储的文件描述符是0,因此scanf实际上就是向文件描述符为0的文件读取数据。

标准输出流和标准错误流对应的都是显示器,它们有什么区别?

答:
在这里插入图片描述

使用 dup2 系统调用

函数原型

#include <unistd.h>

int dup2(int oldfd, int newfd);

函数功能:
dup2 用于复制一个文件描述符,将 oldfd 复制到 newfd,使得两个描述符都指向同一个打开的文件/设备。

参数:

  • oldfd:需要复制的原始文件描述符。
  • newfd:目标文件描述符,如果它已打开,会先被关闭;否则会被用作复制的目标。

返回值:

  • 成功时,返回 newfd。
  • 出错时,返回 -1,并设置 errno。

主要特点:

  • 如果 newfd 已经打开,则会被关闭,然后再复制 oldfd。
  • 复制后,两个描述符都指向同一个文件/设备,共享文件位置、状态等信息。

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

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
	int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
	if (fd < 0){
		perror("open");
		return 1;
	}
	close(1);
	dup2(fd, 1);
	printf("hello printf\n");
	fprintf(stdout, "hello fprintf\n");
	return 0;
}

代码运行后,我们即可发现数据被输出到了log.txt文件当中。
在这里插入图片描述

添加重定向功能到minishell

4.引用计数在文件中的工作原理

思考一个问题:当父进程打开一个文件后,通过 fork() 创建子进程时,子进程会复制父进程的进程控制块(PCB)和文件描述符表。这意味着子进程虽然拥有自己独立的文件描述符表,但其中的文件描述符会指向与父进程相同的打开文件对象。

此时可能会有疑问:如果子进程关闭了某个文件描述符,父进程是否还能继续访问该文件?答案是肯定的,父进程依然可以访问。这是因为操作系统通过引用计数机制管理打开的文件对象。

当文件被打开时,内核会创建一个文件对象并维护其引用计数(即指向该对象的文件描述符数量)。在 fork() 之后,父子进程的文件描述符虽然独立,但指向同一个文件对象,因此该对象的引用计数会增加。当子进程调用 close(fd) 时,实际上只是将该文件描述符对应的引用计数减 1,而非直接关闭文件。只有当引用计数降为 0 时(即没有任何进程的文件描述符指向该对象),操作系统才会真正释放该文件资源。

这种机制确保了进程间共享文件的安全性:一个进程关闭文件描述符不会影响其他进程对同一文件的访问,除非所有进程都关闭了对应的描述符。这就是引用计数在文件系统中的工作原理。

如下图所示:
在这里插入图片描述
struct_file 结构有文件访问计数 f_count,记录了引用这个 struct_file 结构的文件描述符个数,只有当引用计数为 0 时,内核才销毁它。因此某个进程关闭文件,不影响与之共享同一个 struct_file 结构的进程;

所以提问一个问题:为什么子进程关闭文件不影响父进程?

答:当子进程调用 close(fd) 时:

子进程的文件描述符表中,fd 对应的条目被清空(不再指向文件表项)。
文件表项的引用计数减 1(从 2 变为 1)。
由于引用计数仍大于 0,文件表项和实际的文件资源不会被释放。
父进程的文件描述符表未受影响,其 fd 仍然指向有效的文件表项,因此可以继续访问文件。
只有当所有指向该文件表项的文件描述符都被关闭(引用计数归零)时,操作系统才会释放文件表项和对应的资源(如关闭实际文件)。

五、如何理解 Linux 一切皆文件

在这里插入图片描述

  • 不管是键盘、显示器、磁盘,还是网卡等外部或外围设备,任何数据处理都需要先将数据读入内存,处理完毕后再将内存中的数据刷新到外设,这就是 I/O

  • 操作系统要管理所有软硬件资源,必须先对其进行描述,再组织起来。因此,每个设备(如键盘)内部都有对应的结构体 struct keyboard {…}、struct disk{…}。

  • 所有设备(如显示器、磁盘、网卡)都有各自的 I/O 函数,因为要进行输入输出,所以每套设备都有自己的读写方法。这些硬件的具体读写方法在匹配的驱动程序中,且每种硬件的访问方法各不相同。

对于操作系统而言,不同文件对应的读写方法不同,那如何保证 “一切皆文件” 呢?

  • 我们的 struct file 结构体中有 int (*readp)() 和 int (*writep)() 函数指针。当打开键盘时,系统会为其创建一个 struct file 对象,初始化 read 和 write 属性后,让函数指针指向键盘具体的读方法和写方法。

  • 操作系统打开文件时,会在内核中创建一个 struct file 对象,该对象的所有属性可根据硬件不同进行填充。初始化不同设备时,让其函数指针指向具体硬件的读写方法,这样就完成了对设备的描述和组织。

  • 从 struct file 上层看,所有设备和文件统一为 struct file,通过函数指针指向底层不同方法。因此,上层调用时只需调用 readp 或 writep,就能直接调用具体设备的方法。这就是在 Linux 用户级看到的“一切皆文件”。

  • struct file 可看作操作系统层面虚拟出的文件对象,在 Linux 中,这一层被称为 VFS(虚拟文件系统)。通过 VFS,可屏蔽底层设备差异,让用户无需关心底层细节,统一使用文件接口进行所有文件操作。

六、缓冲区

1.抛出一个问题

因为 IO 相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过 fd 访问的。那么 C 库当中的 FILE 结构体内部,必定封装了 fd。
来段代码在研究一下:

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

int main()
{
    // C 语言接口
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    fputs("hello fputs\n", stdout);

    // 系统接口
    const char *msg = "hello write\n";
    write(1, msg, strlen(msg));

    // 在代码结束之前创建子进程
    fork(); 
    // fork 之后什么也没做
    
    return 0;
}

运行结果:在这里插入图片描述
但如果对进程实现输出重定向呢?./hello > log.txt , 我们发现结果变成了:
在这里插入图片描述
我们发现 printf、fprintf、fputs(库函数)都输出了 2 次,而 write 只输出了一次(系统调用),为什么呢?肯定和 fork 有关!

2. 缓冲区是什么

举个例子:从快递行业看数据存储原理在这里插入图片描述
现实中,顺丰等快递行业的核心价值在于 节省发送者的时间。比如,若你需要从四川送东西到北京,自己骑自行车往返可能需要三个月;但通过快递,你只需下楼把物品交给顺丰,就能立刻回去做其他事 —— 快递会替你完成运输,你节省了三个月的时间成本。

在计算机领域,缓冲区的工作原理和快递非常相似:

  • 四川 = 内存:数据当前所在的 “高速区域”,操作速度极快。
  • 北京 = 磁盘:数据需要存储的 “目标区域”,属于外部设备(外设),访问速度较慢。
  • 你 = 进程:需要处理数据的程序或任务。
  • 文件 = 磁盘中的存储位置:数据最终要写入的目标。

如果进程直接把数据从内存写入磁盘(类似你自己从四川骑车送东西到北京),会因为 外设 IO 操作效率极低 而浪费大量时间。就像你亲自送快递会耽误三个月,进程直接操作磁盘也会被低速IO “阻塞”,无法继续执行其他任务。

缓冲区如何工作?

  • 在内存中开辟 “快递站”:进程先在内存中创建一段空间(即 “缓冲区”,类比顺丰的收件点)。
  • 快速 “交件” 给快递:通过 fwrite 等函数(类似“交件”动作),将数据从进程拷贝到缓冲区,函数立即返回,进程可以继续执行后续代码(你交件后直接回家做事)。
  • 缓冲区自动 “运输”:在进程执行其他任务的同时,缓冲区会定期将数据写入磁盘(顺丰定期运输包裹)。

缓冲区的核心意义:节省进程进行数据IO的时间

进程无需等待低速的磁盘写入完成,而是通过缓冲区实现 “异步操作” —— 就像你不用等快递送到北京,就能继续做自己的事。

fwrite函数的作用可理解为 “数据拷贝器”,它负责将数据从进程转移到缓冲区中,是缓冲区机制的关键执行环节。

3. 缓冲区刷新策略

当进程把数据拷贝到缓冲区后,若立刻刷新数据到外设,虽然能及时响应,但缓冲区本身 “节省 IO 时间” 的价值就无法体现。这里的关键问题在于:一次性写入整块数据多次少量写入数据,哪种方式效率更高?

显然,一次性写入更高效。比如一块数据若直接通过一次 IO 操作写入外设,只需访问一次外设;但若拆分成多份多次写入,就会触发多次 IO,而外设 IO 本身速度极慢,必然导致效率大幅降低。

缓冲区会根据设备特性制定不同策略,核心是在 “效率” 和 “设备需求” 间找平衡:

1)立即刷新(无缓冲)

  • 特点:数据写入缓冲区后立即刷新到外设,不做任何缓存。
  • 场景:极少使用,仅在对实时性要求极高且不考虑 IO 效率时(如某些紧急日志记录)。

2)行刷新(行缓冲)—— 显示器的选择

  • 理论上,缓冲区攒满数据(如 1024 字节)后一次性刷新到显示器,IO次数最少,效率最高。

  • 但是,显示器是给人看的,人的阅读习惯是 “按行读取”。若攒满一整块数据再显示,可能出现 “半行内容卡着不显示” 的情况,用户体验极差。

  • 所以采用 “行缓冲”,即每当写入一行数据(即遇到换行符)时刷新缓冲区。这样既保证了按行显示的可读性,又通过 “攒一行数据再刷新” 减少了 IO 次数,平衡了效率和体验。

3)缓冲区满(全缓冲)—— 磁盘文件的首选

  • 数据先全部存入缓冲区,等缓冲区满或达到特定条件时,一次性刷新到磁盘。
  • 无论数据量多大,只需要一次真正的内存到磁盘的 IO 操作,外设等待时间仅一次,是三种策略中IO效率最高的。

刷新策略的特殊情况

  • 用户强制刷新:程序中可通过代码(如 fflush() 函数)主动触发缓冲区刷新,无视默认策略。
  • 进程退出时刷新:进程结束前,系统会自动刷新所有未清空的缓冲区,避免数据丢失(比如文本编辑器退出前保存临时数据)。

缓冲区刷新策略的本质,是根据设备特性(如显示器的 “人读需求”、磁盘的 “效率优先”),在 “减少IO次数” 和 “满足设备功能” 之间找到最优解。理解这些策略,能帮助我们更好地优化程序的 IO 性能。

4. 缓冲区在哪儿

回到一开始的问题:

代码示例

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

int main()
{
    // C 语言标准库输出函数
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    fputs("hello fputs\n", stdout);

    // 系统调用接口
    const char *msg = "hello write\n";
    write(1, msg, strlen(msg));

    // 创建子进程
    fork(); 
    
    return 0;
}

当直接运行程序时,输出如下:
在这里插入图片描述

当重定向输出到文件时,日志文件内容为:
在这里插入图片描述
这里的现象与用户空间的缓冲区密切相关,而不是内核缓冲区。如果是内核缓冲区导致的问题,那么系统调用 write 也应该出现两次输出,但实际情况并非如此。

我们通常所说的缓冲区是 C 语言标准库在用户空间提供的,它包含在 FILE 结构体中。这个结构体定义在系统头文件中,包含文件描述符fd以及相关的缓冲区数据结构

// 查看FILE结构体定义
vim /usr/include/libio.h 
/IO_FILE

如下图所示:在这里插入图片描述
为什么会出现重定向后的重复输出?这需要从两个方面分析:

一方面:标准输出的缓冲模式

  • 输出到终端时:采用行缓冲模式,遇到换行符 \n 立即刷新缓冲区
  • 输出到文件时:采用全缓冲模式,只有缓冲区满或显式刷新时才写入文件

另一方面:fork 系统调用的影响

  • 当执行 fork() 时,子进程会复制父进程的内存空间,包括FILE结构体和其中的缓冲区
  • 如果缓冲区中有未刷新的数据,父子进程都会各自持有一份
  • 进程结束时会自动刷新缓冲区,导致数据被重复写入文件

为什么 write 不受影响?

系统调用 write 直接将数据传递给内核,不经过 C 标准库的用户空间缓冲区,因此不会出现重复输出的问题。这也证明了重复输出是由于用户空间缓冲区的复制导致的,而非内核缓冲区的影响。

总结

  • 标准 I/O 函数(printf 等)使用用户空间缓冲区,由 C 标准库管理
  • 系统调用(write)直接与内核交互,不使用用户空间缓冲区
  • 重定向到文件时采用全缓冲模式,数据会在进程结束时被重复刷新
  • 这一现象验证了 “写时拷贝”(Copy-on-Write)机制在父子进程间的作用

5.模拟缓冲区机制

下面这段代码实现了一个简化版的标准 I/O 库,包含文件操作函数。

myStdio.h 代码如下:

#pragma once

#include <stdio.h>

#define MAX 1024
#define NONE_FLUSH (1<<0)
#define LINE_FLUSH (1<<1)
#define FULL_FLUSH (1<<2)

typedef struct IO_FILE
{
    int fileno;
    int flag;
    char outbuffer[MAX];
    int bufferlen;
    int flush_method;
}MyFile;


MyFile *MyFopen(const char *path, const char *mode);
void MyFclose(MyFile *);
int MyFwrite(MyFile *, void *str, int len);
void MyFFlush(MyFile *);

myStdio.c 代码如下:

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

static MyFile *BuyFile(int fd, int flag)
{
    MyFile *f = (MyFile*)malloc(sizeof(MyFile));
    if(f == NULL) return NULL;
    f->bufferlen = 0;
    f->fileno = fd;
    f->flag = flag;
    f->flush_method = LINE_FLUSH;
    memset(f->outbuffer, 0, sizeof(f->outbuffer));
    return f;
}

MyFile *MyFopen(const char *path, const char *mode)
{
    int fd = -1;
    int flag = 0;
    if(strcmp(mode, "w") == 0)
    {
        flag = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(path, flag, 0666);
    }
    else if(strcmp(mode, "a") == 0)
    {
        flag = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(path, flag, 0666);
    }
    else if(strcmp(mode, "r") == 0)
    {
        flag = O_RDWR;
        fd = open(path, flag);
    }
    else
    {
        //TODO
    }
    if(fd < 0) return NULL;
    return BuyFile(fd, flag);
}
void MyFclose(MyFile *file)
{
    if(file->fileno < 0) return;
    MyFFlush(file);
    close(file->fileno);
    free(file);
}
int MyFwrite(MyFile *file, void *str, int len)
{
    // 1. 拷贝
    memcpy(file->outbuffer+file->bufferlen, str, len);
    file->bufferlen += len;
    // 2. 尝试判断是否满足刷新条件!
    if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen-1] == '\n')
    {
        MyFFlush(file);
    }
    return 0;
}
void MyFFlush(MyFile *file)
{
    if(file->bufferlen <= 0) return;
    // 把数据从用户拷贝到内核文件缓冲区中
    int n = write(file->fileno, file->outbuffer, file->bufferlen);
    (void)n;
    fsync(file->fileno);
    file->bufferlen = 0;

}

fflush 里面做两件事情:

  • 第一件事情如果缓冲区有数据,把数据写到操作系统。
  • 第二件事情将操作系统内的数据直接显示到文件当中

6. 缓冲区与 OS 的关系

我们上面的代码已经实现了封装,能清楚的认识到,缓冲区是在我们的 FILE 当中!

那么思考一下:它和 OS 有什么关系?

当我们最终调用 write(),比如调用 fflush() 的时候,数据是直接写到文件上了吗?会不会这样呢?

实际上,我们的数据并不是直接写到磁盘当中的,那是写到哪里去了呢?其实是写到了我们操作系统内与文件对应的缓冲区里。

struct file 结构体中,包含了一组操作方法(也就是函数指针),它还对应着自身文件的内核缓冲区。当我们把数据刷新到对应的磁盘上时,并非是直接写磁盘,而是将数据通过文件描述符直接写到内核缓冲区里面。之后,内核缓冲区里的数据再刷新到对应的磁盘时,数据才真正从内存刷新到磁盘,这个过程是由操作系统自主决定的。既然是自主决定,那就不一定只是行缓冲、全缓冲这么简单了。

操作系统自主决定将数据刷到磁盘,这可比所谓的缓冲策略要复杂得多。为什么会这样呢?因为操作系统需要权衡自身整体的内存使用情况来对数据进行刷新操作。

所以,你的数据其实是先直接写到了对应文件的操作系统缓冲区里,然后才写到外设当中,而且这个写外设的过程和用户毫无关系。

如下图所示:在这里插入图片描述
例如,现在有个char* msg = “hello edison”,你可能以为自己把数据直接写到文件里了,但其实并非如此。

首先,会使用 C 语言的接口,将缓冲区拷贝到 C 语言的缓冲区里面,这里它会采用行刷新、全缓冲或者无缓冲的方式,然后再把数据经过 write() 接口拷贝到对应的内核缓冲区中,接着再由操作系统定期将数据刷到外设里。

从你自己的代码拷贝到 C 语言的缓冲区里,这是第一次拷贝;再从 C 语言的缓冲区拷贝到内核缓冲区,这是第二次拷贝;最后将数据刷新到外设里,这也算是一次拷贝。

所以,一个数据要被写到硬件上,是要经历很长周期的。

无论是 fwrite() 还是 write(),从最本质上来说,它们都属于拷贝函数。要是操作系统突然崩溃了,而此时缓冲区里还存有大量数据,那该怎么办呢?

也就是说,如果我们非要让数据同步到磁盘里,该怎么办呢?这时就需要用到一个新的函数 fsync(上面代码已经用到了)。

它能够强制性地把对应文件的内核缓冲区的数据持久化到磁盘上,这是一种强制手段,也就是不需要操作系统进行缓存了,赶紧把数据更新到磁盘上,所以我们可以调用它来进行数据同步。

七、文件系统

1.感性理解文件系统

文件可以分为磁盘文件和内存文件。我们之前所谈论的是一个已经被打开的文件呀。那么,要是文件没有被打开的话,又该如何被操作系统进行管理呢?

那些没有被打开的文件,就只能静静地待在磁盘上了。

磁盘上存有大量的文件,而这些文件是必须要进行静态管理的。这里所说的静态管理,其实就是为了方便我们能随时将其打开。这就好比你今天去快递点,虽然你没有去取自己的快递,但是你心里很清楚,你的快递肯定就在快递点里的某个位置上呀。

而这整个的情况,就是所谓的文件系统了。

我们使用 ls -l 的时候看到的除了看到文件名,还看到了文件元数据。
在这里插入图片描述

其中,各列信息所对应的文件属性如下:
在这里插入图片描述
其实这个信息除了通过这种方式来读取,还有一个 stat 命令能够看到更多信息:
在这里插入图片描述

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

在命令行当中输入ls -i,即可显示当前目录下各文件的inode编号。
在这里插入图片描述
注意: 无论是文件内容还是文件属性,它们都是存储在磁盘当中的。

2.了解磁盘

磁盘是一种永久性存储介质,在计算机中,磁盘几乎是唯一的机械设备。与磁盘相对应的就是内存,内存是掉电易失存储介质,目前所有的普通文件都是在磁盘中存储的。

2.1磁盘的物理结构

在这里插入图片描述

扇区:是磁盘存储数据的基本单位,512字节,块设备
在这里插入图片描述
在这里插入图片描述

如何定位⼀个扇区呢?

答: CHS地址定位
• 可以先定位柱⾯(cylinder)

• 确定访问哪⼀个磁道 (磁头位置)(header)

• 定位⼀个扇区(sector)

2.2磁盘的逻辑结构

那么磁盘本质上虽然是硬质的,但是逻辑上我们可以把磁盘想象成为卷在⼀起的磁带,那么磁盘的逻辑存储结构我们也可以类似于:
在这里插入图片描述
这样每⼀个扇区,就有了⼀个线性地址(其实就是数组下标),这种地址叫做 LBA
在这里插入图片描述
整个磁盘不就是多张⼆维的扇区数组表(三维数组?)

所以,寻址⼀个扇区:先找到哪⼀个柱⾯(Cylinder) ,在确定柱⾯内哪⼀个磁道(其实就是磁头位置,
Head),在确定扇区(Sector),所以就有了 CHS 。

我们之前学过C/C++的数组,在我们看来,其实全部都是⼀维数组:
在这里插入图片描述
LBA = 柱⾯号C单个柱⾯的扇区总数 + 磁头号H每磁道扇区数 + 扇区号S - 1
注意:物理扇区编号 S 是「1-based」,LBA 下标是「0-based」

2.3 磁盘分区和 “块”概念

引入块

其实硬盘是典型的“块”设备,操作系统读取硬盘数据的时候,其实是不会⼀个个扇区地读取,这样
效率太低,⽽是⼀次性连续读取多个扇区,即⼀次性读取⼀个”块”(block)。

硬盘的每个分区是被划分为⼀个个的”块”。⼀个”块”的⼤⼩是由格式化的时候确定的,并且不可
以更改,最常⻅的是4KB,即连续⼋个扇区组成⼀个 ”块””块”是⽂件存取的最⼩单位

注意:

• 磁盘就是⼀个三维数组,我们把它看待成为⼀个"⼀维数组",数组下标就是LBA,每个元素都是扇

• 每个扇区都有LBA,那么8个扇区⼀个块,每⼀个块的地址我们也能算出来。
• 知道LBA:块号 = LBA/8
• 知道块号:LAB=块号*8 + n. (n是块内第⼏个扇区)
在这里插入图片描述

引入分区

其实磁盘是可以被分成多个分区(partition)的,以Windows观点来看,你可能会有⼀块磁盘并且将它分区成C,D,E盘。那个C,D,E就是分区。分区从实质上说就是对硬盘的⼀种格式化。但是Linux的设备都是以⽂件形式存在,那是怎么分区的呢?

答:柱⾯是分区的最⼩单位,我们可以利⽤参考柱⾯号码的⽅式来进⾏分区,其本质就是设置每个区的起始柱⾯和结束柱⾯号码。 此时我们可以将硬盘上的柱⾯(分区)进⾏平铺,将其想象成⼀个⼤的平⾯,如下图所⽰:
在这里插入图片描述
注意:柱⾯⼤⼩⼀致,扇区个位⼀致,那么其实只要知道每个分区的起始和结束柱⾯号,知道每⼀个柱⾯多少个扇区,那么该分区多⼤,其实和解释LBA是多少也就清楚了.
在这里插入图片描述
在Linux操作系统中,我们也可以通过以下命令查看我们磁盘的分区信息:

 ls /dev/vda* -l

在这里插入图片描述

当磁盘完成分区后,我们还需要对磁盘进行格式化。磁盘格式化就是对磁盘中的分区进行初始化的一种操作,这种操作通常会导致现有的磁盘或分区中所有的文件被清除。

简单来说,磁盘格式化就是对分区后的各个区域写入对应的管理信息。
在这里插入图片描述
其中,写入的管理信息是什么是由文件系统决定的,不同的文件系统格式化时写入的管理信息是不同的,常见的文件系统有EXT2、EXT3、XFS、NTFS等。

3.EXT2文件系统

3.1底层原理

计算机为了更好的管理磁盘,会对磁盘进行分区。而对于每一个分区来说,分区的头部会包括一个启动块(Boot Block),对于该分区的其余区域,EXT2文件系统会根据分区的大小将其划分为一个个的块组(Block Group)。在这里插入图片描述
注意: 启动块的大小是确定的,而块组的大小是由格式化的时候确定的,并且不可以更改。

其次,每个组块都有着相同的组成结构,每个组块都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、inode位图(inode Bitmap)、inode表(inode Table)以及数据表(Data Block)组成。
在这里插入图片描述

  1. Super Block: 存放文件系统本身的结构信息。 记录的信息主要有:Data Block和inode的总量、未使用的Data Block和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: 存放文件内容。

注意:

  • 其他块组当中可能会存在冗余的Super Block,当某一Super Block被破坏后可以通过其他Super Block进行恢复。
  • 磁盘分区并格式化后,每个分区的inode个数就确定了。

如何理解创建一个新文件?

答:1.确定你所在的路径。当你所在的路径确定下来后,相应地,你处于哪个分区也就明确了。
2.通过遍历inode位图的方式,找到一个空闲的inode,内核把文件信息记录到其中。
3.存储数据:该文件需要存储在三个磁盘块,内核找到了三个空闲块:300、500、800。将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推。
4.记录分配情况:文件内容按顺序 300、500、800 存放。内核在 inode 上的磁盘分布区记录了上述块列表。
5.添加文件名到目录:新的文件名 abc。Linux 如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文件。文件名和 inode 之间的对应关系将文件名和文件的内容及属性连接起来。

在这里插入图片描述

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

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

说明一下:

一个文件使用的数据块和inode结构的对应关系,是通过一个数组进行维护的,该数组一般可以存储15个元素,其中前12个元素分别对应该文件使用的12个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充。
在这里插入图片描述

如何理解删除一个文件?

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

因此操作并不会真正将文件对应的信息删除,而只是将其inode号和数据块号置为了无效,所以当我们删除文件后短时间内是可以恢复的。

为什么说是短时间内呢,因为该文件对应的inode号和数据块号已经被置为了无效,因此后续创建其他文件或是对其他文件进行写入操作申请inode号和数据块号时,可能会将该置为无效了的inode号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了。

为什么拷贝文件的时候很慢,而删除文件的时候很快?

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

这就像建楼一样,我们需要很长时间才能建好一栋楼,而我们若是想拆除一栋楼,只需在这栋楼上写上一个“拆”字即可。

3.2⽬录与⽂件名

我们访问⽂件,都是⽤的⽂件名,没⽤过inode号啊?
⽬录是⽂件吗?如何理解?

答:⽬录也是⽂件,但是磁盘上没有⽬录的概念,只有⽂件属性+⽂件内容的概念。

⽬录的属性不⽤多说,内容保存的是:⽂件名和Inode号的映射关系。

结论:

•访问⽂件,必须打开当前⽬录,根据⽂件名,获得对应的inode号,然后进⾏⽂件访问 。

• 访问⽂件必须要知道当前⼯作⽬录,本质是必须能打开当前⼯作⽬录⽂件,查看⽬录⽂件的内容!

3.3路径解析

⽽实际上,任何⽂件,都有路径,访问⽬标⽂件,⽐如:

/home/fx/code/test/test/test.c

都要从根⽬录开始,依次打开每⼀个⽬录,根据⽬录名,依次访问每个⽬录下指定的⽬录,直到访问到test.c。这个过程叫做Linux路径解析。

注意:

  • 所以,我们知道了:访问⽂件必须要有⽬录+⽂件名=路径的原因
  • 根⽬录固定⽂件名,inode号,⽆需查找,系统开机之后就必须知道

3.4路径缓存

访问任何⽂件,都要从/⽬录开始进⾏路径解析?
答:原则上是,但是这样太慢,所以Linux会缓存历史路径结构

Linux⽬录的概念,怎么产⽣的?
==答:打开的⽂件是⽬录的话,由OS⾃⼰在内存中进⾏路径维护 ==

Linux中,在内核中维护树状路径结构的内核结构体叫做: struct dentry
在这里插入图片描述

注意:

  • 每个⽂件其实都要有对应的dentry结构,包括普通⽂件。这样所有被打开的⽂件,就可以在内存中形成整个树形结构。
  • 整个树形节点也同时会⾪属于LRU(Least Recently Used,最近最少使⽤)结构中,进⾏节点淘汰 。
  • 整个树形节点也同时会⾪属于Hash,⽅便快速查找 。
  • 更重要的是,这个树形结构,整体构成了Linux的路径缓存结构,打开访问任何⽂件,都在先在这棵树下根据路径进⾏查找,找到就返回属性inode和内容,没找到就从磁盘加载路径,添加dentry结构,缓存新路径。

3.5⽂件系统总结

在这里插入图片描述

在这里插入图片描述

4.软硬连接

4.1软链接

软链接又叫做符号链接,软链接文件相对于源文件来说是一个独立的文件,该文件有自己的inode号,但是该文件的内容只包含了源文件的路径名,所以软链接文件的大小要比源文件小得多。软链接就类似于Windows操作系统当中的快捷方式。

我们可以通过以下命令创建一个文件的软连接。

 ln -s myproc myproc-s

在这里插入图片描述

通过ls -i -l命令我们可以看到,软链接文件的inode号与源文件的inode号是不同的,并且软链接文件的大小比源文件的大小要小得多。
在这里插入图片描述

但是软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容了。
在这里插入图片描述

4.2硬连接

我们现在知道,真正找到磁盘上⽂件的并不是⽂件名,⽽是inode。其实在linux中可以让多个⽂件名对应于同⼀个inode。

我们可以通过以下命令创建一个文件的硬连接:

 ln myproc myproc-h

在这里插入图片描述

通过ls -i -l命令我们可以看到,硬链接文件的inode号与源文件的inode号是相同的,并且硬链接文件的大小与源文件的大小也是相同的,特别注意的是,当创建了一个硬链接文件后,该硬链接文件和源文件的硬链接数都变成了2。
在这里插入图片描述
硬链接文件就是源文件的一个别名,一个文件有几个文件名,该文件的硬链接数就是几,这里inode号为924344的文件有myproc和myproc-h两个文件名,因此该文件的硬链接数为2。

与软连接不同的是,当硬链接的源文件被删除后,硬链接文件仍能正常执行,只是文件的链接数减少了一个,因为此时该文件的文件名少了一个。
在这里插入图片描述
总之,硬链接就是让多个不在或者同在一个目录下的文件名,同时能够修改同一个文件,其中一个修改后,所有与其有硬链接的文件都一起修改了。

我们创建一个普通文件,该普通文件的硬链接数是1,因为此时该文件只有一个文件名。那为什么我们创建一个目录后,该目录的硬链接数是2?
在这里插入图片描述
答:因为每个目录创建后,该目录下默认会有两个隐含文件.,它们分别代表当前目录和上级目录,因此这里创建的目录有两个名字,一个是dir另一个就是该目录下的.,所以刚创建的目录硬链接数是2。通过命令我们也可以看到dir和该目录下的.的inode号是一样的,也就可以说明它们代表的实际上是同一个文件。
在这里插入图片描述

Linux 为什么不允许普通用户给目录建立硬链接呢?

如下图所示,当我尝试着给 subdir 目录建立硬链接时,系统提示不允许,这是为什么呢?
在这里插入图片描述
简单来说,Linux(以及所有 Unix-like 系统)不允许给目录建立硬链接,主要是为了以下三个关键原因:
1、防止文件系统产生 “循环引用”(Preventing Filesystem Loops)

想象一下,如果系统允许你为目录创建硬链接,你就可以做出下面这种操作:

# 假设我们能这么做
[edison@vm-centos:~]$ mkdir dir1
[edison@vm-centos:~]$ mkdir dir1/dir2

# 现在,我们创建一个指向父目录的硬链接,放在子目录里
# 让 dir1/dir2/loop_link 和 dir1 指向同一个目录
[edison@vm-centos:~]$ ln dir1 dir1/dir2/loop_link

现在,文件系统的结构就出现了一个无限循环:dir1 包含 dir2,而 dir2 包含一个叫 loop_link 的东西,这个 loop_link 就是 dir1。如果你尝试遍历这个目录树,比如用 find、ls -R 或者 du 命令:

  • find dir1 会进入 dir2,然后进入 loop_link,这又回到了 dir1,然后再次进入 dir2…如此无限循环下去,直到程序崩溃或耗尽系统资源。

备份工具如 tar 或 rsync 同样会陷入这个“黑洞”,导致备份任务永远无法完成。文件系统检查工具 fsck 在分析和修复文件系统时,会被这种循环结构彻底搞糊涂,可能导致更严重的数据损坏。

为了从根本上杜绝这种能让系统工具瘫痪的循环结构,设计者决定禁止为目录创建硬链接。文件系统被设计成一个有向无环图 (DAG),保证了从根目录出发,总能遍历到任何一个文件或目录,并且不会走回头路。

2、保持 . 和 … 目录项的清晰和一致性

每个目录内部都有两个特殊的硬链接:

  • . (点):指向当前目录自身。
  • … (点点):指向上级(父)目录。

这两个链接是命令行导航(如 cd … )和相对路径解析的基础。如果允许目录硬链接,… 的含义就会变得混乱。

还是用上面的例子,当你 cd dir1/dir2/loop_link 时,你实际上进入了 dir1。这时,如果你执行 cd …,应该去哪里?从你输入的路径来看,应该带你回到 dir1/dir2,但从目录的实际结构来看,dir1 里的 指向的是 /home/fx

这种逻辑上的矛盾会彻底破坏文件系统的导航功能,使得相对路径变得不可靠。

3、简化链接数(Link Count)的管理和目录删除逻辑

  • 链接数:每个文件或目录的元数据(inode)中都存有一个“链接数”,记录有多少个文件名指向它。当这个数减到 0 时,系统才会真正删除数据。

    • 对于目录,它的链接数有一个固定的计算规则:初始为 2 (来自目录自身的 . 和父目录对它的引用),每当在它下面创建一个子目录,它的链接数就 +1 (因为子目录的 … 会指向它)。所以目录的链接数通常是 2 + 子目录数量
    • 如果允许任意创建目录硬链接,这个简单直观的规则就被打破了,·ls -l· 显示的链接数将变得毫无意义,难以管理。
  • 删除目录:删除目录的命令 rmdir 要求目录必须为空。如果一个目录有多个硬链接(比如 dir_A 和 dir_B 指向同一个地方),那么当你 rmdir dir_A 时,系统应该怎么做?它不能删除目录,因为 dir_B 还在。这使得删除逻辑变得异常复杂,也容易让用户混淆。

总结一下:Linux 不允许给目录创建硬链接,是一个深思熟虑的设计决策,旨在维护文件系统树状结构的完整性、防止无限循环、保证路径导航的可靠性,并简化系统管理。这是一个为了系统稳定性和健壮性而存在的、非常有益的限制。

另外,这个限制并非针对“普通用户”,即使是 root 超级用户,在现代 Linux 系统上通常也无法通过标准的 ln 命令为目录创建硬链接,因为这个限制是在内核的虚拟文件系统(VFS)层面强制执行的。

4.3 软硬链接的区别

从概念上来看:

  • 软链接是一个独立的文件,有独立的inode,而硬链接没有独立的inode。
  • 软链接相当于快捷方式,硬链接本质没有创建文件,只是建立了一个文件名和已有的inode的映射关系,并写入当前目录。

从应用场景来看:

  • 硬链接适用于需要多个文件名访问同一文件内容的场景(如系统日志文件的备份),但不能跨文件系统创建。
  • 软链接更灵活,可以指向目录、文件,甚至可以跨文件系统创建,但依赖于目标文件的存在。

文件的三个时间:ACM

在Linux当中,我们可以使用命令stat 文件名来查看对应文件的信息。
在这里插入图片描述
这其中包含了文件的三个时间信息:

  • Access: 文件最后被访问的时间。
  • Modify: 文件内容最后的修改时间。
  • Change: 文件属性最后的修改时间。

当我们修改文件内容时,文件的大小一般也会随之改变,所以一般情况下Modify的改变会带动Change一起改变;但修改文件属性一般不会影响到文件内容,所以一般情况下Change的改变不会带动Modify的改变。

我们若是想将文件的这三个时间都更新到最新状态,可以使用命令touch 文件名来进行时间更新。

注意: 当某一文件存在时使用touch命令,此时touch命令的作用变为更新文件信息。

5.文件系统总结

我们在学习文件系统时,永远记住这么一句话:

任何对磁盘数据的读取操作,都需要先将数据预加载到内存中才能进行后续操作。因为对数据的操作必然要通过代码实现,而代码是由 CPU 执行的。根据冯诺依曼体系结构,CPU 只能直接与内存进行交互,因此必须先将数据加载到内存中才能进行处理,这是一个很重要的细节。


网站公告

今日签到

点亮在社区的每一天
去签到