跳转至

Linux文件系统

约 5782 个字 36 行代码 17 张图片 预计阅读时间 20 分钟

机械硬盘介绍

机械硬盘基本外观结构

Note

上面的部分英文名称需要在下面介绍存储结构中使用到,可以进行对照

机械硬盘的存储结构由多个磁盘盘片(Platter)组成,每一个盘片都有两个面(surface),每个面都会被涂上一些磁性记录材料,多个盘片全部挂在一个主轴上,这个主轴会带动所有的盘片以每分钟5400到15000转的速度(转速RPM,Revolutions Per Minute)旋转。为了保证数据保存在磁盘上不会损坏,一般情况下,上面的结构外层会有一层保护壳

Info

点击链接可以观看机械磁盘运行时的状态

机械硬盘的存储结构

将每一个盘片单独拿出来看,可以看到下面的抽象图:

在上面的图中,Surface代表盘片的一面,Tracks代表磁道(每一圈圆形的轮廓),将磁道再继续放大,可以看到磁道的圆形轮廓并不是由连续的线条组成,每一个组成圆形轮廓的线条中间会存在一定的间隙(Gaps),而这些线条组成的一个范围也被称为扇区(Sector),扇区是磁盘存储数据的基本单位,一般大小为512字节

Note

目前将每一个磁道的扇区的容量视为相同

扇区的示意图如下:

将每一个盘片设计正反两面,再串在主轴上,就形成了下面的抽象图:

此时当所有盘片拥有相同半径的磁道时,这些面所组成的圆柱体也被称为柱面(Cylinder),柱面容量是所有盘面相同半径的磁道容量总和。磁盘的柱面数量与一个盘面上的磁道数量是相等的,但每个柱面容量大小为=每一个磁道容量*盘面数量

Note

注意区分柱面数量和柱面容量

为了在每一个盘片的每一面读和写,磁头分为上下两面,每一个盘片都会对应一个磁头,当盘片在高速旋转时,磁头在盘片上横向滑动

一个盘片运动时,磁头运动示意图如下:

多个盘片一起时,示意图如下:

为了构成一个柱面(Cylinder),所有的磁头都是共进退的

有了上面的图像介绍,现在就可以理解下面的磁盘容量计算公式:

\(磁盘容量 = 单个扇区的大小 \times 单个磁道上的扇区数量 \times 盘片磁道的数量(等于柱面数)\times 盘片的面数(等于磁头数)\)

例如下面的例题:

假设有一个机械硬盘有5个盘片,每一个盘片的盘面有20000的磁道,每一个磁道有平均300的扇区,每一个扇区的大小为512字节,则该机械硬盘的容量\(capacity\)为:

\(capacity = 300 \times 512 \times 20000 \times 5 \times 2 = 30720000000bytes = 30.72GB\)

机械硬盘的数据查找方式

前面提到机械硬盘的物理结构,那么机械硬盘的磁头是如何准确定位哪一个数据是要查找的数据就是接下来要考虑的问题

实际上,在物理结构上,机械硬盘采用CHS寻址方法

CHS寻址步骤如下:

  1. 找到数据所在的柱面(Cylinder)
  2. 定位第几个磁头(确定哪一个盘片)(Head)
  3. 定位第几个扇区(Sector)

但是,如果将这个方式直接让操作系统去处理就会比较困难,所以为了简化操作系统层面的操作,就有了机械硬盘数据的逻辑查找方式,即通过机械硬盘的逻辑结构(LBA结构,Logical Block Address,逻辑块地址)进行查找

每一个扇区处于一个磁道,每一个磁道处于一个盘面,每一个盘面处于一个盘片,如果将这些进行分组就可以得到下面的图:

图中的结构就是机械硬盘的LBA结构,此时变成了一个数组,当需要访问某一个扇区就可以通过数组下标的方式(地址)去访问

Note

第一个扇区的实际编号一般从1开始

例如访问磁盘2的第二个扇区,就可以表示为surface[6],表示第一个盘片的第二个磁道的第二个扇区

如果将上面的结构扩充到柱面,就有下面的结构:

如果将上图推广到所有的柱面,则有下图所示的示意图:

