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_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).

Field by field, with the lifetime each one has:

MemberTypeWho sets itPurpose
nameconst char *the driver, staticallyThe string userspace passes as mount -t <name>. A . is forbidden (BUG_ON in register_filesystem) because it separates type.subtype.
fs_flagsintthe driver, staticallyCapability/behaviour bits — see the next table.
init_fs_contextint (*)(struct fs_context *)the driverModern entry point. Called by alloc_fs_context to install fc->ops and allocate fc->fs_private.
parametersconst struct fs_parameter_spec *the driverTyped option schema. Validated at registration time by fs_validate_description(); a malformed spec makes register_filesystem return -EINVAL.
mountstruct dentry *(*)(...)the driverLegacy entry point. Returns a root dentry directly. Adapted onto fs_context by legacy_get_tree.
kill_sbvoid (*)(struct super_block *)the driverTeardown, called on the last deactivate_super. Almost always kill_block_super, kill_anon_super, or a wrapper around one.
ownerstruct module *THIS_MODULEReference-counted with try_module_get/module_put so the module cannot unload while an instance is mounted.
nextstruct file_system_type *register_filesystemThe singly-linked global list. Non-NULL is itself the “already registered” test.
fs_supersstruct hlist_headsget_fcEvery live superblock of this type. This is how sync, freeze, and unmount-on-unload iterate instances.
s_lock_key, s_umount_key, s_vfs_rename_key, s_writers_key[], i_lock_key, i_mutex_key, invalidate_lock_key, i_mutex_dir_keystruct lock_class_keylockdepPer-type lock classes, so lockdep does not conflate ext4’s i_mutex with tmpfs’s. Eight of the structure’s members exist purely for this.

struct file_system_type in v6.12, fs.h:2536-2564. What it shows: the structure is three things at once — an identity (name, owner), a vtable with two alternative entry points (init_fs_context or mount, plus kill_sb), and bookkeeping (next, fs_supers, the lockdep keys). The insight to take: there is no super_block anywhere in it. A registered type owns a list of superblocks, never one, which is the structural statement of the type/instance split this note is about.

The fs_flags word is small but decides a lot of behaviour, including who is allowed to mount the type at all:

FlagValueMeaningExamples in-tree
FS_REQUIRES_DEV1Needs a real block device. This is the only input to the nodev column of /proc/filesystems.ext4, xfs, btrfs
FS_BINARY_MOUNTDATA2The legacy mount-data page is a binary struct, not a comma-separated string, so the generic option parser must not touch it.nfs, afs
FS_HAS_SUBTYPE4Accepts type.subtype; do_new_mount splits at the . and feeds the tail in as the subtype parameter.fuse (fuse.sshfs)
FS_USERNS_MOUNT8Mountable by a user-namespace root. Changes mount_capable() from capable(CAP_SYS_ADMIN) (initial user namespace) to ns_capable(fc->user_ns, CAP_SYS_ADMIN).tmpfs, ramfs, proc, sysfs, devpts, overlay, fuse
FS_DISALLOW_NOTIFY_PERM16fanotify permission events are refused on this filesystem, to avoid deadlocking the notifier against its own filesystem.proc
FS_ALLOW_IDMAP32The driver has been audited for idmapped mounts, so mount_setattr(MOUNT_ATTR_IDMAP) is permitted on it.ext4, xfs, and a growing list
FS_RENAME_DOES_D_MOVE32768The driver calls d_move() itself inside ->rename; the VFS must not.ocfs2 and similar

fs_flags values as defined inline in struct file_system_type (fs.h:2539-2545). What it shows: seven independent bits covering block-device dependency, option-parsing style, naming, permission model, notification, idmapping, and rename semantics. The insight to take: FS_USERNS_MOUNT is the one with security consequences, and it is necessary but not sufficient — the measured section below shows proc, sysfs and cgroup2 all setting it and still returning EPERM, because their own init_fs_context adds a namespace-ownership check on top.

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

The whole call chain, drawn as an exchange between the layers. Each arrow is a real function call in v6.12, and the four alternative outcomes on the right are the four ways a mount(2) most commonly fails:

