文件系统 —— Ext2

原创文章,未经许可严禁转载

Author:ex@m1ne2

Learn File System

Disk Structure

首先区分「磁盘」和「硬盘」的区别

「硬盘(Hard Disk)」的全称其实是「硬磁盘」,也就是说「硬盘」属于「磁盘」

「磁盘(Disk)」可以细分为「硬盘」和「软盘」,现如今「软盘(Floppy Disk)」已经被淘汰,因此通常所说的「磁盘」就是指「硬盘」

而「硬盘」又可以细分为「固态硬盘」和「机械硬盘」


然后放两张磁盘的结构图:

image-20201220211454991 image-20201220211431540

磁盘(Disk)是「磁质硬盘」的缩写,它内部包含一组圆形磁片,每个磁片都有两个磁盘面(Platter)

磁片的数量是受限制的,通常在5片以内;而对磁片的编号从下往上开始,最下面的磁片有0面和1面,然后上一个磁片就编号为2面和3面,以此类推

每个磁盘的表面都被划分为多个同心圆盘,称为磁道(Track),通常一个面有上万条磁道;而磁道同样是从外向内开始编号,最外边缘的磁道为「0磁道」

每条磁道上同样被划分为多个用于存储信息的扇区(Sector)

image-20201220214148674

扇区的容量通常是512 Bytes - 4096 Bytes;对于硬盘而言,扇区可能的字节数是 $128 × 2^n$,在大多数情况下 $n = 2$,也就是512 Bytes大小

注意相邻磁道之间不是紧挨着的,而是各个磁道之间间隔出一定的空间:

image-20201222104736061

这是因为磁化单元相隔太近时,磁性会产生相互影响,同时给磁头的读写带来困难

在众多圆形磁片的旁边,有一根固定的机械臂杆,上面同样固定着许多的磁臂(Boom),磁臂的尽头是用于读取扇区数据的磁头(Head)

完整的磁盘读取数据的流程是:

  1. 寻道

    磁臂通过伸长或缩短,将磁头移动到将要读取的磁道之上

    「寻道时间」:将磁头移动到目标磁道所需的时间

  2. 旋转

    当磁头到达正确的磁道后,就需要等待将要访问的扇区转动到磁头下面

    「旋转延时」:磁头等待扇区的时间

  3. 读取

    定位成功后,开始进行扇区数据的传递;传输时间是扇区大小、旋转速度、磁道信息密度的一个函数

    注意,磁盘在计算机启动后就持续保持高速旋转的状态,而磁头的移动是径向移动,只能沿着磁盘的半径方向走

此外还有一个概念 —— 柱面(Cylinder),它表示不同磁片相同半径的磁道构成的集合;有时候它与概念「磁道」混用,因为它们都表示不同半径的圆

所有磁头都是连在一起协调运动的,因此每个磁盘面上的磁头都位于相同位置的扇区

最后放一张磁盘的物理图:

image-20201222104736061

整个磁盘的存储容量 = 磁头数(磁盘面数) × 磁道数 × 每个磁道的扇区数 × 每个扇区存储的字节数

CHS Parameter

一般情况下每个扇区可以存储的字节数是已知的、固定的,因此一块磁盘通常会给出其余3个参数,以供买家计算容量

而这三个参数「柱面(Cylinder)数」、「磁头(Head)数」、「扇区(Sector)数」就构成了磁盘的CHS参数

CHS默认每个磁道上扇区的数量是一样的(也就是Sector参数),早期的磁盘也的确是这样,但后来为了增大磁盘容量采用了新技术,使得越往外面,磁道上的扇区数量越多

但仍可以采用CHS的方式来定位和计算,因为磁盘自身做了内部转换


CHS Addressing Mode

CHS除了作为容量计算的参数外,还通常用于定位

例如CHS = 0/0/1,则表示磁头为0、柱面为0上的扇区1

值得一提的是,柱面(C)、磁头(H)都是从0开始编码,但扇区(S)则是从1开始编码;因此地址CHS = 0/0/0是不合法的

具体可以参考StackExchange上的讨论 Why does sector-number addressing in CHS start at sector 1 and not 0?,我们直接将「扇区从1开始编号」作为约定俗成即可


LBA Addressing Mode

CHS寻址模式必须以硬盘上某个柱面、磁头、扇区的硬件位置来合成对应的地址,以此来指定区块;而更常见的LBA寻址模式将硬盘的细节封装,只需提供一个LBA编号,即可寻址到对应的硬盘区块

与CHS直接提供物理地址不同,「LBA(Logical Block Address,逻辑区块地址)」则是提供了逻辑地址

例如:

  • 「LBA = 0」表示磁头0、柱面0上的扇区1,也就是CHS = 0/0/1
  • 「LBA = 1」表示磁头0、柱面1上的扇区2,也就是CHS = 0/0/2

LBA寻址模式取代了像CHS这样的必须面对存储设备硬件构造的寻址方式,但实际上硬件控制器还是使用CHS来寻址区块,两者有如下的转换关系:
$$
LBA = (C × H_{per} + H) × S_{per} + S - 1
$$
其中 $H_{per}$ 表示每个柱面的磁头数,也就是总的盘面数;$S_{per}$ 表示每个磁道的扇区数

留意到LBA的编号分配是先分配第一个柱面,分配完之后再分配第二个柱面,以此类推,因此先根据 $C × H_{per}$ 求得外部柱面的所有扇区、然后加上 $H$ 表示当前柱面的磁头编号,得到总磁道数

结果乘以 $S_{per}$ 得到总的扇区数,最后加上 $S - 1$ 得到确切的扇区地址

由于CHS以 0/0/1 开始,所以在公式中有 $-1$ 的偏移量

同理可得:
$$
C = LBA ÷ (S_{per} × H_{per})\
H = (LBA ÷ S_{per})\ %\ H_{per}\
S = (LBA\ %\ S_{per}) + 1
$$
其中的 $÷$ 是整数除法

例如,CHS参数为{600, 10, 84},则LBA = 1234对应的CHS编号为:
$$
S = (LBA\ %\ S_{per}) + 1 = (1234\ %\ 84) + 1 = 59\
H = (LBA ÷ S_{per})\ %\ H_{per} = (1234 ÷ 84)\ %\ 10 = 4\
C = LBA ÷ (S_{per} × H_{per}) = 1234 ÷ (10 × 84) = 1
$$
CHS = 1/4/59


最后推荐好文:

  • https://www.cnblogs.com/kerrycode/p/12701772.html

    「扇区的大小是固定的吗?」—— 默认情况下扇区大小就是512 Bytes,虽然2009年就开发出了4 KB扇区的硬盘,但至今未普及

    「每个磁道上的扇区数量是一样的吗?」—— 涉及到CLV、CAV、ZBR技术,旧式的非ZBR区位记录不同磁道上的扇区数相同,而新式的ZBR区位记录则不同了

    「扇区在磁道上的面积大小是固定的吗?」—— 非ZBR区位记录不是,但ZBR区位记录则是


File

CHS详细到各个数据存储的扇区位置,但在实际的文件操作中,我们并不需要了解“扇区”、“柱面”等术语,这是因为在应用程序和磁盘之间,存在文件系统操作系统来作为中介,将细节封装,将扇区抽象成文件

在文件系统的帮助下,只需传入路径名和文件名,就可以自动在磁盘中定位到所需的数据起始地址、以及数据的长度;而不是“取磁盘中磁头编号为2、柱面编号为3的第5个扇区的数据,连续取4个”这样去访问数据

因而文件也是操作系统中最小的操作单位

下面我们来学习文件系统,主要是基于开源的Linux操作系统,闭源的Windows就暂时算了;后面会讲解Linux的Ext2、Ext3等文件系统


Block

硬盘的最小存储单位是扇区(Sector),每个扇区的大小通常是512 Bytes(0.5 KB),操作系统在读取硬盘时,通常不会一次只读取一个扇区,这样太慢了,而是一次性连续读取多个扇区(称之为一个块(Block));通常8个扇区组成1个块,即1个块 = 4 KB

硬盘最小的存储单位是「扇区」,而文件存取的最小单位是「块」

以下部分内容整理自刘欣《码农翻身》—— 我是一块硬盘

