【Linux】第5期 深入理解 Linux 基础 IO 与 Ext 文件系统:从系统调用到磁盘底层

目录

开头:

ok了,今天继续我们的Linux系统的学习,如标题所见,这一期我们要来学习基础的IO以及Linux系统下的文件系统,废话不说直接开始

在 Linux 系统中,"一切皆文件" 是最核心的设计哲学。无论是普通的磁盘文件,还是键盘、显示器、网卡等硬件设备,甚至是进程间通信的管道、网络套接字,最终都可以通过一套统一的文件 IO 接口进行操作

本文将从用户态的 C 语言 IO 接口出发,深入内核的文件描述符机制,讲解重定向、缓冲区的底层原理,再下沉到磁盘硬件,拆解 Ext 系列文件系统的结构与工作流程,带你完整打通 Linux 文件 IO 的全链路知识

一.基础 IO:从库函数到系统调用

1.理解"文件"

任何一个文件,都由两部分组成:

文件内容 :文件中实际存储的业务数据,比如文本内容、程序代码、图片二进制数据等

文件属性:描述文件本身的信息,比如文件大小、所有者、权限、创建 / 修改时间、inode 编号、硬链接数等

哪怕是大小为 0KB 的空文件,也会占用磁盘空间 ------ 因为它的属性数据需要被存储,属性本身也是数据

狭义与广义的文件

狭义文件 :存储在磁盘、U 盘等永久性存储介质中的数据。磁盘作为典型的块设备,既是输入设备也是输出设备,对磁盘文件的读写本质就是 IO(输入输出)操作

广义文件:Linux 提出了一切皆文件的设计思想 ------ 键盘、显示器、网卡、管道、套接字、进程、甚至内存,所有系统资源都被抽象成了文件形态,上层可以用统一的read/write接口进行访问。这种设计的最大价值是:开发者只需要学习一套 API,就可以操作几乎所有系统资源

2.回顾C语言标准的IO接口

C 语言标准库提供了一套流式 IO 接口,是我们最常用的文件操作方式,它属于用户态的封装层

(1)文件打开与关闭:fopen /fclose

• path:要打开的文件路径,如果只写文件名,默认在进程的 ** 当前工作目录(CWD)** 下查找 / 创建

• mode:打开方式,常见选项如下:

• 返回值:成功返回FILE*类型的文件指针,失败返回 NULL

问题:进程怎么知道当前工作目录?

每个进程的 PCB(task_struct)中都记录了当前工作目录的 inode 和dentry,我们可以通过/proc/pid/cwd这个符号链接查看进程的工作目录。这也是为什么只写文件名时,系统能准确知道文件位置的原因------ 进程自己知道自己在哪,具体的后面我们会讲解

(2)文件读写:fread /fwrite

• ptr:内存缓冲区的首地址,读操作时数据存入这里,写操作时数据从这里发出

• size:每个数据项的大小(单位:字节)

• nmemb:期望读写的数据项个数

• 返回值:成功读写的完整数据项个数 ;如果到达文件尾或出错,返回值小于 nmemb;读取到文件末尾时返回 0

示例:向文件循环写入 5 次字符串

如上图,strlen(msg)表示你要写入的一块数据的大小(字节数)是多少,有很多同学想问:字符串末尾不是有隐藏的 \0 吗,为什么不把他算进来??注意:\0 是 C 语言的标准,fwrite 底层还是调用系统接口,系统中都是一个字符一个字符打印,不存在 \0 的概念,所以不算进去

另外 ,fwrite 参数中那个 1 是什么意思,strlen(msg) 是一块数据的字节数大小,那这个 nmemb 就代表一次要写入多少块,如果 nmemb 为 n ,就表示一次写入 n 块(次)

(3)文件定位:fseek /ftell/rewind

• fseek:移动文件流的读写指针位置

◦ whence:基准位置,SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件结尾)

◦ offset:相对于基准位置的偏移量,正数向后,负数向前

• ftell:返回当前读写指针的位置

• rewind:将读写指针重置到文件开头,等价于fseek(stream, 0, SEEK_SET)

(4)标准流:stdin /stdout/stderr

C 程序启动时,会默认打开三个文件流,类型都是FILE*:

• stdin(标准输入):对应键盘设备,底层文件描述符为 0

• stdout(标准输出):对应显示器设备,底层文件描述符为 1

• stderr(标准错误):对应显示器设备,底层文件描述符为 2

所以我们可以直接向 stdout 写入数据来打印到屏幕:

printf本质就是向 stdout 输出,scanf本质就是从 stdin 输入

3.系统级文件 IO 接口

库函数终究是封装,真正直接和内核交互的是系统调用接口:open、read、write、close、lseek。这些接口直接由操作系统内核提供,没有用户级缓冲

(1)位图传递标志位

系统调用的参数设计非常精简,通常用一个整数的不同比特位表示不同的选项,通过 ** 按位或 | 传递多个选项,通过按位与&** 检查选项是否存在。这是 Linux 内核中非常常见的参数传递方式

如上图,原理就是运用了位运算,简单来说按位或(|)是将目标位置置为 1,而按位与(&)是检查目标位置是否有 1,大家可以自己来推到下

open函数的flags参数,就是用这种位图方式传递的

(2)系统级调用

a.open:打开 / 创建文件

• pathname:目标文件路径

• flags:打开标志,必须且只能指定一个读写模式:

  1. O_RDONLY:只读打开
  2. O_WRONLY:只写打开
  3. O_RDWR:读写打开

可以额外按位或附加选项:(通过按位或 | 叠加多个)

• mode:新建文件的权限,比如0644,最终权限 = mode & (~umask),会受进程umask(文件掩码)影响

• 返回值:成功返回文件描述符(非负整数),失败返回 - 1

