The Virtual File System (VFS) Layer
VFS is Linux's C-based object system for file I/O: four core objects, four vtable structs, and one elegant dispatch layer that lets ext4, NFS, and /proc all answer the same open() call.
Overview
The Virtual File System (VFS) is one of the most elegant engineering decisions in Linux. Imagine you have a dozen completely different storage systems — a hard disk formatted with ext4, a USB stick with FAT32, a remote server mounted via NFS, a tmpfs living entirely in RAM, a /proc that generates files on-the-fly from kernel data structures — and yet a single C program can open(), read(), and write() all of them with the exact same calls. No special cases. No different APIs. Just files. How? That is VFS.
VFS is a software indirection layer sitting between the POSIX system-call interface (open, read, write, stat, readdir, …) and every concrete filesystem implementation. It defines a set of abstract objects — superblock, inode, dentry, and file — and a set of operation tables (function-pointer structs) that every filesystem must fill in. The system-call layer talks to VFS objects; VFS dispatches to the right filesystem's implementation at runtime. This is the classic object-oriented pattern, implemented in pure C using function pointers decades before C++ became popular in OS kernels.
Understanding VFS is a prerequisite for understanding almost everything else in Linux storage: block I/O, page cache, memory-mapped files, namespace isolation (containers), overlayfs (Docker layers), and more. It is also one of the best-documented, most-readable subsystems for a new kernel contributor — the abstractions are clean, the code is full of comments, and there are dozens of simple "example" filesystems (ramfs, debugfs, sysfs) that you can study and even write yourself.
In this module we will build the full mental model: what each VFS object is and why it exists, how a single open("foo/bar.txt", O_RDONLY) cascades from a syscall all the way to a specific filesystem's block-read routine, and how to read and navigate the relevant kernel source. By the end you will be able to trace any file operation through the kernel with confidence.
For a Linux kernel contributor, VFS is almost unavoidable. Nearly every storage-related patch — whether it is a new filesystem, a fix to caching behavior, a security hardening of permission checks, or a new system call — touches VFS objects or their operation tables. The four core structures (super_block, inode, dentry, file) appear in thousands of patches every year. If you cannot read a patch that adds a new file_operations method, you cannot review it or write one yourself.
Beyond contribution, VFS is the concrete example that makes abstract CS concepts viscerally real. You will see object-oriented design without classes, you will see the cost of every abstraction (the path-lookup cache, the RCU-walk optimization, the per-dentry locks), and you will see how the kernel achieves both generality and raw performance simultaneously. The mental model you build here — a layered dispatch table that separates interface from implementation — is a pattern you will recognize everywhere: network sockets, device drivers, security modules (LSM), and even user-space plugin systems. Mastering VFS is not just about files; it is about learning to think the way kernel engineers think.
VFS Object Relationships and open() Call Flow
Left: the path lookup flow for open(), showing the dcache fast-path, filesystem miss path, mount-crossing, and symlink following. Right: the four core VFS objects and their containment hierarchy from mounted filesystem down to a single open file description.
The concepts
01Why VFS Exists: The Abstraction Problem
Before VFS (pre-0.96c Linux, 1992), each system call had to know which filesystem it was talking to. Want to open a file? Is it on minix? On ext2? On NFS? The code was a maze of if/switch statements. Sun's Network File System group (and before them, the BSD folks with their vnode layer) realized the answer was polymorphism via function pointer tables: define a fixed interface that all filesystems must implement, and let the system-call layer call through that interface blindly.
VFS in Linux does exactly this. Every filesystem registers itself with VFS by filling in a file_system_type structure that contains a mount callback. When you mount the filesystem, it creates a super_block with operation tables populated with the filesystem's own functions. From that point forward, VFS code never calls ext4_read_inode() or fat_readdir() directly — it calls sb->s_op->read_inode() or file->f_op->iterate_shared(), and C's function-pointer dispatch does the right thing.
This is the single most important insight in this module: VFS is a vtable-based OOP system written in C. Once you see it that way, every data structure and every function signature makes immediate sense.
/* How a filesystem registers itself with VFS */
static struct file_system_type ext4_fs_type = {
.owner = THIS_MODULE,
.name = "ext4",
.mount = ext4_mount, /* called on mount(2) */
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};
/* In module_init or fs/ext4/super.c */
static int __init ext4_init_fs(void)
{
return register_filesystem(&ext4_fs_type);
}
02The Four Core VFS Objects
VFS defines four fundamental objects. Think of them as four different views of the same underlying storage reality:
1. super_block — represents a mounted filesystem instance. One superblock per mount. It holds filesystem-wide metadata: block size, total/free blocks, flags (read-only?), a pointer to the root inode, and a pointer to super_operations. If ext4 is mounted at /home, there is exactly one super_block for that mount.
2. inode — represents a file or directory as a storage object, independent of its name. An inode holds: file type (regular, dir, symlink, …), permissions, uid/gid, size, timestamps, and crucially, a pointer to inode_operations and address_space_operations. Inodes are identified by number (i_ino). The same inode can have multiple names (hard links). An inode exists even after all directory entries pointing to it are deleted, as long as a process still has the file open (this is why a running program can delete its own log file and the kernel keeps the data accessible).
3. dentry (directory entry) — represents a name-to-inode binding in the path hierarchy. A dentry caches the result of mapping one path component (e.g., bar) to an inode. Dentries form a tree that mirrors the directory tree in memory. They are kept in a hash table (the dentry cache, dcache) to avoid re-reading directories from disk on every path lookup. A dentry can be positive (points to an inode), negative (caches a "does not exist" result), or disconnected.
4. file — represents an open file description — the per-open-call state. Two open() calls to the same file produce two file structs (each with its own f_pos cursor), both pointing to the same dentry and inode. file holds: current position, open flags, credentials, and a pointer to file_operations.
The relationship: file → dentry → inode ← super_block. Many files can point to one dentry; many dentries can point to one inode (hard links); all inodes belong to one superblock.
/* Simplified view of the four structures (fs.h) */
struct super_block {
const struct super_operations *s_op;
struct inode *s_root; /* root inode */
unsigned long s_blocksize;
/* ... */
};
struct inode {
umode_t i_mode; /* permissions + type */
unsigned long i_ino;
loff_t i_size;
const struct inode_operations *i_op;
struct address_space *i_mapping; /* page cache */
/* ... */
};
struct dentry {
struct inode *d_inode; /* NULL = negative dentry */
struct dentry *d_parent;
struct qstr d_name; /* quick string: hash + name */
const struct dentry_operations *d_op;
/* ... */
};
struct file {
struct dentry *f_path.dentry;
const struct file_operations *f_op;
loff_t f_pos; /* current offset */
unsigned int f_flags; /* O_RDONLY etc. */
/* ... */
};
03Operation Tables: The VFS Vtable System
Each VFS object type has an associated operations struct — a plain C struct full of function pointers. This is the vtable. The kernel code calls through these pointers without ever knowing which filesystem is behind them. There are four primary operation tables:
super_operations — lifecycle of the filesystem: alloc_inode, destroy_inode, write_inode, sync_fs, put_super, statfs. Called by VFS when it needs to allocate a new inode, flush the filesystem to disk, query free space, etc.
inode_operations — operations on the namespace via an inode: lookup (find a child by name), create (create a regular file), mkdir, rmdir, rename, symlink, link, getattr, setattr, permission. These are the operations invoked during path lookup and file creation.
file_operations — operations on an open file: read, write, llseek, mmap, ioctl, open, release, fsync, poll, iterate_shared (readdir). These are what read(2), write(2), mmap(2) etc. ultimately call.
address_space_operations — operations connecting the page cache to the filesystem: readpage, writepage, readahead, direct_IO. These are called when VFS needs to load/store a page of file data from/to disk.
If a filesystem does not support an operation, it sets that function pointer to NULL. VFS checks for NULL before calling and returns -ENOSYS or -EPERM as appropriate. For example, a read-only filesystem leaves inode_operations.create as NULL.
The beauty: to write a new filesystem, you write C functions with matching signatures and plug them into these structs. Everything else — caching, locking, namespace, system calls — is handled by VFS for free.
/* ramfs: the simplest real filesystem — great for study */
const struct inode_operations ramfs_dir_inode_operations = {
.create = ramfs_create,
.lookup = simple_lookup, /* VFS helper */
.link = simple_link,
.unlink = simple_unlink,
.symlink = ramfs_symlink,
.mkdir = ramfs_mkdir,
.rmdir = simple_rmdir,
.rename = simple_rename,
};
const struct file_operations ramfs_file_operations = {
.read_iter = generic_file_read_iter, /* VFS generic */
.write_iter = generic_file_write_iter,
.mmap = generic_file_mmap,
.fsync = noop_fsync,
.llseek = generic_file_llseek,
};
/* VFS generic helpers do the page-cache work;
ramfs only needs to supply address_space_operations. */
04Path Lookup: Walking the Dentry Tree
When you write open("/home/alice/notes.txt", O_RDONLY), the kernel must translate that string into an inode. This process is called path lookup (or pathname resolution) and it is one of the most complex and performance-critical operations in VFS.
The core function is filename_lookup() in fs/namei.c, which calls link_path_walk(). The algorithm:
-
Determine the starting point. If the path begins with
/, start from the filesystem root (stored in the calling process'sfs_struct.root). Otherwise start from the current working directory (fs_struct.pwd). Both arepathstructs (adentry + mountpair). -
Split the path into components.
/home/alice/notes.txt→["home", "alice", "notes.txt"]. -
For each component, look up the next dentry: a. Check the dentry cache (dcache). Hash the current parent dentry + component name. If found in the dcache hash table, use it. This is the fast path and avoids any disk I/O. b. If not cached, call
d_inode->i_op->lookup(dir_inode, dentry, flags). This is the filesystem'slookupimplementation, which reads the directory from disk (or wherever), finds the child, and populates the dentry. c. If the component is a symlink, call the symlink resolution logic (get_link), which may recurse up to 40 times before returningELOOP. d. If the component is a mount point, the VFS lookup crosses into the mounted filesystem by following the mount table. -
After all components, VFS has the final dentry. If the intent was
open(), VFS now creates afilestruct and callsf_op->open()(if defined).
RCU-walk optimization: Most lookups succeed on the fast (cached) path with no locks at all, using RCU (Read-Copy-Update) for lock-free dentry reading. Only on a cache miss or on dentries that require mutation does the kernel fall back to the slower ref-counted walk.
This is why cold-cache performance and hot-cache performance of file opens differ by orders of magnitude. The dcache is one of the most carefully optimized data structures in the kernel.
/* Conceptual pseudocode of link_path_walk */
struct dentry *current = start; /* root or cwd */
for each component 'name' in path:
if (current->d_inode is not a directory):
return -ENOTDIR;
check permissions (execute bit on dir);
/* 1. Try dcache first (RCU, no lock) */
next = d_lookup(current, &name_hash);
if (!next):
/* 2. Miss: ask filesystem */
next = alloc_dentry();
err = current->d_inode->i_op->lookup(
current->d_inode, next, flags);
if (d_is_symlink(next) && not last component):
current = follow_symlink(next);
continue;
if (d_is_mountpoint(next)):
current = follow_mount(next);
continue;
current = next;
return current; /* the final dentry */
05From open() to a Real Read: The Full Call Stack
Let us trace a single read(fd, buf, 4096) from user space all the way to disk, naming each layer:
-
User space calls
read(fd, buf, 4096)— a libc wrapper that issues syscall number__NR_read. -
Syscall entry (
arch/x86/entry/syscalls/syscall_64.tbl) dispatches toksys_read()infs/read_write.c. -
VFS layer (
ksys_read): convertsfd(a small integer) to astruct file *by indexing the process'sfiles_struct.fd_array. Then callsvfs_read(file, buf, count, &pos). -
vfs_readchecks permissions, then callsfile->f_op->read()(orfile->f_op->read_iter()vianew_sync_read()). This is the dispatch through the vtable — at this point VFS does not know which filesystem it is talking to. -
Page cache (
mm/filemap.c):generic_file_read_iter()(which ext4 uses viafile_operations) checks whether the needed pages are already in the page cache (a radix/xarray tree ofstruct pageindexed by file offset). If so, copy to user buffer and done — no disk I/O at all. -
On a cache miss,
generic_file_read_itercallsaddress_space->a_ops->readpage()(orreadahead). For ext4 this isext4_readpage(), which constructs abio(block I/O request). -
Block layer (
block/blk-core.c): thebiois queued, possibly merged with other requests by the I/O scheduler, and eventually submitted to the block device driver. -
Driver / hardware: the NVMe or SATA driver converts the
biointo a device command, issues it to the controller, and receives a completion interrupt. -
Back up the stack: the interrupt handler marks the page clean and wakes the waiting process.
read()returns the byte count to user space.
The key insight: steps 1–4 are VFS (filesystem-agnostic). Steps 5–6 are page cache + filesystem-specific address_space_operations. Steps 7–9 are the block layer, completely separate from VFS. VFS cleanly separates these concerns.
/* Tracing with ftrace — see the real call stack */
# Enable function_graph tracer
cd /sys/kernel/debug/tracing
echo function_graph > current_tracer
echo 'vfs_read' > set_graph_function
echo 1 > tracing_on
# In another terminal: cat /etc/hostname
echo 0 > tracing_on
cat trace | head -40
# You will see something like:
# vfs_read() {
# rw_verify_area();
# new_sync_read() {
# ext4_file_read_iter() {
# generic_file_read_iter() {
# filemap_read() {
# page_cache_sync_readahead();
# copy_page_to_iter();
# }
# }
# }
# }
# }
06The Superblock and Filesystem Mounting
Mounting a filesystem is the act of connecting a concrete storage object (a block device, a network path, or thin air for virtual filesystems) to a point in the VFS namespace. The result is a super_block hanging off a vfsmount.
The mount system call chain: mount(2) → do_mount() → do_new_mount() → calls file_system_type.mount() (e.g., ext4_mount()).
ext4_mount() reads the on-disk superblock into memory, validates the magic number (0xEF53), fills a struct super_block with metadata, and populates sb->s_op with ext4_sops. It then reads the root inode (inode #2 for ext4) and creates a dentry for / rooted at this mount. The kernel then attaches this mount to the VFS mount tree.
Mount namespaces: Each process has a mnt_namespace. Mount operations are scoped to the current namespace. This is the foundation of container isolation — a container has its own mount namespace with a completely different filesystem tree, even though the same kernel is running.
super_operations.alloc_inode: When VFS needs a new inode, it calls sb->s_op->alloc_inode(sb). Most filesystems use kmem_cache_alloc to return a larger struct that embeds a struct inode as its first member (the "container_of" trick). This way the filesystem can store extra data alongside each VFS inode without a separate allocation:
struct ext4_inode_info {
struct inode vfs_inode; /* MUST be first */
__u32 i_data[15]; /* ext4-specific block map */
/* ... many more fields ... */
};
When VFS gives you an inode *, you cast back to ext4_inode_info * using container_of(inode, struct ext4_inode_info, vfs_inode).
/* The container_of trick — used everywhere in VFS */
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
static struct inode *ext4_alloc_inode(struct super_block *sb)
{
struct ext4_inode_info *ei;
ei = kmem_cache_alloc(ext4_inode_cachep, GFP_NOFS);
if (!ei)
return NULL;
/* return pointer to the embedded vfs inode */
return &ei->vfs_inode;
}
/* Later, to get back the ext4 part: */
static inline struct ext4_inode_info *EXT4_I(struct inode *inode)
{
return container_of(inode, struct ext4_inode_info, vfs_inode);
}
07The Dentry Cache (dcache) and Negative Dentries
The dentry cache is the most performance-critical data structure in VFS. Every open(), stat(), access(), and similar call must walk a path. Without caching, every component in the path would require a directory read from disk. With a warm dcache, the entire walk is in memory, often in L2/L3 cache.
Structure: The dcache is a hash table (dentry_hashtable) keyed by (parent_dentry, name_hash). Dentries also form a tree via d_parent and d_subdirs/d_child lists. When a dentry is no longer referenced, it goes to the LRU list and may eventually be evicted by memory pressure (called dentry shrinking).
Negative dentries are a crucial optimization. When you look up a file that does not exist — say stat("/etc/foobar") — the kernel creates a dentry with d_inode = NULL. The next stat("/etc/foobar") hits this negative dentry in the hash table and immediately returns ENOENT without touching disk. Negative dentries are evicted on low memory pressure or when a file with that name is created.
RCU-walk (lockless fast path): Introduced in Linux 2.6.38, the RCU-walk allows path lookup to proceed without taking any locks when the dcache is warm and no filesystem-specific operations are needed. It uses rcu_read_lock() and checks sequence counters (d_seq) to detect concurrent modifications. If any check fails, it falls back to the reference-counted ref-walk. This optimization dramatically reduces contention on multi-core systems doing parallel path lookups.
Why this matters for contributors: d_lookup(), d_add(), d_instantiate(), d_splice_alias() — these are the dcache APIs you will use in any filesystem lookup implementation. Getting them wrong causes use-after-free bugs or lookup failures. Reading Documentation/filesystems/vfs.rst carefully here is non-negotiable.
/* Typical filesystem .lookup implementation */
static struct dentry *myfs_lookup(struct inode *dir,
struct dentry *dentry,
unsigned int flags)
{
struct inode *inode = NULL;
/* Try to find the inode by name in our storage */
inode = myfs_find_inode(dir->i_sb, dir, &dentry->d_name);
/* d_splice_alias handles hard links and negative dentries:
- if inode is NULL → installs a negative dentry
- if inode exists → creates positive dentry
- if inode is a dir → may reuse existing alias */
return d_splice_alias(inode, dentry);
}
/* NOTE: on failure, return ERR_PTR(-ENOENT) etc., not NULL.
NULL means "I created a negative dentry myself" — subtle! */
08Writing a Minimal In-Memory Filesystem
The best way to understand VFS from the inside is to write a tiny filesystem. Linux provides ramfs and tmpfs as reference implementations, and libfs.c (fs/libfs.c) provides a collection of generic helper functions so you do not have to reinvent everything.
A minimal in-kernel filesystem needs:
- A
file_system_typestruct with amountcallback. - A
super_operationsstruct (at minimumstatfsanddrop_inode). - An
inode_operationsstruct for directories (at minimumlookupandcreate). - A
file_operationsstruct for regular files (often just reusegeneric_file_*helpers). - An
address_space_operationsstruct for page cache I/O.
For a RAM-based filesystem, you store data entirely in the page cache. When a user writes to a file, generic_file_write_iter stores pages in the inode's address_space. When reading, generic_file_read_iter retrieves them. You never touch a block device.
For a kernel module, you also need module_init/module_exit that call register_filesystem/unregister_filesystem.
Studying fs/ramfs/inode.c (around 250 lines of actual code) is the single best introduction to writing a VFS filesystem. It is short enough to read in one sitting but complete enough to mount and use.
/* Skeleton of a minimal module filesystem */
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/pagemap.h>
#include <linux/init.h>
#define MYFS_MAGIC 0x4D594653 /* 'MYFS' */
static const struct super_operations myfs_ops = {
.statfs = simple_statfs,
.drop_inode = generic_delete_inode,
};
static int myfs_fill_super(struct super_block *sb,
void *data, int silent)
{
struct inode *root;
sb->s_magic = MYFS_MAGIC;
sb->s_op = &myfs_ops;
sb->s_maxbytes = MAX_LFS_FILESIZE;
root = new_inode(sb);
if (!root) return -ENOMEM;
root->i_ino = 1;
root->i_mode = S_IFDIR | 0755;
root->i_op = &simple_dir_inode_operations;
root->i_fop = &simple_dir_operations;
set_nlink(root, 2);
sb->s_root = d_make_root(root); /* creates root dentry */
return sb->s_root ? 0 : -ENOMEM;
}
static struct dentry *myfs_mount(struct file_system_type *fst,
int flags, const char *dev, void *data)
{
return mount_nodev(fst, flags, data, myfs_fill_super);
}
static struct file_system_type myfs_type = {
.owner = THIS_MODULE,
.name = "myfs",
.mount = myfs_mount,
.kill_sb = kill_litter_super,
};
static int __init myfs_init(void)
{ return register_filesystem(&myfs_type); }
static void __exit myfs_exit(void)
{ unregister_filesystem(&myfs_type); }
module_init(myfs_init);
module_exit(myfs_exit);
MODULE_LICENSE("GPL");
09File Descriptors, the Open File Table, and Reference Counting
A file descriptor (fd) is just a small non-negative integer — an index into the current process's files_struct.fd_array[]. This array holds pointers to struct file. When you call open(), VFS allocates a new struct file, fills it in, and installs it at the lowest available index in fd_array. That index is your file descriptor.
Three-level table:
task_struct.files→files_struct.fd_array[]→struct file→struct dentry→struct inode
The struct file is reference-counted (f_count). If you dup(fd) or fork(), both the original and the copy point to the same struct file, sharing the file position (f_pos). If you open() the same path twice, you get two separate struct file structs with independent positions.
fork() semantics: fork() increments f_count on all open file structs — the child inherits copies of all file descriptors pointing to the same underlying file. exec() closes file descriptors marked FD_CLOEXEC (set by O_CLOEXEC or fcntl(fd, F_SETFD, FD_CLOEXEC)).
close() semantics: decrements f_count. When it reaches zero, fput() is called, which (eventually) calls file->f_op->release(), then frees the file struct. The dentry and inode may still be cached.
Why does this matter? Understanding this three-level structure explains many Unix behaviors: why writing to a file from two dup'd fds advances the same position, why sendfile() works the way it does, why /proc/<pid>/fd/ shows a process's open files, and why processes can hold deleted files open.
/* Demonstrating shared file position via dup */
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
int main(void) {
int fd1 = open("/tmp/test.txt", O_CREAT|O_RDWR|O_TRUNC, 0644);
int fd2 = dup(fd1); /* same struct file, same f_pos */
write(fd1, "hello", 5); /* f_pos now 5 */
write(fd2, "world", 5); /* f_pos now 10 (!) */
/* File contains "helloworld" — one stream, two fds */
int fd3 = open("/tmp/test.txt", O_RDWR); /* NEW struct file */
/* fd3->f_pos starts at 0, independent of fd1/fd2 */
close(fd1); close(fd2); close(fd3);
return 0;
}
10Special Filesystems: proc, sysfs, and debugfs
Not all filesystems represent persistent data. Linux has a family of pseudo-filesystems that generate file content on demand from kernel data structures. Understanding them requires VFS knowledge because they are implemented using exactly the same VFS interface as ext4 or FAT — they just fill in their operation tables differently.
procfs (/proc): Exposes per-process information. Each PID subdirectory is synthesized on lookup: proc_pid_lookup() checks the process table and creates dentries/inodes dynamically. Files like /proc/meminfo have a file_operations.read that calls a seq_file helper, which formats kernel memory statistics into text on each read() call. No data is ever stored on disk.
sysfs (/sys): Exposes the kernel object (kobject) hierarchy. Each kobject automatically gets a directory in sysfs. Attributes (files) are backed by sysfs_ops.show/sysfs_ops.store — functions that read/write kernel variables. This is how /sys/class/net/eth0/mtu lets you read and change the MTU of a network interface.
debugfs (/sys/kernel/debug): A simple, low-overhead filesystem for kernel debugging and tracing. Kernel subsystems call debugfs_create_file() to expose internal state. The ftrace subsystem, perf events, and many device drivers use it. It is intentionally simple: no persistence, no locking guarantees beyond what the creator provides.
seq_file: Most procfs/debugfs text files use the seq_file interface (fs/seq_file.c). You provide start, next, stop, and show callbacks; VFS handles buffering, read() semantics, and large-output pagination automatically. This is the idiomatic way to expose read-only kernel data as text files.
/* Creating a debugfs file from a kernel module */
#include <linux/module.h>
#include <linux/debugfs.h>
#include <linux/seq_file.h>
static struct dentry *dbg_dir;
static int counter = 42;
static int my_show(struct seq_file *m, void *v)
{
seq_printf(m, "counter = %d\n", counter);
return 0;
}
DEFINE_SHOW_ATTRIBUTE(my); /* creates my_fops with open/read/llseek/release */
static int __init mymod_init(void)
{
dbg_dir = debugfs_create_dir("mymod", NULL);
debugfs_create_file("counter", 0444, dbg_dir, NULL, &my_fops);
return 0;
}
static void __exit mymod_exit(void)
{
debugfs_remove_recursive(dbg_dir);
}
module_init(mymod_init);
module_exit(mymod_exit);
MODULE_LICENSE("GPL");
/* After insmod: cat /sys/kernel/debug/mymod/counter → counter = 42 */
Key insights
- ✦VFS is object-oriented C: every filesystem implements the same vtable interfaces (super_operations, inode_operations, file_operations), and the syscall layer dispatches through function pointers without knowing which filesystem is behind them.
- ✦The four objects — superblock, inode, dentry, file — represent four distinct views of storage: a mounted instance, a storage object, a name binding, and an open description. Conflating any two of them leads to subtle bugs.
- ✦The dentry cache is the critical performance path. A warm dcache means path lookup is an in-memory hash table lookup. A cold dcache means disk I/O on every path component. Filesystem performance is often dcache performance.
- ✦Negative dentries (d_inode == NULL) cache 'this name does not exist', turning repeated failed lookups into fast in-memory no-ops — a key optimization for shell command resolution in $PATH.
- ✦The container_of pattern embeds the VFS inode inside the filesystem's own larger struct. This lets a filesystem attach private data to every inode without a separate allocation or hash table.
- ✦An inode outlives its directory entries: as long as any process has the file open, the inode (and its data) remains accessible even after all hard links are removed — the classic 'delete while open' pattern.
- ✦Pseudo-filesystems (proc, sysfs, debugfs) are real VFS filesystems with real operation tables — they just generate content dynamically from kernel data structures instead of reading it from a block device.
- ✦The RCU-walk optimization allows lock-free path lookup when the dcache is warm — one of the highest-impact scalability improvements in recent Linux history, and a master class in how the kernel uses RCU.
Common pitfalls
- ⚠Confusing inode and dentry: the inode is the file itself (data + metadata); the dentry is one name for that file. A file can have many dentries (hard links) but only one inode. Getting this wrong makes lookup code completely wrong.
- ⚠Returning NULL from a .lookup implementation to mean 'not found' is WRONG. NULL means 'I already installed a negative dentry'. Return ERR_PTR(-ENOENT) for errors. The kernel will silently misinterpret a NULL return.
- ⚠Forgetting to call d_splice_alias (or d_add) in .lookup: if you find an inode but do not install the dentry, the dentry remains unattached and future lookups re-enter .lookup forever — causing infinite loops or cache corruption.
- ⚠Holding a dentry reference without holding the corresponding inode reference: the dentry can become stale (negative) under you if another thread unlinks the file. Always understand the reference counting contract.
- ⚠Implementing file_operations.read without using copy_to_user (or a helper that calls it): directly accessing user-space pointers from kernel context causes a fault or a security vulnerability. Always use copy_to_user/copy_from_user.
- ⚠Forgetting that struct file is per-open, not per-inode: if your filesystem stores per-open state in the inode, it will be shared across all openers. Per-open state belongs in file->private_data.
- ⚠Using inode->i_size without the i_rwsem lock in a writable filesystem: concurrent writes can produce a torn size read. Read-only filesystems are fine; writable ones need proper locking.
Practice
- 01easy
Trace a real open() call with ftrace. Enable the function_graph tracer on a Linux system (VM or WSL2), set the graph function to vfs_read, then cat a small file. Print the trace and annotate each function call, identifying which layer it belongs to (VFS, filesystem, page cache, block layer).
- 02easy
Write and load a kernel module that creates a debugfs directory 'mymod' with a file 'uptime_ms' that returns the number of milliseconds since boot when read (use ktime_get_boottime_ns()). After insmod, verify with: cat /sys/kernel/debug/mymod/uptime_ms
- 03medium
Extend the minimal myfs skeleton from the concepts section to support creating regular files and writing/reading content. Mount it with: mkdir /tmp/mnt && mount -t myfs none /tmp/mnt. Then: echo hello > /tmp/mnt/test.txt && cat /tmp/mnt/test.txt should work.
- 04medium
Write a user-space program that demonstrates the 'delete-while-open' behavior: open a file, unlink it (deleting the directory entry), then continue reading from the open fd. Use stat() on the fd (fstat) to show the inode still exists. Use lsof or /proc/self/fd to confirm the kernel is keeping the inode alive.
- 05hard
Implement a custom .lookup for your myfs that rejects names longer than 8 characters, returning -ENAMETOOLONG. Write a test that tries to create files with names of various lengths and confirms the limit is enforced. Also implement .getattr so that stat() works on files in your filesystem.
Resources
The canonical reference — describes every operation table method, the dentry cache, and mount semantics. Dense but authoritative.
The filesystem chapter gives a visual overview of all four VFS objects with diagrams. Part of the famous embedded Linux training materials.
LWN's historical perspective on how the VFS evolved; great for understanding why certain design decisions were made.
Neil Brown's three-part deep dive into RCU-walk and pathname resolution — essential reading once you understand the basics.
The most readable single-volume kernel internals book; Chapter 13 is the best short VFS introduction in print.
Chapter on filesystem drivers and the proc interface; free PDF from LWN. Older but concepts are still valid.
Browse the kernel source with cross-references. Start with fs/ramfs/inode.c, fs/libfs.c, and fs/namei.c to understand VFS implementation.
A gentler introduction with pointers to beginner-friendly VFS-related projects suitable for first contributions.
Checkpoints — you can now…
- ✓You can explain the difference between an inode and a dentry without hesitation, and give a concrete example where a single inode has multiple dentries.
- ✓You can describe the four VFS operation tables (super_operations, inode_operations, file_operations, address_space_operations) and name at least two methods from each.
- ✓You can trace a call to open('/some/path', O_RDONLY) through the kernel, naming each major function and which layer (VFS, dcache, filesystem, page cache, block) it belongs to.
- ✓You can explain what a negative dentry is and why it exists as a performance optimization.
- ✓You can write the skeleton of a kernel module that registers a new filesystem type and mounts successfully with mount -t myfs none /mnt.
- ✓You can explain the container_of pattern and why filesystems embed struct inode as the first member of their own larger inode struct.
- ✓You can explain why two open() calls to the same file produce independent file positions, but dup() of one fd shares the position with the original.