sequenceDiagram
    autonumber
    participant U as User
    participant NS as fs/namespace.c
    participant FSY as fs/filesystems.c
    participant FC as fs/fs_context.c
    participant SU as fs/super.c
    participant DRV as filesystem driver
    U->>NS: mount(source, target, type, flags, data)
    NS->>NS: do_mount - user_path_at resolves target
    NS->>NS: path_mount - split MS_* into mnt_flags and sb_flags
    alt not may_mount()
        NS-->>U: -EPERM (before any type lookup)
    end
    NS->>NS: dispatch - remount, bind, propagation, move, or new
    NS->>FSY: get_fs_type("ext4")
    alt not registered
        FSY->>FSY: request_module("fs-ext4")
        FSY->>FSY: retry __get_fs_type
    end
    alt still not found
        FSY-->>U: -ENODEV
    end
    FSY-->>NS: file_system_type * with module ref held
    NS->>FC: fs_context_for_mount(type, sb_flags)
    FC->>DRV: type->init_fs_context(fc), else legacy_init_fs_context
    DRV-->>FC: fc->ops and fc->fs_private installed
    NS->>FC: vfs_parse_fs_string(fc, "source", dev_name)
    NS->>FC: parse_monolithic_mount_data(fc, options page)
    NS->>SU: mount_capable(fc)
    alt capability check fails
        SU-->>U: -EPERM
    end
    NS->>SU: vfs_get_tree(fc)
    SU->>DRV: fc->ops->get_tree(fc)
    DRV->>SU: get_tree_bdev / nodev / single / keyed
    SU->>SU: sget_fc - find or create super_block
    SU->>DRV: fill_super(sb, fc) only if newly created
    DRV-->>SU: sb->s_root built
    SU->>SU: super_wake(sb, SB_BORN)
    SU-->>NS: fc->root set
    NS->>NS: vfs_create_mount(fc) - struct mount, self-parented
    NS->>NS: lock_mount(target), do_add_mount, graft_tree
    NS->>NS: commit_tree - hash table and namespace rbtree
    NS-->>U: 0

One mount(2) call, end to end. What it shows: the five files that cooperate on a mount and the exact order of the checks — permission first, then type lookup with module autoload, then context construction, then the capability check, then superblock creation, and only last the graft into the tree. The insight to take: the ordering is observable from userspace. may_mount() runs before get_fs_type(), so an unprivileged caller naming a nonexistent filesystem gets EPERM and a privileged one gets ENODEV — the errno tells you how far the call got. That is measured below.

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.

The excerpt above elides two details of the real path_mount (fs/namespace.c:3759-3835) that bite in practice. First, MNT_RELATIME is the default: the function opens with if (!(flags & MS_NOATIME)) mnt_flags |= MNT_RELATIME; before looking at any other atime flag, so a mount that specifies nothing gets relatime, and MS_STRICTATIME works by clearing MNT_RELATIME | MNT_NOATIME rather than by setting a bit of its own. Second, a remount that names no atime flag at all preserves the existing one by copying path->mnt->mnt_flags & MNT_ATIME_MASK — which is why mount -o remount,ro does not silently reset a noatime mount back to relatime.

Here is the full dispatch, in the order the code tests it. Order matters: the first matching branch returns, so MS_REMOUNT|MS_BIND is a distinct operation from either flag alone.

Condition (tested in this order)Branch takenWhat it doesCreates a superblock?
flags & MS_NOUSER—returns -EINVAL immediately; the flag exists to mark mounts userspace may not requestno
!may_mount()—returns -EPERM; this is checked before the type name is even looked atno
(MS_REMOUNT|MS_BIND) both setdo_reconfigure_mntchanges only the MNT_* flags of this mountno
MS_REMOUNTdo_remountreconfigures the superblock (fs_context_for_reconfigure → ->reconfigure) and the mount flagsno — reuses the existing one
MS_BINDdo_loopbackclones an existing subtree at a new mountpoint (MS_REC makes it recursive)no — a second mount over the same superblock
any of MS_SHARED, MS_PRIVATE, MS_SLAVE, MS_UNBINDABLEdo_change_typechanges propagation type only; see Bind Mounts and Mount Propagationno
MS_MOVEdo_move_mount_olddetaches a mount and re-attaches it elsewhereno
(fall through)do_new_mountthe path this note tracesyes