一个文件可能占用多个硬盘块,因此需要规划如何在硬盘中存放完整的文件数据;存放方式有:

  1. 连续存储

    image-20210123175815010
    文件名 起始块 块数
    Hello.java 1 3
    World.java 8 5
    Temp.java 15 6

    连续存储的顺序访问速度快、随机访问的速度也快,但是缺点是容易产生碎片,例如当前有一个长度为5块的文件数据需要存放,上图的位置都不合适

  2. 链式存储

    image-20210123180438630

    采用链表的形式,每个数据块都存储下一个数据块的编号

    链式存储的顺序访问速度还好,但随机访问速度很慢

  3. 索引存储

    image-20210123181055173

    为每个文件单独使用一个硬盘块(称之为索引节点(Index Node,简称inode))来存放文件的元数据和该文件依次占用的硬盘块的信息

    元数据也就是文件属性,包含权限、所有者、时间戳等信息

    索引存储的顺序访问速度和随机访问速度都很快,并且不会产生碎片,唯一的缺点是需要占用额外的空间(inode)

就目前而言,广泛采用索引存储的方式来存放文件数据

因此,用户所接触到的“文件”本质上是一个指向相应inode的链接

如果文件的体积特别大,那么可以通过「inode指向inode」的方式来扩展有限的inode;inode中除了文件属性外,剩余的索引区域可以划分为直接块间接块 —— 直接块指向数据块、间接块指向另一个inode


Index Node

以下内容参考自阮一峰前辈的 理解inode

inode是特殊的磁盘块,用于存放一个文件的元信息,每个文件都有一个对应的inode,在Linux下可以通过 stat 命令来查看对应的inode:

image-20210126191408044

每个inode包含的信息有:

  • Size :文件的字节数

  • Block :块数

  • IO Blocks :IO块大小

  • regualr file :文件类型(常规文件)

  • Device :设备号码

  • Inode :inode编号,文件数据块的位置

  • Links :链接数(有多少文件名指向该inode)

  • Access :文件权限,包括读、写、执行

  • Uid :文件拥有者的User ID

  • Gid :文件的Group ID

  • Access —— 上次打开文件的时间;Modify —— 上次改变文件内容的时间;Change —— 上次改变inode的时间

    如果是一次普通的访问操作,那么只会修改 Access 时间戳;而如果是一次普通的修改操作,那么会同时修改 AccessModifyChange 这三个时间戳,因为修改文件需要打开文件,首先修改了 Access,修改后文件内容以及inode的信息(例如 SizeAccess 等)也发生了改变,所以 ModifyChange 也会改变

    参考文章 https://blog.csdn.net/pointer_y/article/details/54347968,使用 chmod 命令修改文件的访问权限,那么只修改了文件的元数据,Change 会发生改变,而 AccessModify 则不会

    文章同样提到,使用 touch 命令配合 -a-m 参数可以直接修改文件元数据中的 AccessModify;但是在修改这两者的同时,Change 也会自动改变,因为inode记载的元数据发生了改变

更多有关 stat 命令可以参考 https://blog.csdn.net/paicmis/article/details/60479639

inode本身也会占用硬盘空间,因此在硬盘格式化时,操作系统就将硬盘分为了两个区域:

  1. 数据区 —— 存放文件数据
  2. inode区 —— 又称之为inode table,存放inode所包含的信息

与硬盘块通常为8个扇区(4 KB)的大小不同,inode通常是128 Bytes或256 Bytes;因为一个inode对应一个文件,因此在文件未创建时,会默认为每1 KB或2 KB设置一个inode,如果创建的文件大于预先分配的inode大小,那么就采用前面提到了“间接块”的方式,确保一个inode对应一个文件


我们可以使用 df 命令来查看每个硬盘分区的inode总数和已经使用的数量,通过 -i 参数显示inode:

image-20210126201048436

df 是「Disk Free」的缩写,用于显示Linux系统上文件系统磁盘的使用情况统计

如上图所示,罗列了 udev/dev/vda1tmpfs 等多个文件系统,其中:

  • 「tmpfs」主要存储暂存的文件

  • 「udev」和「/dev/vda1」的了解,可以参考 https://zhidao.baidu.com/question/315932412.html

    类似Windows系统上的本地磁盘、U盘、光驱等设备,Linux访问这些设备需要将这些设备挂载到 /dev 目录下,映射成文件才能访问

    早期Linux的 /dev 目录下有一大堆设备文件,每个文件相当于提供一个标准接口,比如 /dev/sda 一般表示SCSI盘的第一块磁盘;但即使你的主机上没有这种磁盘,/dev/sda 还是存在

    /dev 目录下其实并不需要那么多文件,尽管这些文件占用不了多少空间

    后来Linux只在 /dev 上保留了一些必要的设备,比如 /dev/console(表示控制台)等,其它的由 udev (user device)在系统启动时检测并加载

    比如扫描到你有SCSI设备(包括U盘),就在 /dev 目录下增加一个设备文件,比如 /dev/sda;因此,在有 udev 的前提下,/dev 下的设备文件表示你的主机真的有这个设备

因此可以知道,上图中的 /dev/vda1 是我们磁盘对应的文件系统,它支持的inode数量高达3276800,而当前只使用了107133个,占总数的4%,inode table空间充足;而由于是磁盘对应的文件系统,因此它被挂载(Mount)在根目录 / 下,我们平常对文件的访问都是借助这个文件系统

此外,我们可以使用 dumpe2fs 命令获得文件系统的各个参数,从其中获取inode的大小:

image-20210127205820699

由此可知在该文件系统中,每个inode占用256 Bytes

命令 dumpe2fs 本意是"Dump Ext2 File System",但随着改进,已经能够获取Ext2、Ext3、Ext4等文件系统的基本信息


File Name

每个inode都有一个编号,因而每个文件也有对应的inode编号,可以通过 ls 命令添加 -i 参数来查看:

image-20210127210316380

目录可以视为特殊的文件,在磁盘中也有对应的inode,inode中存放着目录的属性和磁盘块,指向的磁盘块中存储着该目录下的内容(一般文件的磁盘块存储的是文件数据)

image-20210123182538927

目录指向的磁盘块中,存储的是{文件名:inode编号}的键值对


普通用户操作文件通过文件名,但在Unix/Linux系统内部则不使用文件名,而是使用inode编号来识别文件 —— 对于系统而言,文件名只是inode编号便于识别的别称

表面上,用户通过文件名打开文件的内部过程为:

  1. 系统根据文件名找到对应的inode编号
  2. 通过inode编号,在inode table中定位到inode,获取其中的信息
  3. 根据inode中记录的信息,找到文件数据对应的块,读出数据

Unix/Linux系统运行多个文件名指向同一个inode编号,这意味着,可以通过不同的文件名对文件进行访问,对文件内容的修改会影响所有的文件名;但是删除一个文件名,只是切断了这个文件名到对应inode编号的映射,不影响另一个文件名的访问

这种情况就被称为硬链接(Hard Link)

基于这个原理,在Unix/Linux系统中误删文件后,还有挽回的余地


File Deletion

文件本质上是一个指向相应inode的硬链接,当我们删除某个文件时,实际上是把「文件 → inode」的链接给切断了,使得该inode无法通过文件符来定位

但是如果我们还有其它方式定位到该inode,那么这个文件就没有被彻底删除;只有某个inode没有任何访问的途径,存储在磁盘中的数据才算是真正被“清除”了

我们可以尝试在Linux中删除文件后,通过inode来恢复

Ctrl + z

在Linux中,我们通常使用 Ctrl + c 来强制结束程序;而相似的,快捷键 Ctrl + z 会中断当前的程序,但是却没有结束程序,它会将程序维持在挂起的状态

当通过 Ctrl + z 挂起程序后,可以通过Linux的 jobs 命令查看所有挂起的程序,每个被挂起的程序前都有一个编号

Linux中的命令 fg 可以将挂起的程序搬到前台(Foreground)继续运行,该命令可以指定挂起程序的编号,例如 fg 2;默认恢复编号为1的挂起的程序

Linux中的命令 bg 则可以将挂起的程序搬到后台(Background)运行,同样可以指定编号来恢复挂起的程序,如 bg 2;通常在Linux终端中,如果一个程序的运行需要很长时间,要想把它设置为后台自动运行,那么就可以 Ctrl + z 中止程序,然后通过命令 bg 来设置后台运行

