Linux内核(6.1源码阅读)-超级块&inode&dentry在内核中的分配

超级块&inode&dentry在内核中的分配

在上一篇文章中笔者大致分析了一下6.1内核中文件系统的注册和挂载的大致流程,但是其中关于超级块和文件inode的实例如何分配(在前文提到的vfs_get_tree中调用的超级块操作结构中的mount来创建超级块和inode,dentry)都没有去做分析,碍于篇幅原因所以放到本文做分析,当然由于笔者是第一次去学习内核源码,如果有分析的不清楚不对的地方欢迎大家加以改正😀

超级块操作

在之前分析中在mount函数调用中调用了我们自己定义的代码

static struct dentry *myfs_mount(struct file_system_type *fs_type, int flags,
                                 const char *dev_name, void *data)
{
    struct dentry *dentry =  mount_nodev(fs_type, flags, data, myfs_fill_super);
    if(IS_ERR(dentry))
        printk(KERN_ERR "myfs:failed to mount\n");
    else
        printk(KERN_INFO "myfs:mount succ\n");
    return dentry;
}

而在mount_nodev中首先是去获取当前文件系统的超级块

struct super_block *s = sget(fs_type, NULL, set_anon_super, flags, NULL);

这里调用的就是sget函数

sget

它的作用是根据给定的文件系统类型和标识参数,在全局超级块列表中查找已有的超级块;如果找不到,则创建并初始化一个新的超级块。

函数原型与参数

//  fs/super.c
struct super_block *sget(struct file_system_type *type,
 int (*test)(struct super_block *, void *),
 int (*set)(struct super_block *, void *),
 int flags,
 void *data)
  • type:文件系统类型,每个文件系统(如 ext4、btrfs)在注册时都会有一个对应的 file_system_type 结构,其中包含 fs_supers 链表头,用于挂载属于该类型的所有超级块。
  • test:一个回调函数,用于判断给定的超级块是否与当前请求匹配(例如根据设备号、设备名等)。sget 会遍历 type->fs_supers 链表,对每个超级块调用 test(old, data),如果返回非零,则认为匹配。
  • set:如果没找到已有超级块,会分配一个新块,然后调用 set(s, data) 来填充特定于该文件系统的字段(如设备信息、读取超级块到内存等)。
  • flags:挂载标志(如 MS_RDONLY 等),其中特殊标志 SB_SUBMOUNT 用于子挂载(如绑定挂载或挂载命名空间内部的传播)。
  • data:传递给 testset 的私有数据,通常包含设备标识或挂载选项。

用户命名空间(user_ns)的处理

首先是对于用户命名空间的处理

struct user_namespace *user_ns = current_user_ns();
if (flags & SB_SUBMOUNT)
    user_ns = &init_user_ns;
  • 默认使用当前进程的用户命名空间。
  • 如果挂载标志中设置了 SB_SUBMOUNT(表示这是一个“子挂载”,即由内核内部发起,而非用户主动挂载),则强制使用初始命名空间(init_user_ns)。

循环查找已有超级块

retry:
spin_lock(&sb_lock);
if (test) {
    hlist_for_each_entry(old, &type->fs_supers, s_instances) {
        if (!test(old, data))
            continue;
        if (user_ns != old->s_user_ns) {
            spin_unlock(&sb_lock);
            destroy_unused_super(s);
            return ERR_PTR(-EBUSY);
        }
        if (!grab_super(old))
            goto retry;
        destroy_unused_super(s);
        return old;
    }
}
  • 遍历:从 type->fs_supers 链表中依次取出每个超级块,调用 test 进行匹配。
  • 命名空间检查:如果找到匹配的 old,但它的 s_user_ns 与当前请求的 user_ns 不一致,则不能复用,直接解锁、销毁可能已分配的 s(后面解释),返回 -EBUSY。注意此时 s 可能为 NULL 或指向之前分配失败的临时对象。
  • 引用计数增加:调用 grab_super(old) 尝试增加超级块的引用计数,并确保它没有被正在销毁。如果 grab_super 返回 0(表示超级块正在被销毁或无法获得引用),则跳到 retry 重新尝试(因为超级块状态可能在锁释放期间发生变化)。这是一个经典的“重试”模式,避免在持有锁时等待。
  • 销毁临时对象:如果此前已经分配了新的超级块 s(因为在第一次遍历时没找到,于是分配了 s 并跳回 retry,但第二次遍历时找到了旧块),则调用 destroy_unused_super(s) 释放这个多余的临时对象,然后返回找到的旧超级块。

