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_typeso the kernel knows the name"ext4"exists and which function to call to mount it; this isregister_filesystem(), done once per driver, usually at module load. Second, a specific instance must be mounted — amount(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 permount, a live superblock) — is the central distinction this note makes. The modern path routes both legacymount(2)and the newfsopen/fsmountAPI through a commonstruct fs_context. This note is pinned to Linux 6.12 LTS (released 2024-11-17), readingfs/filesystems.c,fs/namespace.c,fs/super.c, andfs/fs_context.cfrom 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_tree→get_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_tree → get_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 bydev_t(so the same device mounted twice yields one superblock), and callsfill_superonly on first creation.get_tree_nodev(fc, fill_super)— for filesystems with no backing device; always creates a fresh anonymous superblock (viaset_anon_super_fc) and callsfill_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_super → sget_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_fc → vfs_create_mount → graft_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_mount → graft_tree → attach_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
ENODEVfrommount.get_fs_typereturned NULL — the type is not registered andmodprobe fs-<name>failed (no module, or a typo’d type).EINVALinstead means a flag/option problem; do not confuse the two. Checkcat /proc/filesystemsto see if the type is even present.- “I never loaded the module but it mounted.” The
request_module("fs-%.*s")autoload inget_fs_typeranmodprobe fs-ext4for you. If a custom filesystem mounts only after a manualmodprobe, it is missingMODULE_ALIAS_FS("name"). - Confusing per-mount and per-superblock flags.
mount -o remount,roon one of two bind mounts of a filesystem changes that mount’sMNT_READONLY, not the shared superblock’sSB_RDONLY. Expecting a remount-ro on one bind to make the other read-only is a category error rooted in thismnt_flagsvssb_flagssplit. - Assuming a fresh superblock every mount.
get_tree_bdevfinds or creates the superblock keyed by device; mounting the same device at a second path reuses the existingsuper_block, it does not callfill_superagain. Filesystems that genuinely want a shared instance useget_tree_single. get_treethat forgets to setfc->root. A buggy filesystemget_treethat returns 0 without settingfc->roottrips theBUG()invfs_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
- The Mount Tree and vfsmount — the
struct mount/struct vfsmountstructures that the graft step populates, and/proc/self/mountinfo - VFS Superblock Object —
fill_super,sget_fc,kill_sb, and the superblock the instance creates - The New Mount API —
fsopen/fsconfig/fsmount/move_mount, the modern front end tofs_context - VFS Operation Tables —
s_op,i_op,f_opthatfill_superinstalls - Bind Mounts and Mount Propagation — the
do_loopback/do_change_typebranches ofpath_mount - The Virtual File System Layer — the dispatch layer all of this serves
- MOC: Linux Filesystems and VFS MOC