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.
- superblock — one per mounted file system; holds the on-disk layout parameters,
the free-space maps, and \texttt{s\_op} (mount, sync, allocate-inode);
- inode — one per file object (regular file, directory, device, pipe); holds the
metadata and \texttt{i\_op} (create, lookup, link, rename);
- dentry (directory entry) — one per path component; the in-memory glue that binds
a name like \texttt{ann} to its inode and to its parent dentry, forming the tree;
- file — one per open file description; holds the current offset and
\texttt{f\_op} (read, write, mmap, ioctl). This is what a file descriptor points at.
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.