Ext File System

Ext文件系统

综述

通过我们都是通过系统调用readwrite等操作文件,实际上,这是一类统一的接口,具体操作是需要底层的文件系统来实现的。在Linux下,这层抽象就是VFS(Virtual File System),而Linux默认的文件系统是Ext文件系统,从Ext2到Ext4,处在迭代之中。当然由于VFS的存在,也可以操作其他文件系统,比如XFS,NTFS等,甚至LInux的内存中有一个proc的文件系统,实现了VFS的接口,这也是Linux一切皆文件思想的体现。整体布局如下。

本文主要讲讲Linux默认的Ext文件系统。在说到Ext文件系统前,先说一下操作系统的引导区在硬盘中的布局。

引导分区Boot Sector

引导分区的格式通常不是Ext文件系统,而采用FAT32等兼容性更强的文件系统。

操作系统是存在于硬盘上的,从硬盘启动。
现在有两种分区格式,MBR(Master Boot Recorder)和GPT(GUID Partition Table)。
这两种分区格式分别对应Legacy和UEFI引导模式。
分区格式是对于硬盘来说的,引导模式是针对主板来说的。因此,分区是GPT格式,也得主板支持UEFI引导模式才行。

GPT分区格式逐渐取代了MBR,原因如下。

  1. 支持更大的磁盘容量:MBR分区表只支持最大2TB的磁盘容量,而GPT分区表最大支持9.4ZB(1ZB=1024EB)的磁盘容量。这意味着GPT分区表可以支持更大的硬盘、RAID阵列和SAN存储设备。
  2. 支持更多的分区:MBR分区表最多支持4个主分区或3个主分区和1个扩展分区,而GPT分区表最多支持128个分区。这意味着GPT分区表可以更灵活地分割磁盘空间,以满足更多的应用场景。
  3. 更好的数据完整性:GPT分区表使用CRC校验和来检测和修复分区表损坏的情况,可以更好地保护磁盘数据的完整性。而MBR分区表没有这种保护机制,一旦分区表损坏,就可能导致磁盘数据的丢失。
  4. 更好的兼容性:GPT分区表可以被多个操作系统兼容,包括Windows、Linux、Mac OS等,而MBR分区表则受到一些操作系统的限制,例如Windows操作系统只支持MBR分区表的引导方式。

MBR分区

MBR分区布局如下。

其中,分区表的内容如下。

GPT分区

GPT布局如下

其中LBA 0是为了兼容老设备,还保留了MBR的信息。

一个Entry描述一个分区的信息。最多128个Entry,因此最多不超过128个分区。

此外,GPT的信息尤为重要,所以会备份到磁盘的末尾的34LBA中。

布局

说完了磁盘的引导部分,我们说说Ext文件系统的详细的布局,整体的布局如下。

分区Parition

不同分区可以是不同的文件系统,比如引导区是FAT32文件系统,而Linux通常含有一个swap分区。

例如,在Linux系统下,

  1. 可以通过fdisk 来查看一个硬盘信息。
  2. 可以通过ntfs-3g或者mkfs.ntfs将一个分区格式化为ntfs文件系统。
  3. /dev目录有硬盘和分区信息,根据硬盘的协议有/dev/sd*/dev/nvme*。例如,/dev/nvme0n1p1代表nvme协议,设备编号n1,该分区为p1。

块组Block Group

前面我们说一个分区就相当于是一个文件系统,相互隔离开。

一个分区中含有多个Block Group,其中含有Super Block和Group description table等信息。

但Super Block和Group description table都是全局信息,而且这些数据很重要。如果这些数据丢失了,整个文件系统都打不开了,这比一个文件的一个块损坏更严重。所以,这两部分我们都需要备份,但是采取不同的策略。

  • 默认策略:在每个块中均保存一份超级块和块组描述表的备份
  • sparse_super策略:采取稀疏存储的方式,仅在块组索引为 0、3、5、7 的整数幂里存储。
  • Meta Block Groups策略:我们将块组分为多个元块组(Meta Block Groups),每个元块组里面的块组描述符表仅仅包括自己的内容,一个元块组包含 64 个块组,这样一个元块组中的块组描述符表最多 64 项。这种做法类似于merkle tree,可以在很大程度上优化空间。

Super Block