此时访问单个扇区就可以按照suface[i][j][k]的方式访问,其中i代表柱面,j代表盘片,j代表扇区

通过上面的形式,操作系统就可以通过像访问数组的方式访问磁盘的每一个扇区,接下来的问题就是如何实现CHS和LBA之间的互相转化,而这个转化过程就是有磁盘本身来进行,一般是磁盘的伺服系统完成

CHS和LBA之间的转化

CHS转LBA

LBA本质就是CHS的逻辑形式,那么CHS转换为LBA就通过抽象方式转化即可

首先计算扇区总数,根据前面计算硬盘容量的公式可以看出:

\(所有盘面(单个柱面)的扇区数 = 单个磁道的扇区数 \times 磁头数\)

所以有下面的等式:

\(某一个扇区的LBA地址 = 柱面号(C,Cylinder)\times 指定盘面前的所有盘面扇区个数 + 磁头号(确定盘面H,Head)\times 指定磁道之前的每个磁道扇区个数 + 扇区号(S,Sector)- 1\)

在CHS中,一般扇区号是从1开始的,但是LBA中,地址是从0开始的,柱面和磁道都是从0开始编号,所以需要进行减1

Note

为了更好得理解上面的公式,可以一个生活例子展开:

现在一个军营需要站队,但是需要将所有营的士兵排在一条线上,而不是分营排序,在这个条件下考虑下面的问题:

题目:假设原本有2个营,每个营对应有3排,每一排对应的5个士兵,正常情况下,一个营的士兵个数就是\(3 \times 5 = 15\),一共2个营,所以总共营的士兵个数为30

问题:找到第2个营第2排的第2个士兵在直线排列中的编号(确定一个士兵的位置编号,假设第一个士兵的编号为1,第一个营的编号都为0)

计算方式为\(1 \times 15 + 1 \times 5 + 2(第2排第2个士兵在第二排中的编号) - 1 = 21\)

示意图如下:

在上面的例子中,柱面号就是营号,单个柱面的扇区个数就是每一个营的士兵个数,磁头号就是排数,每个磁道的扇区个数就是一排的人数,扇区号就是该士兵所在的一排的编号

LBA转CHS

\(柱面号(C,Cylinder) = LBA地址 \text{mod} (磁头数 \times 每个磁道的扇区数) (即单个柱面的扇区个数)\)

\(磁头号(H,Head) = (LBA \text{mod} 单个柱面的扇区个数) \text{mod} 每个磁道的扇区个数\)

\(扇区号(S,Sector) = (LBA \text{mod} 每磁道的扇区数) + 1\)

Note

为了方便理解上面的公式,同样以上面的例子进行展开,只不过这次是逆过程:

假设现在需要找到第21个士兵在分营排序时的位置,先计算他在哪一个营,因为前面提到一个营一共15个士兵,所以$15 \times 1 \lt 21 \lt 15 \times 2 $(本步相当于21 / 15 = 1,即求出柱面号),所以该士兵在第2个营

第2个营的第一个兵在直线上编号为16,所以可以得出,第21个士兵前面有\(21 - 16 = 5\)个士兵(本步相当于\(21 \text{mod} 15 = 6\),即求出磁头号)

最后\(21 \text{mod} 5 = 1 + 1 = 2\)即可算出该士兵是在第2个军营第2排第2个位置

分区

操作系统和磁盘进行输入输出操作时,以扇区为基本单位,一般每一个扇区为512字节的大小,但是如果每一次都是访问一个扇区,那么访问的次数就会特别多,所以为了尽可能减少访问次数,操作系统每一次访问磁盘会访问4KB的数据(8个扇区),而这4KB的数据也被称为数据块,所以操作系统每一次访问磁盘访问的是8个LBA地址

在有了块号之后,LBA地址就可以进一步简化为「块号+[1, 8]」来指定某一个扇区

访问数据的方式已经确定了,现在操作系统就需要对访问到的数据进行管理,前面提到文件=内容+属性,操作系统为了管理文件,就需要文件的信息,但是磁盘中的文件量远远大于内存中的文件量,所以操作系统采用了分区的思想,例如磁盘大小为500GB,每100GB的数据为一个分区,那么一共分为5个分区,此时操作系统只需要确保自己有一套方法可以管理好每一个100GB的分区即可

