Filesystem Registration and Mounting Internals

Two distinct things must happen before a file can be read from a filesystem. First, the filesystem’s driver must register its type with the VFS — handing over a struct file_system_type so the kernel knows the name "ext4" exists and which function to call to mount it; this is register_filesystem(), done once per driver, usually at module load. Second, a specific instance must be mounted — a mount(2) call that creates a superblock for one device and grafts it into the mount tree. The gap between these — a filesystem type (one per driver, listed in /proc/filesystems) versus a mounted instance (one per mount, a live superblock) — is the central distinction this note makes. The modern path routes both legacy mount(2) and the new fsopen/fsmount API through a common struct fs_context. This note is pinned to Linux 6.12 LTS (released 2024-11-17), reading fs/filesystems.c, fs/namespace.c, fs/super.c, and fs/fs_context.c from the v6.12 tag.

This note owns the dynamic process: how a type registers, and how one mount(2) call walks from syscall entry to a fresh mount stitched into the tree. The static structures that the final graft populates — struct mount, struct vfsmount, the mount_hashtable, the namespace rbtree — are defined and explained in The Mount Tree and vfsmount; the superblock the mount exposes and its fill_super/kill_sb lifecycle are owned by VFS Superblock Object; the userspace-facing fsopen/fsconfig/fsmount/move_mount syscalls are owned by The New Mount API. This note traces the in-kernel call chain that ties them together.

Mental Model

Think of two registries with very different lifetimes. The type registry is a single global linked list of struct file_system_type, one entry per filesystem driver, guarded by a reader-writer lock (file_systems_lock). Registering is “add my type to the list”; it costs no I/O and creates no superblock. The instance population happens later, every time mount(2) runs: the kernel looks the type up by name, asks it to produce a superblock for a given device, and records the result as a new mount in the tree.

The bridge object that makes a mount happen is struct fs_context — a short-lived “mount in progress” scratchpad. Userspace (or the legacy mount(2) adapter) fills it with the source device, options, and flags; then vfs_get_tree() asks the filesystem to turn that context into a superblock and a root dentry; then vfs_create_mount() wraps that root in a struct mount; then the graft attaches it. The same fs_context carries both the new incremental API and the legacy monolithic-options API — the difference is only which get_tree callback fires.

flowchart TB
  REG["register_filesystem(fs)<br/>module load: add file_system_type<br/>to global list (file_systems_lock)"] --> LIST["/proc/filesystems<br/>type registry"]
  SYS["mount(2)"] --> DM["do_mount -> path_mount<br/>split MS_* into mnt_flags + sb_flags"]
  DM -->|"plain mount"| DNM["do_new_mount"]
  DNM --> GFT["get_fs_type(name)<br/>look up type, autoload module if missing"]
  GFT -.->|"miss"| MOD["request_module(\"fs-NAME\")"]
  MOD --> GFT
  GFT --> FC["fs_context_for_mount(type)<br/>-> type->init_fs_context()"]
  FC --> PARSE["vfs_parse_fs_string(source/opts)<br/>parse_monolithic_mount_data"]
  PARSE --> VGT["vfs_get_tree(fc)<br/>-> fc->ops->get_tree()"]
  VGT --> GT["get_tree_bdev / nodev / single<br/>-> sget_fc -> fill_super()"]
  GT --> SB["new super_block,<br/>fc->root set, SB_BORN"]
  SB --> CRT["do_new_mount_fc<br/>-> vfs_create_mount(fc)"]
  CRT --> GRAFT["do_add_mount -> graft_tree<br/>-> commit_tree / __attach_mnt<br/>(see The Mount Tree and vfsmount)"]
  GRAFT --> TREE["new mount in the tree"]

The two phases. What it shows: the left branch (register_filesystem) is the one-time type registration that populates /proc/filesystems; the right branch is what every mount(2) runs — look up the type, build an fs_context, ask the filesystem for a superblock via vfs_get_treeget_tree_*fill_super, wrap it in a mount, and graft it. The insight to take: registration and mounting are decoupled in both code and time — a type can be registered with zero instances, and the climax of mounting (commit_tree/__attach_mnt) merely populates the structures that The Mount Tree and vfsmount defines.

Phase 1: Registering a Filesystem Type

struct file_system_type

A filesystem driver describes itself with struct file_system_type, from include/linux/fs.h (v6.12):

