Filesystems · Filesystem Implementation

VFS Objects and the Operation Vector

The four in-kernel objects every filesystem must supply, the function table each one carries, and how a single open() call ends up in a filesystem-specific method.

A system call cannot know which filesystem it is talking to. open receives a path and a set of flags; the path may cross a mount point, the file may live on a network volume, and the implementation may be a module that was loaded after the call site was compiled. The kernel therefore defines a small set of abstract objects with fixed interfaces, requires every filesystem to implement those interfaces, and dispatches through function pointers. That indirection layer is the virtual filesystem switch, and its cost is one indirect call per operation in exchange for the ability to add a filesystem without recompiling the kernel.

Four objects and what each one stands for

The layer names four object types, and the distinction between them is the distinction between the things POSIX distinguishes.

A superblock represents one mounted filesystem instance: a block device, a network export, a pseudo-filesystem. It holds the filesystem’s private data, a pointer to the operation table for superblock-level actions, and the mount parameters. A second mount of the same block device normally reuses the superblock that already describes it, so two paths into one volume reach one object; a filesystem whose mounts must remain distinguishable, such as a network export mounted with different options or one subvolume of a shared pool, creates a separate superblock for each mount.

An inode represents one file object on that filesystem: its metadata, its size, its permissions, and a pointer to the operation table for inode-level actions. An inode in memory is not the same thing as an inode on disk; the in-memory structure adds reference counts, a lock, page cache state, and the address space that maps file offsets to pages.

A dentry represents one path component, and it exists to make name lookup cheap. A dentry is a (parent, name) pair that resolves to an inode, and the kernel caches them so that resolving /usr/lib/libc.so a second time does not require reading the parent directory again. Dentries have no on-disk counterpart at all: they are a pure cache structure.

A file represents one open file description: a position, flags, and a pointer to the operation table for file-level actions. This is the object open returns a descriptor for, and it is distinct from the inode because two opens of the same file have separate positions and separate flags.

The four-way split is not arbitrary. Inode operations are things that act on the object itself (create, link, unlink, mkdir, truncate); file operations are things that act on an open handle (read, write, llseek, fsync); dentry operations are cache and lookup behaviour (lookup, d_revalidate, d_delete); superblock operations are volume-wide actions (statfs, sync_fs, alloc_inode, evict_inode).

The operation tables

Each object type carries a structure of function pointers. A filesystem fills in the ones it supports and leaves the rest null; the layer checks for null before calling and returns -EINVAL or a sensible default if the method is absent. This is what allows a filesystem to be partial: a read-only filesystem implements read and leaves write null, and the layer refuses the write before the filesystem sees it.

static const struct file_operations ramfs_file_operations = {
        .read_iter  = generic_file_read_iter,
        .write_iter = generic_file_write_iter,
        .mmap       = generic_file_mmap,
        .fsync      = noop_fsync,
        .llseek     = generic_file_llseek,
};

Three things about that table are worth noticing. The methods are read_iter and write_iter rather than read and write, because the layer passes an iterator over user buffers so that a single call can serve a vectored I/O request. generic_file_mmap and generic_file_llseek are shared helpers that implement the common behaviour for any filesystem that stores data in the page cache, so a simple filesystem does not write them. noop_fsync is a deliberate no-op, legal only because the filesystem has no persistent state to flush.

The layer’s dispatch is not a single call either. A read on a regular file goes through the generic path first: the layer checks permissions, checks that the file is not opened write-only, handles the offset under the file’s lock, and then calls either the filesystem’s method or the generic page-cache reader. Only if the filesystem has its own storage strategy does control reach filesystem code at all. That layering is why a filesystem can implement almost nothing and still work: the generic paths do the work, and the filesystem supplies only the block-mapping methods the page cache needs.

What happens during one open

