The Virtual File System Layer

As an undergraduate you learned how one file system lays bytes onto a disk — inodes, blocks, directories. But your laptop is not running one file system. Right now it is almost certainly running ext4 on the root disk, tmpfs in RAM for \texttt{/tmp}, procfs behind \texttt{/proc}, a FAT partition on the USB stick you just plugged in, and maybe an NFS mount from a server three racks away. And yet your program opens all of them with the same five system calls: \texttt{open}, \texttt{read}, \texttt{write}, \texttt{lseek}, \texttt{close}. It never asks which file system it is talking to.

That miracle of indifference is the Virtual File System (VFS) — the kernel layer, born in Sun's SunOS in 1985, that sits between the system-call interface and the dozens of concrete file-system drivers. It defines an abstract file system: a handful of object types and a table of operations each driver must implement. Write your file system to that contract and the whole of userspace, and every tool that has ever been compiled, works with it for free. This lesson is about that contract — the great polymorphic dispatcher at the heart of Unix.

Four objects model every file system

The VFS is an exercise in object-oriented programming written in C: a small family of structs, each carrying a table of function pointers (its "operations"). Learn these four and you have the whole mental model — every concrete file system is just a different implementation of the same four interfaces.

The split between inode and file is the subtle one, and it is worth pausing on. An inode is the file itself — unique, shared, on disk. A file object is a single open session against it. Two processes that both open \texttt{/etc/passwd} get two file objects (two independent offsets) pointing at the one inode. That is why one process reading does not move the other's read position.

One interface, many implementations

When your program calls \texttt{read(fd, buf, n)}, the kernel finds the file object behind \texttt{fd} and calls \texttt{file->f\_op->read(...)} — an indirect call through a function pointer that ext4, NFS, and procfs each filled in differently. ext4 walks extents on a local disk; NFS packages an RPC and waits for a server; procfs runs a function that invents the bytes on the spot (there is no \texttt{/proc/cpuinfo} file on any disk). Same call site, wildly different behaviour — that is polymorphism, and it is the entire point.

Follow a \texttt{read} down: the trap enters the syscall layer, which hands to the VFS, which reads the file object's \texttt{f\_op} table and jumps into whichever driver owns this file. The VFS itself knows nothing about extents, RPC, or disk geometry — it only knows the shape of the contract.

The dentry cache — where path lookup actually lives

Resolving \texttt{/home/ann/notes.txt} naively means four directory reads: read \texttt{/} to find \texttt{home}, read that to find \texttt{ann}, and so on — each a potential disk trip. Do that on every path in every syscall and the machine drowns. The dentry cache (dcache) is the VFS's answer: an in-memory hash table mapping (\text{parent dentry},\ \text{name}) \to \text{dentry}, so a hot path resolves at memory speed without touching the concrete file system at all.

The dcache even remembers failures — a negative dentry records that a name does not exist, so a program that stats a missing config file a thousand times a second (they do this constantly) is answered instantly instead of pounding the disk. The dcache is one of the most performance-critical data structures in the whole kernel; its scalability under many cores was a decade-long engineering saga (RCU lookups, per-dentry seqlocks).

// A toy VFS. Each "file system" is just an object implementing the same read operation. // The mount table maps a path prefix to a driver — exactly what file->f_op->read dispatches to. interface FileOps { read(path: string): string; } const ext4: FileOps = { read: (p) => `ext4 : walk extents on local disk for ${p}` }; const procfs: FileOps = { read: (p) => `procfs : SYNTHESISE ${p} on the fly (no disk block exists)` }; const nfs: FileOps = { read: (p) => `nfs : send an RPC to the server for ${p}` }; // Longest-prefix-first so /proc beats the root "/" mount. const mounts: { prefix: string; ops: FileOps }[] = [ { prefix: "/proc", ops: procfs }, { prefix: "/mnt/nfs", ops: nfs }, { prefix: "/", ops: ext4 }, ]; function vfsRead(path: string): string { for (const m of mounts) if (path.startsWith(m.prefix)) return m.ops.read(path); return "ENOENT: no file system mounted here"; } // One call site, three totally different drivers do the work: for (const p of ["/home/ann/notes.txt", "/proc/cpuinfo", "/mnt/nfs/report.pdf"]) { console.log(`read("${p}") -> ${vfsRead(p)}`); }

Mounting — grafting one tree onto another

A file system does not appear at a drive letter; it is mounted onto a directory of an already-visible tree. After \texttt{mount /dev/sdb1 /mnt/usb}, a lookup that reaches the \texttt{/mnt/usb} dentry is transparently redirected to the root dentry of the new file system. The mount table is what makes a machine's many file systems look like a single unified namespace hanging off one root \texttt{/} — no \texttt{C:}, no \texttt{D:}, just one tree.

This is also the mechanism behind modern containers: give a process its own mount namespace and it sees an entirely different tree, without any other process noticing. Bind mounts, overlay file systems, and \texttt{chroot} jails are all just clever bookkeeping in the VFS mount layer.

"Everything is a file" is the VFS taken to its logical extreme. Because reading is just \texttt{f->f\_op->read}, anything that can define a read/write function can masquerade as a file. So your keyboard is \texttt{/dev/input/event0}, a random-number source is \texttt{/dev/urandom}, a running process's memory map is \texttt{/proc/1234/maps}, and you can tune the kernel by echoing a number into \texttt{/proc/sys/...}. Plan 9, Unix's research successor, pushed this even further — the network stack, the window system, even remote machines were all mounted as files. The reward is colossal: every tool that manipulates files — \texttt{cat}, \texttt{grep}, shell redirection, permissions — instantly works on devices, kernel state, and network endpoints, with no new API to learn.

Three objects sound almost synonymous, and beginners melt them together. Keep them distinct. The inode is the file — one per file, holding the metadata and block pointers; delete it and the data is gone. A dentry is merely a name pointing at an inode, and a single inode can have many (that is exactly what a hard link is — two dentries, one inode, link count 2). A file object is a transient open session with its own offset; close it and the inode is untouched. So "how many names does this file have?" is a dentry/link-count question; "where is this reader up to?" is a file-object question; and "how big is it, who owns it?" is an inode question. Confusing them makes hard links, shared offsets, and \texttt{unlink}-while-open behaviour seem like magic when they are just three different objects doing three different jobs.

Why this layer matters for everything ahead

Every remaining lesson in this module — inode design, crash consistency, journaling, log-structured and copy-on-write file systems, \texttt{fsync} — plugs in below the VFS. When we say ext4 journals its metadata or ZFS never overwrites a block, we mean their implementation of the same \texttt{s\_op}/\texttt{i\_op}/\texttt{f\_op} contract does so. The VFS is the stable stage on which all the interesting drama plays out; keep its four objects in your head as you descend.