命令 jobs 除了罗列挂起程序的编号外,添加参数 -l 还可以显示这些程序的进程号,知道了进程号,就可以通过 kill 命令结束任意一个进程

Linux的 /proc 目录包含了正在运行的所有进程的信息,除了一些特殊的进程外,其它大部分进程都是通过进程号(PID)来创建一个目录,然后在目录中存放相应的数据

进程可能会使用文件资源,每打开一个文件,进程都会在对应的 /proc/PID 目录下的子目录 fd 中添加一个文件描述符(File Descriptor),例如 /proc/1038/fd/1 就表示PID为1038的进程访问了inode编号为1的文件资源,它本质是一个符号连接,以inode编号命名,存放在对应进程的 fd 目录下

前面有提,“文件”本质上也是一个指向inode的连接,那么在删除了文件的情况下,如果知道对应的inode,那么就可以恢复文件

首先我们创建一个文件 Data.txt,其中填充任意的数据:

image-20210127214211746

然后我们键入命令 more Data.txt 来查看文件中的内容;more 命令会以一页一页的形式来呈现文件内容,我们的 Data.txt要稍微大一点,使得进入 more 的状态页;太小会导致直接 cat 文件内容

image-20210127214256784

键入 more Data.txt 后,如果文件体积足够大,more 会等待你的翻页操作

这时我们通过 Ctrl + z 挂起 more 进程,然后直接通过 rm 命令将 Data.txt删除

Linux终端并没有类似Windows回收站的工具,因此一般这个时候很难找回被删除的文件 Data.txt

虽然通过文件名找到inode的方式「文件 → inode」被删除了,但是我们前面有提,进程在使用到某些文件时,会在对应的 /proc/PID/fd 中添加文件资源的inode

于是我们先通过 lsof 命令查看所有的文件资源使用情况,lsof 是List Open Files的意思,可以查看所有进程打开的文件资源;按理说在删除 Data.txt之前,我们使用 more 打开过,并且到目前为止都处于挂起状态,文件资源仍被打开中,我们通过 grep 检索下:

image-20210127214356076

发现在打开的文件资源中,的确有 more 进程访问 Data.txt;虽然这时 Data.txt已经被标识为 deleted 了,但 more 进程对原始的 Data.txt 的数据访问仍在,删除的只是原始的 Data.txt文件名

命令 lsof 罗列出来的各个字段分别对应:

Command PID User FD Type Device Size Node Name
more 12006 ubuntu 3r REG 252,1 10518 262963 /home/ubuntu/Data.txt

所以我们直接复制这个符号链接(本质上是对inode的指向),重命名为 Data.txt.recover

image-20210127214814596

打开 Data.txt.recover 发现是原来的 Data.txt的数据

基于上面这个实验可以知道,“文件”本质上只是硬链接,删除文件只是切断了「文件 → inode」这条获取数据的途径,但如果还存在其它方式访问数据(例如通过 /proc/PID/fd 下记载的文件描述符获得inode),那么就能恢复对数据的访问


在创建文件时,就自动定义了「文件名 → inode」的硬链接,而在Linux中,我们可以通过 ln 命令来创建一个硬链接:

1
$ ln 源文件 目标文件

前面我们使用 stat 命令显示文件对应inode的情况:

image-20210126191408044

其中的 Links 字段就表示有多少个文件名链接到了该inode

通过 ln 指令为一个文件创建硬链接,那么 Links 数就会增加;反之,删除一个文件名,Links 数就会减少;当 Links 为0时,就表明没有文件名指向这个inode,当确保没有进程访问该资源后,系统就会回收这个inode号码,以及其对应的块区域

此外,关于目录的 Links,在创建目录时,都会默认创建①表示当前目录的 .;②表示上级目录的 ..;前者相当于当前目录的硬链接,而后者等同于父目录对当前目录的硬链接,因此任何一个目录的硬链接总数 = 2 + 子目录总数(含隐藏目录)


与硬链接(Hard Link)不同的是软链接(Soft Link),它又被称之为符号链接(Symbolic Link)

文件A与文件B的inode编号不同,但其实文件A的内容是文件B的路径;当读取文件A时,系统会自动导向文件B,因此无论打开哪个文件,其实最终读取的都是文件B的数据 —— 这时文件A就被称为文件B的软链接

这就导致,文件A依赖于文件B,如果删除了文件B,打开文件A就会报错:"No such file or directory"

ln 命令可以通过添加 -s 参数来创建软链接:

1
$ ln -s 源文件或目录 目标文件或目录

由于每个文件都必须有一个inode,因此当创建的文件数目过多时,inode有可能被用完,这时哪怕硬盘空间足够,也无法创建新文件

造成inode耗尽通常是因为有大量的小字节缓存文件,其占用的Block不多,但占用了大量的inode;例如 http://zyan.cc/post/295/2/1/


Inode Usage

  • 删除

    有时候文件名包含特殊字符,无法正常删除,这时可以直接删除inode节点,也能起到删除文件的效果

  • 平滑升级

    打开一个文件后,系统就以inode编号去识别这个文件,不再考虑文件名;这也表示,系统无法从inode中获悉文件名

    软件可以在不关闭的情况下进行更新,这也得益于inode机制:更新的时候,新版文件以相同的文件名生成一个inode,不影响正在运行中的文件;等到下次运行这个文件时,系统就将文件名指向新的inode中,旧inode被回收,完成更新


Manage Empty Block

在采用索引存储来管理磁盘块后,一个问题是如果管理未使用的空闲数据块;主要方法有:

  • 「链表法」

    将每个未使用的磁盘块的编号使用链表存放,每次有新数据生成时,就从链表头取下一个磁盘块,分配空间;如果一个磁盘块号用32 Bits来表示,那么这种方式就有可能造成大量空间的浪费

  • 位图(Bitmap)法

    在磁盘中创建一张位图,位图上的每个比特位都指向一个磁盘块,用0表示空闲块、1表示已占用的数据块

    image-20210123220028616

    位图法中,每个磁盘块只占用1 Bit的额外空间

目前广泛使用的是位图法,而磁盘块按照作用被分为数据块和inode(索引块),因此通常有对应的inode位图磁盘块位图,分别用于管理空闲的inode和空闲的磁盘块


dev

前面我们在介绍inode的时候,使用 df -i 命令查看了当前硬盘分区的情况:

image-20210126201048436

这里再介绍一下

Windows操作系统上的本地磁盘、U盘、光驱等设备能够直接访问,但在Linux上,这些设备需要挂载到一个目录中,映射成文件才能访问;通常我们将设备文件挂载(Mount)到 /dev 目录下,该目录是「设备(Device)」的含义,其中的每个子目录都表示一个设备

「tmpfs」主要存储暂存的临时文件,全称为Temporary File System;从上图可以看到,磁盘中有许多tmpfs,分别被挂载到不同的位置

早期Linux的 /dev 目录有一大堆设备文件,每个文件相当于提供一个标准接口,比如 /dev/sda 一般表示SCSI盘的第一块磁盘;但即使你的主机上没有这种磁盘,/dev/sda 也还是存在

为了减少空间浪费,Linux后来只在 /dev 上保留一些必要的设备,比如 /dev/console(表示控制台),其它设备由 udev(user device)在系统启动时检测并加载

比如当扫描到你有SCSI设备(包括U盘),就在 /dev 目录下增加一个设备文件,比如 /dev/sda;因此,在有 udev 的前提下,/dev 下的设备文件表示你的主机真的有这个设备

参考 https://blog.csdn.net/qq_43211632/article/details/104186368 可知,硬盘接口主要分为两类:

  • IDE接口

    Integrated Drive Electronics

  • SCSI接口

    Small Computer System Interface,使用50针接口

具体涉及到硬件知识,这里略过

IDE接口的硬盘前缀为hd,例如,系统第一块IDE接口的硬盘命名为 /dev/hda、第二块为 /dev/hdb ...硬盘中存在不同的分区,用数字表示,例如,系统第一块IDE接口硬盘的第1个分区称为 /dev/hda1、第4个分区则为 /dev/hda4 ...

SCSI接口的硬盘前缀为sd,同理,第二块SCSI接口硬盘的第1个分区称为 /dev/sdb1、第3个分区则为 /dev/sdb3 ...