struct file_system_type {
        const char *name;        /* "ext4", "tmpfs", "overlay" */
        int fs_flags;            /* FS_REQUIRES_DEV, FS_USERNS_MOUNT, ... */
        int (*init_fs_context)(struct fs_context *);   /* NEW API entry */
        const struct fs_parameter_spec *parameters;    /* typed option schema */
        struct dentry *(*mount)(struct file_system_type *, int,
                                const char *, void *);  /* LEGACY entry */
        void (*kill_sb)(struct super_block *);          /* teardown */
        struct module *owner;    /* THIS_MODULE — pins the module while in use */
        struct file_system_type *next;   /* link in the global list */
        struct hlist_head fs_supers;     /* all superblocks of this type */
        ...                              /* lockdep keys */
};

The two mutually-exclusive entry points are init_fs_context (the modern callback, sets up the fs_context) and mount (the legacy callback, returns a root dentry directly). A driver implements one; the VFS adapts the other. owner is the driver’s module, used with try_module_get/module_put so a filesystem module cannot be unloaded while any of its instances are mounted. fs_flags carries FS_REQUIRES_DEV (needs a real block device, e.g. ext4 — this is what splits /proc/filesystems into nodev and non-nodev lines), FS_USERNS_MOUNT (mountable by a non-init-userns root, e.g. tmpfs, fuse), and FS_HAS_SUBTYPE (supports type.subtype, used by FUSE).

register_filesystem

Registration is short (fs/filesystems.c v6.12):

int register_filesystem(struct file_system_type *fs)
{
        int res = 0;
        struct file_system_type **p;
 
        if (fs->parameters && !fs_validate_description(fs->name, fs->parameters))
                return -EINVAL;
        BUG_ON(strchr(fs->name, '.'));   /* '.' reserved for type.subtype */
        if (fs->next)
                return -EBUSY;           /* already on a list */
        write_lock(&file_systems_lock);
        p = find_filesystem(fs->name, strlen(fs->name));
        if (*p)
                res = -EBUSY;            /* a type with this name exists */
        else
                *p = fs;                /* link it in */
        write_unlock(&file_systems_lock);
        return res;
}

It validates the typed parameter description, forbids a . in the name (reserved for type.subtype), refuses double-registration (fs->next already set, or a duplicate name found by find_filesystem walking the singly-linked file_systems list), and links the type into the global list under the file_systems_lock writer lock. unregister_filesystem reverses this and crucially calls synchronize_rcu() after unlinking, because readers (/proc/filesystems, get_fs_type) may be walking the list under RCU. A driver typically calls register_filesystem(&ext4_fs_type) from its module_init function and unregister_filesystem from module_exit.

/proc/filesystems

The type registry is exposed by filesystems_proc_show (fs/filesystems.c):

while (tmp) {
        seq_printf(m, "%s\t%s\n",
                (tmp->fs_flags & FS_REQUIRES_DEV) ? "" : "nodev",
                tmp->name);
        tmp = tmp->next;
}

So a line is either nodev<TAB>name (filesystem needs no block device — tmpfs, proc, sysfs, overlay) or <TAB>name (needs a device — ext4, xfs, btrfs). Reading /proc/filesystems enumerates exactly the types currently in the global list, i.e. drivers either built in or with their module loaded (filesystems(5)). This is the type registry made visible; it says nothing about what is mounted.

Phase 2: Mounting an Instance, End to End

Syscall entry → path_mount

mount(2) enters SYSCALL_DEFINE5(mount, ...) in fs/namespace.c, which copies the source string, type string, and options page from userspace and calls do_mount. do_mount resolves the target directory string to a struct path with user_path_at() and hands off to path_mount. path_mount is the dispatcher: it translates the MS_* flags into two separate flag sets and routes to the right operation:

/* per-mount flags (live on the new vfsmount) */
if (flags & MS_NOSUID)  mnt_flags |= MNT_NOSUID;
if (flags & MS_RDONLY)  mnt_flags |= MNT_READONLY;
...
/* per-superblock flags */
sb_flags = flags & (SB_RDONLY | SB_SYNCHRONOUS | SB_MANDLOCK | ...);
 
if ((flags & (MS_REMOUNT | MS_BIND)) == (MS_REMOUNT | MS_BIND))
        return do_reconfigure_mnt(path, mnt_flags);