但是100GB的区域还是太大,进而可以考虑将100GB的数据进一步划分为每一个大小10GB的组,每一个分区为10个组,每一个组就有自己的内容区和属性区,以及相关的文件标记,示意图如下:

在Linux中,如果想查看当前系统中存在的分区,可以使用下面的命令查看:

Bash
1
2
ls /dev/vda
# 在虚拟机下是sda

文件系统简单介绍

文件系统是操作系统用来管理和组织存储设备上的数据的一种方式。它定义了如何将数据结构化地存储在磁盘或其他介质上,以及如何管理这些数据的访问和维护。

前面提到操作系统可以对磁盘上的整个空间进行分区。分区后,每一个区可以有不同的文件系统,不同的分区彼此相互独立

Note

下面的讨论主要以ext2文件系统为例

操作系统为了保证每一个磁盘文件都有迹可循,在文件系统中存在一个inode结构,一般大小为128字节,这个结构主要保存文件的属性信息,而对应的文件会在其结构中存在一个成员可以访问其inode信息

下面是inode结构中一般会有的属性:

  1. inode节点编号(该编号对于不同的文件不会重复)
  2. 权限信息
  3. 文件所属主
  4. 文件三大时间(访问、改变和修改)
  5. 数据区指针(一共三级指针)

Note

需要注意,inode结构中不会保存文件的文件名

在Linux中,如果想查看文件的inode结构体中的编号,可以使用下面的命令:

Bash
1
ls -i

有了分组后,操作系统通过管理好一个组,再将管理该组的方式移植到其他的组即可,下面主要考虑一个组中的字段:

  1. inode Table:为了管理每一个inode结构,就需要一张表进行管理,即inode Table,这个表中存储的就是每一个文件的inode
  2. inode Bitmap:前面提到操作系统访问磁盘一次是按照4kb的数据块进行访问,所以假设4kb中全是inode table中的数据,那么就有\(4kb \div 128B = 4 \times 1024 \div 128 = 32\)inode结构,操作系统为了更快知道inode Table指定的位置是否被使用就需要使用位图快速确定,这也就是inode Bitmap的作用
  3. Data Blocks:有属性区还不够,文件内容需要存储到一个指定的区域,而在一个组中,这个区域就是Data Blocks,在Data Blocks中就是一个一个4kb大小的数据块
  4. Block Bitmap:同样,操作系统为了知道Data Blocks中指定的位置是否被占用就需要使用到位图,所以对应Data Blocks的位图就是Block Bitmap
  5. Group Descriptor Table:组描述符表,这个表也是一个结构体,其中存在当前组的信息,比如前面提到的每一个字段的起始值。之所以可以确定起始值是因为组内每一个字段以及大小都是固定的

    Group Descriptor Table对应的部分源码如下:

    C
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    struct ext2_group_desc
    {
        __u32    bg_block_bitmap;        /* Blocks bitmap block */
        __u32    bg_inode_bitmap;        /* Inodes bitmap block */
        __u32    bg_inode_table;        /* Inodes table block */
        __u16    bg_free_blocks_count;    /* Free blocks count */
        __u16    bg_free_inodes_count;    /* Free inodes count */
        __u16    bg_used_dirs_count;    /* Directories count */
        // ... 
    };
    
  6. Super Block:一般保存着当前分区的信息,例如有当前分区有多少个组,但是这个Super Block并不是只有第一个组才有,但是第一个组必须有,在一个分区中也不是只存在一个,而是多个随机分散在当前分区的其他组,每一个Super Block的内容完全相同。之所以要这样做是因为如果有一个Super Block出现问题,就可以将其他块中的数据拷贝到出现问题的Super Block中防止当前分区无效

    Super Block部分源码如下:

    C
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    struct ext2_super_block {
        __u32    s_inodes_count;        /* Inodes count */
        __u32    s_blocks_count;        /* Blocks count */
        // ...
        __u32    s_free_blocks_count;    /* Free blocks count */
        __u32    s_free_inodes_count;    /* Free inodes count */
        __u32    s_first_data_block;    /* First Data Block */
        // ...
        __u32    s_blocks_per_group;    /* # Blocks per group */
        // ...
        __u32    s_inodes_per_group;    /* # Inodes per group */
        // ...
    
        __u32    s_first_ino;         /* First non-reserved inode */
        __u16   s_inode_size;         /* size of inode structure */
        // ...
    };
    