The path resolution walk is the part of the layer that does the most work per call, and it is where the four objects interact. For each component the layer consults the dentry cache; on a miss it calls the parent inode’s lookup method with the component name, and the filesystem returns a dentry for the child or an error. On reaching the final component, the layer checks permissions, applies the O_CREAT and O_EXCL rules, and calls the inode’s open method if one exists.

Mount points interrupt the walk. When the layer resolves a dentry that is a mount point, it switches to the superblock mounted there and continues with that filesystem’s root inode. The switch is recorded in the dentry’s mount field, and it is why a path can traverse several filesystems without any of them knowing about the others.

The result of open is a file object whose f_op pointer was taken from the inode at open time. That snapshot matters: replacing a file’s operations after it is open would change the behaviour of a live descriptor. The kernel provides replace_fops for the few places that must do it, and each of them does so at a point where it knows what other references to the file exist.

/* Schematic: the layer validates, then the filesystem's method runs.
 * Not kernel source; the layer's real checks are more extensive. */
ssize_t layer_read(struct file *f, void *buf, size_t n, loff_t *pos)
{
        if (!(f->mode & READ))
                return -EBADF;                 /* descriptor not opened for reading */
        if (!f->ops->read)
                return -EINVAL;                /* filesystem has no read method */
        return f->ops->read(f, buf, n, pos);   /* one indirect call per request */
}

Caches as part of the interface

Three caches sit inside the layer and change what a filesystem must do. The dentry cache holds resolved name-to-inode mappings; the inode cache holds inodes with reference counts; the page cache holds file data indexed by (inode, offset). None of them are optional in the sense that a filesystem can ignore their existence, because the layer will populate them on the filesystem’s behalf.

The page cache is the one with the deepest consequences. If a filesystem lets the generic paths handle reads and writes, then data written by write lands in a page, not on the device, and the filesystem’s own write path is invoked later by writeback. That means the filesystem’s block allocation happens in a different context from the write, with different locking and a different notion of when errors can be reported. The interaction is covered in The Page Cache and the Writeback Path. A filesystem that bypasses the cache, as some network filesystems do, must implement the read and write methods itself and loses the layer’s coherency guarantees.

The inode cache imposes a lifecycle. The layer allocates an inode through the superblock’s alloc_inode method, fills it from the on-disk record the first time the inode is needed, and frees it through evict_inode when the last reference drops. A filesystem that keeps state in the in-memory inode must clean it in evict_inode, and a filesystem that defers work to inode eviction must be careful: eviction happens under memory pressure, when allocating memory to complete the work may fail.

The cost of the abstraction

Every operation pays one or more indirect calls, and each indirect call is a branch the processor cannot predict as well as a direct call. On the scale of a system call that already crosses the user/kernel boundary, the indirect call is not the dominant cost, which is why the design survives. The real cost is structural: the layer’s generic paths assume the page cache and a block-mapped file, and a filesystem that does not fit that model must reimplement large parts of the layer.

The benefit is that the interface is stable across filesystems and across time. A driver written against the current interface works with the caches, the permission checks, the mount machinery, and the file-descriptor table without implementing any of them. The inode-number arithmetic described in Inode Tables and the Arithmetic of Inode Numbers is the on-disk half of the same contract: the layer requires a stable number, the filesystem supplies one derived from its own geometry, and neither side needs to know how the other works.

The interface also defines what a filesystem may not do. It may not sleep in a method called under the wrong lock, it may not return a dentry it has not instantiated, and it may not assume the inode it is handed is the only reference. Those constraints are documented per method rather than enforced by the type system, which is why the operation tables are the place where most filesystem bugs are found.

References

  1. Overview of the Linux Virtual File System — Linux kernel documentation
  2. Linux Kernel Development, 3rd Edition — Robert Love, Addison-Wesley, 2010
  3. The Design and Implementation of the FreeBSD Operating System, 2nd Edition — Marshall Kirk McKusick, George V. Neville-Neil, Robert N. M. Watson, Addison-Wesley, 2014
  4. open(2) — Linux manual page — man7.org