The path_mount dispatcher. What it shows: seven of the eight outcomes of a mount(2) call do not create a filesystem instance at all. The insight to take: “mount” is an overloaded syscall — most of its flag combinations manipulate the mount tree, and only the unflagged fall-through asks a filesystem driver for a new superblock. That is why a bind mount is instant and mount -t ext4 is not.

The flag translation is the other half, and the distinction it creates is permanent for the life of the mount:

mount(2) flagBecomesScopeNotes
MS_NOSUIDMNT_NOSUIDper-mount
MS_NODEVMNT_NODEVper-mount
MS_NOEXECMNT_NOEXECper-mount
MS_NOATIMEMNT_NOATIMEper-mountalso suppresses the MNT_RELATIME default
MS_NODIRATIMEMNT_NODIRATIMEper-mount
MS_RELATIMEMNT_RELATIMEper-mountthe default when no atime flag is given
MS_STRICTATIMEclears MNT_RELATIME|MNT_NOATIMEper-mountthe only “un-flag” in the set
MS_RDONLYMNT_READONLYper-mountand SB_RDONLY per-superblock — the one flag in both columns
MS_NOSYMFOLLOWMNT_NOSYMFOLLOWper-mount
MS_SYNCHRONOUSSB_SYNCHRONOUSper-superblock
MS_MANDLOCKSB_MANDLOCKper-superblocktriggers warn_mandlock() — mandatory locking is deprecated
MS_DIRSYNCSB_DIRSYNCper-superblock
MS_SILENTSB_SILENTper-superblock
MS_POSIXACLSB_POSIXACLper-superblock
MS_LAZYTIMESB_LAZYTIMEper-superblock
MS_I_VERSIONSB_I_VERSIONper-superblock

The MS_* → MNT_* / SB_* split, from the literal mnt_flags |= ... assignments and the sb_flags = flags & (...) mask in path_mount. What it shows: eight MNT_* bits are set from MS_* flags (and MS_STRICTATIME clears two rather than setting any), while seven other flags become properties of the superblock, with MS_RDONLY uniquely landing in both columns. The insight to take: this is why two bind mounts of one filesystem can disagree about noexec but can never disagree about lazytime — the first is stored in each vfsmount, the second in the single shared super_block. The MNT_* values themselves are defined in include/linux/mount.h:25-32, where a second block of MNT_LOCK_* bits (MNT_LOCK_READONLY, MNT_LOCK_NOSUID, …) records which of these a less-privileged namespace is forbidden to relax.

One implementation detail makes the sb_flags line work at all: MS_* and SB_* are numerically identical. MS_RDONLY is 1 in uapi/linux/mount.h:13 and SB_RDONLY is BIT(0) in fs.h:1173; the same holds for MS_SYNCHRONOUS/SB_SYNCHRONOUS (16), MS_I_VERSION/SB_I_VERSION (1<<23), and the rest. That is why the superblock half needs no translation at all — it is a plain bitmask (sb_flags = flags & (SB_RDONLY | SB_SYNCHRONOUS | SB_MANDLOCK | SB_DIRSYNC | SB_SILENT | SB_POSIXACL | SB_LAZYTIME | SB_I_VERSION)) while the mount half needs nine explicit if statements. The two naming schemes are the same bits seen from userspace and from the superblock respectively. Note also that SB_NOSUID, SB_NODEV and SB_NOEXEC exist in fs.h but are deliberately absent from the mask — those three are per-mount properties in the modern kernel, and a filesystem that consults sb->s_flags for them is reading a stale concept.

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 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).

The four are not implemented the same way, and the difference matters. get_tree_nodev, get_tree_single and get_tree_keyed are one-line wrappers around a shared helper, vfs_get_super(fc, test, fill_super) (fs/super.c:1267-1319), which calls sget_fc(fc, test, set_anon_super_fc) and differs only in the comparison callback it passes: NULL (never match — always a fresh superblock), test_single_super (return 1 — always match), or test_keyed_super (sb->s_fs_info == fc->s_fs_info). get_tree_bdev does not go through vfs_get_super at all; it is a separate implementation (fs/super.c:1604-1660, since v6.12 a thin wrapper around get_tree_bdev_flags) that resolves the source string with lookup_bdev(), matches on dev_t via sget_dev(), ORs in SB_NOSEC, and calls setup_bdev_super() before fill_super. All of them reach sget_fc eventually, but only three share a code path.