超级块(Super Block)描述整个分区的文件系统信息,如inode/block的大小、总量、使用量、剩余量,以及文件系统的格式与相关信息。超级块在每个块组的开头都有一份拷贝(第一个块组必须有,后面的块组可以没有,取决于备份策略)。

为了保证文件系统在磁盘部分扇区出现物理问题的情况下还能正常工作,就必须保证文件系统的super block信息在这种情况下也能正常访问。所以一个文件系统的super block会在多个block group中进行备份,这些super block区域的数据保持一致。 超级块记录的信息有:

  1. block 与 inode 的总量(分区内所有Block Group的block和inode总量);
  2. 未使用与已使用的 inode / block 数量;
  3. block 与 inode 的大小 (block 为 1, 2, 4K,inode 为 128 bytes);
  4. filesystem 的挂载时间、最近一次写入数据的时间、最近一次检验磁盘 (fsck) 的时间等文件系统的相关信息;
  5. 一个 valid bit 数值,若此文件系统已被挂载,则 valid bit 为 0 ,若未被挂载,则 valid bit 为 1 。

对于ext2/3/4文件系统,以上介绍的这些inode bitmap, data block bitmap和inode table,都可以通过一个名为”dumpe2fs”的工具来查看其在磁盘上的具体位置

格式

每一个块组都含有inode Table和inode bitmap、Data Block和Block Bitmap来管理索引节点和数据节点。

inode

inode即index node,用来索引文件,跟踪一个文件(Linux下目录也是一种文件)所有的块,即跟踪所有的Data Block。

一个文件必须对应一个inode。

具体的inode信息存放在inode table里头。

inode bitmap & Block bitmap

bitmap用一个bit位来描述inode和data node的空闲状态,可以大幅节省空间。

目录文件

在Linux中,目录也被视为一种文件,文件内容是文件。

对于每一个文件,存储如下内容,有两个版本的存储方式。第二个版本 ext4_dir_entry_2 是将一个 16 位的 name_len,变成了一个 8 位的 name_len 和 8 位的 file_type。

struct ext4_dir_entry {
    __le32  inode;      /* Inode number */
    __le16  rec_len;    /* Directory entry length */
    __le16  name_len;    /* Name length */
    char  name[EXT4_NAME_LEN];  /* File name */
};
struct ext4_dir_entry_2 {
    __le32  inode;      /* Inode number */
    __le16  rec_len;    /* Directory entry length */
    __u8  name_len;    /* Name length */
    __u8  file_type;
    char  name[EXT4_NAME_LEN];  /* File name */
};

最简单的保存格式是列表,就是一项一项地将 ext4_dir_entry_2 列在哪里。每一项都会保存这个目录的下一级的文件的文件名和对应的 inode,通过这个 inode,就能找到真正的文件。第一项是“.”,表示当前目录,第二项是“…”,表示上一级目录,接下来就是一项一项的文件名和 inode。

这样的存储方式,查找的复杂度为$O(n)$,如果在 inode 中设置 EXT4_INDEX_FL 标志,则会采用哈希表来存储。目录文件的内容如下。

struct dx_root
{
	struct fake_dirent dot;
	char dot_name[4];
	struct fake_dirent dotdot;
	char dotdot_name[4];
	struct dx_root_info
	{
		__le32 reserved_zero;
		u8 hash_version;
		u8 info_length; /* 8 */
		u8 indirect_levels;
		u8 unused_flags;
	}
	info;
	struct dx_entry	entries[];
};

其中,每一个文件的索引信息是dx_entry

struct dx_entry
{
	__le32 hash;
	__le32 block;
};

大文件存储

在ext2和ext3格式的文件系统中,我们用前12个块存放对应的文件数据,每个块4KB,如果文件较大放不下,则需要使用后面几个间接存储块来保存数据,下图很形象的表示了其存储原理。

image

该存储结构带来的问题是对于大型文件,我们需要多次调用才可以访问对应块的内容,因此访问速度较慢。为此,ext4提出了新的解决方案:Extents。简单的说,Extents以一个树形结构来连续存储文件块,从而提高访问速度,只有叶子节点才存放数据,大致结构如下图所示。

软链接和硬链接

硬链接

创建一个硬链接文件,指向了另一个文件的inode,由于inode是Ext文件系统的特有格式,所以是无法跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。相当与是建立了引用,引用计数,为0才删除。