if (flags & MS_REMOUNT)  return do_remount(...);
if (flags & MS_BIND)     return do_loopback(...);       /* bind mount */
if (flags & (MS_SHARED|MS_PRIVATE|MS_SLAVE|MS_UNBINDABLE))
        return do_change_type(path, flags);             /* propagation */
if (flags & MS_MOVE)     return do_move_mount_old(...);
return do_new_mount(path, type_page, sb_flags, mnt_flags, dev_name, data);

This split is the source of a deep VFS distinction: MS_NOSUID/MS_NODEV/MS_NOEXEC/MS_RELATIME/MS_RDONLY become per-mount MNT_* flags (so two bind mounts of one filesystem can differ), while SB_RDONLY/SB_SYNCHRONOUS/… become per-superblock flags (shared by all mounts of that instance). Bind, remount, propagation-change, and move are all not new-instance operations — only the fall-through do_new_mount creates a brand-new superblock. Bind mounts (do_loopback) and propagation (do_change_type) are owned by Bind Mounts and Mount Propagation; this note follows do_new_mount.

do_new_mount: type lookup and fs_context

static int do_new_mount(struct path *path, const char *fstype, int sb_flags,
                        int mnt_flags, const char *name, void *data)
{
        struct file_system_type *type = get_fs_type(fstype);   /* (1) */
        if (!type) return -ENODEV;
        ...
        fc = fs_context_for_mount(type, sb_flags);             /* (2) */
        put_filesystem(type);
        fc->oldapi = true;                                     /* (3) */
        if (name)
                err = vfs_parse_fs_string(fc, "source", name, strlen(name));
        if (!err)
                err = parse_monolithic_mount_data(fc, data);   /* (4) */
        if (!err && !mount_capable(fc)) err = -EPERM;
        if (!err) err = vfs_get_tree(fc);                      /* (5) */
        if (!err) err = do_new_mount_fc(fc, path, mnt_flags);  /* (6) */
        put_fs_context(fc);
        return err;
}

(1) get_fs_type finds the type by name and, if missing, autoloads its module (see below). (2) fs_context_for_mount allocates a struct fs_context and calls type->init_fs_context(fc) (or the legacy fallback). (3) fc->oldapi = true records that this context came from the legacy mount(2) syscall, which affects how some filesystems interpret options. (4) the source device and the comma-separated options page are parsed into the context. (5) vfs_get_tree is the heart — it produces the superblock. (6) do_new_mount_fc wraps the result in a mount and grafts it.

get_fs_type and module autoload

struct file_system_type *get_fs_type(const char *name)
{
        const char *dot = strchr(name, '.');
        int len = dot ? dot - name : strlen(name);
        struct file_system_type *fs = __get_fs_type(name, len);
        if (!fs && request_module("fs-%.*s", len, name) == 0) {
                fs = __get_fs_type(name, len);
                ...
        }
        ...
}

__get_fs_type looks the name up under the file_systems_lock reader lock and takes a module reference with try_module_get(fs->owner). If the type is not registered, request_module("fs-%.*s", ...) runs modprobe fs-<name> — this is why mounting an ext4 filesystem whose module is not yet loaded just works: every filesystem module declares MODULE_ALIAS_FS("ext4"), which expands to MODULE_ALIAS("fs-ext4"), so modprobe fs-ext4 resolves to ext4.ko. After the module loads (running its module_init, which calls register_filesystem), the second __get_fs_type finds the now-registered type. This is the kernel side of “I never modprobed ext4 but mounting still worked.”

vfs_get_treeget_tree_*fill_super

vfs_get_tree (in fs/super.c) is a thin wrapper around the filesystem’s own get_tree:

int vfs_get_tree(struct fs_context *fc)
{
        if (fc->root) return -EBUSY;
        error = fc->ops->get_tree(fc);       /* the filesystem's callback */
        if (error < 0) return error;
        if (!fc->root) { ... BUG(); }        /* get_tree must set fc->root */
        sb = fc->root->d_sb;
        super_wake(sb, SB_BORN);             /* mark superblock fully born */
        ...
        return 0;
}