HelperMatch callbackWhen is fill_super called?Keyed byUsed by
get_tree_bdevtest_bdev_super: s->s_dev == dev and not SB_I_RETIREDonly when no superblock for that dev_t existsthe block device’s dev_text4, xfs, btrfs — every FS_REQUIRES_DEV filesystem
get_tree_nodevNULL — never matchesevery mountnothing; a fresh anonymous dev_t from set_anon_super_fc each timeproc, and synthetic filesystems that want independent instances
get_tree_singletest_single_super: return 1 — always matchesonly on the very first mount, system-widenothing; there is one instance, periodfilesystems with genuinely one global state
get_tree_keyedtest_keyed_super: sb->s_fs_info == fc->s_fs_infoonly when no superblock carries that keya driver-chosen key stashed in fc->s_fs_info (typically a namespace pointer)kernfs-based filesystems keyed per namespace

The four get_tree library helpers. What it shows: all four do “find or create a superblock”, and the only thing that varies is the predicate deciding whether an existing superblock counts as a match. The insight to take: whether a second mount of the same thing reuses or duplicates an instance is not a property of “mounting” — it is one function pointer chosen by the driver. get_tree_bdev’s predicate is why mounting /dev/sda1 twice gives you one superblock and two mounts, and get_tree_nodev’s NULL predicate is why mounting tmpfs twice gives you two of everything. Both are demonstrated with measured output below.

A consequence of get_tree_bdev’s dev_t keying that shows up as a confusing error: because the second mount reuses the existing superblock, it cannot change superblock-wide state. The code refuses explicitly — if ((fc->sb_flags ^ s->s_flags) & SB_RDONLY) { warnf(fc, "%pg: Can't mount, would change RO state", s->s_bdev); ... return -EBUSY; }. Mounting an already-read-write device a second time with -o ro therefore fails with EBUSY, not with a silent downgrade.

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).

Inside struct fs_context: purpose, phase, and the user namespace swap

alloc_fs_context (fs/fs_context.c:275-326) is the single constructor for every mount-in-progress in the kernel, and it takes a purpose argument that selects one of three initialisation behaviours:

purposeCreated byfc->user_ns initialised fromOther setup
FS_CONTEXT_FOR_MOUNTfs_context_for_mount() — the legacy mount(2) path and fsopen(2)fc->cred->user_ns — the caller’s user namespacenone
FS_CONTEXT_FOR_SUBMOUNTfs_context_for_submount() — automounts (autofs, NFS referrals)reference->d_sb->s_user_ns — the parent superblock’s user namespacenone
FS_CONTEXT_FOR_RECONFIGUREfs_context_for_reconfigure() — remount and fspick(2)reference->d_sb->s_user_nstakes an s_active reference and pre-sets fc->root = dget(reference)

enum fs_context_purpose and what each value changes (include/linux/fs_context.h:29-33). What it shows: the same structure serves three jobs, and the only thing purpose really decides is which user namespace the mount is credited to — plus, for reconfigure, pre-loading the existing root so vfs_get_tree refuses to run (if (fc->root) return -EBUSY). The insight to take: a remount is not “a mount with a flag”, it is a differently-constructed context that deliberately starts in the state a successful mount ends in.

Then comes the line that determines the permission model for the rest of the call:

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

The driver’s init_fs_context runs before mount_capable(fc) is consulted, and it is allowed to overwrite fc->user_ns. Two in-tree filesystems do exactly that, and it is why the FS_USERNS_MOUNT flag alone does not tell you whether a mount will succeed:

  • procfs (fs/proc/root.c:243-258) captures task_active_pid_ns(current) and then does put_user_ns(fc->user_ns); fc->user_ns = get_user_ns(ctx->pid_ns->user_ns); — the context is credited to whoever owns the PID namespace, not to the caller.
  • sysfs (fs/sysfs/mount.c:55-80) first gates on kobj_ns_current_may_mount(KOBJ_NS_TYPE_NET) and then swaps fc->user_ns to netns->user_ns — credited to the owner of the network namespace.

mount_capable(fc) (fs/super.c:690-696) then reads that swapped value:

bool mount_capable(struct fs_context *fc)
{
        if (!(fc->fs_type->fs_flags & FS_USERNS_MOUNT))
                return capable(CAP_SYS_ADMIN);       /* initial user namespace */
        else
                return ns_capable(fc->user_ns, CAP_SYS_ADMIN);
}