从上面的组字段可以看出,在Linux中,文件的属性和内容是分开存储的

格式化

操作系统在建立完分区之后就需要对当前分区进行分组,而这个过程也被称为格式化分区,本质上就是向Group Descriptor TableSuper Block中写入数据,既然要写入数据,就需要先获得到对应的数据,操作系统会根据一定的比率分配inode Tabledata blocks,所以整个分区的大小都是固定的,这就会导致出现inode用完,但是data Blocks没有用完。所以前面说「可以确定起始值是因为组内每一个字段以及大小都是固定的」

Note

需要注意,同时也存在出现inode没用完,但是data Blocks用完的情况

实际上,如果一个分区不进行格式化,那么这个分区就无法被用户使用,除了因为制定的分组信息没有填写外,还存在没有给当前分区建立目录索引的原因。这个原因在接下来的内容中会提及

如何查找文件

前面提到每一个文件有对应的属性,而属性存储在inode结构中,操作系统为了查找一个文件首先就需要对应文件的inode结构的编号成员,接着根据这个编号减去当前分组的inode Table的起始值就可以算出一个偏移量,在inode Bitmap中根据这个偏移量判断指定inode编号是否有效,即判断这个编号在inode中的对应位置是否为1,如果为1,证明指定文件存在。

因为每一个分组的起始值不同,导致inode Tableinode编号的偏移量加上对应的起始值就不会相同,所以就可以解释为什么inode不会相同

接着在该inode结构中根据数据区指针指向的数据块在block Bitmap中判断是否有效,如果有效,就找出对应的文件内容即可

如果现在这个文件非常大,其内容不仅在当前组中存在,也存在于其他组,此时就需要不同组之间的Data Blocks进行连接,所以实际上,Data Blocks在Linux中是可以跨分组的,但是不可以跨分区,因为不同的分区可能使用的文件系统不同

对于其他的「如何删除文件」、「如何修改文件」以及「如何新增文件」都涉及到「如何查找文件」,所以基本思路都是一致的

对于删除文件来说,本质上就是先进行查找文件的操作,而删除的过程就是将对应的inode Bitmapblock Bitmap对应位置置为0,此时就表示指定的文件内容和属性无效,新的文件或者已有的文件可以使用对应的数据块

Note

正是因为有上面的删除机制,所以删除的文件理论上是可以被恢复的,但是恢复的难度很大

对于修改文件,依旧是先查找文件,再修改文件对应的数据块的内容即可

对于新增文件,之所以要先查找,是因为需要防止出现文件冲突,如果没有发生冲突,就需要先在inode Bitmap中申请一块空间(将指定位置记为1),在根据这个位置加上指定inode Table的起始值就可以找到一个位置存储inode节点,再将inode节点中的编号属性赋值即可

文件内容与inode映射

inode结构中除了有编号属性外,还有一个属性,这个属性是存储指向data Blocks中指定数据块的指针,其有四种分类:

  1. 12个直接映射指针,每个指针直接指向每一个存储内容的数据块,一共可以存储\(4kb \times 12 = 48kb\)的数据,对于小文件非常有效
  2. 一级指针,指向一个数据块,一个4kb的数据块中每4个字节存储其他块的索引,此时有\(4 \times 1024 \div 4 \times 4 \times 1024 = 4MB\)个存储空间
  3. 二级指针,指向一个数据块,一个4kb的数据块中每4个字节存储与一级指针指向的数据块相同的数据块,此时有\(4 \times 1024 \div 4 \times 4 = 4096MB = 4GB\)个存储空间
  4. 三级指针,指向一个数据块,一个4kb的数据块中每4个字节存储与二级指针指向的数据块相同的数据块,此时有\(4 \times 1024 \div 4 \times 4 = 4096GB = 4TB\)个存储空间