The contract is precise: fc->ops->get_tree(fc) must, on success, set fc->root to the mountable root dentry and leave a reference on both that dentry and its superblock; vfs_get_tree then sets SB_BORN so the superblock is visible to the rest of the kernel. Most filesystems do not write get_tree from scratch; they call one of the library helpers in fs/super.c:

  • get_tree_bdev(fc, fill_super) — for filesystems backing a real block device (ext4, xfs, btrfs). It opens the device, finds or creates the superblock keyed by dev_t (so the same device mounted twice yields one superblock), and calls fill_super only on first creation.
  • get_tree_nodev(fc, fill_super) — for filesystems with no backing device; always creates a fresh anonymous superblock (via set_anon_super_fc) and calls fill_super. Used by tmpfs-like and synthetic filesystems.
  • get_tree_single(fc, fill_super) — for filesystems that want exactly one shared superblock system-wide (e.g. a single sysfs instance per namespace); subsequent mounts share it.
  • get_tree_keyed(fc, fill_super, key) — one superblock per key (e.g. per network namespace).

All four funnel through vfs_get_supersget_fc (find-or-create the superblock) → fill_super. fill_super is the filesystem-specific function that reads the on-disk superblock, validates the magic number, sets sb->s_op/sb->s_blocksize/sb->s_maxbytes, and builds the root inode and root dentry (sb->s_root). The full superblock lifecycle — sget_fc, fill_super, kill_sb, generic_shutdown_super — is owned by VFS Superblock Object; here the point is only that get_tree is where the superblock is born during a mount.

The legacy duality: how a .mount-only filesystem still uses fs_context

Not every filesystem has been converted to init_fs_context. A driver that still only provides the old .mount callback is transparently adapted in alloc_fs_context (fs/fs_context.c v6.12):

init_fs_context = fc->fs_type->init_fs_context;
if (!init_fs_context)
        init_fs_context = legacy_init_fs_context;   /* fallback */
ret = init_fs_context(fc);

legacy_init_fs_context installs legacy_fs_context_ops, whose get_tree is legacy_get_tree:

static int legacy_get_tree(struct fs_context *fc)
{
        struct legacy_fs_context *ctx = fc->fs_private;
        struct dentry *root = fc->fs_type->mount(fc->fs_type, fc->sb_flags,
                                                 fc->source, ctx->legacy_data);
        if (IS_ERR(root)) return PTR_ERR(root);
        fc->root = root;          /* satisfy the vfs_get_tree contract */
        return 0;
}

So the new control flow (vfs_get_tree → fc->ops->get_tree) is universal; for a legacy filesystem the get_tree it lands on simply calls the old .mount callback and copies its returned root into fc->root. The old .mount typically called mount_bdev/mount_nodev (the pre-fs_context analogues of get_tree_bdev/get_tree_nodev), which still exist for these drivers. This is the mechanism by which one mount path serves both APIs — there is no second code path, only an adapter callback. A fully-converted filesystem like ext4 instead sets init_fs_context = ext4_init_fs_context and get_tree via ext4_get_tree, which is just get_tree_bdev(fc, ext4_fill_super) (fs/ext4/super.c v6.12).

The graft: do_new_mount_fcvfs_create_mountgraft_tree

With a superblock in hand (fc->root set), do_new_mount_fc builds the mount and attaches it:

mnt = vfs_create_mount(fc);          /* wrap fc->root in a struct mount */
mp = lock_mount(mountpoint);         /* pin the target dentry as a mountpoint */
error = do_add_mount(real_mount(mnt), mp, mountpoint, mnt_flags);

vfs_create_mount (fs/namespace.c) is where the mount object is born:

mnt->mnt.mnt_sb        = fc->root->d_sb;   /* the new superblock */
mnt->mnt.mnt_root      = dget(fc->root);   /* its mountable root */
mnt->mnt_mountpoint    = mnt->mnt.mnt_root;
mnt->mnt_parent        = mnt;              /* self-parent until grafted */
list_add_tail(&mnt->mnt_instance, &mnt->mnt.mnt_sb->s_mounts);  /* link to sb */