So the rule is two-stage: without FS_USERNS_MOUNT you need CAP_SYS_ADMIN in the initial user namespace (capable() is ns_capable(&init_user_ns, ...)), and with it you need CAP_SYS_ADMIN in whatever namespace the driver decided to credit the mount to. The measured consequences are in the section below.

The phase field is a different animal. It is declared alongside purpose but is manipulated only by fs/fsopen.c and fspick; the legacy mount(2) path never reads or writes it, because do_new_mount performs the whole sequence inside one syscall with nothing to sequence against. It exists because the new API hands userspace a file descriptor and lets it make many calls, so the kernel must remember how far along the context is:

stateDiagram-v2
    state "mount(2): one syscall, phase never used" as LEGACY
    [*] --> LEGACY: do_new_mount()
    LEGACY --> Done: init_fs_context, parse, get_tree, graft<br/>all inside path_mount
    Done --> [*]: put_fs_context(fc)

    state "New API: phase is a real state machine" as NEWAPI {
        [*] --> CREATE_PARAMS: fsopen(2)
        CREATE_PARAMS --> CREATE_PARAMS: fsconfig(FSCONFIG_SET_*)<br/>allowed only in a *_PARAMS phase
        CREATE_PARAMS --> CREATING: fsconfig(FSCONFIG_CMD_CREATE)
        CREATING --> AWAITING_MOUNT: vfs_get_tree() succeeded
        CREATING --> FAILED: get_tree returned an error
        AWAITING_MOUNT --> Mounted: fsmount(2) -> detached vfsmount fd
        Mounted --> Attached: move_mount(2)

        [*] --> AWAITING_RECONF: fspick(2)
        AWAITING_RECONF --> RECONF_PARAMS: fsconfig(FSCONFIG_SET_*)
        RECONF_PARAMS --> RECONFIGURING: fsconfig(FSCONFIG_CMD_RECONFIGURE)
        RECONFIGURING --> AWAITING_RECONF: reconfigure succeeded
        RECONFIGURING --> FAILED: reconfigure returned an error
        FAILED --> [*]: context is dead<br/>close the fd and start over
    }

enum fs_context_phase (fs_context.h:38-46) and the transitions in fs/fsopen.c:141-265. What it shows: two entry points into the same seven-state enum — fsopen for creating an instance, fspick for reconfiguring one — plus the legacy path, which traverses the equivalent steps but stores no state because it never returns to userspace in between. The insight to take: phase is the price of making mounting incremental. It is also the enforcement mechanism: fsconfig rejects a parameter unless the phase is CREATE_PARAMS or RECONF_PARAMS, and FS_CONTEXT_FAILED is a terminal state — a context that failed cannot be retried, only discarded. The userspace-facing syscalls are owned by The New Mount API; what this diagram adds is the kernel-side state the file descriptor is a handle on.

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.

Measured: Registration and Instantiation on a Live System

The schematic transcript above uses /dev/sda1 and /dev/sdb1 for readability. Everything in this section was actually run and pasted verbatim, on 2026-09-12, Fedora 44, kernel 7.1.13-200.fc44.x86_64 (uname -r) — a stable-series kernel, not the v6.12 tag the source citations point at. unshare -rm is the enabling trick throughout: it creates a new user namespace in which the invoking uid-1000 user is mapped to root and holds CAP_SYS_ADMIN, plus a new mount namespace so the mounts are private and are torn down when the shell exits. No real root was used anywhere below.

The type registry, counted

$ wc -l < /proc/filesystems ; grep -c '^nodev' /proc/filesystems ; grep -vc '^nodev' /proc/filesystems
35
28
7
$ grep -v '^nodev' /proc/filesystems
	ext3
	ext2
	ext4
	btrfs
	fuseblk
	squashfs
	vfat

Thirty-five registered types, of which only seven set FS_REQUIRES_DEV. The ratio is the point: the overwhelming majority of registered filesystems are synthetic — sysfs, proc, cgroup2, bpf, pipefs, sockfs, tracefs, and so on — and the nodev column is a direct, one-bit readout of fs_flags & FS_REQUIRES_DEV from the seq_printf in filesystems_proc_show quoted earlier. Note also fuseblk: FUSE registers two types, fuse (nodev) and fuseblk (requires a device), from one module.

