The Virtual File System Layer

The Virtual File System (VFS) — also called the Virtual Filesystem Switch — is the kernel layer that lets a single set of system calls (open, read, write, stat, mount, and dozens more) drive any of the dozens of filesystems Linux supports, without userspace knowing or caring which one backs a given file. As the kernel’s own documentation puts it, the VFS “provides the filesystem interface to userspace programs” and “an abstraction within the kernel which allows different filesystem implementations to coexist” (per Documentation/filesystems/vfs.rst, v6.12). It does this by defining four in-memory object types — the superblock, inode, dentry, and file — and a discipline whereby every operation on them is dispatched through a table of function pointers that the concrete filesystem fills in. The VFS is therefore not itself a filesystem; it is the switchboard that routes a read() on /etc/hostname into ext4’s read path and a read() on /proc/cpuinfo into procfs’s, using identical generic code up to the moment it calls through the table.

This is the orientation note for the whole Filesystems and VFS subsystem. It frames why the VFS exists, what its four objects are and how they reference one another, and how a syscall flows through generic VFS code into a concrete filesystem. The four objects each get a dedicated deep-dive — VFS Superblock Object, VFS Inode Object, VFS Dentry Object, VFS File Object — and the function-pointer machinery that makes the dispatch polymorphic is the subject of VFS Operation Tables. This note stays at the level of the abstraction and links out for the mechanism.

Why the VFS Exists

Imagine the kernel without a VFS. A program calls read(fd, buf, 4096). The kernel must figure out which filesystem owns the file behind fd, then call the right read routine for it — ext4_read, xfs_read, btrfs_read, nfs_read, proc_read, and so on. If every system call had to carry a giant switch over filesystem types, then (a) adding a new filesystem would mean patching every syscall, (b) userspace would need to know what filesystem a path lives on, and (c) common behavior (permission checks, offset bookkeeping, the page cache) would be re-implemented dozens of times. None of that is acceptable for a system that mounts ext4, XFS, tmpfs, procfs, NFS, and overlayfs simultaneously in one directory tree.

The VFS solves this with the classic object-oriented trick implemented in plain C: indirection through function-pointer tables. The generic VFS code knows the shape of every filesystem — that it has a mounted instance (superblock), files-with-metadata (inodes), name bindings (dentries), and open handles (files) — but it never hard-codes which filesystem it is talking to. Instead, each object carries a pointer to a table of operations (super_operations, inode_operations, file_operations, and so on), and the concrete filesystem populates that table at mount time. When the VFS needs to look up a name it calls dir_inode->i_op->lookup(...); when it needs to read it calls file->f_op->read(...). The same line of generic code dispatches to ext4 or procfs depending only on which pointers were installed. This is the polymorphism that VFS Operation Tables details.

The historical payoff is enormous: Linux supports on the order of 50+ filesystems, and a brand-new one — when bcachefs was merged, or when a hobbyist writes a toy FUSE filesystem — plugs into the existing syscall surface for free. Userspace never changes. A cp from an ext4 directory to an NFS mount is the same two read/write loops it always was; the VFS routes each side to its own filesystem.

"Common file model" is a textbook term, not kernel terminology

The framing “the VFS imposes a common file model inspired by Unix” comes from Bovet & Cesati’s Understanding the Linux Kernel (a respected secondary source), not from the kernel’s own documentation. The kernel docs describe the same idea as “an abstraction within the kernel which allows different filesystem implementations to coexist.” Both describe the four-object model below; the phrase itself is pedagogical, not a name used in the source tree.

Mental Model — The Switchboard and the Four Objects

flowchart TB
  subgraph US["Userspace"]
    APP["read(fd, buf, n)<br/>stat(path, &st)<br/>open(path, flags)"]
  end
  APP -->|"syscall trap"| GEN["Generic VFS code<br/>vfs_read / vfs_getattr / do_filp_open<br/>(permission checks, offset, page cache)"]
  GEN -->|"dispatch through op table"| OPS["object->ops->method()<br/>(function pointer)"]
  subgraph OBJ["The four VFS objects (in memory)"]
    direction LR
    FI["file<br/>open handle + f_pos + f_op"]
    DE["dentry<br/>name to inode binding"]
    IN["inode<br/>metadata + i_op + i_fop"]
    SB["superblock<br/>mounted instance + s_op"]
    FI -->|"f_path.dentry"| DE
    DE -->|"d_inode"| IN
    IN -->|"i_sb"| SB
  end
  OPS --> OBJ
  OBJ -->|"fs-supplied pointers"| FS["Concrete filesystem<br/>ext4 / xfs / tmpfs / procfs / nfs"]

The VFS as a switchboard. What it shows: a syscall lands in generic VFS code that does the filesystem-independent work, then dispatches through one of the object’s operation tables into the concrete filesystem. The four objects form a chain — a file points at a dentry, which points at an inode, which belongs to a superblock. The insight to take: learn the four objects and their chain once, and every filesystem becomes “the same four objects with different operation tables.” The generic code above the dotted dispatch line is shared by all filesystems; only the function pointers below it differ.

The Four Core Objects

The VFS models everything with four object types. Each is a struct defined in include/linux/fs.h (the dentry lives in dcache.h), and each carries one or more operation tables. This note introduces them and their relationships; each links to its own deep-dive.

The superblock (struct super_block) represents one mounted instance of a filesystem — the whole of “the ext4 on /dev/sda2 mounted at /home.” It holds filesystem-wide state: the block size (s_blocksize), the maximum file size (s_maxbytes), the magic number identifying the format (s_magic), a back-pointer to the filesystem type (s_type), the root dentry of the mount (s_root), filesystem-private data (s_fs_info), and crucially s_op, the pointer to its super_operations (verified at fs.h v6.12, struct super_block around line 1253). There is one superblock per mount, not per filesystem type: mount two ext4 volumes and you get two superblocks, both with s_op pointing at ext4’s super_operations. The deep-dive is VFS Superblock Object.

The inode (struct inode) is a file’s metadata — everything about a file except its name and its contents. One inode per file object (regular file, directory, symlink, FIFO, device node, socket). It carries the mode and type bits (i_mode), owner (i_uid/i_gid), size (i_size), link count (i_nlink), timestamps, the inode number (i_ino), and two distinct operation-table pointers: i_op (its inode_operations, for namespace operations like lookup/create/unlink) and i_fop (its file_operations, the default ops for files opened from this inode). It also points at its address_space via i_mapping (the page-cache index, whose own a_ops table is address_space_operations) and back at its superblock via i_sb. A single inode can be referenced by several dentries — that is exactly what a hard link is. The deep-dive is VFS Inode Object.

i_op and i_fop are two different tables, both on the inode

A common confusion is to think the file’s operations are reached through the inode’s inode_operations. They are not. The inode carries two independent table pointers: i_op (inode_operations) governs operations on the name and metadata (look up a child, create a child, change attributes); i_fop (file_operations) is the default table for an open file and is copied into the file object at open time. The fs.h v6.12 comment on the i_fop field even reads /* former ->i_op->default_file_ops */, a fossil of the days when it lived inside inode_operations. Keep them straight: i_op->lookup resolves a name; i_fop->read_iter reads bytes.

The dentry (struct dentry, “directory entry”) is a name-to-inode binding. It is the node from which the kernel builds the directory tree in memory: /usr/bin/env is four dentries (/, usr, bin, env) chained by d_parent, the last pointing via d_inode at the inode for the env program. Dentries live only in RAM and are never written to disk — they are a pure performance cache (the dentry cache or dcache) that translates pathnames into inodes without re-reading directory blocks on every lookup. A dentry whose d_inode is NULL is a negative dentry, caching the fact that a name does not exist. Dentries carry d_op (their dentry_operations). The deep-dives are VFS Dentry Object and, for the cache that holds them, The Dentry Cache and Path Resolution and Name Lookup.

The file (struct file) is a process’s open handle on a file — the kernel object behind a file descriptor. It records the current read/write position (f_pos), the access mode and flags (f_mode, f_flags), the path it was opened through (f_path, which contains the dentry), the page-cache index (f_mapping), and f_op, its file_operations table — copied from the inode’s i_fop at open and then used for every read/write/mmap/ioctl/close. The same on-disk file opened twice yields two file objects with independent f_pos but the same underlying inode. The deep-dive is VFS File Object, and the descriptor-to-file plumbing is File Descriptors and the fd Table and The struct file and Open File Description.

The chain file → dentry → inode → superblock is the spine of the whole layer. Every VFS operation starts somewhere on this chain and dispatches through the corresponding object’s op table.

How a Syscall Flows Through the VFS

The whole point of the VFS is best seen by tracing real system calls. Three short paths show the pattern; the literal call sites and the generic-helper machinery are dissected in VFS Operation Tables, so here we stay at the level of where the abstraction routes the call.

read(fd, buf, n). The descriptor fd indexes the process’s file table to find the struct file. Generic code in vfs_read() (fs/read_write.c, v6.12) does the filesystem-independent work — checks FMODE_READ, validates the user buffer, caps the count — and then dispatches: if (file->f_op->read) ret = file->f_op->read(...) and otherwise falls through to read_iter. That one indirect call is the switch. For a regular file on ext4 it lands in generic_file_read_iter, which consults the page cache; for a character device it lands in the driver’s read routine; for procfs it lands in a seq_file handler. Generic code did the bookkeeping; the function pointer did the routing.

stat(path, &st). First the path is resolved to a dentry (and thus an inode) by walking the dcache — see Path Resolution and Name Lookup. Then vfs_getattr_nosec() (fs/stat.c, v6.12) fills the kstat either by calling the inode’s own method, inode->i_op->getattr(...) if the filesystem provides one, or by falling back to generic_fillattr(), which simply copies the in-memory inode fields. Most filesystems that store standard POSIX metadata leave getattr mostly to the generic helper.

open(path, flags). Path resolution finds (or, with O_CREAT, creates via i_op->create) the dentry and inode. Then a fresh struct file is allocated and, in do_dentry_open() (fs/open.c, v6.12), its operation table is wired up: f->f_op = fops_get(inode->i_fop) — the file inherits the inode’s default file_operations. The filesystem’s own ->open method (if it set one) is then called so it can do per-open setup. The new file is installed in the descriptor table and the integer fd returned. From then on, every operation on that fd dispatches through f->f_op.

The pattern is identical every time: generic VFS code does the filesystem-independent work, then calls through a function pointer in one of the four objects’ op tables to reach the concrete filesystem. Name lookups go through inode->i_op; data operations through file->f_op; whole-filesystem operations (sync, statfs, inode allocation) through superblock->s_op; and page-cache operations through inode->i_mapping->a_ops.

Registration — How a Filesystem Joins the Switchboard

A filesystem advertises itself to the VFS by registering a struct file_system_type, which names the filesystem and supplies the entry points for creating a mounted instance. From vfs.rst, v6.12:

extern int register_filesystem(struct file_system_type *);

The file_system_type carries the filesystem’s name (the string you pass to mount -t, e.g. "ext4"), an init_fs_context callback (the modern mount entry point — see The New Mount API), a legacy mount callback, and a kill_sb teardown. When userspace mounts the filesystem, the VFS calls into it to create and populate a super_block, and — per the docs — “the most interesting member of the superblock structure that the mount() method fills in is the s_op field,” the pointer to its super_operations. From that superblock the filesystem hands the inode and file operation tables down to each inode it instantiates. Every registered filesystem is visible in /proc/filesystems. The full mount mechanism is Filesystem Registration and Mounting Internals and The Mount Tree and vfsmount.

A concrete, minimal example is ramfs (a RAM-backed filesystem, the simplest real filesystem in the tree). Its file_system_type is just five fields (fs/ramfs/inode.c, v6.12):

static struct file_system_type ramfs_fs_type = {
	.name		= "ramfs",
	.init_fs_context = ramfs_init_fs_context,   // build the superblock
	.parameters	= ramfs_fs_parameters,
	.kill_sb	= ramfs_kill_sb,
	.fs_flags	= FS_USERNS_MOUNT,
};

When ramfs creates an inode, it switches on the file type and installs the matching tables — a directory gets &ramfs_dir_inode_operations and &simple_dir_operations, a regular file gets &ramfs_file_inode_operations and &ramfs_file_operations, a symlink gets &page_symlink_inode_operations. Notice how thin those tables are: ramfs’s file operations are almost entirely generic helpers (generic_file_read_iter, generic_file_write_iter, generic_file_mmap), and its directory lookup is simple_lookup. This is the everyday face of the VFS — a real filesystem is mostly a set of tables wired to library functions, with a handful of filesystem-specific methods filled in where the behavior genuinely differs. VFS Operation Tables walks these tables field by field.

Common Misunderstandings

“The VFS is a filesystem.” No. The VFS owns no on-disk format and stores no data. It is pure dispatch and shared bookkeeping (path resolution, the page cache, permission checks, offset management). Remove every concrete filesystem and the VFS has nothing to route to; remove the VFS and every filesystem loses its connection to the syscall layer.

f_op comes from i_op.” A specific case of the i_op/i_fop confusion above. The file’s operations are inherited from the inode’s i_fop (a file_operations), copied to file->f_op at open. i_op (an inode_operations) is a separate table used for namespace/metadata operations and is never the source of f_op.

“Every operation reaches the filesystem.” Many do not. A read() that hits the page cache returns from the cached folio without the filesystem ever being called; lseek on most files just updates f_pos in generic code; stat usually returns from in-memory inode fields via generic_fillattr. The VFS calls into the concrete filesystem only when it genuinely needs new data or metadata from the backing store.

“All operations live on one table.” They are split across four objects deliberately: whole-mount operations on the superblock, namespace/metadata on the inode (i_op), open-file operations on the file (f_op, defaulted from i_fop), and page-cache operations on the address_space (a_ops). Putting a method on the wrong object is a category error — read is never a super_operation, mount is never a file_operation.

Where the VFS Meets the Rest of the Kernel

The VFS is a hub. Above it sits the system call interfaceopen/read/write/stat/mount are its entry points. Below it sit the concrete filesystems: the journaling ext4 and XFS, the copy-on-write Btrfs, the RAM-backed tmpfs, the layered overlayfs behind container images, and FUSE. Beside it, the page cache is the great connector shared with memory management (which writes dirty pages back under reclaim) and the block layer (a cache miss becomes a block read). And the dcache that makes path resolution fast is the marquee user of RCU — see RCU-Walk and Ref-Walk.

See Also