这里的s_instanceshlist_node类型, 是一个哈希链表的节点,而type->fs_supers是哈希链表头,相当于使用s_instances当作遍历整个链表的指针

补充:超级块结构体中的两个链表节点

超级块结构体super_block中有两个struct list_head成员:lists_instances,他们的作用分别不同:

成员挂载到哪个链表头?作用域主要用途
s_list全局链表头 super_blocks全局(所有类型)用于内核全局遍历所有的超级块,例如系统关机时同步所有文件系统数据、或内存回收时遍历所有超级块的缓存(shrink)。
s_instances类型专属链表头 type->fs_supers同类型内专门用于 sget 查找复用,以及卸载文件系统模块(unregister_filesystem)时,确保该类型下已没有正在使用的超级块。

打个比方:s_list 像是“全国人口总户口本”(查全国所有人),而 s_instances 像是“按姓氏分类的家族族谱”(只查同姓的人)。sget 要查找特定设备的超级块,只需要翻“家族族谱”(s_instances)即可,无需翻全国户口本,效率更高。

分配新超级块(当没有匹配项时)

if (!s) {
    spin_unlock(&sb_lock);
    s = alloc_super(type, (flags & ~SB_SUBMOUNT), user_ns);
    if (!s)
        return ERR_PTR(-ENOMEM);
    goto retry;
}
  • 如果遍历结束后未找到匹配的超级块,且当前 sNULL(即还没有为新块分配内存),则解锁(因为分配可能睡眠),调用 alloc_super 分配一个全新的 super_block 结构,传入文件系统类型、挂载标志(去除 SB_SUBMOUNT,因为新块不应该是子挂载)和用户命名空间。
  • 分配成功后,跳回 retry,重新持有锁并再次遍历链表。
    为什么分配后要重试? 因为在分配过程中(锁被释放),可能有其他 CPU 同时创建了同样标识的超级块,导致出现重复。重新遍历能够检测到这种情况,避免重复创建,保证全局唯一性。

这里的重复试错就和之前分析的0.11内核源码中获取一个空闲的高速缓冲区块getblk一样。如果再次遍历链表还是没有当前超级块,那就说明这个超级块分配出来是唯一的空闲的,可以接下来拿来使用

设置新超级块并加入全局链表

err = set(s, data);
if (err) {
    spin_unlock(&sb_lock);
    destroy_unused_super(s);
    return ERR_PTR(err);
}
s->s_type = type;
strlcpy(s->s_id, type->name, sizeof(s->s_id));
list_add_tail(&s->s_list, &super_blocks);
hlist_add_head(&s->s_instances, &type->fs_supers);
spin_unlock(&sb_lock);
get_filesystem(type);
register_shrinker_prepared(&s->s_shrink);
return s;
  • 调用 set:在持有锁的情况下调用(因为此时 s 还未加入任何链表,所以其他线程无法访问它,可以安全修改)。set 负责从 data 中读取设备信息,读取磁盘上的超级块等,可能失败并返回错误码。
  • set 成功,则填充剩余字段s_type 指向文件系统类型,s_id 设为类型名(用于调试),然后将该超级块加入全局超级块链表 super_blocks 和所属文件系统类型的实例链表 fs_supers
  • 解锁后,增加文件系统模块的引用计数(get_filesystem),并注册内存 shrinker(用于回收缓存),最后返回新超级块。

set函数

mount_nodev函数中给sget传递的set参数是set_anon_super

int set_anon_super(struct super_block *s, void *data)
{
    return get_anon_bdev(&s->s_dev);
}

函数底层调用的get_anon_bdev函数

int get_anon_bdev(dev_t *p)
{
    int dev;

    /*
     * Many userspace utilities consider an FSID of 0 invalid.
     * Always return at least 1 from get_anon_bdev.
     */
    dev = ida_alloc_range(&unnamed_dev_ida, 1, (1 << MINORBITS) - 1,
            GFP_ATOMIC);
    if (dev == -ENOSPC)
        dev = -EMFILE;
    if (dev < 0)
        return dev;

    *p = MKDEV(0, dev);
    return 0;
}