Module autoload, without mounting anything

xfs is compiled as a module on this host and is not loaded, so it does not appear in the type registry at all:

$ grep -cw xfs /proc/filesystems
0
$ modprobe -R fs-xfs
xfs
$ modprobe -R fs-ext4
ext4
$ modprobe -R fs-nosuchfs
modprobe: FATAL: Module fs-nosuchfs not found in directory /lib/modules/7.1.13-200.fc44.x86_64

modprobe -R resolves an alias without loading anything, so it shows exactly what request_module("fs-%.*s", len, name) inside get_fs_type would resolve to. fs-xfs → xfs because fs/xfs/xfs_super.c declares MODULE_ALIAS_FS("xfs"), which the macro at fs.h:2566 expands to MODULE_ALIAS("fs-xfs"). fs-nosuchfs fails, and that failure is precisely what makes get_fs_type return NULL and do_new_mount return -ENODEV. This is the whole autoload mechanism, observable without privileges.

The check order is visible in the errno

path_mount runs may_mount() before do_new_mount ever looks up the type. That ordering is directly measurable by asking for a filesystem that does not exist, from two different privilege positions. A three-line C program calling mount(2) and printing errno:

$ ./e none /tmp/w2y tmpfs          # plain uid 1000, no namespace
mount(type=tmpfs)    -> errno 1  (Operation not permitted)
$ ./e none /tmp/w2y nosuchfs       # same, bogus type
mount(type=nosuchfs) -> errno 1  (Operation not permitted)
 
$ unshare -rm sh -c '/tmp/w2e none /tmp/w2x nosuchfs'
mount(type=nosuchfs) -> errno 19 (No such device)

Outside a user namespace, both calls return EPERM — the caller never gets far enough for the type name to matter. Inside unshare -rm, where may_mount() now passes, the bogus type reaches get_fs_type, request_module("fs-nosuchfs") fails, and the call returns ENODEV. So EPERM on a mount means “you were stopped at the door” and ENODEV means “you got in and the filesystem you asked for is not there” — a useful first bisection when debugging a container that will not start.

FS_USERNS_MOUNT is necessary but not sufficient

Running the same probe against a range of types inside unshare -rm (new user + mount namespace, nothing else):

$ unshare -rm sh -c 'for t in tmpfs ramfs devpts proc sysfs cgroup2 ext4; do /tmp/w2e none /tmp/w2x $t; done'
mount(type=tmpfs)   -> OK
mount(type=ramfs)   -> OK
mount(type=devpts)  -> OK
mount(type=proc)    -> errno 1 (Operation not permitted)
mount(type=sysfs)   -> errno 1 (Operation not permitted)
mount(type=cgroup2) -> errno 1 (Operation not permitted)
mount(type=ext4)    -> errno 1 (Operation not permitted)

ext4 failing is expected: it does not set FS_USERNS_MOUNT, so mount_capable falls to capable(CAP_SYS_ADMIN), which is evaluated against the initial user namespace where uid 1000 has nothing. But proc, sysfs and cgroup2 all do set FS_USERNS_MOUNT — fs/proc/root.c:282 literally reads .fs_flags = FS_USERNS_MOUNT | FS_DISALLOW_NOTIFY_PERM — and they still fail. The reason is the fc->user_ns swap described above. Adding the namespaces those drivers demand makes each one succeed, one at a time:

$ unshare -rmp --fork sh -c '/tmp/w2e ... proc; /tmp/w2e ... sysfs'
mount(type=proc)    -> OK                                   # new PID namespace
mount(type=sysfs)   -> errno 1 (Operation not permitted)
 
$ unshare -rmn sh -c '/tmp/w2e ... sysfs; /tmp/w2e ... proc'
mount(type=sysfs)   -> OK                                   # new network namespace
mount(type=proc)    -> errno 1 (Operation not permitted)
 
$ unshare -rmpnC --fork sh -c '/tmp/w2e ... proc; /tmp/w2e ... sysfs; /tmp/w2e ... cgroup2'
mount(type=proc)    -> OK
mount(type=sysfs)   -> OK
mount(type=cgroup2) -> OK

