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).
Field by field, with the lifetime each one has:
| Member | Type | Who sets it | Purpose |
|---|---|---|---|
name | const char * | the driver, statically | The string userspace passes as mount -t <name>. A . is forbidden (BUG_ON in register_filesystem) because it separates type.subtype. |
fs_flags | int | the driver, statically | Capability/behaviour bits — see the next table. |
init_fs_context | int (*)(struct fs_context *) | the driver | Modern entry point. Called by alloc_fs_context to install fc->ops and allocate fc->fs_private. |
parameters | const struct fs_parameter_spec * | the driver | Typed option schema. Validated at registration time by fs_validate_description(); a malformed spec makes register_filesystem return -EINVAL. |
mount | struct dentry *(*)(...) | the driver | Legacy entry point. Returns a root dentry directly. Adapted onto fs_context by legacy_get_tree. |
kill_sb | void (*)(struct super_block *) | the driver | Teardown, called on the last deactivate_super. Almost always kill_block_super, kill_anon_super, or a wrapper around one. |
owner | struct module * | THIS_MODULE | Reference-counted with try_module_get/module_put so the module cannot unload while an instance is mounted. |
next | struct file_system_type * | register_filesystem | The singly-linked global list. Non-NULL is itself the “already registered” test. |
fs_supers | struct hlist_head | sget_fc | Every 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_key | struct lock_class_key | lockdep | Per-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:
| Flag | Value | Meaning | Examples in-tree |
|---|---|---|---|
FS_REQUIRES_DEV | 1 | Needs a real block device. This is the only input to the nodev column of /proc/filesystems. | ext4, xfs, btrfs |
FS_BINARY_MOUNTDATA | 2 | The 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_SUBTYPE | 4 | Accepts type.subtype; do_new_mount splits at the . and feeds the tail in as the subtype parameter. | fuse (fuse.sshfs) |
FS_USERNS_MOUNT | 8 | Mountable 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_PERM | 16 | fanotify permission events are refused on this filesystem, to avoid deadlocking the notifier against its own filesystem. | proc |
FS_ALLOW_IDMAP | 32 | The 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_MOVE | 32768 | The 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 taken | What it does | Creates a superblock? |
|---|---|---|---|
flags & MS_NOUSER | — | returns -EINVAL immediately; the flag exists to mark mounts userspace may not request | no |
!may_mount() | — | returns -EPERM; this is checked before the type name is even looked at | no |
(MS_REMOUNT|MS_BIND) both set | do_reconfigure_mnt | changes only the MNT_* flags of this mount | no |
MS_REMOUNT | do_remount | reconfigures the superblock (fs_context_for_reconfigure → ->reconfigure) and the mount flags | no — reuses the existing one |
MS_BIND | do_loopback | clones 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_UNBINDABLE | do_change_type | changes propagation type only; see Bind Mounts and Mount Propagation | no |
MS_MOVE | do_move_mount_old | detaches a mount and re-attaches it elsewhere | no |
| (fall through) | do_new_mount | the path this note traces | yes |
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) flag | Becomes | Scope | Notes |
|---|---|---|---|
MS_NOSUID | MNT_NOSUID | per-mount | |
MS_NODEV | MNT_NODEV | per-mount | |
MS_NOEXEC | MNT_NOEXEC | per-mount | |
MS_NOATIME | MNT_NOATIME | per-mount | also suppresses the MNT_RELATIME default |
MS_NODIRATIME | MNT_NODIRATIME | per-mount | |
MS_RELATIME | MNT_RELATIME | per-mount | the default when no atime flag is given |
MS_STRICTATIME | clears MNT_RELATIME|MNT_NOATIME | per-mount | the only “un-flag” in the set |
MS_RDONLY | MNT_READONLY | per-mount | and SB_RDONLY per-superblock — the one flag in both columns |
MS_NOSYMFOLLOW | MNT_NOSYMFOLLOW | per-mount | |
MS_SYNCHRONOUS | SB_SYNCHRONOUS | per-superblock | |
MS_MANDLOCK | SB_MANDLOCK | per-superblock | triggers warn_mandlock() — mandatory locking is deprecated |
MS_DIRSYNC | SB_DIRSYNC | per-superblock | |
MS_SILENT | SB_SILENT | per-superblock | |
MS_POSIXACL | SB_POSIXACL | per-superblock | |
MS_LAZYTIME | SB_LAZYTIME | per-superblock | |
MS_I_VERSION | SB_I_VERSION | per-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 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).
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.
| Helper | Match callback | When is fill_super called? | Keyed by | Used by |
|---|---|---|---|---|
get_tree_bdev | test_bdev_super: s->s_dev == dev and not SB_I_RETIRED | only when no superblock for that dev_t exists | the block device’s dev_t | ext4, xfs, btrfs — every FS_REQUIRES_DEV filesystem |
get_tree_nodev | NULL — never matches | every mount | nothing; a fresh anonymous dev_t from set_anon_super_fc each time | proc, and synthetic filesystems that want independent instances |
get_tree_single | test_single_super: return 1 — always matches | only on the very first mount, system-wide | nothing; there is one instance, period | filesystems with genuinely one global state |
get_tree_keyed | test_keyed_super: sb->s_fs_info == fc->s_fs_info | only when no superblock carries that key | a 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:
purpose | Created by | fc->user_ns initialised from | Other setup |
|---|---|---|---|
FS_CONTEXT_FOR_MOUNT | fs_context_for_mount() — the legacy mount(2) path and fsopen(2) | fc->cred->user_ns — the caller’s user namespace | none |
FS_CONTEXT_FOR_SUBMOUNT | fs_context_for_submount() — automounts (autofs, NFS referrals) | reference->d_sb->s_user_ns — the parent superblock’s user namespace | none |
FS_CONTEXT_FOR_RECONFIGURE | fs_context_for_reconfigure() — remount and fspick(2) | reference->d_sb->s_user_ns | takes 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 doesput_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 swapsfc->user_nstonetns->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
vfatThirty-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_64modprobe -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) -> OKThe 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.
| Type | FS_USERNS_MOUNT? | Extra namespace required | Result in plain unshare -rm |
|---|---|---|---|
tmpfs | yes | none | OK |
ramfs | yes | none | OK |
devpts | yes | none | OK |
proc | yes | a PID namespace (unshare -p --fork) | EPERM |
sysfs | yes | a network namespace (unshare -n) | EPERM |
cgroup2 | yes | a cgroup namespace (unshare -C) | EPERM |
ext4 | no | CAP_SYS_ADMIN in the initial user namespace | EPERM |
nosuchfs | n/a | n/a | ENODEV (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,inode64Two 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/p2Two 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
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.
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.
| Syscall | x86-64 number | First tag containing it | Tag date | Role |
|---|---|---|---|---|
open_tree | 428 | v5.2 | 2019-07-07 | clone a subtree into a detached fd (OPEN_TREE_CLONE) |
move_mount | 429 | v5.2 | 2019-07-07 | attach a detached mount to a mountpoint |
fsopen | 430 | v5.2 | 2019-07-07 | create an fs_context for a named type, return a fd |
fsconfig | 431 | v5.2 | 2019-07-07 | set one parameter, or issue CMD_CREATE/CMD_RECONFIGURE |
fsmount | 432 | v5.2 | 2019-07-07 | turn a created superblock into a detached vfsmount fd |
fspick | 433 | v5.2 | 2019-07-07 | create an fs_context from an existing mount, for reconfigure |
mount_setattr | 442 | v5.12 | 2021-04-25 | change mount attributes, incl. MOUNT_ATTR_IDMAP; absent at v5.11 |
statmount | 457 | v6.8 | 2024-03-10 | structured per-mount query; absent at v6.7 |
listmount | 458 | v6.8 | 2024-03-10 | enumerate 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:
| Dimension | Legacy mount(2) | New API (fsopen/fsconfig/fsmount/move_mount) |
|---|---|---|
| Syscalls per mount | 1 | 4 or more |
| Options passed as | one comma-separated string in a single page (data_page) | one typed fsconfig call per parameter |
| Option types | strings only; the filesystem re-parses | string, binary blob, path, path-empty, fd, or flag — the kernel validates against fs_type->parameters |
| Error reporting | a bare errno; “which option was wrong?” is unanswerable | per-parameter messages readable from the context fd |
Which fs_context field records it | fc->oldapi = true | fc->oldapi stays false |
| Does the kernel track progress? | no — phase is never used | yes — enum fs_context_phase gates every call |
| Mounting into another namespace | impossible in one step; you must be in the target namespace | natural: create and configure anywhere, fsmount to a detached fd, move_mount after setns |
| Superblock reuse semantics | decided by the get_tree_* helper | identical — the same helper runs |
| Availability | since forever | 5.2 for the core six; see the table above |
| When to reach for it | scripts, portability, anything that must run on old kernels | container 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
- 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 - Mount Namespaces — the namespace the graft step inserts into, and the
unshare -rmmachinery used for the measurements here - User Namespaces — what
mount_capable()and thefc->user_nsswap are actually testing against - VFS File Object — the
struct filethat pins a mount throughf_path.mnt - The Virtual File System Layer — the dispatch layer all of this serves
- MOC: Linux Filesystems and VFS MOC