该函数的功能是为没有真实块设备(如硬盘分区)的文件系统分配一个虚拟的块设备号。调用成功后,p 指向的变量会得到一个合法的设备号

这里面调用了一个ida_alloc_range函数,首先要直到ida是什么

ida 是 Linux 内核中一个非常核心的基础设施,全称是 ID Allocator(ID 分配器)。

  • 核心作用:它是一个数字位图(Bitmap)管理器,专门用来高效地分配和释放整数 ID(本质是 int 类型)。它只关心“哪个数字被用了,哪个数字空闲”,不负责把 ID 和具体数据指针绑定。
  • idr 的区别:它有个“亲戚”叫 idr(ID Radix Tree),idr 能将 ID 映射到指针(存数据)。而 ida 更轻量,只管理 ID 号码本身(存“占用/空闲”状态),不存任何额外数据。
  • 在本函数中的行为
  1. unnamed_dev_ida 是全局维护的一个 ida 结构,专门管理匿名块设备的次要设备号(minor number)。
  2. ida_alloc_range(&unnamed_dev_ida, 1, (1 << MINORBITS) - 1, GFP_ATOMIC)
    • [1, 1023](假设 MINORBITS=10)的范围内寻找一个空闲的次设备号。
    • 找到后,立即在位图中标记该数字“已占用”,并返回数字。
    • 由于参数带有 GFP_ATOMIC,分配过程不会睡眠,因此可以安全地在持有自旋锁的上下文中调用。

mount_nodev中的文件节点获取

在完成了上面的分配并获取一个超级块之后,接下来就是对这个超级块进行填充,并且创建根目录i节点dentry

mount_nodev中调用了回调函数指针fill_super

fill_super

这里fill_super是传入的自定义函数

#define MYFS_MAGIC 0x12345678

//块操作定义
static struct super_operations myfs_super_ops = {
    .statfs = simple_statfs,
    .drop_inode = generic_drop_inode,
    .evict_inode = myfs_evict_inode,
};

static int myfs_fill_super(struct super_block *sb, void *data, int silent)
{
    struct inode *root_inode;

    sb->s_magic = MYFS_MAGIC;
    sb->s_op = &myfs_super_ops;
    sb->s_maxbytes = MAX_LFS_FILESIZE;
    sb->s_blocksize = PAGE_SIZE;
    sb->s_blocksize_bits = PAGE_SHIFT;

    //创建根目录的inode
    root_inode = myfs_get_inode(sb,NULL,S_IFDIR | 0755,0);
    if(!root_inode)
    {
        return -ENOMEM;
    }

    //创建根目录的dentry
    sb->s_root = d_make_root(root_inode);
    if(!sb->s_root) {
        iput(root_inode);
        return -ENOMEM;
    }

    return 0;
}

首先去填充超级块的一些内容,比如幻数,超级块的操作结构体super_operations,文件最大大小,一个块的大小

myfs_get_inode

//创建和初始化inode
struct inode *myfs_get_inode(struct super_block *sb,const struct inode *dir,umode_t mode,dev_t dev)
{
    struct inode *inode = new_inode(sb);

    if(!inode)
    {
        return NULL;
    }

    //获取一个inode号,在文件系统中唯一,这里因为创建的是一个内存文件系统,所以使用get_next_ino自增获取一个inode号
    inode->i_ino = get_next_ino();
    inode->i_mode = mode;
    inode->i_atime = inode->i_mtime = inode->i_ctime = current_time(inode);

    if(S_ISDIR(mode))
    {
        inode->i_op = &myfs_dir_inode_ops;
        inode->i_fop = &simple_dir_operations; //使用linux内核中定义的通用目录操作
        inc_nlink(inode); //增加链接数,对应 . 目录
        inc_nlink(inode); //增加链接数,对应 .. 目录
    }
    else if (S_ISREG(mode)) {
        inode->i_op = &myfs_file_inode_ops;
        inode->i_fop = &myfs_file_ops;
        inode->i_mapping->a_ops = &ram_aops;
    }

    return inode;
}

