Linux 内核设计,基本上是围绕着“一切皆文件”的思想来展开的。所以,我们能见到的系统调用或者模块功能,都需要使用文件系统。假如没有文件系统,我们就只能直接操作块设备、字符设备等,这需要对外部存储设备运行原理非常了解才行,比如机械盘的结构、不同设备的驱动程序。文件系统的出现给用户屏蔽了这些细节,让我们把精力集中在数据结构组织上,而不用关注硬件的运行原理。
本章着重介绍 Linux 的文件系统,包括以下内容:
Linux 文件系统的架构以及文件系统领域模型概念。
Linux 文件系统的主要功能:安装、卸载、创建、删除、读写等。
ext4 文件系统的相关功能。
TFS 小文件系统。
6.1 Linux 文件系统架构
我们首先围绕文件系统相关的数据结构来介绍文件系统的系统架构。在一个操作系统上,往往会跟多种不同的文件系统打交道,例如上一章提到的 sysfs 和 proc 文件系统等。为了让应用程序开发人员友好地使用文件系统,屏蔽底层各类文件系统的细节,Linux 通过 VFS(虚拟文件系统)抽象层暴露给上层(见图6-1),至于底层是用 ext4 文件系统还是其他文件系统,甚至网络文件系统,可以有不同的具体实现。
下面是 VFS 层的接口(代码详见:Linux-4.15.8/include/Linux/fs.h):
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iterate) (struct file *, struct dir_context *);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
…
图6-1 VFS 接口层
下面我们来了解一下 Linux 文件系统的领域模型,Linux 对文件系统主要抽象了如下概念:
超级块(super block)存放已安装文件系统的有关信息。主要保存了文件系统的类型、逻辑块的大小、文件系统挂载的根目录等,其中文件系统的根目录对应了 dentry 结构。
索引节点(inode)存放关于具体文件的一般信息。每个索引节点对象都有一个索引节点号,这个节点号唯一地标识文件系统中的文件,inode 指向数据块,为了解决保存大文件的问题,inode 的块指针是由多级指针结构组成的。
文件(file)存放打开文件与进程之间进行交互的有关信息,这类信息仅当进程访问文件期间存在内核内存中。
目录项(dentry)存放目录项(也就是文件的特定名称)与对应文件进行链接的有关信息,如文件的名字和目录项对应的 inode。假如 dentry 是个目录,那么 inode 的块数据中就保存了目录下所有文件或者子目录的名字。否则 inode 中的块数据中保存的是文件的数据。
下面从进程视角来展开,一个进程保存了 fs 结构和 files 结构。其中 fs 结构对应了用户对应文件系统的根目录和当前目录,都指向了 dentry 结构。files 结构中保存了一个 fdtable 结构,里面有该进程打开的所有文件句柄 fd,fd 指向了 file 文件结构,里面保存了文件对应的 dentry 项和文件的操作集合 files_operations,如图6-2所示。
图6-2 Linux 文件系统领域模型
6.2 文件系统的主要功能
了解完文件系统的核心概念之后,我们接着介绍文件系统的主要功能。
关于文件读写的部分,在查找获取文件句柄之后,就可以对该文件进行读写了。文件的读写过程已在前一章中介绍,因为 block 层在文件系统之下,所以已经覆盖了流程,这里不再进行介绍了。下面详细介绍文件系统的安装和文件路径查找。
6.2.1 文件系统的安装
虽然我们不知道先有鸡还是先有蛋,但是我们知道要使用文件系统,必然需要初始化,这个初始化安装其实是对超级块(super block)数据结构的初始化。这个工作由 mount 系统调用完成,sys_mount 会最终找到具体文件系统的 file_system_type 结构,然后执行挂载方法,比如 ext4 文件系统的挂载方法为 ext4_mount:
static struct file_system_type ext4_fs_type = {
.owner = THIS_MODULE,
.name = "ext4",
.mount = ext4_mount,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};
ext4_mount 最终调用了 mount_bdev,该函数主要完成 superblock 对象的内存初始化,并且加入到全局 superblock 链表中:
struct dentry *mount_bdev(struct file_system_type *fs_type,
int flags, const char *dev_name, void *data,
int (*fill_super)(struct super_block *, void *, int))
{
struct block_device *bdev;
struct super_block *s;
fmode_t mode = FMODE_READ | FMODE_EXCL;
int error = 0;
if (!(flags & MS_RDONLY))
mode |= FMODE_WRITE;
// 根据设备名字打开块设备
bdev = blkdev_get_by_path(dev_name, mode, fs_type);
…
// 在块设备上查找或者创建一个 super_block
s = sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC,
bdev);
…
if (s->s_root) {
// 被挂载文件系统的根目录项已经存在
if ((flags ^ s->s_flags) & MS_RDONLY) {
deactivate_locked_super(s);
error = -EBUSY;
goto error_bdev;
}
…
} else {
// 文件系统根目录项不存在,通过 filler_super 函数读取磁盘上的 superblock 元数据信息,并且初始化 superblock 内存结构
s->s_mode = mode;
snprintf(s->s_id, sizeof(s->s_id), "%pg", bdev);
sb_set_blocksize(s, block_size(bdev));
// 在 ext4 中传入的是 ext4_fill_super
error = fill_super(s, data, flags & MS_SILENT ? 1 : 0);
if (error) {
deactivate_locked_super(s);
goto error;
}
s->s_flags |= MS_ACTIVE;
bdev->bd_super = s;
}
// 返回被挂载文件系统的根目录
return dget(s->s_root);
…
}
6.2.2 文件路径查找
当文件系统安装完之后,假如我们需要读写一个文件,就必须先查找该文件的路径。为什么要有这个概念呢?因为给定某个路径之后,我们需要找到对应的 inode,执行这一任务的标准过程就是分析路径名,并把它拆分成一个文件名序列。除了最后一个文件名以外,所有其他的文件名都必定是目录。
如果路径名的第一个字符是“/”,例如:/Users/chenke/softs/tfs-2.6.6/build.sh,那么这个路径名是绝对路径,需要从 current->fs->root 所标识的目录开始搜索。否则,路径名是相对路径,需要从 currrent->fs->pwd 所标识的目录开始搜索。
和查找相关的有两个比较重要的概念,nameidata 和 path:
struct nameidata {
struct path path; // 查找的路径
struct qstr last; // 路径名的最后一个分量
struct path root; // 开始查找的根路径
struct inode *inode; // 文件或者文件夹的 inode
unsigned int flags;
unsigned seq, m_seq;
int last_type;
unsigned depth; // 符号链接嵌套的当前级别
int total_link_count;
struct saved {
struct path link;
struct delayed_call done;
const char *name;
unsigned seq;
} *stack, internal[EMBEDDED_LEVELS];
struct filename *name;
struct nameidata *saved;
struct inode *link_inode;
unsigned root_seq;
int dfd;
};
struct path {
struct vfsmount *mnt; // 该路径挂载的设备(或者文件系统)
struct dentry *dentry; // 路径的 dentry 结构
};
下面我们通过 sys_open 系统调用来分析文件路径查找的过程,sys_open 通过 sys_open->do_sys_open->do_filp_open->path_openat 的调用,其核心为 path_openat 函数:
static struct file *path_openat(struct nameidata *nd,
const struct open_flags *op, unsigned flags)
{
const char *s;
struct file *file;
int opened = 0;
int error;
// 获取一个未被使用的 file 结构
file = get_empty_filp();
if (IS_ERR(file))
return file;
…
s = path_init(nd, flags);
if (IS_ERR(s)) {
put_filp(file);
return ERR_CAST(s);
}
while (!(error = link_path_walk(s, nd)) &&
(error = do_last(nd, file, op, &opened)) > 0) {
nd->flags &= ~(LOOKUP_OPEN|LOOKUP_CREATE|LOOKUP_EXCL);
s = trailing_symlink(nd);
…
}
terminate_walk(nd);
out2:
if (!(opened & FILE_OPENED)) {
…
put_filp(file);
}
…
return file;
}
path_openat 的主要工作就是根据指定的路径一层一层查找,直到找到最终的文件或者文件夹,并且返回文件句柄(file)为止。
其主要的步骤如下:
1)通过 path_init 初始化查找的根路径,这里会用到上面介绍的 nameidata 结构,用来保存查找的临时数据:
static const char *path_init(struct nameidata *nd, unsigned flags)
{
int retval = 0;
const char *s = nd->name->name;
nd->last_type = LAST_ROOT; /* 到头了 */
nd->flags = flags | LOOKUP_JUMPED | LOOKUP_PARENT;
nd->depth = 0;
if (flags & LOOKUP_ROOT) { // 直接从根开始搜索
struct dentry *root = nd->root.dentry; // 根目录 dentry
struct inode *inode = root->d_inode; // 根目录 inode
if (*s) {
if (!d_can_lookup(root)) // 假如不是普通目录结构,出错
return ERR_PTR(-ENOTDIR);
retval = inode_permission(inode, MAY_EXEC); // 文件夹是否有 x 权限
if (retval)
return ERR_PTR(retval);
}
nd->path = nd->root;
nd->inode = inode;
if (flags & LOOKUP_RCU) {
rcu_read_lock();
nd->seq = __read_seqcount_begin(&nd->path.dentry->d_seq);
nd->root_seq = nd->seq;
nd->m_seq = read_seqbegin(&mount_lock);
} else {
path_get(&nd->path);
}
return s;
}
nd->root.mnt = NULL;
nd->path.mnt = NULL;
nd->path.dentry = NULL;
nd->m_seq = read_seqbegin(&mount_lock); // 如果文件名是以'/'开头的
if (*s == '/') {
if (flags & LOOKUP_RCU)
rcu_read_lock();
set_root(nd); // 因为有可能 chroot 已经改变了真正的/,所以需要获取 mntget(path->>mnt);dget(path->dentry);
if (likely(!nd_jump_root(nd))) // 非 LOOKUP_JUMPED 情况
return s;
nd->root.mnt = NULL;
rcu_read_unlock();
return ERR_PTR(-ECHILD);
} else if (nd->dfd == AT_FDCWD) { // 假如是相对路径
if (flags & LOOKUP_RCU) {
struct fs_struct *fs = current->fs;
unsigned seq;
rcu_read_lock();
do {
seq = read_seqcount_begin(&fs->seq);
nd->path = fs->pwd;
nd->inode = nd->path.dentry->d_inode;
nd->seq = __read_seqcount_begin(&nd->path.dentry->d_seq);
} while (read_seqcount_retry(&fs->seq, seq));
} else {
get_fs_pwd(current->fs, &nd->path); // 从 current->fs 的当前目录下开始查找
nd->inode = nd->path.dentry->d_inode;
}
return s;
} else { // 假如是个文件
struct fd f = fdget_raw(nd->dfd);
struct dentry *dentry;
if (!f.file)
return ERR_PTR(-EBADF);
dentry = f.file->f_path.dentry;
if (*s) { // 不是个目录
if (!d_can_lookup(dentry)) {
fdput(f);
return ERR_PTR(-ENOTDIR);
}
}
nd->path = f.file->f_path;
if (flags & LOOKUP_RCU) {
rcu_read_lock();
nd->inode = nd->path.dentry->d_inode;
nd->seq = read_seqcount_begin(&nd->path.dentry->d_seq);
} else {
path_get(&nd->path);
nd->inode = nd->path.dentry->d_inode; // 获取文件的 inode
}
fdput(f);
return s;
}
}
path_init 在执行过程中,主要任务是设置 nd 结构的 path 和 inode,分成以下4种情况:
假如 flag 中强制指定从根开始查找,则直接获取根的 dentry 和 inode。
如果文件名是以'/'开头的,则需要判断有没有因为 chroot 后'/'发生了变化。
如果文件名是相对路径,则从 current->fs 获取当前工作目录。
假如是个文件,获取该文件的 inode。
其中 dentry 和 inode 之间的关系如图6-3所示,我们从图6-2中已经知道,每个进程都会对应一个 fs_struct 结构,其中的 root 就是根路径的 dentry,而图6-3则说明了 dentry 和 inode 的关系,dentry 结构中保存了其 inode 索引,inode 中可以读取 dentry 数据,dentry 结构中的 d_subdirs 则保存了该目录下的子目录。
图6-3 dentry 和 inode 的关系(其中的 /home/test1和/home/test2 之间是硬连接关系)
path_init 返回之后 nd 中的 path 就已经设定为起始路径了,现在可以开始遍历路径了。
2)link_path_walk 根据 path_init 之后的 nd,从指定的根路径开始一级一级读取节点数据,最终找到指定的文件或者目录:
static int link_path_walk(const char *name, struct nameidata *nd)
{
int err;
while (*name=='/') // 跳过'/'
name++;
if (!*name) // 假如后面没东西,则返回0
return 0;
for(;;) {
u64 hash_len;
int type;
err = may_lookup(nd);
…
hash_len = hash_name(name);
type = LAST_NORM;
if (name[0] == '.') switch (hashlen_len(hash_len)) {
case 2:
if (name[1] == '.') { // ..的情况
type = LAST_DOTDOT;
nd->flags |= LOOKUP_JUMPED;
}
break;
case 1: // .的情况
type = LAST_DOT;
}
if (likely(type == LAST_NORM)) { // 正常情况
struct dentry *parent = nd->path.dentry;
nd->flags &= ~LOOKUP_JUMPED;
if (unlikely(parent->d_flags & DCACHE_OP_HASH)) {
struct qstr this = { { .hash_len = hash_len }, .name = name };
err = parent->d_op->d_hash(parent, &this); // 对 parent 目录进行 hash 计算
…
hash_len = this.hash_len;
name = this.name;
}
}
nd->last.hash_len = hash_len;
nd->last.name = name;
nd->last_type = type;
name += hashlen_len(hash_len);
if (!*name) // 假如后面没东西了
goto OK;
do {
name++;
} while (unlikely(*name == '/')); // 当遇到'/'跳出循环
if (unlikely(!*name)) { // 如果 name 存在值
OK:
// 路径名 body 获取完成
if (!nd->depth)
return 0;
name = nd->stack[nd->depth - 1].name;
// 追踪 symlink, 完成
if (!name)
return 0;
// 该部分 name 不为空,则需要处理符号链接
err = walk_component(nd, WALK_GET | WALK_PUT);
} else {
err = walk_component(nd, WALK_GET);
}
…
}
}
link_path_work 首先在跳过最前面的'/'后(因为在 path_init 后,我们已经知道了 root 路径),就进入了 for 循环进行路径的处理。首先处理了.或者..开头的情况。假如是..则需要跳到父目录,则设置 type=LAST_DOTDOT;nd->flags|=LOOKUP_JUMPED;如果是.开头,说明是当前目录,则设置 type=LAST_DOT. 默认 type 为 LAST_NORM。
接着在遍历路径的时候,遇到'/'则跳出,截取出来一个 component,这里分为两种情况,假如 component 的 name 已经为空了,则说明已经到结尾了,否则,有可能该 component 为符号链接,那么需要进行处理,传入的 flag 需要附加 WALK_PUT。
nd 的设置如下:
nd->last.hash_len = hash_len;
nd->last.name = name;
nd->last_type = type;
下面我们来分析一下对 component 的处理 walk_component:
static int walk_component(struct nameidata *nd, int flags)
{
struct path path;
struct inode *inode;
unsigned seq;
int err;
if (unlikely(nd->last_type != LAST_NORM)) {
// 处理目录是'.'和'..’的情况,'.'很好处理,直接跳过就可以了,'..'稍微麻烦,因为当前目录有可能是一个装载点,跳到上一级目录就要切换文件系统
err = handle_dots(nd, nd->last_type);
if (flags & WALK_PUT)
put_link(nd); // 把当前的 nd 存入 dcache 中
return err;
}
err = lookup_fast(nd, &path, &inode, &seq);
if (unlikely(err)) {
..
err = lookup_slow(nd, &path);
…
}
if (flags & WALK_PUT)
put_link(nd);
err = should_follow_link(nd, &path, flags & WALK_GET, inode, seq);
…
path_to_nameidata(&path, nd);
nd->inode = inode;
nd->seq = seq;
return 0;
out_path_put:
path_to_nameidata(&path, nd);
return err;
}
在 walk_component 处理过程中,如果传入的 nd->lasttype 为.或者..则进入 handle_dots 进行处理:
static inline int handle_dots(struct nameidata *nd, int type)
{
if (type == LAST_DOTDOT) {
if (!nd->root.mnt)
set_root(nd);
if (nd->flags & LOOKUP_RCU) {
return follow_dotdot_rcu(nd);
} else
return follow_dotdot(nd);
}
return 0;
}
假如是,那么直接返回0,否则..的情况需要进行特殊处理,我们这里仅分析 follow_dotdot 的情况:
static int follow_dotdot(struct nameidata *nd)
{
while(1) {
struct dentry *old = nd->path.dentry;
if (nd->path.dentry == nd->root.dentry &&
nd->path.mnt == nd->root.mnt) { // 如果搜索的当前路径和进程的 root 路径一样,那就不能再往上一级查找了
break;
}
if (nd->path.dentry != nd->path.mnt->mnt_root) { // 如果当前的路径不是设备的根路径,则还是同一个设备,获取父目录即可
nd->path.dentry = dget_parent(nd->path.dentry);
dput(old);
if (unlikely(!path_connected(&nd->path)))
return -ENOENT;
break;
}
if (!follow_up(&nd->path)) // 节点已经到达设备的根路径,假如设备挂在了父设备,则先获取父设备
break;
}
follow_mount(&nd->path); // 从父设备中获取子节点路径
nd->inode = nd->path.dentry->d_inode;
return 0;
}
follow_dotdot 在处理..的时候,分为以下三种情况:
搜索的当前路径和进程的 root 路径一样,并且当前路径对应的设备和进程 root 路径对应的设备一样,那么..就无法往上一级目录查找了。
假如当前路径不是路径对应设备挂载树的根,那么通过 dget_parent 获取上一级目录,即获取 dentry->d_parent。
搜索路径节点已经到达设备的根路径,假如设备挂载了父设备,则先通过 follow_up 获取父设备:
int follow_up(struct path *path)
{
struct mount *mnt = real_mount(path->mnt);
struct mount *parent;
struct dentry *mountpoint;
parent = mnt->mnt_parent;
mntget(&parent->mnt);
mountpoint = dget(mnt->mnt_mountpoint);
…
path->dentry = mountpoint;
…
path->mnt = &parent->mnt;
return 1;
}
在获取父设备成功后,通过 follow_mount 从父设备中获取子节点路径。
图6-4描述了文件系统的挂载结构。
图6-4 文件系统的挂载结构
在处理完.和..之后,就通过 lookup_fast 或者 lookup_slow 查询当前 path 的 dentry 结构,lookup_fast 会优先从 rcu 内存散列表中找到对应的 dentry 结构,我们这里仅分析 lookup_slow:
static int lookup_slow(struct nameidata *nd, struct path *path)
{
struct dentry *dentry, *parent;
parent = nd->path.dentry;
…
dentry = __lookup_hash(&nd->last, parent, nd->flags);
…
path->mnt = nd->path.mnt;
path->dentry = dentry;
return follow_managed(path, nd);
}
其中 __lookup_hash 最终会通过具体文件系统(比如 ext4 文件系统)的 dir->i_op->lookup(dir,dentry,flags)函数进行查询。
walk_component 最后一步会通过 should_follow_link 对符号链接进行处理:
static inline int should_follow_link(struct nameidata *nd, struct path *link,
int follow,
struct inode *inode, unsigned seq)
{
if (likely(!d_is_symlink(link->dentry))) // 假如路径不是符号链接
return 0;
if (!follow)
return 0;
…
return pick_link(nd, link, inode, seq);
}
static int pick_link(struct nameidata *nd, struct path *link,
struct inode *inode, unsigned seq)
{
int error;
struct saved *last;
if (unlikely(nd->total_link_count++ >= MAXSYMLINKS)) {
path_to_nameidata(link, nd);
return -ELOOP;
}
if (!(nd->flags & LOOKUP_RCU)) {
if (link->mnt == nd->path.mnt)
mntget(link->mnt);
}
error = nd_alloc_stack(nd);
…
last = nd->stack + nd->depth++;
last->link = *link;
…
nd->link_inode = inode;
last->seq = seq;
return 1;
}
为防止链接死循环,经过 MAXSYMLINKS 最大链接深度判断之后,把 path 转换成了 namei,然后会在上层 link_path_walk->get_link 中进行真正的获取 link 的操作:
const char * (*get)(struct dentry *, struct inode *,
struct delayed_call *);
get = inode->i_op->get_link;
if (nd->flags & LOOKUP_RCU) {
res = get(NULL, inode, &last->done);
if (res == ERR_PTR(-ECHILD)) {
if (unlikely(unlazy_walk(nd, NULL, 0)))
return ERR_PTR(-ECHILD);
res = get(dentry, inode, &last->done);
}
} else {
res = get(dentry, inode, &last->done);
}
if (IS_ERR_OR_NULL(res))
return res;
这个过程依赖文件系统自己实现的 inode->i_op->get_link。
最后,我们来总结一下文件路径查找的过程:
1)通过 path_init 初始化查找的根路径,把数据保存到 nd 结构中。
2)通过 link_path_walk 遍历路径,遇到'/'就跳出,通过 walk_component 处理该 component,然后获取路径的 inode、dentry、mnt 等数据。
6.3 ext4 文件系统
目前 Linux 上安装的主要是 ext4 文件系统,ext4 是 ext3 的改进版,修改了 ext3 中部分重要的数据结构,而不仅仅像 ext3 对 ext2 那样,只是增加了一个日志功能而已。
ext4 文件系统的主要特性包括以下内容:
支持更大的文件系统和更大的文件。较之 ext3 目前所支持的最大 16TB 文件系统和最大 2TB 文件,ext4 分别支持 1EB(1048576TB,1EB=1024PB,1PB=1024TB)的文件系统,以及 16TB 的文件。
无限数量的子目录。ext3 目前只支持32000个子目录,而 ext4 支持无限数量的子目录。
extent tree 结构。ext3 采用间接块映射,当操作大文件时,效率极其低下。ext4 提供了 extent tree 结构,来解决大文件问题。
多块分配,支持一次调用分配多个数据块。
延迟分配,只有缓存数据脏的时候才写入到磁盘块。
No Journaling 模式。ext4 允许关闭日志,以便某些有特殊场景提升性能,去除日志开销。
6.3.1 磁盘布局
文件系统中最关键的问题就是数据如何在磁盘布局,ext4 引入了2种新的布局方式:灵活块、元块组集。
1.灵活块(flex_bg)
在一个 flex_bg 中,几个块组在一起组成一个逻辑块组 flex_bg。flex_bg 的第一个块组中的位图空间和 inode 表空间扩大为包含了 flex_bg 中其他块组上位图和 inode 表。
这样做的好处是把元数据都聚集在了 BGD0 中,另外使大文件的数据尽量连续了。
图6-5 flex_bg 原理图
上面的 GDT 部分用于将来扩展文件系统。
2.元块组集(mbg)
整个文件系统被分成多个元块组集(meta block groups,mbg),每个元块组集都由一簇“块组”组成(簇的含义:一系列物理地址连续的单元),组成元块组集的块组描述符都存放在一个 block 中。对于 block 大小为 4K 的 ext4 文件系统,一个元块组集包含64个块组,也就是 64G 的磁盘空间(128M*64=8G)。元块组集特性将存放在系统第一个块组的元数据分割存放在多个元块组集中。因为 ext4 支持的是48位 block 寻址方式,所以最大卷大小为2^48个 block,2^48*2^12=2^60B=1EB,而每个组为 128M=2^27B,所以有2^60/2^27=2^33个组。
元块组集特性的出现使得 ext3 和 ext4 的磁盘布局有了一定的变化,以往超级块后紧跟的是变长的 GDT 块,现在是超级块依然决定是否是3,5,7的幂,而块组描述符集则存储在元块组的第一个、第二个和最后一个块组的开始处。
图6-6 元块组集特性
文件系统创建时,用户可以指定使用这种布局,当文件系统增长而且预留的组描述符块耗尽时,超级块中有一个域 s_first_meta_bg 用于描述第一个使用元块组的块组。
6.3.2 inode 定位
当创建文件或者目录的时候,需要创建一个 inode,下面我们来分析 inode 的创建过程:
const struct inode_operations ext4_dir_inode_operations = {
.create = ext4_create,
...
ext4_create->ext4_new_inode_start_handle->__ext4_new_inode,由于该函数过长,只摘录其中一部分:
struct inode *__ext4_new_inode(handle_t *handle, struct inode *dir,
umode_t mode, const struct qstr *qstr,
__u32 goal, uid_t *owner, int handle_type
unsigned int line_no, int nblocks)
{
…
// load inode bitmap 前先检查是否有空闲的 inode
if (ext4_free_inodes_count(sb, gdp) == 0) {
if (++group == ngroups)
group = 0;
continue;
}
grp = ext4_get_group_info(sb, group);
if (EXT4_MB_GRP_IBITMAP_CORRUPT(grp)) {
if (++group == ngroups)
group = 0;
continue;
}
brelse(inode_bitmap_bh);
inode_bitmap_bh = ext4_read_inode_bitmap(sb, group);
if (EXT4_MB_GRP_IBITMAP_CORRUPT(grp) ||
IS_ERR(inode_bitmap_bh)) {
inode_bitmap_bh = NULL;
if (++group == ngroups)
group = 0;
continue;
}
repeatinthis_group:
// 在 inode 位图中查找 ino+1 开始的下一个为0的位
ino = ext4_find_next_zero_bit((unsigned long *)
inode_bitmap_bh->b_data,
EXT4_INODES_PER_GROUP(sb), ino);
// ino >=s_inodes_per_group 或者 ino+1<s_first_ino 则 free inode 无效
if (ino >= EXT4_INODES_PER_GROUP(sb))
goto next_group;
if (group == 0 && (ino+1) < EXT4_FIRST_INO(sb)) {
ext4_error(sb, "reserved inode found cleared - "
"inode=%lu", ino + 1);
continue;
}
…
// 更新 inode bitmap,若更新失败则重新搜索
ext4_lock_group(sb, group);
ret2 = ext4_test_and_set_bit(ino, inode_bitmap_bh->b_data);
ext4_unlock_group(sb, group);
ino++;
…
// 更新 group 中的 free_inodes_count
ext4_free_inodes_set(sb, gdp, ext4_free_inodes_count(sb, gdp) - 1);
…
// 设置inode->ino
inode->i_ino = ino + group * EXT4_INODES_PER_GROUP(sb);
inode->i_blocks = 0;
inode->i_mtime = inode->i_atime = inode->i_ctime = ei->i_crtime =
ext4_current_time(inode);
…
}
block 定位,可以通过 inode->ino 计算 inode 所处的 group 和 block:
Group_id=(inode->ino-1)/inodes_per_group
Inode_blkid=first_inode_blk_in_group+ ((inode->ino-1) % inodes_per_group) / (inodes_per_block);
Inode_offset= first_inode_blk_in_group * blk_size+ ((inode->ino-1) % inodes_per_group) * inode_size
6.3.3 碎片问题解决方案
ext4 在设计上利用了数据局部性原理,不管是机械硬盘还是 SSD,这种策略都可以减少磁盘或者块的请求次数,集中处理。ext4 提供如下几种预防碎片机制:
延迟写入,先写缓存,等数据脏后才写入磁盘,可以对相近的块集中处理。
尽可能使得一个文件的数据和其 inode 在相同的块组。
整个卷被分割为大小为 128M 的组。这样可以最大限度地保证数据局部性。
Prealloc 预分配,ext4 为每个文件在内存中维护一段预分配空间,用于解决并发分配情况下的碎片问题。
6.3.4 extent tree 结构
通过前面的章节我们已经知道文件系统的磁盘组织形式,一般是通过直接+多级间接映射的方式来存储逻辑块号到物理块号的映射关系。这种方式在面对大文件的时候效率较为低下,且会浪费很多的间接块用以存储映射关系。
extent 指的是一段连续的物理磁盘块,ext4 文件系统中的 extent 数据结构的主要作用是索引,即根据逻辑块号查询文件的 extent 能够定位逻辑块对应的物理块号,extent 在文件较小的时候存储在 inode 的 i_data[] 中,在文件较大的时候,extent 会被组织成一棵 B+ 树(如图6-7所示),每个 extent 节点由有1个头和多个 body 组成,假如是非叶子节点,则 body 为 ext4_extend_idx,叶子节点为 ext4_extent:
struct ext4_extent {
__le32 ee_block; // extent 第一个逻辑块
__le16 ee_len; // extent 覆盖的块数量
__le16 ee_start_hi; // 高16位物理块
__le32 ee_start_lo; // 低32位物理块
};
struct ext4_extent_idx {
__le32 ei_block; // 索引所覆盖的文件范围的起始 block
__le32 ei_leaf_lo; // 下一级 extent block 的逻辑地址的低32位
__le16 ei_leaf_hi; // 下一级 extent block 的逻辑地址的高16位
__u16 ei_unused;
};
struct ext4_extent_header {
__le16 eh_magic; // 魔术数,ext4 extent 标识 0xF30A
__le16 eh_entries; // 当前节点中有效 entrie 的数目
__le16 eh_max; // 当前节点中 entry 的最大数目
__le16 eh_depth; // 当前节点在树中的深度
__le32 eh_generation; // generation of the tree
};
ext4 创建文件/目录的时候,会初始化一棵 extent tree,在 __ext4_new_inode 函数中调用了 ext4_ext_tree_init 进行初始化:
if (ext4_has_feature_extents(sb)) {
if (S_ISDIR(mode) || S_ISREG(mode) || S_ISLNK(mode)) {
ext4_set_inode_flag(inode, EXT4_INODE_EXTENTS);
ext4_ext_tree_init(handle, inode);
}
}
…
…
int ext4_ext_tree_init(handle_t *handle, struct inode *inode)
{
struct ext4_extent_header *eh;
eh = ext_inode_hdr(inode);
eh->eh_depth = 0;
eh->eh_entries = 0;
eh->eh_magic = EXT4_EXT_MAGIC;
eh->eh_max = cpu_to_le16(ext4_ext_space_root(inode, 0));
ext4_mark_inode_dirty(handle, inode);
return 0;
}
图6-7 extent tree 数据结构
6.4 淘宝 TFS 小文件系统分析
TFS 是淘宝开发的解决小文件存储的分布式文件系统。关于它的分布式原理我们这里不做介绍,只介绍它的 DataServer 中的存储机制如何解决小文件(通常文件大小不超过 1MB)的问题。当前 SAAS 服务井喷,业界出现了很多云存储公司,比如百度网盘、亿方云等,其核心的存储思路都是把逻辑文件系统跑在物理文件系统之上,TFS 的小文件系统实现思路有一定参考价值。
在 TFS 中,将大量的小文件(实际用户文件)合并成为一个大文件,这个大文件称为块(block)。TFS 以块的方式组织文件的存储。每一个块在整个集群内拥有唯一的编号,这个编号是由 NameServer 进行分配的,而 DataServer 上实际存储了该块。在 NameServer 节点中存储了所有的块的信息,一个块存储于多个 DataServer 中以保证数据的冗余。对于数据读写请求,均先由 NameServer 选择合适的 DataServer 节点返回给客户端,再在对应的 DataServer 节点上进行数据操作。NameServer 需要维护块信息列表,以及块与 DataServer 之间的映射关系,其存储的元数据结构如图6-8所示。
图6-8 TFS 存储元数据结构图
在 Server 节点上,在挂载目录上会有很多物理块,物理块以文件的形式存在磁盘上,并在 Server 部署前预先分配,以保证后续的访问速度和减少碎片产生。为了满足这个特性,Server 现一般在 ext4 文件系统上运行。物理块分为主块和扩展块,一般主块的大小会远大于扩展块,使用扩展块是为了满足文件更新操作时文件大小的变化。每个块在文件系统上以“主块+扩展块”的方式存储。每一个块可能对应于多个物理块,其中包括一个主块、多个扩展块。
在 Server 端,每个块可能会由多个实际的物理文件组成:一个主物理块文件,N 个扩展物理块文件和一个与该块对应的索引文件。块中的每个小文件会用一个块内唯一的 field 来标识。Server 会在启动的时候把自身所拥有的块和对应的 Index 加载进来。
6.5 本章小结
对于使用 Linux 的普通开发者来讲,平时会经常对文件进行读写,因为在 Linux 里一切皆是文件,所以,我们大部分时候对外设的操作、磁盘的读写、网络的读写都是基于文件句柄来进行的。不过,对于普通开发者来讲,我们很少会关注文件系统的底层实现。
要了解文件系统的核心概念,首先要了解 vfs 的超级块、索引节点、文件、目录项等概念,并搞清楚它们之间的关系,然后了解 inode 的定位、文件内容的读写等。
在云计算火热普及的今天,出现了很多围绕文件系统来展开的 SAAS 服务,比如个人网盘,很多时候网盘存储了大量的小文件,inode 就会不够用,这对相关的云计算平台存储架构提出了挑战,本章最后介绍了 TFS 小文件系统的实现方案,假如需要实现相关的系统,可以借鉴这个思路。