Linux文件系统
Linux文件系统(二)磁盘文件系统
我们常见的磁盘长下面这样子,左边中间圆是磁盘的盘片,右边是抽象出来的图
每一层有多个磁道,每个磁道有多个扇区,每个扇区大小为512个字节
文件系统是安装在磁盘之上的,本文将讲解Linux主流的文件系统 —— ext系列的文件系统格式
一、inode与块的存储
为了方便管理,我们将硬盘分为大小相同的单元,称之为块,每个块的大小为扇区的整数倍,常见为4K。在格式化的时候,这个值是可以设置的
一大块硬盘被分为一个一个的块,这样子,我们存放文件的时候,就不需要分配一段连续的空间,可以将其分为一块一块存储,然后记录好相应的位置,方便添加、删除和插入数据
为了维护这些文件的存储信息,我们需要建立一个索引区域,来方便查找文件的存储位置。另外,文件还有元数据部分,例如名字、权限等信息,这些信息存放在inode结构中
什么是inode?i 表示 index,“索引”,inode 就表示索引节点,每个文件都会对应一个 inode,一个文件夹也是一个文件,所以也对应一个 inode
inode 在内核中的定义如下
struct ext4_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size_lo; /* Size in bytes */
__le32 i_atime; /* Access time */
__le32 i_ctime; /* Inode Change time */
__le32 i_mtime; /* Modification time */
__le32 i_dtime; /* Deletion Time */
__le16 i_gid; /* Low 16 bits of Group Id */
__le16 i_links_count; /* Links count */
__le32 i_blocks_lo; /* Blocks count */
__le32 i_flags; /* File flags */
......
__le32 i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
__le32 i_generation; /* File version (for NFS) */
__le32 i_file_acl_lo; /* File ACL */
__le32 i_size_high;
......
};
- i_mode:文件的读写权限
- i_uid:属于哪个用户
- i_gid:属于哪个组
- i_size_lo:文件大小
- i_blocks_lo:文件占了多少块
ls命令获取文件信息,就是从这里面取出来的
- i_atime:Access time 最近一次访问的时间
- i_ctime:最近一个修改文件的时间(修改文家属性或者内容都会改变)
- i_mtime:最近一次修改文件内容的时间
上面说的,“每个文件会被分为几块,这些块的位置在哪里“,这些都保存在 inode 中的 i_block 中
具体是如何保存的?先看 EXT4_N_BLOCKS 的定义,总共有 15 项
#define EXT4_NDIR_BLOCKS 12
#define EXT4_IND_BLOCK EXT4_NDIR_BLOCKS
#define EXT4_DIND_BLOCK (EXT4_IND_BLOCK + 1)
#define EXT4_TIND_BLOCK (EXT4_DIND_BLOCK + 1)
#define EXT4_N_BLOCKS (EXT4_TIND_BLOCK + 1)
在 ext2 和 ext3 中,前12项直接保存文件存储的块的位置,也就是直接通过 i_block[0-11] 就可以直接得到存储文件内容块的位置
一个块的大小默认是 4K,显然,如果文件太大,12块是放不下的。当我们放在 i_block[12] 的时候,就不能直接存放数据块的位置了,这时候我们将其指向一个块,但是这个块不存放文件内容,而是存放其它数据块的位置,这个块我们称之为 间接块
接下俩的 i_block[13] 和 i_block[14] 也是一样的道理,分别是 二次间接 和 三次间接,通过这样的方式,一个 inode 可以指向的数据块空间就非常大了
但是这里有一个显著的缺点,对于大文件,因为间接块的缘故,需要多次读取磁盘,才能找到相应的块,这样子访问速度就比较慢
这样子,ext4 做了一定的改进,引入了一个新概念,叫做 Extents
下面来解释以下 Extents
假设一个文件有128M,那么就需要 32K 个块。如果按照 ext2 和 ext3 这样的方式存放,那么数据块会非常分散,数量非常多。但是 Extents 可以存放连续的块,也就是我们可以在128M的连续空间记录到一个 Extents里面。这样子,文件的读写性能就提高了,文件碎片就减少了
Extents 是如何保存数据块的,它起始存储成一棵树
树有一个一个的节点,有分支节点,也有叶子节点。每个节点都有一个头部,叫做 ext4_extent_header,定义如下
struct ext4_extent_header {
__le16 eh_magic; /* probably will support different formats */
__le16 eh_entries; /* number of valid entries */
__le16 eh_max; /* capacity of store in entries */
__le16 eh_depth; /* has tree real underlying blocks? */
__le32 eh_generation; /* generation of the tree */
};
eh_entries 表示这个节点里面有多少项
这里的项分为两种,如果是叶子节点,那么节点里的项会指向硬盘上的连续块的地址,我们称为数据节点 ext4_extent;如果是分支节点,那么节点里的项就会指向分支节点或者叶子节点,我们称之为索引节点 ext4_extent_idx。这两种类型的项大小都是12字节,定义如下
/*
* This is the extent on-disk structure.
* It's used at the bottom of the tree.
*/
struct ext4_extent {
__le32 ee_block; /* first logical block extent covers */
__le16 ee_len; /* number of blocks covered by extent */
__le16 ee_start_hi; /* high 16 bits of physical block */
__le32 ee_start_lo; /* low 32 bits of physical block */
};
/*
* This is index on-disk structure.
* It's used at all the levels except the bottom.
*/
struct ext4_extent_idx {
__le32 ei_block; /* index covers logical blocks from 'block' */
__le32 ei_leaf_lo; /* pointer to the physical block of the next *
* level. leaf or next index could be there */
__le16 ei_leaf_hi; /* high 16 bits of physical block */
__u16 ei_unused;
};
其中一个 ext4_extent 就可以指向128MB连续的数据块
在 ext4_inode 中,i_block 是一个15个32位元素的数据,总共有60个字节
而 ext4_extent_header 大小为12字节,ext4_extent 的大小为12字节
所以 单纯靠 inode 中的 i_block 就可以存储1个 ext4_extent_header 和4个 ext4_extent
而1个 ext4_extent 就可以指向一大片连续的块,所以对于文件不大,这样子完全足够;如果文件太大,没有足够的连续块空间,那么就需要裂变成一棵树
除了根节点,其他节点都存放在一个块 4K 里面,4K 扣除 ext4_extent_header 的12个字节,还能存储340项,每个 ext4_extent 可以指向 128MB 连续的数据块,所以340个 extent 能够表达的最大小为 42.5 G,这已经非常大了
二、inode 位图和块位图
到这里,我们知道了,磁盘中肯定有一系列的 inode 和一系列的块排列起来
接下来的问题是,如果要保存一个 inode 或者一个数据块,应该存放到磁盘上的哪个位置?拿到要将所有的 inode 列表和块列表扫面一遍,然后找个空位放吗?
显然,这样子的效率是非常低的。所以在文件系统中,我们需要专门弄一块来保存 inode 的位图。这这4k里面,每一位对应一个 inode,如果为1,就表示这个 inode 被占用,如果为0,就表示没被用。同样的,也弄了一块来保存 block 的位图
接下来看位图在Linux内核中是如何起作用的。上一篇文章讲过,如果创建一个新文件,通过调用 open,然后指定参数 O_CREAT。这表示当文件找不到的时候,就创建一个。open 是一个系统调用,对应内核的 sys_open,定义如下
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
这里重点看对于 inode 的操作
调用链为:do_sys_open -> do_filp_open -> path_openat -> do_last -> lookup_open,这个调用链的逻辑是,要打开一个文件,先根据路径找文件夹,如果发现文件夹下面没有这个文件,同时又设置了 O_CREAT,就说明我们需要在这个文件夹下面创建一个文件,这就需要一个新的 inode
static int lookup_open(struct nameidata *nd, struct path *path,
struct file *file,
const struct open_flags *op,
bool got_write, int *opened)
{
......
if (!dentry->d_inode && (open_flag & O_CREAT)) {
......
error = dir_inode->i_op->create(dir_inode, dentry, mode,
open_flag & O_EXCL);
......
}
......
}
创建新的 inode,需要调用 dir_inode->i_op->create。它的定义如下
const struct inode_operations ext4_dir_inode_operations = {
.create = ext4_create,
.lookup = ext4_lookup,
.link = ext4_link,
.unlink = ext4_unlink,
.symlink = ext4_symlink,
.mkdir = ext4_mkdir,
.rmdir = ext4_rmdir,
.mknod = ext4_mknod,
.tmpfile = ext4_tmpfile,
.rename = ext4_rename2,
.setattr = ext4_setattr,
.getattr = ext4_getattr,
.listxattr = ext4_listxattr,
.get_acl = ext4_get_acl,
.set_acl = ext4_set_acl,
.fiemap = ext4_fiemap,
};
这里定义了文件夹 inode 的操作集,create 对应 ext4_create
接下来的调用链是:ext4_create -> ext4_new_inode_start_handle -> __ext4_new_node。在 __ext4_new_node 函数中,会创建新的 inode
struct inode *__ext4_new_inode(handle_t *handle, struct inode *dir,
umode_t mode, const struct qstr *qstr,
__u32 goal, uid_t *owner, __u32 i_flags,
int handle_type, unsigned int line_no,
int nblocks)
{
......
inode_bitmap_bh = ext4_read_inode_bitmap(sb, group);
......
ino = ext4_find_next_zero_bit((unsigned long *)
inode_bitmap_bh->b_data,
EXT4_INODES_PER_GROUP(sb), ino);
......
}
这里的逻辑是,从文件系统读取 inode 位图,找到下一个0,也就下一个空闲的 inode
对于 block 位图,在写入文件的时候,也有这个过程
三、文件系统的格式
看起来,我们可以 inode 位图和 block 位图来创建文件了。但是如果仔细算一算,还是有问题
数据块的位图存放在一个块里面,也就是4K大小,总共 4*1024*8 = 2^15 个位。一个数据块大小为 4K,所以可以表示 2^15 * 4K = 128M,也就是一个块的位图最多只能表示128M大小的连续数据块
这是否太小了,所以又分为一个一个的块组,每个块组里面有 一个块的位图 + 一系列的块 外加 一个块的 inode 位图 + 一系列的inode结构,大小最大为128M
对于块组,我们需要一个数据结构来表示(ext4_group_desc)。这里面有 inode 位图(bg_inode_bitmap_lo)、块位图(bg_block_bitmap_lo)、inode 列表(bg_inode_table_lo)
这样一个一个的块组,就基本构成了整个文件系统的结构。因为块组有多个,所以块组描述符也组成了一个列表,我们称之为块组描述符
当然,我们还需要一个数据结构,对整个文件系统进行描述,这个就是超级块(ext4_super_block)。这里里面有整个文件系统一共有多少个 inode(s_inodes_count);一共有多少块(s_block_count_lo);每个块组有多少个 inode(s_inodes_pre_group);每个块组有多少块(s_blocks_per_group)等。这些都是全局信息
如果是一个启动盘,我们需要预留一块区域作为引导区,所以第一个块组前面要预留 1K,用于启动引导区
最终,整个文件系统就变成下面这样
这里需要注意的是,超级块和块组描述符都是比较重要的全局信息,如果这些信息损坏,那比损坏一个文件严重多了。所以对于这两部分数据,需要做好备份,但是采用不同的备份策略
默认情况下,超级块和块组都有副本保存在每一个块组里面
如果开启 sparse_super 特性,超级块和块组描述符的副本只会保存在块组索引为 0、3、5、7 的整数幂里
对于超级块来说,超级块不是很大,所以备份多也没关系
但是对于块组描述符表来讲,如果每个块组里面都保存一份块组描述符表,一方面是浪费空间;一方面是由于一个块组最大128M,而块组描述符有多少项,就限制了有多少个块组,整个文件系统的大小 = 128M * 块组总数,这样子,文件系统的大小就被限制住了
我们改进的思路就是引入 Meta Block Groups 特性
首先,块组描述符表不会保存所有块组的描述符了,而是讲块组分为多个组,我们称为元块组(Meta Block Groups)。一个元块组包含64个块组,也就是一个元块组的块组描述符表仅仅包含64个块组描述符
假设一共有256个块组,原来是一整个块组描述符表有256项,现在是分为4个元块组,每个元块组有64个块组,所以每个元块组的块组描述符表就有64个块组描述符,这就小得多了,而且这四个元块组是自己备份自己的
图中,每个元块组包含64个块组,其中有其对应的块组描述符表,分别在该元块组的第一项和第二项以及最后一项备份
这样可以发挥 ext4 的48位块寻址的优势了,在超级块 ext4_super_block 的定义中,我们可以看到块寻址的高位和低位,均为32位,其中有用的是48位,2^48个块是 1EB
struct ext4_super_block {
......
__le32 s_blocks_count_lo; /* Blocks count */
__le32 s_r_blocks_count_lo; /* Reserved blocks count */
__le32 s_free_blocks_count_lo; /* Free blocks count */
......
__le32 s_blocks_count_hi; /* Blocks count */
__le32 s_r_blocks_count_hi; /* Reserved blocks count */
__le32 s_free_blocks_count_hi; /* Free blocks count */
......
}
四、目录的存储形式
通过前面的描述,我们知道一个普通文件是如何存储的。有一类特殊的文件 —— 目录,它是如何保存的呢?
起始目录也是个文件,也有 inode,inode 也指向一些块。和普通文件不同的是,普通文件的块保存的是文件的信息,而目录文件的块保存的是一项一项的文件信息。这些信息我们称为 ext4_dir_entry
从代码看,有两个版本,在成员变量几乎没有差别,只不过第二个版本 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,然后就可以查看文件了
第一项为 “.”,表示当前目录;第二项为 “…”,表示上一级目录
有时候目录下的文件太多,一项一项去查找太慢了,于是我们添加了索引模式
如果 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[0];
};
首先出现的还是差不多,第一项是 ”.“,表示当前目录,第二项是 ”…“,表示上一级目录,这两个是不变的。接下来开始发生变化,是一个 dx_root_info 的结构,其中重要的成员是 indirect_levels,表示间接索引的层次
接下来就是索引项 dx_entry,定义如下
struct dx_entry
{
__le32 hash;
__le32 block;
};
查找文件时,首先通过名称获取哈希值,如果哈希能够匹配上,表明这个文件的信息在相应的块中,然后打开这个块,如果里面不在是索引,而是索引树的叶子节点的话,那么这里面就是一项一项的 ext4_dir_entry_2,只需要按照文件名来查找就行
五、软链接和硬链接的存储格式
所谓的链接,可以理解为文件的别名,链接有两种,软链接和硬链接,可以通过下面命令创建
ln [参数][源文件或目录][目标文件或目录]
ln -s 是创建软链接,不带 -s 是创建硬链接,它们有什么区别呢?
首先查找文件首先是通过目录文件的文件列表进行查找的
对于硬链接,只是在目录文件的文件列表中增加一项,而这一项的 inode 指向原文件的 inode,并没有创建新的inode,这种方式是无法跨文件系统的
对于软链接,在目录文件的文件列表中增添一项,然后再创建一个新的 inode,inode 的数据块里的内容是指向另一个文件,这种方式可以跨文件系统,并且原文件被删除,那么软链接文件还存在
六、总结
对于我们最常用到的两个概念是,一个是 inode,一个是数据块
无论是文件还是目录,都是一个文件,它们都有自己的 inode,每个 inode 指向相应的数据块
普通文件的数据块存放着文件信息
目录文件的数据块存放的是一项一项的文件信息,每一项都包含 文件名 和 inode 编号,用于找到文件对应的 inode