在这个函数里面会去创建一个inode并且填充一些初始信息

首先是创建一个inode节点,然后为这个inode初始化,分配一个inode号,以及赋值当前文件目录的权限,还有创建修改时间等,并且诶根据分类是目录还是文件赋值不同的inode节点操作inode_operations和文件操作file_operations

d_make_root

这个函数作用是为刚刚创建的inode分配一个对应的dentry结构体

详细分析见下文

inode内核函数

new_inode

//fs/inode.c
struct inode *new_inode(struct super_block *sb)
{
    struct inode *inode;

    //预先异步获取锁,提高性能
    spin_lock_prefetch(&sb->s_inode_list_lock);

    inode = new_inode_pseudo(sb);
    if (inode)
        inode_sb_list_add(inode);
    return inode;
}

函数最开始是一个预先获取指定超级块的inode链表锁的指令,用于提高系统性能

然后调用new_inode_pseudo根据指定超级块初始化一个inode节点

struct inode *new_inode_pseudo(struct super_block *sb)
{
    struct inode *inode = alloc_inode(sb);

    if (inode) {
        spin_lock(&inode->i_lock);
        inode->i_state = 0;
        spin_unlock(&inode->i_lock);
    }
    return inode;
}

这里底层调用的是alloc_inode,在分配到一个inode之后会将i_state用于管理 inode 生命周期 的核心状态标志位进行置位为0表示新的inode

alloc_inode

static struct inode *alloc_inode(struct super_block *sb)
{
    const struct super_operations *ops = sb->s_op;
    struct inode *inode;

    if (ops->alloc_inode)
        inode = ops->alloc_inode(sb);
    else
        inode = alloc_inode_sb(sb, inode_cachep, GFP_KERNEL);

    if (!inode)
        return NULL;

    if (unlikely(inode_init_always(sb, inode))) {
        if (ops->destroy_inode) {
            ops->destroy_inode(inode);
            if (!ops->free_inode)
                return NULL;
        }
        inode->free_inode = ops->free_inode;
        i_callback(&inode->i_rcu);
        return NULL;
    }

    return inode;
}

这个函数首先是进行inode的内存分配alloc_inode_sb,然后调用inode_init_always进行最开始的初始化

inode_init_always里面大片都是对于inode结构体成员进行初始赋值为0或是初始化锁,链表等

/**
 * inode_init_always - perform inode structure initialisation
 * @sb: superblock inode belongs to
 * @inode: inode to initialise
 *
 * These are initializations that need to be done on every inode
 * allocation as the fields are not initialised by slab allocation.
 */