除此之外,还有表示软驱的「fd」、Terminals的「tty」、virtio磁盘的「vd」等


Pseudo Device

/dev 目录下未必都是硬件设备,也存在一些特殊的软件设备,称之为「伪设备(Pseudo Device)

主要的伪设备有:

  • /dev/null

    传说中的空设备,又被称之为黑洞,它会丢弃一切写入的数据,写入的数据会永远丢失,而且没有任何可以读取的内容

    image-20210128221002777

    有时候,我们会将 /dev/null 用作清除文件内容:

    image-20210128221045938
  • /dev/zero

    该设备与 /dev/null 类似,它会尽可能地提供 \x00 字符(不是字符 "0",而是NULL)

    image-20210129004048874

    NULL字符很多时候会有特殊的作用,例如:

    1
    $ dd if=/dev/zero of=/dev/hda7

    这是条危险的指令,它的作用就是初始化IDE接口的第一块磁盘第7个分区,将其数据全部用NULL代替

    同样,任何写入 /dev/zero 都无用,等效于写入 /dev/null

  • /dev/random

    特殊文件,用于产生随机数据流

    image-20210128171654441

    由于产生的是二进制流数据,因此可能有许多不可打印字符,很难阅读,可以通过 od 命令将其转换为Hex再输出:

    image-20210128172845799
  • /dev/urandom

    /dev/random 的作用相同,都是产生随机数据流

    两个设备的差异在于,/dev/random 的随机池依赖于系统中断,如果系统的中断数不足,会导致 /dev/random 一直处于封锁状态,呈现为“卡住”;尽管慢,但 /dev/random 设备可以确保数据的随机性

    /dev/urandom 不依赖于系统中断,输出很快,但数据的随机性不高

    鉴于 /dev/urandom 产生随机数的速度很快,因此要想通过 cat 查看,可以借助 head 命令查看前n行的随机数据:

    image-20210128173030093

/dev/urandom 设备的用途广泛,例如:

  1. 产生随机数据文件

    1
    $ dd if=/dev/urandom of=1KBfile bs=1 count=1024

    上面通过 dd 指令产生一个1 KB大小的随机数据文件 1KBfile

  2. 用作加密函数的随机初始向量

    例如,PHP 5.6.0以上的版本中,函数 mcrypt_create_iv(int $size [, int $source = MCRYPT_DEV_RANDOM]) 的参数 MCRYPT_DEV_RANDOM 默认从 /dev/random 中获取;但由于 /dev/random 太慢,在PHP 5.6.0+版本中,已改为默认从 /dev/urandom 中获取随机数据

更多可以参考 https://www.cnblogs.com/sammyliu/p/5729026.html


File System

前面有提,得益于文件系统和操作系统的底层封装,我们可以单凭一个LBA编号即可定位到磁盘块的位置

总的来说,文件系统的作用就是在存储设备组织文件,其全称可以认为是「负责管理和存储文件信息的软件」

文件系统的种类很多,例如:

  • FAT —— 文件分配表(File Allocation Table)

    为小磁盘以及简单的目录结构而设计的文件系统,最早为FAT12,随后发展为FAT 16、FAT 32

  • NTFS —— 新技术文件系统(New Technology File System)

    Windows NT采用的独特的文件系统结果,基于保护文件和目录数据的基础,并且尽可能地节省资源

  • exFAT —— 扩展文件分配表(Extended File Allocation Table)

    Microsoft在Windows Embeded 5.0以上引入的新文件系统;解决了FAT 32不支持4G及更大文件的缺陷,适合于闪存

  • Ext —— Linux扩展文件系统(Linux Extended File System)

    • Ext2

      GNU/Linux系统中的标准文件系统,存取文件性能好

    • Ext3

      在兼容Ext2的前提下,添加了日志功能

    • Ext4

      Ext3的改进版,修改了Ext3中部分重要的数据结构,提供更高的性能和可能性

  • HFS —— 分层文件系统(Hierarchical File System)

    使用在Mac OS上的文件系统

不同的文件系统对文件的管理规则不同,全部深入学习需要一定的时间

Ext2/Ext3是Linux上应用最为广泛的文件系统,网络上对文件系统的学习也大多基于Ext2,而Ext3只是在Ext2的基础上添加了日志功能,因此我们对文件系统的学习也从Ext2开始


dd & losetup & mke2fs

工欲善其事,必先利其器

我们的学习打算从Ext2文件系统开始,但是光从理论下手是枯燥的,最好我们可以拥有一个Ext2文件系统 —— 于是我们借助Linux下的 mkfs 命令,来创建一个Ext2文件系统

  1. 创建文件

    image-20210129185327645

    这里借助 dd 命令,生成5 MB大小的全0数据

  2. 将文件虚拟成块设备

    image-20210129203812502

    生成的5 MB文件 Ext2 其实是充当一块硬盘,下一步我们会通过 mke2fs 命令将这5 MB的硬盘格式化为Ext2格式,但 mke2fs 只能格式化设备,现在我们的 Ext2 还只是一个普通的数据文件

    借助命令 losetup,它会将 Ext2 虚拟成一个块设备,然后就能模拟整个文件系统,使得用户可以将被虚拟化的文件视为硬盘;在 /dev 目录下有着 /dev/loop0/dev/loop1 等循环设备,就是为了进行虚拟化而设置的

    通过 losetup -f 命令可以查看目前空闲的循环设备是哪个,然后直接选用这个来虚拟化我们的 Ext2 即可;由于一些原因,/dev/loop0 被占用,所以上图中我使用 /dev/loop1 来虚拟化,效果相同

  3. mke2fs 格式化为Ext2文件系统:

    image-20210129203113678

    mke2fs 的全称是Make Ext2 File System,能够将一个设备文件格式化为Ext2;上面的截图中,我们格式化的是 /dev/loop1,其实本质上格式化的是我们的 Ext2;这时查看 Ext2 的文件类型,可以得到:

    1
    Ext2: Linux rev 1.0 ext2 filesystem data, UUID=2fa8e6bf-8735-4e3d-a2ab-6badf5b3402a (large files)

    网上的文章有可能用的是命令 mkfs.ext2,但其实 mkfs.ext2 是一个指向 mke2fs 命令的链接:

    image-20210129210705761

    从上图可以看到,mkfs.ext2 指向 mke2fs,不仅如此,mkfs.ext3mkfs.ext4 都指向它;这些指令本质上都是 mke2fs,只不过传递的参数不同

    所以 mkfs.ext2 == mke2fs

    最后结束虚拟化,卸载设备:

    1
    $ sudo losetup -d /dev/loop1
  4. 基于上面的三步操作,我们的 Ext2 文件已经从全0的普通文件变成了Ext2文件,可以通过 mount 命令挂载,然后进入该文件系统中,存放其它文件

    这时的 Ext2 文件就相当于一块虚拟硬盘,倘若能够将它变成真正的硬盘、并装入计算机中,那么计算机开机后,就直接通过Ext2文件系统来访问各种数据

可以下载我的 Ext2 文件:https://wws.lanzous.com/iA6LIl4z7eb

在创建完成后,我在该文件系统中添加了一个 Data.txt 文件,并在里面添加了一行文本;此外通过命令 mke2fs 创建文件系统会自动生成一个 lost+found 目录,具体可以参考 mke2fs进行的操作

但是在后面,我们还需要学习Ext2的结构,这里构建的Ext2其实是有缺失了,不利于完整学习其结构;为了后面的讲解,我们还计划创建另外一个文件系统 —— FAT

  1. 键入 Windows + x 键,点击其中的 磁盘管理(K)

  2. 在下方点击常用的盘符,然后点击上方的 操作(A)创建 VHD

  3. image-20210131182219618

    指定创建的位置后,添加VHD的大小为10 MB,然后点击 确定

  4. 新建后,下方会出现一个未分配的磁盘标识;右键点击,然后 初始化磁盘(I)

    image-20210131182443816
  5. 磁盘的分区形式选择MBR:

    image-20210131182546192

    这时会显示我们的磁盘1已分配,剩余空间从之前的10 MB减少到了9 MB,正是因为其中的0.5 MB被用作生成MBR

  6. 右键点击未分配的9 MB空间,点击 新建简单卷(I)

    image-20210131183002410

    在打开的向导中,我们暂时为该卷分配5 MB空间的大小,然后分配驱动器号为 Z:

    image-20210131183138038

    最后选择文件系统为 FAT:

    image-20210131183240406
  7. 点击 完成 后,会看到我们新建的VHD文件被挂载到Z盘上:

    image-20210131183359713