At this point the mount exists but is detached — its parent is itself. do_add_mountgraft_treeattach_recursive_mnt then performs the actual graft: it sets mnt_parent to the parent mount, sets mnt_mountpoint to the target dentry, and calls commit_tree/__attach_mnt, which insert the mount into the mount_hashtable (keyed by parent+mountpoint) and the namespace’s rbtree. Those structures, the hash key, and the seqlock that guards them are defined in The Mount Tree and vfsmount — this is the moment they are populated, not where they are explained. do_add_mount also enforces two rules visible in the code: it refuses to mount the same superblock on the same mountpoint (-EBUSY) and refuses to mount when the new root is a symlink (-EINVAL).

Type vs Instance: the Distinction Made Concrete

The whole note hinges on one separation, now demonstrable:

$ cat /proc/filesystems | grep ext4       # the TYPE registry
        ext4                                # one line, regardless of mounts

$ mount /dev/sda1 /mnt/a                    # INSTANCE 1: a superblock
$ mount /dev/sdb1 /mnt/b                    # INSTANCE 2: another superblock
$ grep ext4 /proc/self/mountinfo           # the INSTANCEs
... /mnt/a ... - ext4 /dev/sda1 ...
... /mnt/b ... - ext4 /dev/sdb1 ...

One file_system_type named ext4 lives in the global list (/proc/filesystems); each mount of it produces a separate super_block and a separate struct mount (/proc/self/mountinfo). Registering ext4 with zero mounts is normal; mounting it twice gives two superblocks but still one type. The type is the class; the superblock is the object; the mount is the placement of that object in the tree. Internally, fs_type->fs_supers (an hlist) links every superblock of one type, which is exactly how umount-on-module-unload safety and sync per-type work.

Failure Modes and Common Misunderstandings

  • ENODEV from mount. get_fs_type returned NULL — the type is not registered and modprobe fs-<name> failed (no module, or a typo’d type). EINVAL instead means a flag/option problem; do not confuse the two. Check cat /proc/filesystems to see if the type is even present.
  • “I never loaded the module but it mounted.” The request_module("fs-%.*s") autoload in get_fs_type ran modprobe fs-ext4 for you. If a custom filesystem mounts only after a manual modprobe, it is missing MODULE_ALIAS_FS("name").
  • Confusing per-mount and per-superblock flags. mount -o remount,ro on one of two bind mounts of a filesystem changes that mount’s MNT_READONLY, not the shared superblock’s SB_RDONLY. Expecting a remount-ro on one bind to make the other read-only is a category error rooted in this mnt_flags vs sb_flags split.
  • Assuming a fresh superblock every mount. get_tree_bdev finds or creates the superblock keyed by device; mounting the same device at a second path reuses the existing super_block, it does not call fill_super again. Filesystems that genuinely want a shared instance use get_tree_single.
  • get_tree that forgets to set fc->root. A buggy filesystem get_tree that returns 0 without setting fc->root trips the BUG() in vfs_get_tree — a hard kernel crash, deliberately, because the locking state of the (possibly half-built) superblock is then unknown.

Alternatives and When to Choose Them

The legacy mount(2) system call traced above and the new fsopen/fsconfig/fsmount/move_mount API (merged in kernel 5.2, 2019) are two front ends to the same fs_context machinery (LWN 2018). mount(2) is monolithic: source, type, target, flags, and a single comma-separated options blob all arrive in one call, and errors come back as a bare errno with no detail. The new API is incremental — fsopen creates the context, repeated fsconfig calls set parameters one at a time (with rich types and per-parameter error messages), fsmount produces a detached mount, and move_mount attaches it — which is strictly better for option validation and for mounting in one namespace then moving into another. Use the legacy call for portability and simplicity; reach for the new API when you need precise option diagnostics or detached-mount workflows. The full new-API surface is owned by The New Mount API.

Production Notes

The autoload path matters in hardened environments: blocking modprobe (via modules_disabled or seccomp) means a filesystem type must be loaded before mount runs, or the mount fails with ENODEV — a common surprise when locking down container hosts. The FS_USERNS_MOUNT flag is what lets unprivileged users in a user namespace mount tmpfs, proc, sysfs, and (since their respective conversions) overlayfs and FUSE — checking whether a filesystem sets it tells you whether rootless containers can mount it. Reading /proc/filesystems is the first diagnostic step when a mount fails with ENODEV: if the type is absent there, the problem is registration/module loading, not the device or options. The fs_supers/s_mounts linkage is why umount -a and filesystem freeze (fsfreeze) can iterate every instance of a type — the kernel never loses track of which superblocks belong to which file_system_type.

See Also