实际上,有一些注意事项

  1. 目录不允许硬链接:因为如果允许,会造成死循环。
  2. 不同分区不允许硬链接:由于inode在不同的分区是独立编号的,所以可能会重复,检索时会出现矛盾。

软链接

创建一个软链接文件,保存另一个文件的完整路径和文件名。由于路径和文件名是独立于每一种文件系统的,所以软链接是可以跨文件系统的。甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。

可以通过ln来创建链接文件。

文件操作

结合上面的布局,说一说每一个操作的步骤。

文件新建

  1. 读取GDT(Group Description Table),找到各个块组中未使用的inode号,并分配。
  2. 在inode table中完善该inode号所在行的记录。
  3. 在目录的data block中添加一条该记录的目录信息。
  4. 将数据填充到data block中,并标记对应的data block的bitmap中的bit位。

文件删除

删除文件分为普通文件和目录文件,知道了这两种类型的文件的删除原理,就知道了其他类型特殊文件的删除方法。

删除普通文件

  1. 找到文件的inode和data block。
  2. 首先判断文件类型,做一些特殊处理。比如如果是硬链接文件,就减一下引用计数。
  3. 从inode table中将该inode记录中的data block指针删除。
  4. 在inode bitmap中标记该inode未使用。
  5. 在对应的目录的数据部分,删除该文件的目录信息。
  6. 设置在bitmap中设置该文件的data block的未使用。

删除目录文件

  1. 找到目录和目录下所有文件、子目录、子文件和inode和data block。
  2. 在inode bitmap中将这些inode号标记为未使用;在block bitmap标记这些块未使用。
  3. 在该目录的父目录的data block中将该目录名的信息删除。需要注意的是,删除父目录是最后一步,如果该步骤提前,将报目录非空的错误,因为该目录中还有文件占用。

文件搜索

例如,执行cat /etc/vimrc 命令。

  1. 找到根目录/对应的inode,并找到对应的目录数据。
  2. 在该目录数据中找到etc目录的inode。
  3. /etc/目录下找到vimrc的inode。最终读取数据

文件移动

在相同的文件系统下(不同分区也行?),执行移动就是修改目录和inode对应的指针,不需要额外拷贝数据。

否则不同文件系统下,就需要拷贝后删除。

文件挂载

挂在实际上就是inode指向了其他文件系统上的目录。

  1. 在挂载时,会新建一个inode,这个inode指向新挂载的文件系统。
  2. 同时将父目录中的旧目录信息改为这个新inode。
  3. 此外,还会将旧inode标记为不可见。

取消挂载就是删除旧inode,并设置父目录信息为旧inode,同时修改旧inode可见。

文件描述符

文件描述符FD(file descriptor)在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。

所以,文件描述符就是用来索引一个文件的,可以通过文件描述符来操作文件。

使用

可以通过lsof查看一个文件被哪些进程打开,也可以查看当前进程打开了哪些文件。

基于Linux一切皆文件的思想,文件描述符除了可以描述普通的文件和目录,还有

  1. 管道
  2. socket
  3. 输入输出源
  4. 其他Unix文件类型

每一个进程中,通常会有3个已经设置好的FD

  1. FD号0:标准输入 Standard input 标准输入 用于程序接受数据
  2. FD号1:标准输出 Standard output 标准输出 用于程序输出数据
  3. FD号2:标准错误(输出) Standard error 标准错误 用于程序输出错误或者诊断信息

输出重定向可以通过1>来实现,这个1也可以不制定;错误重定向可以通过2>

如,ls . > info.txt 或者 ls <FileThatNoExist> 2> error.txt

此外还可以将错误信息重定向到标准输出,如2>&1

ls -al <notExistFile> 2>&1 会将错误输出作为标准输出。

对于每一个进程来说,都会维护一个文件描述符表。除此之外,系统也会维护两个表

  • 打开文件表
  • inode表

整体的布局如下

注意,为什么一个进程描述符表中的不同文件指针可能指向打开文件表的同一个位置呢?因为

  1. 可能是一个线程同时先后打开一个文件。
  2. 不同线程之间共享文件描述符表,不同线程打开同一个文件。

为什么不同进程的文件描述符和文件指针完全相同呢?

  • 这两个进程是父子进程。