The mapping is exact and it matches the source: proc needs a PID namespace owned by the current user namespace (proc_init_fs_context credits the context to ctx->pid_ns->user_ns), sysfs needs a network namespace (sysfs_init_fs_context credits it to netns->user_ns after kobj_ns_current_may_mount), and cgroup2 needs a cgroup namespace. -p alone fixes proc and not sysfs; -n alone does the reverse; all four together fix all three.

TypeFS_USERNS_MOUNT?Extra namespace requiredResult in plain unshare -rm
tmpfsyesnoneOK
ramfsyesnoneOK
devptsyesnoneOK
procyesa PID namespace (unshare -p --fork)EPERM
sysfsyesa network namespace (unshare -n)EPERM
cgroup2yesa cgroup namespace (unshare -C)EPERM
ext4noCAP_SYS_ADMIN in the initial user namespaceEPERM
nosuchfsn/an/aENODEV (got past the permission check)

Measured mountability inside unshare -rm. What it shows: the single FS_USERNS_MOUNT bit answers only the first of two questions. The insight to take: “is this filesystem rootless-mountable?” is a two-part question — does the type opt in, and does the driver’s init_fs_context re-credit the mount to a namespace you happen to own? Container runtimes create a full set of namespaces largely because of the second column. The namespace machinery itself is owned by Mount Namespaces and User Namespaces.

Two mounts of one type: one superblock or two?

This is the get_tree_* table made observable. stat -c %d prints the device number of the filesystem a path lives on, which is the superblock’s identity; /proc/self/mountinfo’s third field prints the same thing as major:minor.

$ unshare -rm sh demo2.sh
+ mount -t tmpfs t1 /tmp/w2/a
+ mount -t tmpfs t2 /tmp/w2/b
+ stat -c 'dev=%d %n' /tmp/w2/a /tmp/w2/b
dev=105 /tmp/w2/a
dev=106 /tmp/w2/b
+ mount --bind /tmp/w2/a /tmp/w2/c
+ stat -c 'dev=%d %n' /tmp/w2/a /tmp/w2/c
dev=105 /tmp/w2/a
dev=105 /tmp/w2/c
+ grep w2 /proc/self/mountinfo
1987 1506 0:105 / /tmp/w2/a rw,relatime - tmpfs t1 rw,seclabel,uid=1000,gid=1000,inode64
2127 1506 0:106 / /tmp/w2/b rw,relatime - tmpfs t2 rw,seclabel,uid=1000,gid=1000,inode64
2128 1506 0:105 / /tmp/w2/c rw,relatime - tmpfs t1 rw,seclabel,uid=1000,gid=1000,inode64

Two mount -t tmpfs calls produce 0:105 and 0:106 — two superblocks, because tmpfs’s get_tree never matches an existing one. A mount --bind of the first produces a third mount ID (2128) over the same 0:105 — one superblock, two mounts — which is do_loopback taking the MS_BIND branch of path_mount and never calling any filesystem’s get_tree at all. Note also that the third mountinfo line reports the source as t1, not /tmp/w2/a: the source string is a property of the superblock, so the bind inherits it.

The same experiment on proc gives the same answer for the same reason — proc_get_tree is literally return get_tree_nodev(fc, proc_fill_super); (fs/proc/root.c:224-227):

$ unshare -rmp --fork sh -c 'mount -t proc none /tmp/w2/p1; mount -t proc none /tmp/w2/p2; stat -c "dev=%d %n" /tmp/w2/p1 /tmp/w2/p2'
dev=105 /tmp/w2/p1
dev=106 /tmp/w2/p2

Two proc mounts in one PID namespace still get two independent superblocks. This is worth stating because it is a common misconception that /proc is a singleton: the contents are determined by the PID namespace recorded in proc_fs_info, but the super_block is per-mount, which is what makes per-mount options like hidepid= differ between two mounts of the same /proc.

The cgroup2 row was isolated rather than inferred. unshare -rmC (a cgroup namespace and nothing else beyond user+mount) succeeds; unshare -rmpn --fork (PID and network namespaces, no cgroup namespace) still returns EPERM:

$ unshare -rmC  sh -c '/tmp/w2e none /tmp/w2x cgroup2'
mount(type=cgroup2) -> OK
$ unshare -rmpn --fork sh -c '/tmp/w2e none /tmp/w2x cgroup2'
mount(type=cgroup2) -> errno 1 (Operation not permitted)