int inode_init_always(struct super_block *sb, struct inode *inode)
{
    static const struct inode_operations empty_iops;
    static const struct file_operations no_open_fops = {.open = no_open};
    struct address_space *const mapping = &inode->i_data;

    inode->i_sb = sb;
    inode->i_blkbits = sb->s_blocksize_bits;
    inode->i_flags = 0;

比如首先i_sb指向当前超级块信息,以及设置文件块bit大小等


如果小概率事件inode_init_always初始化inode失败,那么就要调用清理函数,先看用户是否自己定义的超级块操作结构体super_operations是否定义了destroy_inode,如果是就执行,没有的话就调用i_callback

i_callback会去判断是否有自定义free_inode,函数如果没有那么就最终执行释放内存操作

void free_inode_nonrcu(struct inode *inode)
{
    kmem_cache_free(inode_cachep, inode);
}
EXPORT_SYMBOL(free_inode_nonrcu);

static void i_callback(struct rcu_head *head)
{
    struct inode *inode = container_of(head, struct inode, i_rcu);
    if (inode->free_inode)
        inode->free_inode(inode);
    else
        free_inode_nonrcu(inode);
}

inode_sb_list_add

/**
 * inode_sb_list_add - add inode to the superblock list of inodes
 * @inode: inode to add
 */
void inode_sb_list_add(struct inode *inode)
{
    spin_lock(&inode->i_sb->s_inode_list_lock);
    list_add(&inode->i_sb_list, &inode->i_sb->s_inodes);
    spin_unlock(&inode->i_sb->s_inode_list_lock);
}

在上述获取得到i节点然后将当前inode加入超级块的inode链表中

dentry内核函数

d_make_root

// fs/dcache.c
struct dentry *d_make_root(struct inode *root_inode)
{
    struct dentry *res = NULL;

    if (root_inode) {
        res = d_alloc_anon(root_inode->i_sb);
        if (res)
            d_instantiate(res, root_inode);
        else
            iput(root_inode);
    }
    return res;
}

首先是调用d_alloc_anon从内存中分配一个dentry,最终是调用到__d_alloc传入超级块指针和空的名字

struct dentry *d_alloc_anon(struct super_block *sb)
{
    return __d_alloc(sb, NULL);
}

__d_alloc

static struct dentry *__d_alloc(struct super_block *sb, const struct qstr *name)

__d_alloc() 是 Linux 内核 Dentry Cache (dcache) 的核心构造函数。它的职责很明确:从专用的高速缓存中分配一个 struct dentry 对象,并将它的所有字段初始化到一个安全、合法的“出厂默认”状态,同时处理好文件名的存储。

从专用 Slab 缓存中分配对象

dentry = kmem_cache_alloc_lru(dentry_cache, &sb->s_dentry_lru, GFP_KERNEL);
if (!dentry)
    return NULL;
  • 从全局的 dentry_cache 中分配内存,并将该 dentry 挂入超级块 sb 的 LRU(最近最少使用)链表 (s_dentry_lru)。
  • 这使得内核可以跟踪哪些 dentry 可以被回收(当内存不足时)。

处理文件名的存储

    /*
     * We guarantee that the inline name is always NUL-terminated.
     * This way the memcpy() done by the name switching in rename
     * will still always have a NUL at the end, even if we might
     * be overwriting an internal NUL character
     */
    dentry->d_iname[DNAME_INLINE_LEN-1] = 0;
    if (unlikely(!name)) {
        name = &slash_name;
        dname = dentry->d_iname;
    } else if (name->len > DNAME_INLINE_LEN-1) {
        size_t size = offsetof(struct external_name, name[1]);
        struct external_name *p = kmalloc(size + name->len,
                          GFP_KERNEL_ACCOUNT |
                          __GFP_RECLAIMABLE);
        if (!p) {
            kmem_cache_free(dentry_cache, dentry); 
            return NULL;
        }
        atomic_set(&p->u.count, 1);
        dname = p->name;
    } else  {
        dname = dentry->d_iname;
    }    

    dentry->d_name.len = name->len;
    dentry->d_name.hash = name->hash;
    memcpy(dname, name->name, name->len);
    dname[name->len] = 0;

文件名存储分为 两种路径,目的是最大化性能并避免小字符串频繁申请内存

  • 短文件名(内联存储):如果名字长度 len 小于 DNAME_INLINE_LEN - 1(通常是 31 或 39 字节),直接将文件名拷贝到 dentry 结构体内部的 d_iname 字符数组中。不额外申请内存,极快。
  • 长文件名(外部存储):如果名字很长,使用 kmalloc 分配一个 struct external_name 结构(包含引用计数 u.count 和变长数组 name)。将文件名存在这片外部内存中。
  • 安全性保障:无论哪种情况,代码都强制 d_iname[DNAME_INLINE_LEN-1] = 0,确保即使发生意外,内部缓存区也始终以 \0 结尾,防止 rename(重命名)等操作导致字符串越界。

在这里由于d_alloc_anon传递进来的参数nameNULL,所以就会走到if语句中的第一条,让name = &slash_name

const struct qstr slash_name = QSTR_INIT("/", 1);

可以看到也就是赋值为根目录

使用内存屏障确保数据可见性

smp_store_release(&dentry->d_name.name, dname);

这行代码极其重要。它使用了 release 语义(内存屏障)。

  • 它确保在此之前的所有操作(memcpy 拷贝文件名、设置结尾 \0全部完成之后,才将 d_name.name 指针指向新的地址。
  • 这意味着,其他 CPU 核心一旦通过 smp_load_acquire 看到 d_name.name 被更新,就一定能同时看到完整的、以 \0 结尾的合法文件名。这是锁无关(lock-free)访问的关键保障。

字段“工厂重置”与安全默认值

代码接下来对结构体中的各种指针和链表进行了暴力清零或初始化:

    dentry->d_lockref.count = 1;
    dentry->d_flags = 0;
    spin_lock_init(&dentry->d_lock);
    seqcount_spinlock_init(&dentry->d_seq, &dentry->d_lock);
    dentry->d_inode = NULL;
    dentry->d_parent = dentry;
    dentry->d_sb = sb;
    dentry->d_op = NULL;
    dentry->d_fsdata = NULL;
    INIT_HLIST_BL_NODE(&dentry->d_hash);
    INIT_LIST_HEAD(&dentry->d_lru);
    INIT_LIST_HEAD(&dentry->d_subdirs);
    INIT_HLIST_NODE(&dentry->d_u.d_alias);
    INIT_LIST_HEAD(&dentry->d_child);
    d_set_d_op(dentry, dentry->d_sb->s_d_op);
  • 引用计数d_lockref.count = 1(刚分配出来就被引用一次)。
  • 父子关系d_parent = dentry重要:在未被真正链接前,默认父指针指向自己,这是一个安全的“孤儿”状态)。
  • 关联 inoded_inode = NULL(新 dentry 还没有对应的文件)。
  • 各种链表头初始化d_hash(哈希链)、d_lru(回收链)、d_subdirs(子目录链)、d_child(父目录的子项链)等都被初始化或清零,防止残留的脏数据导致内核崩溃。

调用文件系统特定的初始化(d_init

d_set_d_op(dentry, dentry->d_sb->s_d_op);
if (dentry->d_op && dentry->d_op->d_init) {
 err = dentry->d_op->d_init(dentry);
 // 如果失败,回滚释放外部名称和对象本身
}
  • 设置该超级块默认的 dentry 操作函数表 d_op
  • 如果底层文件系统(如某些网络文件系统)有自定义的 d_init 回调,则调用它,允许文件系统往 dentry 里塞入私有数据。
  • 错误回滚:如果 d_init 失败,且之前为长文件名申请了外部内存,则需要 kfree 释放它,然后再把 dentry 对象本身放回 dentry_cache 缓存池。

d_instantiate

这个函数将一个新分配的目录项对象(dentry)与一个索引节点对象(inode)进行“绑定”,建立它们之间的关联关系。

void d_instantiate(struct dentry *entry, struct inode * inode)
{
    BUG_ON(!hlist_unhashed(&entry->d_u.d_alias));
    if (inode) {
        security_d_instantiate(entry, inode);
        spin_lock(&inode->i_lock);
        __d_instantiate(entry, inode);
        spin_unlock(&inode->i_lock);
    }
}
  • BUG_ON(!hlist_unhashed(&entry->d_u.d_alias));
  • 作用:这是一个极其严厉的断言(调试/防御性编程)。
  • d_u.d_alias 是 dentry 用来挂载到 inode 的 i_dentry 链表上的“钩子”。
  • hlist_unhashed 检查这个钩子当前是否不在任何哈希链表中(即处于“单身”状态)。
  • 如果条件为假(即 dentry 已经关联了其他 inode),内核会触发 BUG_ON 直接崩溃(panic)。这确保了同一个 dentry 绝对不会被重复绑定到两个不同的 inode 上,防止 VFS 缓存数据错乱。
  • security_d_instantiate(entry, inode);
  • 作用:调用 Linux 安全模块(LSM,如 SELinux、AppArmor)的钩子。
  • 让安全子系统有机会在新关联建立时,检查权限或为这个 dentry 设置安全上下文(比如文件的安全标签)。
  • spin_lock(&inode->i_lock);spin_unlock(&inode->i_lock);
  • 作用:自旋锁保护。
  • 因为一个 inode 可能有多个硬链接,对应多个 dentry(它们都挂在 inode->i_dentry 链表上)。修改这个链表时必须持有 i_lock,防止多核 CPU 并发操作导致链表损坏。
  • __d_instantiate(entry, inode);
  • 作用:这是核心动作的“内部实现”。
  • 它主要做两件事(在持有锁的情况下):
    1. entry->d_inode = inode;(将 dentry 的指针指向 inode)。
    2. hlist_add_head(&entry->d_u.d_alias, &inode->i_dentry);(将这个 dentry 添加到 inode 的别名链表中)。

上一篇
下一篇