此外,为什么打开文件表不同inode指针可能指向inode表中同一个位置呢?

  • 因为不同进程可能打开同一个文件。

此外,注意到文件偏移量是系统表记录的,所以进程内的线程是共享文件偏移量的。

所以系统级别的打开文件表隔离出了每一个进程打开的文件和其文件偏移量,线程之间共享文件和偏移量。

代码分析

在Linux下的PCB的具体化是task_struct,用来描述一个进程或者线程。其中含有两个fs_structfiles_struct的指针与文件系统联系紧密。

fs_struct

task_struct中的fs指针指向fs_struct描述了当进程\线程的可执行文件的目录信息。

struct fs_struct {
 atomic_t count;
 rwlock_t lock;
 int umask;
 struct dentry * root, * pwd, * altroot;
 struct vfsmount * rootmnt, * pwdmnt, * altrootmnt;
};
  • count:共享这个表的进程个数
  • lock:用于表中字段的读/写自旋锁
  • umask:当打开文件设置文件权限时所使用的位掩码
  • root:根目录的目录项
  • pwd:当前工作目录的目录项

files_struct

task_strcut中的files指向了files_struct结构体,描述了当前线程\进程打开的文件,即上面我们提到的用户级别的文件描述符表。这是每一个进程私有的,进程内的线程共享。

struct files_struct {
    atomic_t count;
    struct fdtable *fdt;
    struct fdtable  fdtab;
   
    int next_fd;
    struct embedded_fd_set close_on_exec_init;
    struct embedded_fd_set open_fds_init;
    struct file * fd_array[NR_OPEN_DEFAULT];
};

限制

ulimit命令可以查看当前shell下的文件描述符的数量。

ulimit用于限制shell启动进程所占用的资源,作为临时限制,ulimit 可以作用于通过使用其命令登录的 shell 会话,在会话终止时便结束限制,并不影响于其他 shell 会话。而对于长期的固定限制,ulimit 命令语句又可以被添加到由登录 shell 读取的文件中,作用于特定的 shell 用户。。

用法如下

[root@Centos ~]# ulimit -a
core file size          (blocks, -c) 0            #core文件的最大值为100 blocks。
data seg size           (kbytes, -d) unlimited    #进程的数据段可以任意大。
scheduling priority            (-e) 0
file size              (blocks, -f) unlimited    #文件可以任意大。
pending signals               (-i) 3794         #最多有98304个待处理的信号。
max locked memory         (kbytes, -l) 64           #一个任务锁住的物理内存的最大值为32KB。
max memory size          (kbytes, -m) unlimited    #一个任务的常驻物理内存的最大值。
open files                  (-n) 1024         #一个任务最多可以同时打开1024的文件。
pipe size            (512 bytes, -p) 8            #管道的最大空间为4096字节。
POSIX message queues        (bytes, -q) 819200       #POSIX的消息队列的最大值为819200字节。
real-time priority             (-r) 0
stack size             (kbytes, -s) 10240        #进程的栈的最大值为10240字节。
cpu time              (seconds, -t) unlimited    #进程使用的CPU时间。
max user processes             (-u) 1024         #当前用户同时打开的进程(包括线程)的最大个数为98304。
virtual memory          (kbytes, -v) unlimited    #没有限制进程的最大地址空间。
file locks                  (-x) unlimited    #所能锁住的文件的最大个数没有限制。
Linux默认的文件打开数是1024,现在设置打开数为2048.

[root@Centos ~]# ulimit -n        --查看打开数为1024
1024
[root@Centos ~]# ulimit -n 2048   --设置打开数为2048
[root@Centos ~]# ulimit -n        --再次查看
2048

管道

使用

系统操作执行命令的时候,经常有需求要将一个程序的输出交给另一个程序进行处理,这种操作可以使用输入输出重定向加文件搞定,比如:

epoch@Ubuntu:~ $ ls /etc/ > etc.txt
epoch@Ubuntu:~ $ wc -l etc.txt

这样的操作是频繁的,所以有必要简化一下。就引入了管道的概念。

可以使用“|”连接两个命令,shell会将前后两个进程的输入输出用一个管道相连,以便达到进程间通信的目的。上述两个步骤即可合并成一句。

epoch@Ubuntu:~ $ ls -l /etc/ | wc -l