b.read /write:读写文件

• fd:文件描述符

• buf:用户层缓冲区

• count:期望读取 / 写入的字节数

• 返回值:

  1. 成功:返回实际读写的字节数
  2. read返回 0:表示读到文件末尾(EOF)
  3. 失败:返回 - 1,并设置 errno
c.close:关闭文件

关闭文件描述符,释放对应的内核资源。进程退出时,内核会自动关闭所有打开的文件,但良好的编程习惯是手动 close,避免资源泄漏

d.lseek:调整文件读写位置

• whence:基准位置,SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件结尾)

• offset:相对于基准位置的偏移量

• 返回值:成功返回新的文件偏移量,失败返回 - 1

lseek 可以让文件指针超过文件大小,此时再写入会形成文件空洞,空洞部分不占用实际磁盘空间,读取时返回 0

(3)核心:文件描述符(fd)

open成功返回的小整数 fd,就是文件描述符 (file descriptor, fd)。它本质是进程文件描述符表的数组下标

Linux 内核中,每个进程的 PCB(task_struct)里都有一个指向files_struct的指针,files_struct中维护了文件描述符表,表中每个元素是一个struct file*指针,指向内核中一个已打开的文件对象

a. files_struct 和 struct file

files_struct

因为一个进程可能对应着打开了多个文件,而操作系统需要将进程打开的文件管理起来,这就需要文件描述符表(files_struct)

count :引用计数。多个轻量级进程(线程)可以共享同一个files_struct,count 记录有多少个使用者,减到 0 才释放结构体

fd_array:文件指针数组,这就是文件描述符的本质 ------ 数组下标。我们传入的 fd 数字,就是用来索引这个数组,找到对应的struct file对象

struct file

内核中每个打开的文件,由struct file结构体描述,它代表一个 "已打开的文件实例",同一个文件被打开多次,会有多个 file 对象

f_count :一个文件可能被多个进程打开,这就需要引用计数,为 0 时才真正释放文件对象

b. 默认打开的三个 fd

每个进程启动时,默认会打开 3 个文件描述符:

我们可以直接用这三个 fd 完成输入输出,完全不用 C 库:

fd 的分配规则

文件描述符的分配遵循最小未占用原则:从 0 开始遍历文件描述符表,找到第一个空闲的下标,分配给新打开的文件

c.图解

(4)重定向的原理与实现

a.重定向的本质

输出重定向(>)、追加重定向(>>)、输入重定向(<),本质都是:在文件描述符表中,修改指定下标的指针,让它指向新的文件对象

比如我们关闭 1 号 fd(标准输出),然后打开一个普通文件,根据最小分配规则,新文件会占用 1 号 fd。此时所有向 1 号 fd 的写入,都会写到这个普通文件中,而不是显示器 ------ 这就完成了输出重定向

运行后会发现,本应打印在屏幕的内容,写入到了log.txt中

重定向本质就是:在程序启动前,修改文件描述符表项的指向

Shell 执行带重定向的命令时,内部流程是:

  1. fork() 创建子进程(目标程序运行在子进程里)
  2. 子进程打开你指定的目标文件,得到一个新的文件描述符
  3. 调用系统调用 dup2(),把新文件的描述符覆盖到目标编号(比如覆盖 1 号 stdout)
  4. 关闭多余的原始文件描述符
  5. exec() 加载运行目标程序

程序运行后,往 1 号描述符写数据时,内核会顺着表项指针,把数据写到你指定的文件里,而不是终端

b.完整语法与用法
I.标准输出重定向

只修改 1 号描述符的指向,把程序的正常输出写入文件,错误信息依然打印在终端

file 是 1> file 的简写,数字 1 可以省略

II.标准错误重定向

只修改 2 号描述符的指向,把错误信息单独写入文件

注意:这里的 2 和 > 之间不能有空格

III. 输入重定向

修改 0 号描述符的指向,让程序从文件读取数据,而不是从键盘读取

示例:cat < test.txt,cat 从 test.txt 读取内容并打印

IV.标准输出 + 标准错误 合并重定向

把 stdout 和 stderr 都重定向到同一个文件,所有输出(正常 + 报错)全部写入文件

标准写法(最通用,所有 shell 兼容)

含义拆解:

  1. 先执行 > 文件:把 1 号 stdout 指向目标文件
  2. 再执行 2>&1:把 2 号 stderr,指向「1 号描述符当前指向的地方」最终 1 和 2 都指向同一个文件

追加合并写法:

bash 简写写法

c. dup2

之前为了实现重定向功能,我们先会手动 close(0\1\2),然后打开文件按照最小分配原则让系统分配 fd,这样未免也太过于麻烦,所以提供了 dup2 接口

dup2 是 Linux 内核提供的系统调用,作用是:

复制一个旧的文件描述符,覆盖到指定的新文件描述符编号上,让两个编号指向同一个打开的文件

• 参数 oldfd:已经打开的、源文件描述符

• 参数 newfd:目标描述符编号(你想被覆盖的那个编号,比如 1、2)

• 返回值:成功返回新的描述符编号(即 newfd),失败返回 -1

执行的两步核心动作

  1. 如果 newfd 本来已经打开了一个文件,先自动关闭 newfd 原来指向的文件
  2. 让 newfd 这个编号,指向 oldfd 对应的同一个内核文件结构体 struct file

最终效果:oldfd 和 newfd 两个编号,指向完全相同的打开文件,共享文件偏移量、读写权限、内核缓冲区

4.理解"一切皆文件"

"一切皆文件(Everything is a file)" 是 Linux/Unix 最核心的设计哲学之一。它的本质不是 "所有东西都是磁盘上的文件",而是内核给所有系统资源(普通文件、硬件设备、管道、网络等)套上了一层统一的「文件抽象外壳」,上层访问所有资源时,都可以使用完全相同的 API 和操作逻辑

下面我们围绕这张图进行学习

上层:OS 内核的统一抽象层(VFS 虚拟文件系统)

这一层是 "一切皆文件" 的核心,所有资源在内核中都被封装成完全相同的结构:

  1. struct file:每打开一个资源(无论是磁盘文件、显示器还是键盘),内核都会创建一个 struct file 实例。它是所有资源的通用 "身份外壳",所有打开的资源在内核里都用这个结构体表示
  2. struct file_operations:struct file 中的 *f_op 指针会指向这个结构体。它定义了统一的操作接口标准 ,内部是一组函数指针,比如 read、write、open、close 等
    • 所有资源的接口格式(参数、返回值)完全一致,比如 read 永远是 ssize_t (*read)(...) 的格式
    • 这一层只定义 "接口长什么样",不实现具体的读写逻辑

下层:驱动开发层(硬件专属实现)

这一层是真实的硬件和资源,每一种设备(磁盘、显示器、键盘、网卡)都有一套自己的驱动程序:

  1. 每个驱动都独立实现了自己的 read、write 函数,底层逻辑完全不同
  2. 当你打开某个设备时,内核会做一步关键绑定:把该设备驱动的读写函数地址,赋值给对应 struct file 的函数指针

图中的箭头就对应这个绑定关系:上层统一的 read/write 函数指针,分别指向了不同硬件的专属驱动函数

等等,这不就相当于C语言的多态吗

上层程序调用 read(fd, buf, len) 时:

  1. 它只知道 "我要调用 fd 对应的 file 结构体里的 read 函数";
  2. 它完全不知道、也不用管,这个函数背后是读磁盘、读键盘还是收网络包
  3. 函数指针会自动跳转到对应硬件的驱动函数,完成对应的操作

同一个 read 接口,不同的底层实现,调用者代码完全不用改,自动适配不同设备------ 这就是多态,Linux 纯靠 C 语言的函数指针实现了面向对象里的多态特性

这样设计的好处:

1.对开发者:一套 API 通吃所有资源,学习和开发成本直接减半

在 Linux 里,不管你操作的是普通文本文件、磁盘分区、键盘、打印机、网卡、串口,甚至是和另一个进程通信,用的永远是同一套函数:open / read / write / close

你不用为磁盘学一套专属读写函数、为键盘再学一套、为网络再学第三套。记住一套文件操作 API,就能操作系统里 90% 以上的资源,学习成本和写代码的复杂度大幅降低

2.对工具生态:所有通用工具直接复用,不用重复造轮子

Linux 里有海量的文本处理、文件操作工具(cat、cp、grep、head、sort等等),它们本来是为普通磁盘文件写的,但因为「一切皆文件」的设计,这些工具天然就能操作所有硬件设备和系统资源

3.对系统架构:上下层完全解耦,扩展性拉满

这是多态设计在工程架构上的核心优势:接口和实现彻底分离

所有资源都被抽象成「文件」,统一用文件描述符来标识,那系统对资源的所有管理逻辑,都只需要做一套

5.缓冲区

缓冲区就是一块在内存里的临时存储空间,用来「攒一批数据再统一读写」,核心目的是用内存的高速,弥补磁盘、终端这些低速外设的速度差,本质是「空间换时间」的经典设计

打个简单的比方:

你家住在10楼,有了一袋垃圾,你妈就让你下楼倒一次,可能等你倒完垃圾上楼回家又会有一袋垃圾,费时又费力,此时加入"垃圾缓冲区",划分出暂时存放垃圾的区域,直到存了10袋垃圾,我才下一次楼去倒,就提高了效率节省体力

(1)C标准库缓冲区和系统内核缓冲区

如上图,除了我们已经知道存在文件内核缓冲区,平时用的 fopen、fwrite、printf、cout 都属于 C 标准库(stdio),它们自带的缓冲区在用户态内存里 ,由标准库管理,目的是减少系统调用的次数

只有当C标准库缓冲区满足条件时,才会向内核缓冲区进行拷贝

三种缓冲类型(Linux 默认规则):

标准库根据输出目标的不同,默认采用三种缓冲策略

但是,write 是操作系统提供的系统调用,它完全不经过 C 标准库的用户态缓冲区,调用后数据会直接从你的程序内存,拷贝到内核缓冲区中

还记不记得上面在重定向我们写过这样的代码:

为什么这里我们不去 close(fd)不去关这个文件呢???

原因就是因为C语言层有缓冲区导致的!如果我们直接 close(fd),此时我们输入的内容还存放在C语言的缓冲区中,还没有拷贝到内核缓冲区中,但是我们直接 close 了,文件直接被关了,所以此时什么都不输出

下面我们再对比来看一组代码;

我们可以猜猜代码运行的结果是什么

怎么是这样的???不慌,我们来分析分析

第 1 步:执行三个 C 标准库函数

首先因为调用的是C标准库的写入函数,所以存在C语言层的缓冲区,又因为文件模式下是全缓冲,这三行输出全部暂存在用户态的 C 库缓冲区里,没有真正调用系统调用写入内核。此时 stdout 的用户缓冲区里,已经完整存着这三行数据

第 2 步:执行系统调用 write

而write 是系统调用,没有用户态缓冲区,调用后数据直接从用户内存拷贝到内核页高速缓存,相当于已经真正完成写入,这行 hello write 在 fork 之前就已经写入内核,始终只有 1 份

第 3 步:执行 fork () 创建子进程

fork 的核心特性是写时拷贝父进程的整个用户地址空间 ------ 其中就包括 C 标准库的缓冲区

执行完 fork 后:

• 父进程的 C 库缓冲区:保留着三行库函数的输出

• 子进程的 C 库缓冲区:完整复制了同样的三行输出

第 4 步:父子进程先后正常退出

进程正常退出时,C 标准库会自动刷新所有打开的流缓冲区,把数据真正写入内核:

• 子进程退出:刷新一次自己的缓冲区,输出三行库函数内容

• 父进程退出:刷新一次自己的缓冲区,又输出三行库函数内容

(2)两种缓冲区各自的目的

C 语言(用户态)缓冲区 :减少系统调用次数,降低状态切换开销

内核缓冲区(页高速缓存):减少真实磁盘 / 硬件 IO,弥合内存与硬件的速度鸿沟

(3)FILE

在C语言文件操作中,我们一直是使用一个叫 FILE 的指针来接收 fopen 的返回值,那到底什么是 FILE ??

我们平时用的 FILE* 指针,本质就是指向 struct _IO_FILE 结构体的指针。FILE 是 C 标准库定义的通用别名(typedef),而 struct _IO_FILE 是 Linux 下 glibc 标准库的真实实现名称

• int _fileno:这就是我们常说的文件描述符 fd,是连接用户态 FILE 和内核文件的桥梁。

缓冲区已用数据量 = _IO_write_ptr - _IO_write_base

注意,这里 FILE 管理的缓冲区指的是C语言缓冲区(FILE就是C语言的概念)

FILE 与 文件描述符(fd)的核心区别:

二.Ext系列文件系统

文件系统最终要落地到磁盘硬件上,理解磁盘的物理结构与寻址方式,是理解文件系统的前提

1.磁盘

(1)三大结构

a.物理结构
b.存储结构

机械硬盘(HDD)是计算机中唯一的机械设备,也是系统中最慢的核心部件。它依靠磁性介质存储数据,通过磁头的机械移动和盘片的旋转完成读写

核心物理组件:

  1. 盘片(Platter)
    圆形的磁性介质盘片,是数据的存储载体。一块硬盘可以包含 1~ 多张盘片,每张盘片的正反两面都可以存储数据
  2. 磁头(Head)
    每个盘面对应一个磁头,负责读取和写入盘片上的磁信号。所有磁头固定在同一个磁臂上,严格同步移动------ 当一个磁头定位到某条磁道时,所有盘面的磁头都停在同编号的磁道上
  3. 磁道(Track)
    盘面上一圈圈的同心圆就是磁道,数据就存储在磁道上。磁道从外圈向内圈从 0 开始编号,最靠近主轴的同心圆是磁头停靠区,不存储数据
  4. 扇区(Sector)
    每个磁道被等分成多个扇形区域,就是扇区。扇区是磁盘硬件读写的最小物理单位,传统机械盘扇区大小为 512 字节,现代大容量硬盘多为 4KB(4K 扇区)
  5. 柱面(Cylinder)
    所有盘面上半径相同的磁道,共同组成一个柱面。柱面数 = 单盘面的磁道数

磁盘容量计算公式:

CHS 寻址模式

早期磁盘使用CHS 寻址定位扇区,通过三个参数唯一确定一个扇区:

C(Cylinder) :柱面号,确定在哪一个柱面

H(Head) :磁头号,确定哪一个盘面

S(Sector):扇区号,确定磁道上的第几个扇区

c.逻辑结构

磁带上⾯可以存储数据,我们可以把磁带"拉直",形成线性结构

如果我们把磁盘上的盘面一圈一圈的拉直,在逻辑上每一个扇区线性排列

因为有多个盘面,每一个柱面拆成线性结构

这样每⼀个扇区,就有了⼀个线性地址(其实就是数组下标),这种地址叫做 LBA

(2)LBA

LBA(Logical Block Address,逻辑块地址)是现代磁盘的标准寻址方式:

它把磁盘所有扇区逻辑上排成一个巨大的一维数组,每个扇区对应一个从 0 开始的连续编号,这个编号就是 LBA 地址

磁盘内部的固件(控制器)会自动完成 LBA 地址到 CHS 地址的转换,操作系统完全不需要关心物理结构

磁道:

某⼀盘⾯的某⼀个磁道展开:

即:⼀维数组

柱面:

整个磁盘所有盘⾯的同⼀个磁道,即柱⾯展开:

如上图,这就相当于构建了一个二维数组

因为有很多个柱面,就构成了如下:

整个磁盘不就是多张⼆维的扇区数组表,我们之前学过C/C++的二维数组,在物理结构首尾相接,其实全部都是⼀维数组:

所以,每⼀个扇区都有⼀个下标,我们叫做 LBA(Logical Block Address) 地址,其实就是线性

地址。

(3)CHS && LBA地址

CHS 转 LBA:

公式:

• 柱面 C、磁头 H 从 0 开始编号

• 扇区 S 从 1 开始编号,所以最后减 1

LBA 转 CHS

公式:

• 柱⾯号C = LBA // (磁头数每磁道扇区数)【就是单个柱⾯的扇区总数】
• 磁头号H = (LBA % (磁头数
每磁道扇区数)) // 每磁道扇区数

• 扇区号S = (LBA % 每磁道扇区数) + 1

至此,操作系统眼中的磁盘就彻底简化成了:一个元素为扇区的一维数组,下标是 LBA 地址。所有上层操作都基于这个逻辑模型

2.基本概念

(1)块和分区

块(Block):文件系统的最小单位

操作系统不会一个个扇区去读写磁盘,效率太低。文件系统会把连续的多个扇区组成一个块(Block),作为文件存取的最小逻辑单位

• 扇区:磁盘硬件的最小读写单位,通常 512 字节