于是我们就创建了一个虚拟硬盘文件(Virtual Hard Disk,VHD),与真实的实物硬盘相比,VHD是使用软件的形式实现的硬盘,可以视为它是对实物硬盘的一种模拟

毕竟有时候很难找到一块硬盘用作学习

VHD有着诸多的优点:

  • 维护简单、备份轻松

    可以对VHD进行分区、格式化、压缩、删除等操作,但并不影响真正的物理分区,适合初学者

    在备份VHD时,只需将VHD文件复制一份即可

  • 加载、迁移方便

    它能够像U盘一样挂载到设备上,也像U盘一样容易卸载

    在上面创建了Z卷后,默认就挂载到了我们的电脑上,这时就可以等价于插入了一个U盘,在Z盘中添加文件(文件总大小不超过分配的5 MB):

    image-20210131184042642

    卸载该设备只需右键点击,然后 弹出(J)

同样在挂载VHD后,可以向其中新建一些文件

可以下载我的FAT文件:https://wws.lanzous.com/i6FV7l4z7fc


Ext2

Ext2文件系统的结构图为:

image-20210202221246850

其中我们首先着重学习Ext2文件系统对磁盘空间划分的第一块扇区,它被称为主引导记录


MBR

Ext2文件系统中,扇区的大小是512 Bytes,而它的首个扇区(位于CHS = 0/0/1的位置,也就是LEA = 0)被称为主引导记录(Master Boot Record,MBR),也被称之为主引导扇区

image-20210129150014896

主引导记录的512 Bytes中,可以划分为三部分:

  • 446 Bytes的「主引导程序(Boot Loader)

  • 46 Bytes的「硬盘分区表(Disk Partition Table,DPT)

    似乎应该译作“磁盘分区表”,但鉴于软盘的消亡、中文互联网上普遍使用「硬盘分区表」这一术语,因此这里的Disk就表示硬盘(Hard Disk)

  • 2 Bytes的Magic Number 55 AA

由于前面创建的 Ext2 文件MBR处为全 00,因此这里使用 FAT.vhd 文件的数据来进行解析;两者在MBR处的概念通用

image-20210131194115131

DPT

硬盘分区表(Disk Partition Table)主要是提供分区功能,所谓「分区」就是我们在Windows中常见的盘符:

image-20210131112626769

在64 Bytes的DPT中,分为四项,每项的16 Bytes都表示一个分区;也因为64 Bytes总大小的限制,最多只能有4个分区

基于此,我们将这4个分区命名为「主分区」,当想要分区的数量 ≥ 5时,就将其中的一个主分区设置为「扩展分区」,在扩展分区中继续划分「逻辑分区」,以实现分区数量的突破(逻辑分区的数量没有限制)

因此在硬盘中,最多有四个主分区,或者三个主分区、一个扩展分区;一般情况下都采用后者,在扩展分区中再按需划分

DPT中每一项(16 Bytes)的定义如下:

image-20210131115821451

我们以上面的截图中,DPT的第一项为例:

image-20210131203255402

注意这里的数据全都是按照小端序存储

  • 红色下划线为 00,表示非活动分区

  • 黑色下划线为 02 03 00,表示C = 0、S = 0b000011 = 3、H = 0x02 = 2,所以第一个分区数据的起始位置为 CHS = 0/2/3

  • 紫色下划线为 0E,这个在上面的表中没有记录,其实表示的是exFAT16

  • 白色下划线为 A4 24 00,表示C = 0、S = 0b100100 = 36、H = 0xA4 = 164,所以第一个分区数据结束的位置为 CHS = 0/164/36

  • 橙色下划线为 80 00 00 00,小端序表示的数值为 0x80 = 128,即LBA = 128

  • 棕色下划线为 00 78 01 00,小端序表示的数值为 0x2800 = 10240

    也就是说,从 CHS = 0/2/3 到 CHS = 20/254/39 共有10240个扇区,由于默认一个扇区大小为0.5 KB,因此可以计算得该分区共占用 10240 / 2 / 1024 = 5 MB,恰好就是我们前面为该FAT创建初始卷时分配的空间大小

剩余3项DPT中的记录也可以像这样分析

在4项DPT记录中,活跃分区只能被唯一设置,它涉及到「多系统」这个概念

我们的电脑不仅可以设置一个操作系统,如果性能允许,有时会安装双系统;双系统的安装需要选择一个分区,安装对应的操作系统,那么在电脑启动的时候,电脑会直接根据哪个分区为活跃分区,直接启动该操作系统

留意到,后面3项分区记录都为全 00,表示非分区,唯一的第一项记录才有有意义的数据;并且DPT中4个分区记录都是非活跃的,这与VHD本身的属性有关,VHD无需开机引导


55 AA

由于小端序的原因,这里的数值其实是0xAA55

这是一个标志MBR的Magic Number,在读取硬盘的MBR时,首先检查该位置的2 Bytes,如果不为 55 AA,那么则舍弃该MBR(具体作用结合下面的讲解)


Boot Loader

首先明确概念

「MBR」其实一直有两种说法:

  • 广义MBR」表示CHS = 0/0/1的整个扇区(512 Bytes),其中包含Boot Loader、DPT和 55 AA

    我们这篇文章就采用这个概念

  • 狭义MBR」表示CHS = 0/0/1扇区中的主引导程序(446 Bytes),也就是广义MBR中的 Boot Loader

    在狭义MBR中没有Boot Loader这个概念,反而是使用了Boot Sector表示硬盘的第一个扇区

也就是说:广义MBR = 狭义Boot Sector、广义Boot Loader = 狭义MBR

为了区分清楚,这篇文章就是按「MBR中包含Boot Loader、DPT和 55 AA」来进行演示的

Boot Loader是一段程序,众所周知程序由许多指令组成,因而它通常是不可读的,要想直接分析Boot Loader,就需要了解各个指令的功能

而要了解Boot Loader的作用,就涉及到「电脑是如何启动」这个很少有人主动去了解的话题


How Our Computer Start

硬件的运行需要软件的配合,没有执行软件的硬件是没有用的

除了会电人之外... —— 鸟哥

因而诞生了操作系统,操作系统会控制所有的硬件并提供核心功能,进一步读取硬盘中的软件数据、执行该软件等

问题是,操作系统本身就是一个软件,它被执行之前也需要被调用

在主机板上存在固件「BIOS(Basic Input/Output System)」,电脑在启动时,第一个执行的程序就是BIOS!

所谓「固件」,就是写入到硬件上的软件程序

参考 按下开机键后的4.98秒 可知,计算机在刚开机时只有1 MB的内存可用,内存地址从0x0000 到 0xFFFFF,其中BIOS程序的入口地址是0xFFFF0(固定),在开机的一瞬间,CPU会将段基址寄存器CS初始化为0xF000、偏移地址寄存器IP初始化为0xFFF0,计算得到物理地址0xFFFF0

也有一种说法是CS初始化为0xFFFF、IP初始化为0x0000,这可能是由于硬件不同导致的初始化不同,但最终形成的入口地址就是0xFFFF0

开机瞬间计算得到物理地址0xFFFF0,CPU就从这个地址开始,加载指令运行

而前面有提,计算机在刚开始时只有1 MB的内存可用,最大地址为0xFFFFF,那么CPU跳转到0xFFFF0处后,只有少的可怜的空间能够存放指令

事实上,0xFFFF0 - 0xFFFFF这16 Bytes的空间的确只有1条跳转指令:

1
jmp far f000:e05b

该指令的作用是跳转到物理地址为0xFE05B处开始执行