Linux系统直接把管道实现成了一种文件系统,借助VFS给应用程序提供操作接口。

虽然实现形态上是文件,但是管道本身并不占用磁盘或者其他外部存储的空间。在Linux的实现上,它占用的是内存空间。所以,Linux上的管道就是一个操作方式为文件的内存缓冲区。

创建过程如下

类型

Linux上的管道分两种类型:

  • 匿名管道\无名管道
  • 命名管道\有名管道

匿名管道:最常见的形态就是我们在shell操作中最常用的”|”。它的特点是只能在父子进程中使用,父进程在产生子进程前必须打开一个管道文件,然后fork产生子进程,这样子进程通过拷贝父进程的进程地址空间获得同一个管道文件的描述符,以达到使用同一个管道通信的目的。

命名管道:由于匿名管道只能在父子进程中使用,为了扩展使用,出现了命名管道。

mkfifo或mknod命令来创建一个命名管道,这跟创建一个文件没有什么区别

原理及实现

在Linux中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。
通过将两个file结构指向同一个临时的inode节点,而这个inode又指向一个物理页面而实现的。
如下图有两个 file 数据结构,但它们定义文件操作例程地址是不同的,其中一个是向管道中写入数据的例程地址
而另一个是从管道中读出数据的例程地址。

当然,读写是需要互斥的,为此,内核使用了锁、等待队列和信号。

写之前需要判断,管道未加锁,并且内存中有足够空间存储写入的数据;写前加锁,写入后,解锁,而所有休眠在索引节点的读取进程会被唤醒。

这样,用户程序的系统调用仍然是通常的文件操作,而内核却利用这种抽象机制实现了管道这一特殊操作。

管道默认是阻塞的,如果数据会阻塞等待数据的到来;可以设置非阻塞,通过设置管道的打开模式。

可以通过pstree -p <pid> 命令查看进程的父子关系。

通过这个命令可以找到匿名管道的两头的进程pid。举例如下

首先创建一个匿名管打并阻塞,阻塞方便打开另一个shell查看信息。

[lizhipeng@CentOS7 ~]$ { echo $BASHPID; read x ; } | { cat ; echo $BASHPID;  read y; }
[lizhipeng@CentOS7 ~]$ pstree -p
...
        ─sshd(36387)───bash(36388)─┬─bash(37057+
        │            │             └─bash(37058+
        │            └─sshd(36713)───sshd(36717)───bash(36718)───pstree(370+

...

查看这两个父子进程的fd信息。

[lizhipeng@CentOS7 fd]$ ls -alt /proc/37057/fd
总用量 0
lrwx------. 1 lizhipeng lizhipeng 64 11月 17 13:33 0 -> /dev/pts/4
l-wx------. 1 lizhipeng lizhipeng 64 11月 17 13:33 1 -> pipe:[253711]
lrwx------. 1 lizhipeng lizhipeng 64 11月 17 13:33 2 -> /dev/pts/4
lrwx------. 1 lizhipeng lizhipeng 64 11月 17 13:33 255 -> /dev/pts/4
dr-x------. 2 lizhipeng lizhipeng  0 11月 17 13:33 .
dr-xr-xr-x. 9 lizhipeng lizhipeng  0 11月 17 13:30 ..

[lizhipeng@CentOS7 fd]$ ls -alt /proc/37058/fd
总用量 0
lr-x------. 1 lizhipeng lizhipeng 64 11月 17 13:34 0 -> pipe:[253711]
lrwx------. 1 lizhipeng lizhipeng 64 11月 17 13:34 1 -> /dev/pts/4
lrwx------. 1 lizhipeng lizhipeng 64 11月 17 13:34 2 -> /dev/pts/4
lrwx------. 1 lizhipeng lizhipeng 64 11月 17 13:34 255 -> /dev/pts/4
dr-x------. 2 lizhipeng lizhipeng  0 11月 17 13:34 .
dr-xr-xr-x. 9 lizhipeng lizhipeng  0 11月 17 13:30 ..

不难发现,37057 通过重定向 1 号文件描述符来讲管道 重定向到了 37058 的0号描述符。
这就是匿名管道。


Ext File System
https://messenger1th.github.io/2024/07/24/Linux/Ext File System/
作者
Epoch
发布于
2024年7月24日
许可协议