所以作为用户使用的Linux系统来说,三级指针基本上完全够用户使用

文件与目录

前面提到「如何查找一个文件」是通过获取到的inode编号,但是现在的问题是,系统如何拿到的inode编号。换句话说,在使用Linux命令行操作时,用户输入的文件名或者目录名都是字符串,系统是如何知道指定文件对应的inode编号的

inode结构中不存储着文件名,但是系统能根据文件名获取到其对应的inode编号,主要原因是文件所在路径,为了更好理解下面的内容,先讨论目录文件:

对于底层磁盘硬件来说,不论是目录还是普通的文件,它们都是由二进制组成,而系统要找到某一个目录就需要根据当前目录的所在路径从根路径开始解析(根路径是系统加载时一定会被固定加载的),直到进入到当前目录的数据块,而在目录中的数据块存储着当前目录中所有文件名字符串和其inode编号的映射关系

Note

一般来说,这个映射关系是相互映射的

所以系统获取一个文件的inode编号只需要根据其所在路径按照路径解析进入到其所在目录找到映射关系就可以获取到

但是,如果同一个目录有很多个文件,对每一个文件都进行路径解析,那么系统整体的效率就会受到影响,为了尽可能提升系统效率,在Linux中还存在一个多叉树结构dentry,这个结构的根节点就是根路径,剩下的节点就是按照文件路径中的目录构成的一个一个节点。与进程PCB结构一样,dentry结构也是内存级的结构

有了上面的概念,所以在一个程序中之所以可以使用open打开一个文件本质就是因为进程的CWD给出了当前程序的路径,即根据这个路径找到该进程要打开的文件

至此,系统获取到文件的inode编号的方式就已经基本确定,现在也就可以基本解释在前面权限部分提到rwx三个权限为什么缺失时目录会用相应的效果:

  1. 缺失r权限,无法读取目录中的内容:本质就是无法获取到目录中的文件名和inode编号的映射关系
  2. 缺失w权限,无法修改目录中的文件:本质就是无法向目录的数据块写入
  3. 缺失x权限,无法进入目录:本质就是无法进入到目录的数据块中

创建虚拟硬盘实验

上面提到的所有内容都是基于一个分区的,那么在操作Linux时,如何确定是在哪一个分区的,实际上就是因为分区是和指定的目录相关联,进入指定的目录就相当于进入该分区

以下面的示例为例:

使用下面的命令制作⼀个⼤的磁盘块,就当做⼀个分区:

Bash
1
dd if=/dev/zero of=./disk.img bs=1M count=5

上面的命令意思是:

使用dd工具从/dev/zero(一个提供无限零字节流的特殊文件)读取数据,创建一个名为disk.img的5MB大小的文件,该文件内容全部为零字节。具体来说,它以1MB的块大小读取5个块,总共复制5MB的数据到当前目录下的disk.img文件中

运行结果如下:

使用下面的命令格式化写⼊⽂件系统(使用ext4文件系统):

Bash
1
mkfs.ext4 disk.img

运行结果如下:

接着创建一个空目录:

Bash
1
sudo mkdir /mnt/mydisk

但是现在还没有将分区进行挂载,所以当前新建的分区还不能使用,使用下面的命令可以看到当前系统的分区:

Bash
1
df -h

运行结果如下:

最后,执行下面的指令将分区挂载到指定的⽬录:

Bash
1
sudo mount -t ext4 ./disk.img /mnt/mydisk/

再执行df -h就可以看到当前挂载的分区:

其中,/dev/loop0在Linux系统中代表第一个循环设备(loop device)。循环设备,也被称为回环设备或者loop back设备,是一种伪设备(pseudo-device),它允许将文件作为块设备(block device)来使用。这种机制使得可以将文件(比如ISO镜像文件)挂载(mount)为文件系统,就像它们是物理硬盘分区或者外部存储设备一样

使用下面的指令可以卸载挂载的分区:

Bash
1
sudo umount /mnt/mydisk

再使用df -h就看不到当前的分区了: