Linux笔记---文件系统软件部分

1. 前置概念

1.1 块

硬盘是典型的"块"设备,操作系统读取硬盘数据的时候,其实是不会一个扇区一个扇区地读取的,而是一次性连续读取多个扇区,以提升效率,即一次性读取一个"块"(block)。

一个"块"的大小是由格式化的时候确定的,并且不可以更改,最常见的是4KB,即连续八个扇区组成一个 "块"。"块"是文件存取的最小单位。

块号是从零开始连续递增的,结合我们在硬件部分学习的LBA地址,我们可以得出以下结论:

块号 = LBA / 8;

LBA = 块号 * 8 + n(块内的第几个扇区);

1.2 分区

一个磁盘实际上可以被划分为多个分区。以Windows为例,我们的电脑通常只有一块固态硬盘,但是我们可以在逻辑上将其划分为C、D、E、......等多个磁盘。C盘、D盘、E盘等,即为物理硬盘的多个分区。

在操作系统层面上,这些盘作为外设,都是以文件的形式被管理起来的,但他们是一种逻辑上的分区,没有物理参考,如何界定分区范围呢?

柱面是分区的最小单位,所以我们可以利用柱面号码的方式来进行分区,其本质就是设置每个分区的起始柱面和结束柱面号码。

上图中启动块(Boot Block/Sector)的大小是确定的,为1KB,由PC标准规定,用于存储磁盘分区信息和启动信息,任何文件系统都不能修改启动块。启动块之后才是ext2文件系统的开始。

2. 文件系统

我们想要在硬盘上存储文件,必须先把硬盘格式化为某种格式的文件系统,才能存储文件。文件系统的目的就是组织和管理硬盘中的文件。在 Linux 系统中,最常见的是 ext2 系列的文件系统。

从上图就可以看出,一个分区实际上就对应一个文件系统。为了模拟物理意义上的多个独立磁盘,所以各个分区的文件由自己的文件系统进行管理。

2.1 块组(Block Group)

ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成,各个块组可以应用相同的管理策略。

一个块组会被划分为6个部分:超级块(Super Block)、块组描述符表(GDT)、块位图(Block Bitmap)、inode位图(inode Bitmap)、i节点表(inode Table)、数据区(Data Blocks)。

其中,前四个部分是存储的是管理信息,后两个部分存储的才是文件的数据。

2.1.1 数据区(Data Blocks)

从命名上就可以看出,这部分是由一个一个的块(Block)构成的,用于存储文件的内容数据。

上图中块组各部分的比例并不是很正确,实际上数据块部分会占到一个块组的绝大部分空间。

每一个块都有自己对应的编号,一个分区的块统一编号(跨组不跨分区)。

2.1.2 i节点表(inode Table)

显然,inode Table就是用于存储inode的表格,我们只需要把inode搞清楚,这部分就迎刃而解了。

我们说过,文件 = 属性 + 数据。在Linux中,文件的属性和数据是分离存储的。

inode实际上就是存储文件属性(元信息)的结构体,中文译名"索引结点"。每一个文件都有对应的inode,每个inode都有其唯一标识符(inode号,同样跨组不跨分区,分区内统一编号)。

cpp 复制代码
/*
* 磁盘上的inode结构
*/
struct ext2_inode {
    __le16 i_mode; /* 文件模式 */
    __le16 i_uid; /* 所有者UID的低16位 */
    __le32 i_size; /* 以字节为单位的文件大小 */
    __le32 i_atime; /* 访问时间 */
    __le32 i_ctime; /* 创建时间 */
    __le32 i_mtime; /* 修改时间 */
    __le32 i_dtime; /* 删除时间 */
    __le16 i_gid; /* 组ID的低16位 */
    __le16 i_links_count; /* 链接计数 */
    __le32 i_blocks; /* 块计数 */
    __le32 i_flags; /* 文件标志 */
    union {
        struct {
            __le32 l_i_reserved1;
        } linux1;
        struct {
            __le32 h_i_translator;
        } hurd1;
        struct {
            __le32 m_i_reserved1;
        } masix1;
    } osd1; /* 操作系统相关字段1 */
    __le32 i_block[EXT2_N_BLOCKS];/* 指向数据块的指针 */
    __le32 i_generation; /* 文件版本(用于NFS) */
    __le32 i_file_acl; /* 文件访问控制列表 */
    __le32 i_dir_acl; /* 目录访问控制列表 */
    __le32 i_faddr; /* 片段地址 */
    union {
        struct {
            __u8 l_i_frag; /* 片段编号 */
            __u8 l_i_fsize; /* 片段大小 */
            __u16 i_pad1;
            __le16 l_i_uid_high; /* 这两个字段 */
            __le16 l_i_gid_high; /* 原为reserved2[0] */
            __u32 l_i_reserved2;
        } linux2;
        struct {
            __u8 h_i_frag; /* 片段编号 */
            __u8 h_i_fsize; /* 片段大小 */
            __le16 h_i_mode_high;
            __le16 h_i_uid_high;
            __le16 h_i_gid_high;
            __le32 h_i_author;
        } hurd2;
        struct {
            __u8 m_i_frag; /* 片段编号 */
            __u8 m_i_fsize; /* 片段大小 */
            __u16 m_pad1;
            __u32 m_i_reserved2[2];
        } masix2;
    } osd2; /* 操作系统相关字段2 */
};

/*
* 与数据块相关的常量
*/
#define EXT2_NDIR_BLOCKS 12       /* 直接数据块数量 */
#define EXT2_IND_BLOCK EXT2_NDIR_BLOCKS  /* 一级间接块索引 */
#define EXT2_DIND_BLOCK (EXT2_IND_BLOCK + 1)  /* 二级间接块索引 */
#define EXT2_TIND_BLOCK (EXT2_DIND_BLOCK + 1)  /* 三级间接块索引 */
#define EXT2_N_BLOCKS (EXT2_TIND_BLOCK + 1)  /* 总块指针数量 */
// 注意:EXT2_N_BLOCKS 的值为15
  • 文件名属性并未纳入到inode数据结构内部。
  • inode的大小一般是128字节或者256。
  • 任何文件的内容大小可以不同,但是属性大小一定是相同的。

" ls " 指令带上 " -i " 选项即可查看文件对应的inode号。

所以,inode用于存储一个文件的属性信息,而inode Table由于同一管理一个分组当中的文件的inode。

从 inode 到 block

inode 中存在能够找到其对应数据块的字段:

cpp 复制代码
__le32 i_block[EXT2_N_BLOCKS];/* 指向数据块的指针 */

#define EXT2_N_BLOCKS (EXT2_TIND_BLOCK + 1)  /* 总块指针数量 */
// 注意:EXT2_N_BLOCKS 的值为15

总共15个指针,直觉上来说,一个文件最多对应15个数据块,最多只能存储 15 * 8 * 512 字节。

但很明显这样的容量完全不够某些大文件塞牙缝的,如何确保文件被完全存放?

实际上,只有前12个指针是与数据块一一对应的,后面的3个指针依次为一级、二级、三级间接指针,每级间接意味着中间存在着一个索引表(指针数组)。根据需要扩充索引表的内容,就可以不断增加一个文件对应的数据块。

2.1.3 inode位图(inode Bitmap)

inode位图用于记录inode Tbale中哪些inode结点是正在被使用的(存放了某个文件的属性),被使用则对应位置1,未被使用则对应位置0。

2.1.4 块位图(Block Bitmap)

同理,块位图用于记录Data Blocks中哪些数据块是正在被使用的。

2.1.5 快组描述符(GDT)

块组描述符表(Group Descriptor Table),描述块组属性信息,整个分区分成多个块组就对应有多少个块组描述符。

每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是inode Table,从哪里开始是Data Blocks,空闲的inode和数据块还有多少个等等。

cpp 复制代码
// 磁盘级blockgroup的数据结构
/*
* Structure of a blocks group descriptor
*/
struct ext2_group_desc
{
    __le32 bg_block_bitmap; /* Blocks bitmap block */
    __le32 bg_inode_bitmap; /* Inodes bitmap */
    __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];
};

2.1.6 超级块(Super Block)

存放文件系统本身的结构信息,描述整个分区的文件系统信息。

记录的信息主要有:bolck 和 inode的总量,未使用的 block 和 inode 的数量,一个 block 和 inode 的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。

注意,我们说的是超级块存放文件系统本身(即一个分区)的结构信息,并不是块组的结构信息,因此超级块的数据是极其重要的。超级块的数据被破坏则整个文件系统的结构就被破坏了。

超级块在每个块组的开头都有一份拷贝(第一个块组必须有,后面的块组可以没有)。 为了保证文件系统在磁盘部分扇区出现物理问题的情况下还能正常工作,就必须保证文件系统的super block信息在这种情况下也能正常访问。所以一个文件系统的super block会在多个block group中进行备份,这些super block区域的数据保持一致。

cpp 复制代码
/*
* 超级块的结构
*/
struct ext2_super_block {
    __le32 s_inodes_count; /* inode总数 */
    __le32 s_blocks_count; /* 块总数 */
    __le32 s_r_blocks_count; /* 保留块数 */
    __le32 s_free_blocks_count; /* 空闲块数 */
    __le32 s_free_inodes_count; /* 空闲inode数 */
    __le32 s_first_data_block; /* 第一个数据块位置 */
    __le32 s_log_block_size; /* 块大小(对数形式) */
    __le32 s_log_frag_size; /* 片段大小(对数形式) */
    __le32 s_blocks_per_group; /* 每个组的块数 */
    __le32 s_frags_per_group; /* 每个组的片段数 */
    __le32 s_inodes_per_group; /* 每个组的inode数 */
    __le32 s_mtime; /* 挂载时间 */
    __le32 s_wtime; /* 写入时间 */
    __le16 s_mnt_count; /* 挂载次数 */
    __le16 s_max_mnt_count; /* 最大挂载次数 */
    __le16 s_magic; /* 魔数签名(标识文件系统类型) */
    __le16 s_state; /* 文件系统状态 */
    __le16 s_errors; /* 错误检测时的行为 */
    __le16 s_minor_rev_level; /* 次修订版本号 */
    __le32 s_lastcheck; /* 最后检查时间 */
    __le32 s_checkinterval; /* 两次检查的最大间隔时间 */
    __le32 s_creator_os; /* 创建操作系统 */
    __le32 s_rev_level; /* 主修订版本号 */
    __le16 s_def_resuid; /* 保留块的默认用户ID */
    __le16 s_def_resgid; /* 保留块的默认组ID */
    /*
    * 以下字段仅适用于EXT2_DYNAMIC_REV版本的超级块
    *
    * 注意:兼容特性集与不兼容特性集的区别在于:
    * 如果内核无法识别不兼容特性集中设置的某个特性位,
    * 则应拒绝挂载该文件系统。
    *
    * e2fsck的要求更为严格:如果它无法识别兼容特性集
    * 或不兼容特性集中的任何特性,必须终止操作,
    * 不会尝试修改其不理解的内容...
    */
    __le32 s_first_ino; /* 第一个非保留inode编号 */
    __le16 s_inode_size; /* inode结构大小 */
    __le16 s_block_group_nr; /* 本超级块所在的块组号 */
    __le32 s_feature_compat; /* 兼容特性集 */
    __le32 s_feature_incompat; /* 不兼容特性集 */
    __le32 s_feature_ro_compat; /* 只读兼容特性集 */
    __u8 s_uuid[16]; /* 128位卷UUID */
    char s_volume_name[16]; /* 卷名称 */
    char s_last_mounted[64]; /* 最后挂载目录路径 */
    __le32 s_algorithm_usage_bitmap; /* 压缩算法位图 */
    /*
    * 性能提示:仅当EXT2_COMPAT_PREALLOC标志启用时,
    * 目录预分配功能才会生效
    */
    __u8 s_prealloc_blocks; /* 尝试预分配的块数 */
    __u8 s_prealloc_dir_blocks; /* 为目录预分配的块数 */
    __u16 s_padding1;
    /*
    * 日志支持(当设置EXT3_FEATURE_COMPAT_HAS_JOURNAL时有效)
    */
    __u8 s_journal_uuid[16]; /* 日志超级块的UUID */
    __u32 s_journal_inum; /* 日志文件的inode编号 */
    __u32 s_journal_dev; /* 日志文件的设备号 */
    __u32 s_last_orphan; /* 待删除inode链表的起始位置 */
    __u32 s_hash_seed[4]; /* HTREE哈希种子 */
    __u8 s_def_hash_version; /* 默认使用的哈希版本 */
    __u8 s_reserved_char_pad;
    __u16 s_reserved_word_pad;
    __le32 s_default_mount_opts; /* 默认挂载选项 */
    __le32 s_first_meta_bg; /* 第一个元块组 */
    __u32 s_reserved[190]; /* 填充至块末尾 */
};

2.1.7 总结

从这部分我们可以看出为什么 数据写入很慢,而删除却很快。因为删除数据只需要修改对应的标志信息置即可(如将inode Bitmap 和 Block Bitmap对应位置为0)。

由此我们又能大概猜到回收站或数据恢复技术的原理了,只要赶在数据被覆盖之前,找到被删除数据对应的原inode和数据块即可恢复。但如果新的数据被写入到了这些存储空间那就无能为力了。

所以,当重要的数据被误删时,尽可能什么都不做,赶在数据被覆盖之前交由专业人士进行恢复。

所谓的格式化,其实就是对分区进行分组,在每个分组中写入SB、GDT、Block Bitmap、Inode Bitmap等管理信息,这些管理信息统称:文件系统。

2.2 目录

2.2.1 问题提出

  1. 文件名被存放在哪里呢?
    前面我们提到过,inode的大小是固定的(即文件属性信息的大小是固定的),这主要是为了方便根据inode节点号来查找inode。但是这会带来一个问题:文件的名称不能存储在inode中(在前文代码中也有体现,因为名称大小随长度而变化)。
  2. 文件名与inode的联系?
    我们已经知道,根据inode号可以在一个分区当中准确的找到一个文件的属性和数据,但是我们从未关心过一个文件的inode号,我们使用的一直是"路径+文件名"的方式对文件进行访问、操作。也就是说,文件名与inode之间一定建立了某种映射关系。
  3. 目录文件的内容?
    目录文件也是文件,它的内容是什么?

实际上,这三个问题存在着本质的联系。

2.2.2 目录文件的内容

目录文件存放的实际上是该目录下 "文件名 - indoe号" 的映射关系。

当我们通过文件名访问一个文件时,OS 会在上级目录中的文件内容中查找该文件名对应的inode号。但是,OS 如何知道一个文件名对应的文件在哪个目录下呢?

2.2.2.1 路径解析

从上面的分析可以看出,我们访问文件是一定要有路径的,给出路径的方式有两种:

  • 绝对路径:用户显式给出从根目录到目标文件的路径。
  • 相对路径:进程的CWD + 用户以当前目录为起点给出到目标文件的路径。

在获得目标文件的绝对路径之后,OS 从根目录开始将路径中的目录一级一级地展开,直到在目标文件的上一级目录文件中找到目标文件的inode。

当然,根目录拥有固定的文件名和 inode号,无需查找,系统开机之后就必须知道。

2.2.2.2 路径缓存

每次访问文件都从根目录开始将目录逐级展开未免太过麻烦。实际上,OS 会将本次开机曾打开过的文件和目录维护到路径缓存中。这个路径缓存是内存级的一个树状结构。

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

cpp 复制代码
struct dentry {
    atomic_t d_count;        /* 引用计数器 */
    unsigned int d_flags;    /* 由d_lock保护的标志位 */
    spinlock_t d_lock;       /* 每个dentry独立的自旋锁 */
    struct inode* d_inode;   /* 关联的inode指针(NULL表示负向条目) */
    
    /*
    * 以下三个字段会被__d_lookup访问,将它们放在同一缓存行
    */
    struct hlist_node d_hash;  /* 查找哈希链表节点 */ 
    struct dentry * d_parent;  /* 父目录dentry指针 */
    struct qstr d_name;        /* 目录项名称结构体 */
    
    struct list_head d_lru;    /* LRU回收链表 */
    
    /*
    * d_child和d_rcu共享内存空间
    */
    union {
        struct list_head d_child;  /* 父目录的子条目链表 */ 
        struct rcu_head d_rcu;     /* RCU回调结构体 */
    } d_u;
    
    struct list_head d_subdirs;  /* 当前目录的子条目链表 */
    struct list_head d_alias;    /* inode别名链表 */
    unsigned long d_time;        /* 用于d_revalidate的时间戳 */
    struct dentry_operations* d_op;  /* dentry操作函数表 */
    struct super_block* d_sb;     /* 所属的超级块指针 */
    void* d_fsdata;               /* 文件系统私有数据 */
    
#ifdef CONFIG_PROFILING
    struct dcookie_struct* d_cookie; /* 调试用的cookie指针(如果启用性能分析) */
#endif
    
    int d_mounted;                /* 挂载点标记 */
    unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 短文件名内联存储 */ 
};

当然,内存资源是十分珍贵的,这颗树不能在内存中无限生长,他只会维护最近最少使用的数据,长时间未被访问的目录或文件会被剔除。

在使用操作系统的过程中,我们会发现当我们首次展开大量文件目录(如使用find等指令时)时会有肉眼可见的延迟,但是之后再次执行时速度就会变得十分迅速。

2.2.2.3 opendir 和 readdir

opendirreaddir函数是Linux操作系统的两个系统调用,用于目录操作。

opendir函数

  • 功能:打开一个目录并返回一个指向该目录的DIR*类型的指针,该指针可用于后续的目录读取操作。
  • 原型DIR *opendir(const char *name);
  • 参数name是要打开的目录的路径名。
  • 返回值:成功时返回一个DIR*类型的指针,指向打开的目录流;失败时返回NULL。

readdir函数

  • 功能 :读取目录流中的下一个目录项,并返回一个指向struct dirent类型的指针,该结构体包含了目录项的相关信息。
  • 原型struct dirent *readdir(DIR *dir);
  • 参数dir是由opendir函数返回的目录流指针。
  • 返回值 :成功时返回一个struct dirent*类型的指针,指向目录流中的下一个目录项;到达目录末尾或出错时返回NULL。
cpp 复制代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <dirent.h>
#include <sys/types.h>
#include <unistd.h>

int main(int argc, char* argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <directory>\n", argv[0]);
        exit(EXIT_FAILURE);
    } 
    
    DIR* dir = opendir(argv[1]); // 系统调⽤,⾃⾏查阅
    if (!dir) {
        perror("opendir");
        exit(EXIT_FAILURE);
    } 
    
    struct dirent* entry;
    while ((entry = readdir(dir)) != NULL) { // 系统调⽤,⾃⾏查阅
        // Skip the "." and ".." directory entries
        if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..")
            == 0) {
            continue;
        } 
        printf("Filename: %s, Inode: %lu\n", entry->d_name, (unsigned long)entry->d_ino);
    } 
    
    closedir(dir);
    return 0;
}

注意事项

  • 遍历目录时,通常需要忽略...这两个特殊的目录项,以避免无限循环。
  • readdir函数返回NULL时,可能是到达目录末尾,也可能是发生了错误,需要根据errno的值来判断具体情况。

2.3 挂载分区

我们前面提到过,inode的编号是不能跨分区的,这意味着在不同的分区可能有相同的inode号,OS 如何知道该去哪个分区进行查找呢?

2.3.1 挂载点

  1. 挂载点绑定 : 当用户访问文件路径(如/home/user/file)时,系统通过路径解析 逐级确定文件所在的分区。例如,若/home是独立分区(如挂载到/dev/sdb1),则访问/home/user/file时,系统会直接操作/dev/sdb1分区下的文件系统,无需跨分区查找。​​​​​​​

  2. 分区元数据隔离 : 每个分区维护独立的文件系统结构(如超级块、inode表)。即使不同分区的inode号相同,系统通过分区挂载信息将查找范围限定在当前分区的元数据中,避免混淆。

2.3.2 关键数据结构支撑

  • 目录项(dentry) : 目录文件中存储的并非单纯的文件名→inode映射,而是隐含了分区上下文 。例如,当访问/var/log时,若/var是独立分区,系统会直接读取该分区的inode表,无需关注其他分区。

  • 虚拟文件系统(VFS)抽象 : VFS层通过struct super_block记录每个分区的挂载信息,当进程访问文件时,内核通过路径解析确定目标文件所在的super_block,从而锁定对应的分区和inode表。

2.3.3 总结

操作系统通过路径解析→挂载点定位→分区隔离查找的流程,确保即使不同分区存在相同inode号,也能精准定位目标文件。这一机制依赖文件系统的挂载信息隔离和VFS层的抽象管理,而非单纯依赖inode的全局唯一性。

3. 软硬链接

通过上面的介绍我们知道,文件名与文件本身其实并没有什么一对一的联系,甚至文件本身都不知道自己的文件名,而只知道自己的inode。

因此,文件名只是作为用户标识文件的一种手段。实际上,我们可以让多个不同的文件名指向同一个文件,即文件名与inode形成多对一的关系。

3.1 硬链接

如上所述,就是新定义一个文件名,让它指向某个文件的inode,可以理解为给文件起别名。

bash 复制代码
ln [原文件名] [新文件名]

可以看到,两个文件的属性信息完全相同,包括inode号。

可以验证二者就是同一个文件:

3.2 软链接

相比于硬链接,软链接更像是为文件创建快捷方式。

bash 复制代码
ln -s [原文件名] [新文件名]

可以看到,软链接创建的链接文件的inode号与之前的不同,是一个独立的文件,就类似于Windows当中的快捷方式文件。

在使用上来说,效果与硬链接完全一样:

3.3 硬链接数

我们知道,文件的属性当中有一个叫做硬链接数的东西,之前一直不理解是什么意思,但是相信现在大家已经能够理解了。

硬链接数就是指向这个文件的文件名的数量,也可以理解为别名数,或者 --- "引用计数" 。只有当硬链接数归零时,文件才会被实际删除。

这就为我们提供了一种文件备份的方式(防误删不防修改),即为一个重要文件创建硬链接。

目录文件的硬链接数

新创建的目录文件的硬链接数就是2,这是为什么呢?

因为目录文件中会默认存在 " . " 和 " .. " 两个目录,其中 " . " 就是新创建的文件的一个硬链接。

当我们在目录中在创建一个目录时,我们会发现目录文件的硬链接数又会加一:

这是因为test_1内部的 ".." 指向的就是test。

这也就说明了为什么rm在删除目录文件时需要加上选项 "-r" 以进行递归删除(即从内向外一级一级地删除)。

相关推荐
hycccccch10 分钟前
熔断降级(Sentinel解决)
笔记·sentinel
Kriol27 分钟前
深度学习复习笔记(8)特征提取与无监督学习
笔记·深度学习·学习
你觉得20535 分钟前
DeepSeek自学手册:《从理论(模型训练)到实践(模型应用)》|73页|附PPT下载方法
大数据·运维·人工智能·机器学习·ai·自然语言处理·知识图谱
参.商.1 小时前
【RH124】 第五章 创建、查看文本文件
linux·运维
小镇敲码人1 小时前
PCRE2 站内搜索引擎项目
linux·网络·c++·搜索引擎
上层精灵的赞美诗2 小时前
S32K144入门笔记(二十三):FTM宏观介绍
笔记·单片机·嵌入式硬件
JxHillMan2 小时前
Ubuntu 上安装 Docker
linux·ubuntu·docker
网络安全-老纪2 小时前
网络安全工程师逆元计算 网络安全逆向
linux·服务器·web安全
心灵宝贝2 小时前
CentOS 7.2 (1511) 详解功能安装与使用指南(附安装包)
linux·运维·centos
小小的guo2 小时前
初识Brainstorm(matlab)
笔记·学习·matlab·数据分析