• 块:文件系统的最小存取单位,由格式化时确定,常见大小为 1KB、2KB、4KB,最主流的是 4KB(8 个连续扇区

块号和 LBA 的换算:

• 块号 = LBA ÷ 每块扇区数

• 起始 LBA = 块号 × 每块扇区数

为什么是 4KB?

这是性能与空间的折中:块太大,小文件会浪费大量空间(内部碎片);块太小,索引开销变大,随机 IO 性能下降。4KB 是长期工程实践得出的最优值,(同时也和内存页大小对齐,方便实现页缓存和 mmap

分区(partition)

一块完整的磁盘可以划分成多个独立的分区,类似 Windows 的 C 盘、D 盘

分区的最小单位是柱面 ,分区本质就是划定起始柱面和结束柱面的范围

• 每个分区可以独立格式化成不同的文件系统,数据相互隔离

•磁盘最开头的 MBR/GPT 分区表,记录了各个分区的起始和结束位置

(2)inode:文件系统的核心灵魂

前面我们说过: 任何一个文件都包含两部分数据

  1. 文件内容:文件里实际存储的业务数据,比如文本、代码、图片二进制
  2. 文件属性(元数据):描述文件本身的信息,比如文件大小、所有者、权限、创建 / 修改时间、硬链接数等

Linux 文件系统最核心的设计思想:文件的内容和属性分开存储

• 文件内容存在数据块(Data Block)中

• 文件属性统一存在 **inode(索引节点)**中

所以什么是 inode???

inode(Index Node,索引节点)是存储文件属性的结构体,一个文件对应唯一一个 inode。每个 inode 有唯一的 inode 编号,在同一个分区内唯一

用 ls -li 就能看到文件的 inode 号:

其中1052007就是 main.c 的 inode 编号

用stat命令可以查看 inode 里的完整信息:

3.Ext2 文件系统拆解

Ext2 是 Linux 经典的原生文件系统,Ext3、Ext4 都基于它演进而来,核心设计思想一脉相承。我们以 Ext2 为蓝本,彻底拆解文件系统的内部结构

整体结构:块组(Block Group)

Ext2 把整个分区分成若干个大小完全相同的块组(Block Group),每个块组独立管理自己的 inode 和数据块,每个块组从前往后,依次由以下部分组成:

  1. 超级块(Super Block)
  2. 块组描述符表(GDT)
  3. 块位图(Block Bitmap)
  4. inode 位图(inode Bitmap)
  5. inode 表(inode Table)
  6. 数据块(Data Blocks)

(1)块组各部分讲解

a.数据块(Data Blocks)

真正存放文件内容的区域:

• 如果是普通文件 :数据块里存文件的实际内容

• 如果是目录文件:数据块里存该目录下的所有目录项(文件名 + inode 号)

非常重要的注意点:文件名不存在 inode 里!

文件名只存在于目录文件的目录项中,inode 里完全没有文件名信息。这是理解硬链接的关键

b.inode Table(inode 表)

• 定位:存储该块组内所有 inode 节点,是文件元数据的载体

• 每个文件 / 目录都对应一个唯一的 inode,inode 里只存文件的属性信息 ,不存文件名

对应图中最下方的表格,inode 包含的核心信息:

重点:文件名不存在 inode 里。文件名存放在「目录项」中,目录本身也是一个文件,它的数据块里存的就是一条条「文件名 + inode 号」的目录项

c.inode Bitmap(inode 位图)

• 用来管理当前块组内 inode 节点的空闲 / 占用状态

• 每个 bit 对应一个 inode 编号,1 表示已占用,0 表示空闲。创建新文件时,就是从位图里找一个 0 的位置,分配对应的 inode

d.Block Bitmap(块位图)

• 定位:负责管理当前这一个块组内部所有可分配数据块的占用 / 空闲状态

• 工作原理:它是一个二进制数组,每 1 个 bit 对应 1 个数据块:

bit = 1:该数据块已被占用

bit = 0:该数据块空闲

• 优势:分配 / 释放数据块极快 ------ 只需要修改对应 bit 位,无需遍历整个磁盘找空闲空间。

删除文件时,本质只是把对应数据块的位图画 0,不会真正清空数据内容,这也是数据恢复的底层原理

问题:为什么在电脑上下载一个几十G的游戏要很久,但是卸载却是一瞬间的事??

答案:因为下载是下载一个个文件,但是卸载,操作系统根本不会去 "清空" 磁盘上的真实数据,只做 3 件极轻量的元数据操作:

  1. 标记 inode 为空闲:把 inode 位图中,对应文件的那 1 个二进制位,从 1 改成 0,表示这个 inode 可以被新文件复用
  2. 标记数据块为空闲:把块位图中,该文件占用的所有数据块对应的二进制位,全部改成 0,表示这些磁盘空间可以被覆盖
  3. 删除目录项:在父目录的数据块里,删掉这个文件名对应的「文件名 + inode 号」记录
e.GDT(Group Descriptor Table,组描述符表)

• 定位:所有块组的「目录索引表」,记录当前块组的属性

• 每个块组对应一条描述符,里面记录:

该块组的块位图、inode 位图、inode 表分别在哪个块号

该块组的空闲块数、空闲 inode 数、目录数量

• 组描述符表也会在每个块组中备份,保证可靠性

f.Super Block(超级块)

• 定位:整个文件系统的「全局配置总表」

• 存储的核心信息

◦ 块大小、每个块组包含的块数量

◦ 分区总块数、总 inode 数量

◦ 当前空闲块总数、空闲 inode 总数

◦ 块组总数、各个元数据表的大小

关键特性 :超级块是文件系统的核心,一旦损坏整个分区就无法挂载 。因此 ext2 会在每个块组都备份一份超级块,防止第一个块组损坏后整个分区报废

(2)目录与文件

a.底层共性:目录本质也是一种文件

1. 都拥有独立的 inode 节点

无论是普通文件还是目录,都会分配一个唯一的 inode 编号,对应 inode 表中一条定长的 inode 记录

• 二者的 inode 结构体完全相同,都存储:权限位、所有者 UID/GID、大小、创建 / 修改时间、数据块指针、硬链接计数

• 唯一区别:inode 里的 File Type 字段会标记类型,普通文件标记为 -,目录标记为 d

换句话说:inode 只负责描述 "这个文件实体的属性和数据位置",根本不关心你是普通文件还是目录

2. 都共用同一套空间管理机制

• inode 分配:都通过 inode 位图 查找空闲节点,分配后标记为占用;

• 数据块分配:都通过 块位图 查找空闲数据块,按需分配;

• 存储策略:都遵循块组优化,优先和父目录分配在同一个块组,减少机械硬盘寻道开销;

• 删除逻辑:完全一致 ------ 都是删除父目录中的目录项、将 inode 和数据块对应的位图标记为空闲

b.核心差异:数据块里存的内容完全不同

1. 普通文件:数据块存「真实内容」

普通文件的数据块里,存放的就是文件本身的有效数据:

• 文本文件存字符内容;

• 可执行程序存机器指令;

• 图片、游戏资源存二进制数据

文件系统不关心内容的格式和含义,只负责按块存、按块读,内容的解析由上层应用程序负责

普通文件的 size 字段,就是所有数据块中有效内容的总字节数

2. 目录文件:数据块存「目录项映射表」

目录的数据块里,不存任何用户数据,只存一条条目录项(Directory Entry,简称 dentry)

每一条目录项,就对应这个目录下的一个文件 / 子目录,核心作用是建立「文件名 → inode 号」的映射关系

c.完整联动:一次文件读取的全流程

我们以读取 /home/game/game.exe 为例,完整走一遍「目录索引 → 定位文件 → 读取内容」的全过程,你就能清晰看到二者是怎么协作的

前置约定

ext2 文件系统中,根目录 / 的 inode 号固定为 2,这是格式化时就约定好的全局入口,挂载时内核直接读取 2 号 inode

分步执行

  1. 第 1 步:从根目录入口开始
    内核先读取 2 号 inode(根目录的 inode),通过 inode 里的数据块指针,找到根目录对应的数据块。
    根目录的数据块里,存着所有一级目录 / 文件的目录项(比如 bin、etc、home、root 等)
  2. 第 2 步:匹配 home 目录,拿到它的 inode
    内核遍历根目录数据块里的所有目录项,匹配字符串 home,找到它对应的 inode 编号(假设为 100),拿到 inode 号后,就可以去 inode 表中读取 home 目录的 inode 信息
  3. 第 3 步:进入 home 目录,匹配 game 子目录
    读取 100 号 inode(home 目录的 inode),定位到 home 目录的数据块;
    遍历目录项,匹配字符串 game,拿到 game 目录对应的 inode 编号(假设为 200)
  4. 第 4 步:进入 game 目录,找到目标文件
    读取 200 号 inode(game 目录的 inode),定位到 game 目录的数据块
    遍历目录项,匹配字符串 game.exe,最终拿到目标普通文件的 inode 编号(假设为 1024)
  5. 第 5 步:读取普通文件的真实内容
    读取 1024 号 inode(game.exe 的 inode),从 inode 中获取数据块指针;
    根据指针去数据块中读取文件的真实二进制内容,完成整个读取过程

整个流程的角色分工

所有目录 :只负责「查表、转 inode 号」,相当于一层层的索引路标

最终的普通文件 :才是真正存放数据的载体

inode:是贯穿始终的唯一身份标识,所有属性和数据位置都在这里

(3)路径缓存

路径缓存是内核在内存中做的一层加速优化:磁盘上的目录项是持久化的静态存储,内存里的 dcache 是动态缓存,把最近访问过的目录项常驻内存,避免每次解析文件路径都要逐层读取磁盘目录数据块

没有路径缓存时,解析一个深层路径(如 /home/game/game.exe),需要每一级目录都读取一次磁盘

  1. 读根目录的数据块,匹配 home 目录的 inode 号
  2. 读 home 目录的数据块,匹配 game 目录的 inode 号
  3. 读 game 目录的数据块,匹配 game.exe 的 inode 号
  4. 最终读取 inode 和文件内容

如果程序频繁打开同一个文件、遍历同一个目录,每次都要反复发起磁盘 IO,在机械硬盘上会有巨大的寻道开销,性能极差

路径缓存的核心作用,就是把这些已经查找过的目录项常驻在内存里,后续再访问同一路径时,全程在内存中完成匹配,完全跳过磁盘 IO,路径解析速度能提升几个数量级

a.struct dentry

Linux中,在内核中维护树状路径结构 的内核结构体叫做: struct dentry

struct dentry 是 Linux VFS(虚拟文件系统)层的核心结构,全称是目录项对象,完全运行在内核内存中,是路径缓存(dcache)的基本单元

d_count :引用计数器,记录当前有多少个进程 / 内核路径在引用这个 dentry。计数大于 0 时,dentry 处于活跃状态,不会被回收;计数归 0 后进入 LRU 淘汰队列

• *struct inode d_inode:指向该文件名对应的内存 inode 对象,是「文件名 → 文件实体」的关联桥梁

如果指针为 NULL,这就是一个负目录项(negative dentry),对应我们讲过的「负缓存」:表示这个文件名在磁盘上不存在,用来加速 "文件不存在" 这类失败场景的查找,避免反复读取磁盘

struct dentry d_parent:指向 父目录 *的 dentry 对象

通过这个指针,所有 dentry 都可以反向追溯到根目录,天然在内存中构成完整的目录树形结构

也是路径逐层解析、相对路径查找的核心基础

struct qstr d_name :存储文件名,是一个带哈希值的字符串结构。

除了文件名字符串本身,还预计算了哈希值,用于哈希表快速查找,不用每次匹配都逐字符比较,大幅提升检索速度

b.核心工作原理

组织方式:哈希表 + 树形结构

树形结构 :通过 d_parent 指针,所有 dentry 构成和磁盘一致的目录树,支撑路径的逐级解析

哈希表索引:内核维护一张全局哈希表,以「父 dentry 地址 + 文件名」为 key,可以在 O (1) 时间内查到对应的子 dentry,避免遍历整个目录的所有子项,这是缓存高效的核心原因

完整查找流程,以查找 /home/game/game.exe 为例:

第一次查找(缓存未命中)

  1. 从根目录 dentry(内核固定入口)开始,在哈希表中查找「根 dentry + home」,未命中
  2. 发起磁盘 IO,读取根目录的数据块,解析出 home 对应的 inode 号
  3. 从磁盘读取 home 的 inode,创建 home 对应的 dentry 对象,加入 dcache 哈希表
  4. 重复上述步骤,依次创建 game 目录、game.exe 的 dentry 对象,全部加入缓存
  5. 最终拿到 game.exe 的 inode,完成查找

第二次查找(缓存命中)

直接从根 dentry 开始,逐级在哈希表中匹配,所有层级都在内存中直接命中,零磁盘 IO,瞬间完成路径解析

c.缓存淘汰:LRU 机制

内存空间有限,dcache 不会无限膨胀。所有当前没有被进程引用的 dentry,会被放入 LRU(最近最少使用)链表:

• 新创建、刚访问过的 dentry 排在链表头部

• 系统内存不足时,从链表尾部开始淘汰最久没被访问的 dentry,回收内存

d.关键特性:负缓存(Negative Dentry)

这是一个非常反直觉但极其重要的设计:不存在的文件,也会被缓存

当查找一个不存在的文件时,内核不会直接返回失败,而是会创建一个特殊的 dentry ------ 它的 d_inode 指针为 NULL,标记为 "负状态",同样加入 dcache

后续再查找这个不存在的文件名时,直接在缓存中命中负 dentry,立刻返回 "文件不存在",不需要再读磁盘遍历目录

为什么要缓存 "不存在" 的结果??

这是针对高频失败场景的优化,收益非常大:

• 比如 C 语言编译时,会在多个 include 路径下逐个查找头文件,绝大多数路径下的文件都是不存在的

• 程序加载动态库时,会在 /lib、/usr/lib 等多个路径依次查找,大部分路径都会命中失败

比如程序反复尝试打开一个锁文件,检查是否存在

如果没有负缓存,每次失败都要读一次磁盘,大量无效 IO 会严重拖慢性能;有了负缓存后,失败场景的查找也能全程在内存中完成

负缓存同样遵循 LRU 淘汰,内存不足时会优先被回收;当对应的文件被真实创建时,负 dentry 会被立即替换为正常 dentry

(4)挂载分区

我们已经能够根据inode号在指定分区找文件了,也已经能根据⽬录⽂件内容,找指定的inode了

可是,inode不是不能跨分区吗?Linux不是可以有多个分区吗?拿到一个 inode 我怎么知道我在哪⼀个分区???

a.挂载和挂载点

**「挂载」**就是解决这个矛盾的核心机制:

把一个独立的文件系统(磁盘分区、镜像文件等),接入到主目录树的某个指定目录(挂载点)上建立「目录路径 → 目标文件系统」的映射关系。接入之后,访问这个目录及其子路径,就等价于访问这个独立文件系统里的内容

简单说:挂载就是给孤立的分区,在统一目录树上开一个访问入口

挂载点:

挂载点,就是主目录树(/)上用来承接外部文件系统的那个目录

• 它本质就是一个普通的空目录,原本属于根文件系统

• 它是外部文件系统的 "访问入口",文件系统挂在这个目录上就能被系统访问

b.挂载到底解决了什么?

挂载做了最关键的一件事:建立**「路径前缀 ↔ 分区(文件系统)」**的一一映射

可以这样理解,想要找一个文件,这个文件存在某一个区中,操作系统一开始只能从根目录(/)下开始往下根据路径寻找,而挂载相当于就是在这个根目录下创建一个对应着某一个分区的子目录,这样就能从根目录开始,通过根目录找到对应分区的子目录,再往下找文件

整个过程完全不需要 inode 跨分区**:挂载点就是分区的边界,路径前缀就是分区的身份证**。内核只需要做一次前缀匹配,就能精准知道该去哪个分区找文件,然后在分区内部用自己的 inode 体系完成查找

4.软硬链接

硬链接 :多个文件名(目录项)指向同一个 inode,是同一个文件的多个别名

软链接 (符号链接):是一个独立的新文件,有自己的 inode,文件内容里只存源文件的路径字符串

(1)硬链接(Hard Link)

硬链接的本质就是:在目录里新增一条目录项,让它的 inode 号和源文件完全相同,多个文件名共同指向同一个文件实体

创建硬链接时,内核只做两件事:

  1. 在目标目录里新增一条目录项,inode 号填写源文件的 inode 号
  2. 将源文件 inode 里的硬链接计数(i_nlink) 加 1

如上图,通过 ln 对 text.cpp 文件创建了一个硬链接,所以硬链接数量就是 2,两者的 inode 是相同的,所以本质上就是一个文件

核心特性

  1. 本质是同一个文件
    所有硬链接完全平等,没有 "源文件" 和 "链接文件" 的主次之分。删掉最初创建的文件名,文件本身依然存在,只要还有一个硬链接,数据就不会丢失
  2. 不占用额外磁盘空间
    只新增一条目录项(几十字节),数据块只有一份,几乎不消耗额外磁盘空间。
    修改任意一个硬链接的内容,所有其他硬链接看到的内容都会同步变化,因为它们本来就是同一个文件
  3. 不能跨分区 / 跨文件系统
    这是硬链接最核心的限制,正好呼应我们之前讲的「inode 分区内唯一」:
    inode 号只在单个分区内有效,跨分区后相同的 inode 号对应完全不同的文件。硬链接靠 inode 号关联文件,因此天然无法跨分区工
  4. 不允许给目录创建硬链接
    Linux 禁止用户手动给目录创建硬链接(系统内置的 . 和 ... 除外)。
    原因很简单:如果允许目录硬链接,很容易构造出「子目录指向祖先目录」的循环结构,遍历目录树时会陷入死循环,彻底破坏目录树的树形结构
  5. 目录的硬链接计数(经典细节)

    空目录默认是 2,这是硬链接最直观的体现:
    • 1 个计数:目录名本身在父目录中的目录项
    • 1 个计数:目录内部的 .(指向自身)

硬链接的作用

  1. 重要文件防误删

    这是硬链接最直观的用途

    • 原理:文件真正被删除的唯一条件是硬链接计数归 0。给重要文件在备份目录创建一个硬链接,就算原路径下的文件被误删,也只是减少了一个链接计数,文件数据和另一个入口完好无损

  2. 同分区大文件共享,零成本节省空间

    多个目录、多个项目需要用到同一个大文件时,硬链接是最优解

    • 原理:只新增一条几十字节的目录项,数据块只有一份,无论建多少个硬链接,都只占用一份文件的磁盘空间;且修改任意一个入口的内容,所有入口同步生效,天然保持数据一致

  3. 原子性文件更新,保证服务无中断

    这是线上服务、底层软件的经典优化手段,用来避免文件更新过程中读到残缺内容

    • 原理:直接修改原文件时,其他进程可能读到 "一半旧、一半新" 的不完整内容。而基于硬链接的文件替换是原子操作:先把新内容写入临时文件,再用临时文件原子替换原文件的入口,全程访问要么拿到完整旧文件,要么拿到完整新文件,不会出现中间异常状态

(2)软链接(Symbolic Link / Symlink,符号链接)

软链接和硬链接完全不同:它是一个独立的普通文件,拥有自己全新的 inode 和数据块。

它的数据块里不存真实业务数据,只存一个字符串 ------ 源文件的路径

访问软链接的过程:

  1. 先读取软链接自己的 inode,找到数据块
  2. 从数据块里读出源文件的路径字符串
  3. 内核根据这个路径,重新执行一次完整的路径解析,找到目标文件
  4. 最终访问到源文件的内容

你可以把它理解为:文件系统层面的 "快捷方式 ",但比 Windows 快捷方式更底层,对所有程序透明

核心特性

  1. 是独立文件,和源文件有主从关系
    软链接有自己的 inode、自己的权限、自己的创建时间,和源文件是两个独立的文件实体。
    删除源文件后,软链接不会跟着消失,会变成悬空链接(死链接),访问时会报错 "没有那个文件或目录"
  2. 可以跨分区、跨文件系统
    因为它存的是路径字符串,不依赖 inode 号,所以完全不受分区限制,甚至可以指向网络文件系统里的路径
  3. 可以给目录创建软链接
    目录软链接非常常用,比如把深层目录链接到桌面、给长路径起短别名,不会造成目录树成环的问题
  4. 文件大小等于路径字符串长度
    软链接的文件大小,就是它存储的源文件路径的字节长度,和源文件本身的大小无关
  5. 权限特性
    软链接自身的权限永远显示为 rwxrwxrwx,这是个假象。实际访问时的权限,完全由源文件的权限决定,软链接本身不做权限控制

软链接(符号链接)的作用

软链接的本质是「独立文件存储目标路径字符串」,灵活性极强,不受分区、文件类型限制,是日常使用更高频的类型

  1. 简化长路径,创建快捷入口
    给深层目录、低频路径起短别名,大幅提升操作效率
    • 原理:软链接相当于文件系统级的 "快捷方式",访问软链接等价于访问目标路径,对所有程序、命令完全透明
  2. 版本平滑切换与向下兼容
    这是 Linux 系统层面最经典的应用,是动态库、多版本软件的核心管理方式
    • 原理:程序统一调用一个固定名称的软链接,软链接指向具体版本的真实文件;升级版本时只需要修改软链接的指向,不用改动任何上层程序
  3. 跨分区 / 跨设备的空间扩容
    解决「路径不能改,但磁盘空间不够」的经典问题,这是硬链接无法实现的能力
    • 原理:软链接只存储路径字符串,不依赖 inode 编号,因此可以跨分区、跨磁盘指向目标。把大文件迁移到空间充足的数据盘,在原位置创建软链接指向新路径,上层程序完全感知不到变化
  4. 目录快捷映射
    因为硬链接禁止作用于目录,目录的多入口访问只能通过软链接实
  5. 环境适配与配置解耦
    通过软链接把固定路径和实际资源解耦,提升部署灵活性

(3)两者对比

结尾

这一期关于Linux基础IO和文件系统的相关知识全解就结束了,当然随着我们继续学习,知识一定会越来越复杂深入,希望通过这一期大家能够学有所获,内容篇幅很长,大家可以点个赞收藏起来好好学习,谢谢大家,我主页里有更好康的呦!

往期回顾

  1. 【学习篇】第21期 超详解 AVL树
  2. 【Linux】第3期 Linux 基础开发工具全家桶
  3. 【学习篇】第20期 超详解 C++ 多态:从语法规则到底层原理