0xFE05B处也被预先设置了许多指令,运行这些指令,会进行检测外设信息、初始化硬件、建立中断向量表并填写中断例程等工作,这段程序是写死的,暂时无需理解(深入了解可以参考:https://blog.csdn.net/weixin_43971252/article/details/89575297);完成上面的工作后,BIOS最后一项需要完成的是:加载启动区

在这篇文章中介绍到的一个启动区就是MBR

电脑启动是可以设置的,通常有U盘启动、硬盘启动、软盘启动、光盘启动等,一般情况下,我们都是通过硬盘启动的,BIOS的最后一步「加载启动区」其实就是将硬盘 CHS = 0/0/1 处的MBR共512 Bytes完整复制到内存0x07C00这个位置

BIOS会按照既定的顺序去依次检查各个启动项,这时就凸显MBR末尾2 Bytes的Magic Number的关键性了,如果BIOS检测载入0x07C00处的最后两字节不是 55 AA(也就是检查0x07DFE和0x07DFF两个位置),那么就认为这不是一个启动区,会载入下一个启动项

下一个启动项可能源自软盘、U盘或光盘,BIOS检查的顺序是可以人为设置的,并不一定首先是硬盘;但当检查完所有的启动项,均未找到满足条件的启动区,那么电脑就无法启动

载入扇区的最后两字节是 55 AA,那么就称该扇区为「引导扇区」,而这个硬盘就是一个「可引导盘」

假设MBR数据完整无误,BIOS在最后将其512 Bytes加载到内存0x07C00后,同时通过设置PC跳转到0x07C00这个地址,从这里开始运行

至此,BIOS任务完成,控制权从BIOS转交到MBR

许多教程中的“BIOS把控制权转交给排在第一位的存储设备”其实就是「BIOS把启动区的512 Bytes复制到内存0x07C00位置,并用一条跳转指令设置PC寄存器,跳转到0x07C00


Why 0x07C00

前面有提,MBR中的Boot Loader其实是一段固定长度为446 Bytes的代码,当BIOS将MBR加载进内存后,执行的就是Boot Loader!

Boot Loader的作用可以概况为:访问分区表并定位操作系统的位置

Boot Loader与操作系统挂钩,现如今,操作系统的大小至少是以MB为单位的,因此Boot Loader本身肯定不存储操作系统的数据,它也如同BIOS最后加载MBR一样,最后会将操作系统的代码加载到内存,最终完成控制权的转交!当操作系统得以运行,后面的一切都可以交由操作系统来分配了

现在可以给出Boot Loader的定义了:

「Boot Loader是在操作系统内核运行之前运行的一段小程序。通过这段小程序,我们可以初始化硬件设备、建立内存空间的映射图,从而将系统的软硬件环境带到一个合适的状态,以便为最后调用操作系统内核准备好正确的环境」

Boot Loader高度依赖于硬件实现,不同的硬件有不同的Boot Loader

运行Boot Loader的过程中,它会在MBR的DPT中寻找活动分区,当找到活动分区后,就将控制权转交给该分区内部的引导程序,引导程序负责操作系统的启动


为什么BIOS会选择将MBR加载到内存0x07C00的位置?

很简单,可以简单理解为一种约定俗成,所有的启动区代码都是被加载到这个位置的,有了0x07C00的偏移量,其中的代码才能正确运行

参考《30天自制操作系统》一书中第29页的简易启动区代码 helloos.nas

1
2
3
4
5
6
7
8
9
; hello-os
; TAB=4

ORG 0x7c00 ; 指明程序的装载地址

JMP entry
DB 0x90

; ---(略)---

其中通过 ORG 指令规定程序的装载地址,事实上所有的启动区代码都有这一行代码,以确保都被BIOS加载到0x07C00的位置


Summary

综上,我们可以给出概述:

  1. 电脑开机瞬间,CPU计算出物理地址0xFFFF0,并将PC寄存器初始化为这个值,跳转到BIOS程序的入口地址 —— 一跳
  2. 该入口地址是一个跳转指令,跳转到0xFE05B的位置,跳转后继续执行 —— 二跳
  3. 在0xFE05B执行了一些硬件检测的工作后,最后将启动区的内容加载到0x07C00位置,并跳转到这里 —— 三跳,控制权从BIOS转交到MBR
  4. 执行0x07C00处的启动区代码(MBR),主要工作是加载操作系统内核,最后也跳转到加载处 —— 四跳,控制权从MBR转交到操作系统

当控制权交给操作系统,就是分段、分页、建立中断、设备驱动、内存管理、进程管理、文件系统、用户态接口等内容了

最后返过来,对MBR给出概述:

image-20210129150014896

MBR被加载到0x07C00后开始运行Boot Loader,它会从DPT中查找活跃分区,根据活跃分区中记录的起始/结束CHS,从硬盘中加载操作系统内核;如果MBR最后两字节是 55 AA,则认为是正确的启动区,跳转到操作系统内核的代码处,继续执行

最后说一句,Boot Loader又被叫作「Master Boot Code」,以“Code”来凸显它的本质;但是“Loader”本身也有引导程序的意思,因此两者都可以使用

反正我看见大多使用的是Boot Loader,在这里提一句是真的觉得,...,这些概念能不能统一一个名字


Partition

回到这张图:

image-20210202221246850

MBR部分已经基本了解了,而其中的DPT记录着各个分区的数据,Boot Loader会从这些分区记录中找到唯一活跃的,然后根据其中的数据(例如起始CHS、结束CHS等),定位到硬盘中

接下来我们就来解析下DPT中的记录指向的数据到底是什么

在上图中,只画了DPT对两个分区的指向,事实上有四个,但剩下两个分区是一样的,暂时节略不画;每个分区最前方包含一段「引导扇区(Boot Sector)」,然后由于单个分区的容量很大,因此将剩余分区进一步划分为同样大小的「块组(Block Group)」,不同分区包含的块组数可能不同


Boot Sector

Boot Sector中文名为「启动扇区」,它被放置在每个分区的最前面,占用1个扇区的大小(也就是0.5 KB)

对于Boot Sector的理解,可以直接参考MBR!事实上,MBR就是特殊的Boot Sector

从体积上看,MBR也是占用0.5 KB的空间;从功能上看,MBR是为了加载操作系统内核的,而当操作系统内核被加载,才算是真正的“开机”

Boot Sector可以安装启动管理程序,这个设计是为多系统而实现的!

前面有提,分区(Partition)在日常生活中最常见的表示就是盘符:

image-20210131112626769

而安装多系统其实就是选择一个盘符,将该系统的内核代码放入其中;为此,当需要安装多系统的时候,操作系统的引导代码会被置于盘符的Boot Sector中,用于引导操作系统内核代码

MBR与Boot Sector最大的不同是,MBR是唯一的、不可或缺的,它是开机过程中继BIOS之后必然会运行到的区域,而Boot Sector由于有多个,其所处的分区未必是活跃的,因此未必能运行到

在单系统时,假设我们安装的是Windows系统,那么唯一的活跃分区(通常是C盘)中的Boot Sector存储的代码我们不用关心(大多情况下,它与MBR相同),MBR中的Boot Loader会加载对应的操作系统内核;但是如果这时我们安装了Linux双系统,并且假设将其引导代码安装在D盘Boot Sector中,那么在开机时,用户会拥有两种选择:

  1. 直接载入Windows的内核代码来开机

  2. 将开机的管理工作交给D盘的Boot Sector

    随后D盘的Boot Sector会载入Linux的内核代码来开机

可以看到,装载有Windows主系统的C盘的Boot Sector始终不会被访问,因为Windows是主系统,其引导代码存放在MBR的Boot Loader中;而在双系统时,Boot Loader的引导代码不符合要求,就将控制权转交给D盘的Boot Sector,再引导Linux操作系统内核

可以说,分区的Boot Sector只有在多系统时才发挥作用

image-20210131112626769

上面的图片选自《鸟哥的Linux私房菜》,可以看到Windows分区的Boot Sector在开机过程被被“冷落”了

此外,安装多系统时,最好先安装Windows再安装Linux,这是因为:

  • Linux在安装时,可以让用户选择将开机引导程序安装在MBR还是各个分区的Boot Sector,并且能够自定义开机菜单(就是上图中的M1、M2)
  • Windows在安装时,会直接把自己的开机引导程序覆盖在MBR以及对应的分区Boot Sector,并且不提供自定义开机菜单的功能

因此如果后安装Windows,原本在MBR中的Linux开机引导程序会被覆盖掉,导致在开机菜单上无法找到Linux的选项;需要通过其它方式来挽救Linux

值得一提的是,在一些地方也把这里的Boot Sector称之为“Boot Block”,例如 https://en.wiktionary.org/wiki/boot_block 中就将Boot Sector定义为Boot Block的同义词(Synonyms)

但是前面在介绍文件的时候有提,硬盘最小的存储单位是「扇区」,而文件存取的最小单位是「块」,一个块是多个扇区的组合,块的大小可能是1 KB、2 KB等,而这里的Boot Sector固定是0.5 KB,用「块」的说法有点不合适

为了不混淆,我们这里强制使用Boot Sector的概念,不引入Boot Block


Block Group

image-20210202221346670

对于一个分区而言,抛去最前面0.5 KB的Boot Sector,剩余的部分被划分成相同大小的「块组(Block Group)」,块组的大小由分区的大小决定,而在块组内部,又根据功能划分出了6个部分

上图标识了每个组成部分占用的空间,其中K、M、N在不同文件系统中的取值都不同;上图重点说明一点:并非所有块组都有Super Block、Group Description和Reverse GDT


Super Block

Super Block(超级块)位于块组的最前面,它负责记录整个分区的文件系统信息,例如Inode/Block的大小、使用量、剩余量、文件系统类型等

当Super Block的数据遭到破坏,将导致整个分区数据的解析错误;基于Super Block的重要性,Ext2在设计的时候选择将Super Block备份,它以同样的内容存放在多个块组之中,这些Super Block区域的数据保持一致性

我们使用 dumpe2fs 命令可以查看文件系统的参数,这些参数都是基于Super Block的:

image-20210202094702812

我们可以罗列出一些重要的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Filesystem OS type:       Linux
Inode count: 1280
Block count: 5120
Reserved block count: 256
Free blocks: 4921
Free inodes: 1268
First block: 1
Block size: 1024 # 每个块的大小为1 KB,即1 Block = 2 Sectors
Fragment size: 1024
Reserved GDT blocks: 19 # Reverse GDT占用19 Blocks
Blocks per group: 8192 # 每个块组的体积为8192 Blocks
Fragments per group: 8192
Inodes per group: 1280 # 每个块组包含1280个Inodes
Inode blocks per group: 160
......
First inode: 11
Inode size: 128 # 每个Inode的体积为128 Bytse

Group 0: (Blocks 1-5119)
Primary superblock at 1, Group descriptors at 2-2
Reserved GDT blocks at 3-21
Block bitmap at 22 (+21)
Inode bitmap at 23 (+22)
Inode table at 24-183 (+23)
4921 free blocks, 1268 free inodes, 2 directories
Free blocks: 198-540, 542-5119
Free inodes: 12-17, 19-1280

这些重要信息都是从Super Block中获取的

Super Block会存放在哪些块组中?

以我们前面通过 mke2fs 命令创建的 Ext2 文件为例,重新将 Ext2 映射到 /dev/loop0 上,在使用 mke2fs 命令时添加 -n 参数:

image-20210202094050255

有了该参数,mke2fs 并不会真正在设备上创建文件系统,而是模拟整个过程;在上图中也提到,该参数最大的作用是知道特定文件系统中Super Block备份的位置

但是我们创建的 Ext2 只有5 MB,很小,它甚至并没有对Super Block进行备份;参考 http://blog.chinaunix.net/uid-24774106-id-3266816.html 其中就有记录:

1
2
Superblock backups stored on blocks:
8193, 24577, 40961, 57345, 73729, 204801, 221185, 401409

由于一个块组的大小是8192块,因此拥有Super Block的块组0、1、3、5、7、9、25、27、49


我们尝试从之前创建的 Ext2 文件中解析Super Block的数据,首先获取Super Block所占用的1 Block中,各个字节的含义;在 https://elixir.bootlin.com/linux/v4.11.6/source/fs/ext2/ext2.h#L419 可以获得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ext2_super_block {
__le32 s_inodes_count; /* Inodes count */
__le32 s_blocks_count; /* Blocks count */
__le32 s_r_blocks_count; /* Reserved blocks count */
__le32 s_free_blocks_count; /* Free blocks count */
__le32 s_free_inodes_count; /* Free inodes count */
__le32 s_first_data_block; /* First Data Block */
__le32 s_log_block_size; /* Block size */
__le32 s_log_frag_size; /* Fragment size */
__le32 s_blocks_per_group; /* # Blocks per group */
__le32 s_frags_per_group; /* # Fragments per group */
__le32 s_inodes_per_group; /* # Inodes per group */
......
}

其中 __le32 的全称是Little End 32 Bits(小端序32 Bits),是为了增加程序通用性而设置的宏定义

由于前面的MBR和分区1的Boot Sector,共占用1024 Bits,也就是0x400,所以我们对 Ext2 文件数据的解析从0x400开始:

image-20210202165011232

例如,最前面的4 Bytes是 s_inodes_count,通过 dumpe2fs 命令可以知道Inode Count = 1280,校验下发现 0x0500 = 1280,匹配上了;

又例如 s_log_block_size 位于偏移量为24的位置,在数据中的值为 00 00 00 00,也就是为0

事实上,这里直接标识 s_log_block_size 的含义为 /* Block size */ 是有点误导人的,在 https://elixir.bootlin.com/linux/v4.11.6/source/fs/ext2/super.c#L922 第922行可以看到:

1
blocksize = BLOCK_SIZE << le32_to_cpu(sbi->s_es->s_log_block_size);

s_log_block_size 只是作为位移量,令 BLOCK_SIZE 向左位移若干位后,得到最终的Block Size;而在 https://elixir.bootlin.com/linux/v4.11.6/source/include/uapi/linux/fs.h#L32 的31、32行又可以看到:

1
2
#define BLOCK_SIZE_BITS 10
#define BLOCK_SIZE (1<<BLOCK_SIZE_BITS)

BLOCK_SIZE 其实就是 $2^{10}$ = 1024

也就是说,Block Size的大小其实就是 (1024 << s_log_block_size),这也表示Block Size的大小只能是 1024 × $2^n$ (n ≥ 0)

综上,结合图中的 s_log_block_size 值为0,可以得到Block Size值为1024;这与 dumpe2fs 的结果是匹配的

可以通过这种方法,依次推导出源数据文件各字段的含义;总的来说,Super Block中记录的关键信息有:

  1. 分区内所有Block和Inode的数量
  2. 未使用和已使用的Inode/Block数量
  3. Block的大小(1、2、4 KB)和Inode的大小(128 Bytes)
  4. 文件系统最近挂载的时间、上次挂载点;最近一次写入数据的时间
  5. 一个Valid Bit,若文件系统已挂载,则Valid Bit为0;否则为1

Group Descriptor

image-20210202221626311

块组描述符中记录着一个块组的信息,例如:

  • 在该块组中,Inode Table从哪里开始、Data Blocks从哪里开始

    这里其实给出了图中 K + M 的值

  • 该块组中空闲的Inode和Data Blocks还有多少个

  • ......

老规矩,我们在 https://elixir.bootlin.com/linux/v4.11.6/source/fs/ext2/ext2.h#L197 中找到Group Descriptor的定义:

1
2
3
4
5
6
7
8
9
10
11
struct ext2_group_desc
{
__le32 bg_block_bitmap; /* Blocks bitmap block */
__le32 bg_inode_bitmap; /* Inodes bitmap block */
__le32 bg_inode_table; /* Inodes table block */
__le16 bg_free_blocks_count; /* Free blocks count */
__le16 bg_free_inodes_count; /* Free inodes count */
__le16 bg_used_dirs_count; /* Directories count */
__le16 bg_pad;
__le32 bg_reserved[3];
};

其中前缀 bg 表示块组(Block Group),同时留意到,每个Group Descriptor都是固定的32 Bytes大小

目前我们的文件 Ext2 中的数据情况是:0.5 KB的MBR + 0.5 KB的分区1 Boot Sector + 1 KB的分区1 Super Block,所以分区1 Group Descriptor在文件中的偏移应该是 0x800

image-20210203103706038
  • bg_block_bitmap 值为 0x16 = 22,表明Block Bitmap位于第22个块

    由于每个块的大小为1 KB,所以Block Bitmap的位置偏移就是 1024 × 22 = 22528 = 0x5800;我们在 0x5800 的位置找到了Block Bitmap:

    image-20210203104358143 image-20210203104857597

    上面由于截图的原因,其实从第5A80h行到5BF0h行全是 FF

    通过数数可以知道,目前已使用的Block共有197 + 1 + 1 + 384 × 8 = 3271个,而整个Block Bitmap最多记录1024 × 8 = 8192个块,也就是说剩余 8192 - 3271 = 4921 个空闲块可以使用

    对比 dumpe2fs 的结果可知分析正确:

    image-20210203105326012

    并且在前面的数据图中,局部偏移量为12的 bg_free_blocks_count 数值为 39 13,0x1339 = 4921,校验正确

    值得一提的是,我们创建的 Ext2 文件只有5 MB,这其实是过小了(创建的时候没考虑周全,导致的失误),导致整个 Ext2 文件只有 5120个块(如上图所示,Block count),但是分组1的Block Bitmap却能记录8192个块,其中多出来的3072个块是无论如何也使用不了的,为此Block Bitmap始终将这3072个块映射为「已使用的 1」,也就是前面说的第5A70h行的 80 和第5A80h - 5BF0h行的 FF

    此外,同样可以看到 dumpd2fs 对块组0的描述:

    image-20210203111012222

    最前面的MBR和Boot Sector共占用了1 Block,而根据Block size的大小,分区1的块组0应该占用8192个块的大小,可惜由于 Ext2 本身只有5120个块的大小,所以在上面截图的第一行,显示将除了首个Block外的其它所有Block都分配给块组0,块组0的范围在Block 1 - Block 5119

    也就是说 Ext2 文件只有1个分区,该分区只有1个块组

    而在上图的倒数第三、二行,给出了空闲块的数量,以及空闲块的位置(198 - 540、542 - 5119)

  • 基于同样的方法,可以分析位于第 0x17 = 23 个块的Inode Bitmap,这里略过


在介绍后面的Inode Table和Data Block之前,我们再回顾一下Group Descriptor

Group Descriptor是为块组服务的,每个的大小都是32 Bytes,记录着当前块组的信息

最后来探讨一下Group Descriptor的数量问题

参考自 http://blog.chinaunix.net/uid-24774106-id-3266816.html

许多教程都强调“块组描述符在每个块组的开头都有一份拷贝”,这是错误的,从前面的示意图就可以知道,Group Descriptor就像Super Block一样,并非在每个块组中都存在

image-20210202221346670

同样的,我们知道每个Group Descriptor只占用32 Bytes,但上图中的Group Descriptor都是以Block为单位的(也就是 n × 1024 Bytes)

这是因为在一个块组中,存储着所有块组的Group Descriptor,每个块组的Group Descriptor都占用32 Bytes,整体上构成一个Group Descriptor Table(GDT)

由于我们创建的 Ext2 太小,只包含一个块组,所以不能看出这点

假设文件系统中一共有 n 个块组,那么如果某个块组包含GDT,GDT中实际有用的数据其实只有 n × 1024 Bytes;而在另一个包含GDT的块组中,其GDT中的数据是一模一样的

所以Group Descriptor Table和Super Block一样,都是冗余的,通过冗余来确保数据安全


Reverse GDT

image-20210202221626311

前面可以知道,在GDT中,块组对应的Group Descriptor记录着Block Bitmap、Inode Bitmap和Inode Table的位置,这相当于间接告诉了我们上图中 K + M 的值

事实上 K + M 应该视为一个整体,文件系统在创建时就为GDT预留了 K + M 个Block,然后根据块组的数量,使用了其中的 n × 1024 Bytes(可能不足一个Block),剩余未使用到的区域就都用 00 来填充,这就是所谓的「保留(Reverse) GDT」区域

在一些教程的示意图中,有时会将GDT和Reverse GDT视为一个整体,统称为GDT,这个也是可行的


Inode Table

回到这张图:

image-20210203103706038

bg_inode_table 的位置是第0x18 = 24个块,也就是 24 × 1024 = 24576 = 0x6000,于是可以在数据中定位Inode Table的开始位置

那结束位置呢?

回到我们的Super Block,其实在前面已经计算过每个块组中Inode的数量了:

image-20210203165841344

Super Block最前面的4 Bytes就是 s_inodes_count,我们计算得1280,也就是说每个块组中有1280个Inode

后期纠正

事实上Super Block中 s_inodes_count 记录的是的Inode数量,而偏移量为40的 s_inodes_per_group 才是我们真正要探讨的

image-20210204002852084

只是由于我们创建的 Ext2 文件太小,使得只有唯一的一个块组,所以这里有 s_inodes_count = s_inodes_per_group,我们现在主要是针对一个块组来进行的讨论,所以后面发现概念有误后,特定回来纠正

但我们还缺少每个Inode占用的空间大小

实际上,在 https://elixir.bootlin.com/linux/v4.11.6/source/fs/ext2/ext2.h#L419 中仔细翻阅一下,可以找到偏移量为88 Bytes的 s_inode_size 字段:

1
2
3
4
5
6
struct ext2_super_block {
__le32 s_inodes_count; /* Inodes count */
__le32 s_blocks_count; /* Blocks count */
......
__le16 s_inode_size;
}
image-20210203170350240

Ext2 文件中,s_inode_size 的值为 0x80 = 128,所以每个Inode占用128 Bytes的空间

1280 × 128 = 163840 = 0x28000,所以事实上,下图中的 N 在Super Block中早已给出,163840 / 1024 = 160,即 N = 160:

image-20210202221626311

只是要知道Inode Table的确切位置,还需要从Group Descriptor中获得 K + M 的值,才能定位

这与我们 dumpe2fs 的结果中,Inode blocks per group的值是匹配的:

image-20210203171121789

我们从Group Descriptor中知道Inode Table的起始位置是0x6000,所以 0x6000 + 0x28000 = 0x2E000,Inode Table之后的Data Blocks从0x2E000这个位置开始


Data Blocks

每个Inode实际上对应一个文件,在这里我们不打算对Inode Table和Data Blocks进行详细分析,因为分析方法和前面的都是一样的;我们参考 https://www.cnblogs.com/sduzh/p/7056933.html,来试试定位我们在文件 Ext2 中写入 Data.txt 的内容

在开始寻找前,简单回顾一下:

  • Inode Table存储着块组中所有的Inode,Inode的大小记录在Super Block中,而位置需要结合块组对应的Group Descriptor
  • Inode存储着文件的元数据,例如文件类型、文件大小、创建/访问/修改时间等

开始寻找文件内容:

  1. 挂载 Ext2,查看文件的Inode编号

    image-20210203182311846

    Inode编号为18,注意这里的「编号」是从1开始的,而实际数据中Inode是从0开始编号的,所以会有±1的偏移

  2. 计算块组位置

    前面通过Super Block的 s_inodes_per_group 字段知道每个块组有1280个Inode,显然Inode编号位于 (18 - 1) / 1280 = 0 号块组中

    image-20210204002852084
  3. 定位Inode Table

    首先在Group Descriptor中找到Inode Table的起始位置:

    image-20210203103706038

    这个我们之前计算过,就是第 0x18 = 24 个块,所以Inode Table的起始位置为 24 × 1024 = 0x6000

  4. 定位Inode

    通过第2步知道我们要找的文件位于块组0,而 (18 - 1) % 1280 = 17,我们要找的文件在块组0的Inode Table的第17个Inode

    我们知道每个Group Descriptor都是32 Bytes,而每个Inode则是128 Bytes:

    image-20210203170350240

    所以第17个Inode的位置是 0x6000 + 17 × 128 = 0x6880

  5. 解析Inode

    基于 https://elixir.bootlin.com/linux/v4.11.6/source/fs/ext2/ext2.h#L300 结构体 ext2_inode 的解析,我们得出 0x6880 处数据的情况:

    image-20210204100221828

    注意 0x6880 处已经是我们要找的 Data.txt 实际对应的Inode了,因此这里存储的都是我们的文件的元数据

    其中 i_size 的值为 0x21 = 33,表示我们 Data.txt 的文件大小是33 Bytes;而 i_block 的值为 0x021D = 541,表示文件的实际数据位于第541个块,也就是说地址为 541 × 1024 = 0x87400

    跳转到 0x87400 处,发现:

    image-20210204100746818

    由于已知文件大小为33 Bytes,所以 Data.txt 文件的内容就是:

    1
    It's created by testing Ext2 FS.\x0A

    其中 \x0A 是Unix/Linux下的换行符;可知分析结果是正确的:

    image-20210204101009392