and the source agrees: cgroup_init_fs_context performs the same swap the other two do — put_user_ns(fc->user_ns); fc->user_ns = get_user_ns(ctx->ns->user_ns); where ctx->ns is the current cgroup namespace (kernel/cgroup/cgroup.c:2280-2296, with .fs_flags = FS_USERNS_MOUNT on all three cgroup types at lines 2330, 2338 and 2381). Three filesystems, three namespaces, one identical mechanism.

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.

The “merged in 5.2” claim above is not taken on trust; it was established by existence-checking the x86-64 syscall table at successive tags. arch/x86/entry/syscalls/syscall_64.tbl at v5.1 contains none of the six names; at v5.2 it contains all six, at numbers 428-433. The later additions bisect the same way. Tag dates are from the annotated tag objects on git.kernel.org.

Syscallx86-64 numberFirst tag containing itTag dateRole
open_tree428v5.22019-07-07clone a subtree into a detached fd (OPEN_TREE_CLONE)
move_mount429v5.22019-07-07attach a detached mount to a mountpoint
fsopen430v5.22019-07-07create an fs_context for a named type, return a fd
fsconfig431v5.22019-07-07set one parameter, or issue CMD_CREATE/CMD_RECONFIGURE
fsmount432v5.22019-07-07turn a created superblock into a detached vfsmount fd
fspick433v5.22019-07-07create an fs_context from an existing mount, for reconfigure
mount_setattr442v5.122021-04-25change mount attributes, incl. MOUNT_ATTR_IDMAP; absent at v5.11
statmount457v6.82024-03-10structured per-mount query; absent at v6.7
listmount458v6.82024-03-10enumerate mount IDs; absent at v6.7

Landing releases for the mount-API syscalls, established by fetching syscall_64.tbl at each tag and recording presence/absence. What it shows: the “new mount API” is not one event but four, spread over five years — the core six in 5.2, attribute setting in 5.12, and querying in 6.8. The insight to take: “does this kernel have the new mount API?” is not a yes/no question. A 5.10 LTS has fsopen but not mount_setattr; a 6.6 LTS has mount_setattr but not statmount. Feature-detect per syscall (call it and check for ENOSYS) rather than gating on a version number.

And the front-end comparison, which is the practical decision:

DimensionLegacy mount(2)New API (fsopen/fsconfig/fsmount/move_mount)
Syscalls per mount14 or more
Options passed asone comma-separated string in a single page (data_page)one typed fsconfig call per parameter
Option typesstrings only; the filesystem re-parsesstring, binary blob, path, path-empty, fd, or flag — the kernel validates against fs_type->parameters
Error reportinga bare errno; “which option was wrong?” is unanswerableper-parameter messages readable from the context fd
Which fs_context field records itfc->oldapi = truefc->oldapi stays false
Does the kernel track progress?no — phase is never usedyes — enum fs_context_phase gates every call
Mounting into another namespaceimpossible in one step; you must be in the target namespacenatural: create and configure anywhere, fsmount to a detached fd, move_mount after setns
Superblock reuse semanticsdecided by the get_tree_* helperidentical — the same helper runs
Availabilitysince forever5.2 for the core six; see the table above
When to reach for itscripts, portability, anything that must run on old kernelscontainer runtimes, option-validating tools, anything that needs a detached mount or a usable error message

The two front ends to the same fs_context machinery. What it shows: every row below “Syscalls per mount” is a consequence of the first — splitting one call into many is what creates room for typed parameters, readable errors, and a detached intermediate state. The insight to take: the two APIs are not different mount implementations. do_new_mount and fsopen build the same struct fs_context, call the same init_fs_context, and reach the same get_tree; the only recorded difference is the fc->oldapi bit and whether phase is maintained. Anything that behaves differently between them is a filesystem consulting fc->oldapi, not the VFS. The full new-API surface, including fsconfig command codes and the error-log fd, 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 — but, as the measured section above shows, checking whether a filesystem sets it is only half the test. proc, sysfs and all three cgroup types set the flag and re-credit the fs_context to the user namespace owning a PID, network or cgroup namespace respectively, so they fail with EPERM in a user namespace that does not also own one. A rootless container runtime therefore unshares the whole set of namespaces not only for isolation but because otherwise its own /proc mount will not work. 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