R0KernelPath
module 050 / 262

File Systems Concepts

File systems are the art of imposing persistent, named, hierarchical structure on a block device — and surviving crashes while doing it.

~18h focused study11 concepts

Overview

A file system is one of the most consequential inventions in operating systems history. Without it, persistent storage would be a raw sequence of bytes with no structure — you would need to remember that your document starts at byte 1,073,741,824 and is 4,321 bytes long, forever. File systems give names to data, organize those names into hierarchies, track who owns what, and do all of this while surviving power failures. They are the bridge between spinning disks (or flash cells) and the human concept of "a file."

This module builds that bridge from first principles. We start with the physical reality of block devices — storage that only knows "read block 17" or "write block 42" — and layer everything on top: metadata structures that describe files (inodes), the directory tree that gives files names, allocation strategies that decide which blocks to use, journaling that keeps everything consistent after a crash, and the VFS abstraction that lets Linux mount ext4, btrfs, tmpfs, and procfs all at once under a single /.

The ideas here are not just historical curiosities. Every time you open() a file in C, call stat() to check permissions, or wonder why df -h says your disk is full when du -sh * doesn't add up, you are encountering the structures described in this module. Understanding them deeply will make you a better systems programmer, a more effective kernel contributor, and someone who can debug the truly baffling storage problems that stump everyone else.

The Linux kernel's file-system subsystem (fs/) is one of the richest areas for new contributors. The VFS layer alone contains dozens of interfaces that real-world file systems must implement. By the end of this module, you will be able to read that code with genuine comprehension — not just pattern-matching on syntax, but understanding why each field exists.

why this matters for the kernel

File system code lives at the intersection of every major kernel subsystem: memory management (page cache), block I/O (bio layer), concurrency (locks, RCU), and security (permission bits, capabilities, LSM hooks). A kernel contributor who understands file systems can navigate a huge fraction of the tree. The VFS (Virtual File System) abstraction, invented by Sun in 1985 and carried forward in Linux, is a masterclass in object-oriented design in C — every struct file_operations, struct inode_operations, and struct super_operations is a vtable that a file-system author fills in. Reading those structures is your entry point to fs/ contributions.

Beyond kernel hacking, file-system knowledge is essential for storage engineering, database internals, embedded systems, and cloud infrastructure. When a production database crashes and leaves a corrupt WAL file, someone who understands journaling can reason about what happened and whether data is recoverable. When a container image layer uses OverlayFS, understanding mount namespaces and VFS makes the magic transparent. This is foundational, load-bearing knowledge — invest in it once and it compounds for decades.

VFS Layer: From syscall to disk

rendering diagram…

A file read walks from the system call through VFS and the page cache. On a hit, data is copied from RAM. On a miss, the FS layer constructs a bio, the block layer schedules it, and the driver hits the hardware. The loaded page is cached for future reads.

The concepts

01Block Devices: The Raw Foundation

Before we can talk about files, we need to understand what a file system is built on top of: a block device. A block device (hard disk, SSD, USB drive, NVMe) exposes a single interface to the OS: read or write a fixed-size chunk (a block or sector) at a given integer address. That is the entire API. There are no names, no directories, no permissions — just numbered slots.

On traditional HDDs, the smallest addressable unit is a sector (historically 512 bytes, now 4096 bytes for Advanced Format drives). The OS and file system typically work in larger blocks (commonly 4096 bytes = 4 KiB) because the overhead of tracking millions of tiny sectors outweighs the benefit. A 1 TB drive with 4 KiB blocks has ~268 million blocks.

Think of a block device as a massive, perfectly indexed warehouse with 268 million identical shelving units (blocks), each holding 4096 bytes. The warehouse manager knows only one thing: 'fetch shelf #42' or 'store this payload on shelf #42.' Building a file system means designing a cataloging system for that warehouse — deciding which shelves hold the catalog itself, which hold data, and how to find them again.

In Linux, block devices appear as files under /dev (e.g., /dev/sda, /dev/nvme0n1). You can read/write them directly with dd or hexdump. The kernel block layer (block/) sits between file systems and device drivers, handling I/O scheduling, plugging, and merging.

// Check block size of a device (Linux)
#include <sys/ioctl.h>
#include <linux/fs.h>
#include <fcntl.h>
#include <stdio.h>

int main(void) {
    int fd = open("/dev/sda", O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    unsigned int block_size;
    unsigned long long num_blocks;

    ioctl(fd, BLKBSZGET, &block_size);   // logical block size
    ioctl(fd, BLKGETSIZE64, &num_blocks); // total size in bytes
    num_blocks /= block_size;

    printf("Block size: %u bytes\n", block_size);
    printf("Total blocks: %llu\n", num_blocks);
    printf("Total size: %.2f GiB\n",
           (double)num_blocks * block_size / (1 << 30));
    return 0;
}

02Inodes: The Identity of a File

The most important concept in Unix file systems is the inode (index node). An inode is a fixed-size metadata record that describes everything about a file except its name. This separation — name and data stored elsewhere, metadata in the inode — is one of the most elegant design decisions in systems history.

A typical inode stores:

  • File type: regular file, directory, symlink, device, socket, pipe
  • Permissions: the classic rwxrwxrwx bits plus setuid/setgid/sticky
  • Owner: user ID (UID) and group ID (GID)
  • Timestamps: atime (last access), mtime (last data modification), ctime (last metadata change)
  • Link count: how many directory entries point to this inode
  • File size in bytes
  • Block count: how many blocks are allocated
  • Block pointers: addresses of the blocks that hold the file's actual data

Critically, there is no filename in the inode. Names live in directories. This is why hard links are possible: two directory entries in different places can both store inode number 42, and deleting one does not delete the file — the kernel only frees the inode and its blocks when the link count drops to zero (and all file descriptors are closed).

On ext2/ext3/ext4, inodes are stored in a fixed-size table allocated when the file system is created with mkfs. You can see inode usage with df -i. Each inode has a number; ls -i shows it. stat filename dumps the full inode contents in human-readable form.

The inode number, not the name, is the file's true identity at the kernel level. When you open("/etc/passwd", O_RDONLY), the kernel walks the directory tree to find the inode number, then loads that inode to verify permissions and find the data blocks.

// stat() exposes the inode fields to userspace
#include <sys/stat.h>
#include <stdio.h>
#include <time.h>

int main(int argc, char *argv[]) {
    struct stat s;
    const char *path = argc > 1 ? argv[1] : "/etc/passwd";
    if (stat(path, &s) != 0) { perror("stat"); return 1; }

    printf("Inode number : %lu\n", (unsigned long)s.st_ino);
    printf("Hard links   : %lu\n", (unsigned long)s.st_nlink);
    printf("UID/GID      : %u / %u\n", s.st_uid, s.st_gid);
    printf("Size (bytes) : %lld\n", (long long)s.st_size);
    printf("Block size   : %ld\n", (long)s.st_blksize);
    printf("Blocks (512B): %lld\n", (long long)s.st_blocks);

    // Permissions in octal
    printf("Mode (octal) : %o\n", (unsigned)s.st_mode & 07777);

    char buf[64];
    struct tm *t = localtime(&s.st_mtime);
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", t);
    printf("Last modified: %s\n", buf);
    return 0;
}

03Block Pointers and Indirect Addressing

An inode needs to record where a file's data lives on disk. Small files might fit in a handful of blocks; large files might span millions. How does an inode of fixed size handle both?

The classic solution (used in ext2/ext3 and as a historical reference) is a multi-level pointer scheme:

  • 12 direct pointers: each holds the address of one data block. Covers 12 × 4 KiB = 48 KiB.
  • 1 single-indirect pointer: points to a block full of block addresses (4 KiB / 4 bytes per address = 1024 pointers). Covers 1024 × 4 KiB = 4 MiB.
  • 1 double-indirect pointer: points to a block of single-indirect pointers. Covers 1024² × 4 KiB = 4 GiB.
  • 1 triple-indirect pointer: covers 1024³ × 4 KiB = 4 TiB.

This gives a maximum file size of roughly 4 TiB on 32-bit pointer ext2. Accessing a byte in a large file may require reading 3 intermediate index blocks before reaching the data block — three extra disk seeks in the worst case.

Modern file systems have moved away from this scheme. ext4 uses extents: instead of storing individual block numbers, an extent records a (start_block, length) pair. A single extent can describe up to 128 MiB of contiguous data with just two numbers. Most files need only 1–3 extents. The inode has a 60-byte inline extent tree; if that overflows, it grows into an on-disk B-tree.

btrfs and XFS use similar B-tree-based extent maps. The key insight is that contiguous allocation (which we try to achieve) makes file location representable very compactly.

You can inspect the extent map of a file with filefrag -v filename (on ext4) or xfs_bmap filename (on XFS).

# See the extent map of a file on ext4
# Each line is one extent: logical offset -> physical block, length
filefrag -v /bin/bash

# Example output:
# File size of /bin/bash is 1234568 bytes (302 blocks of 4096 bytes)
#  ext:     logical_offset:        physical_offset: length:   expected: flags:
#    0:        0..     301:     12345678..  12345979:    302:             last,eof
# 302 blocks in ONE contiguous extent — perfectly laid out

# Compare a heavily fragmented file:
filefrag -v /var/log/syslog
# You might see dozens of extents if the log has grown incrementally

04Directories: Giving Files Names

If inodes are the identity of files, directories are the naming system. A directory is itself a file — it has an inode — but its contents are a list of (name → inode number) mappings called directory entries (or dentries in kernel parlance).

In the simplest implementation (ext2 original), a directory's data blocks contain a linear list of variable-length records. Each record holds:

  • inode number (4 bytes)
  • record length (so you can skip to the next entry)
  • name length
  • file type (a copy of the type field from the inode, for ls speed)
  • name (variable length, up to 255 bytes, null-padded to alignment)

Linear directories are O(n) to search. For directories with thousands of entries (think /usr/lib), this is slow. ext3/ext4 introduced HTree (a hash-tree variant of a B-tree indexed by filename hash), making lookups O(log n). XFS uses B+-trees for directories from the start.

The kernel maintains a dentry cache (dcache) — an in-memory cache of recently-used name-to-inode mappings. This is separate from the inode cache (icache). Path resolution (/usr/bin/python3) walks the directory tree, consulting the dcache at each component. A cache hit costs almost nothing; a miss requires reading a disk block.

Two special entries exist in every directory: . (the directory itself) and .. (the parent). These are real directory entries. cd .. works because .. is just another entry pointing to the parent's inode. At the root /, both . and .. point to the same inode.

You can inspect raw directory contents with debugfs on ext4 or ls -lai to see inode numbers alongside names.

// Use getdents64 to read raw directory entries (like ls does internally)
#define _GNU_SOURCE
#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>

struct linux_dirent64 {
    ino64_t        d_ino;
    off64_t        d_off;
    unsigned short d_reclen;
    unsigned char  d_type;
    char           d_name[];
};

int main(void) {
    int fd = open(".", O_RDONLY | O_DIRECTORY);
    char buf[4096];
    long n;
    while ((n = syscall(SYS_getdents64, fd, buf, sizeof(buf))) > 0) {
        for (long pos = 0; pos < n; ) {
            struct linux_dirent64 *d = (void *)(buf + pos);
            printf("ino=%llu type=%u name=%s\n",
                   (unsigned long long)d->d_ino,
                   (unsigned)d->d_type, d->d_name);
            pos += d->d_reclen;
        }
    }
    close(fd);
    return 0;
}

05The File System as a Tree: Paths, Links, and Traversal

Unix file systems form a rooted tree (technically a directed acyclic graph when hard links are considered). The root is /. Every other file or directory is reachable by following directory entries downward from /. This is why it is called a tree — it has one root, branches (directories), and leaves (files, symlinks, devices).

Path resolution is the process of turning a string like /usr/bin/python3 into an inode. The kernel:

  1. Starts at the root inode (or the process's current working directory for relative paths).
  2. Looks up usr in the root directory → finds inode N.
  3. Looks up bin in inode N's directory contents → finds inode M.
  4. Looks up python3 in inode M's directory contents → finds inode K.
  5. Loads inode K, checks permissions, and returns a file descriptor.

Each step may consult the dentry cache. This traversal can cross mount points (see the Mounting concept).

Hard links are additional directory entries pointing to the same inode. ln file1 file2 creates a second entry with the same inode number. Both names are equally valid; neither is the 'original'. The inode's link count tracks this.

Symbolic links (symlinks) are files whose contents are a path string. When the kernel encounters a symlink during path resolution, it substitutes the stored string and continues resolving. Symlinks can cross file-system boundaries; hard links cannot (inode numbers are only meaningful within one file system). Symlinks can be dangling (target doesn't exist). The kernel limits symlink depth to 40 to prevent loops.

A critical security rule: never use access() followed by open() on a path in security-sensitive code — the path might change between the two calls (TOCTOU race). Always open() first and use fstat() on the fd.

# Observe hard link counts changing
touch original.txt          # link count = 1
ln original.txt hardlink.txt # link count = 2
stat original.txt | grep Links  # Links: 2
rm original.txt              # link count drops to 1, inode NOT freed
stat hardlink.txt | grep Links  # Links: 1 — file still alive
rm hardlink.txt              # link count = 0 → inode + blocks freed

# Trace path resolution with strace
strace -e trace=openat,newfstatat ls /usr/bin/python3 2>&1 | head -20
# You'll see: openat(AT_FDCWD, "/usr/bin/python3", ...) = 3

06Space Allocation Strategies

When a file grows, the file system must decide which free blocks to assign to it. This decision — the allocation strategy — profoundly affects performance because disk reads are fastest when data is contiguous (adjacent blocks).

Free space tracking: Most file systems use a block bitmap — one bit per block, 1=used, 0=free — stored in well-known locations. ext4 stores one bitmap per block group (a 128 MiB region). Finding free blocks is a scan of this bitmap. XFS and btrfs use B-tree-based free space indexes.

Block groups (ext4) / allocation groups (XFS): the disk is divided into equal-sized regions. Each region is semi-independent: it has its own inode table, block bitmap, and data area. The allocator tries to keep a file's inodes and data blocks within the same group, and tries to keep sibling files in the same directory in the same group. This is called locality of reference and is the key to good performance.

Allocation policies:

  • First fit: scan bitmaps from the start, take the first run of free blocks. Fast but fragments over time.
  • Best fit: find the smallest run that fits. Minimizes wasted space but fragments small holes everywhere.
  • Next fit: start scanning from where the last allocation ended. Spreads allocations evenly.
  • Delayed allocation (ext4 delalloc): don't choose physical blocks until the page cache is flushed to disk. This lets the file system see the final file size and allocate one large contiguous extent instead of many small ones dribbled out over time. This is how ext4 achieves large single-extent files for sequentially-written data.

Copy-on-Write (CoW) allocation (btrfs, ZFS): never overwrite in place. Write new data to new blocks, then update the tree to point there. Old blocks are freed after the tree update commits. This makes snapshots nearly free and eliminates the 'torn write' problem but increases write amplification.

Fragmentation occurs when a file's blocks are scattered. e4defrag can defragment ext4 files online.

# See block group layout on an ext4 filesystem (run as root or on a loop device)
mke2fs -t ext4 /tmp/test.img 100M  2>/dev/null
dumpe2fs /tmp/test.img 2>/dev/null | grep -A8 'Group 0:'
# Output shows:
#   Inode table at block N
#   Block bitmap at block M
#   Free blocks: ...
#   Free inodes: ...

# See allocation policy effects: write a large file sequentially
dd if=/dev/zero of=/tmp/bigfile bs=1M count=50 2>/dev/null
filefrag -v /tmp/bigfile
# Typically: 1 extent — delayed allocation worked perfectly

07Journaling: Surviving Crashes

Here is the fundamental problem: updating a file requires multiple disk writes. You might need to update an inode (new size), a block bitmap (mark blocks used), and data blocks. If power fails between these writes, the disk is left in an inconsistent state — maybe blocks are marked used but the inode doesn't point to them, or an inode claims a size that no blocks back up.

The old solution was fsck (file system check): on reboot, scan the entire disk looking for inconsistencies and fixing them. On a 1 TB disk, this takes 30+ minutes. Unacceptable.

The modern solution is journaling (borrowed from database transaction logs). Before making any changes to the file system, write a description of those changes — a transaction — to a special circular log area called the journal. Only after the journal entry is safely on disk do you apply the changes to the main file system. If power fails:

  • If the journal entry isn't there: the operation never started. No inconsistency.
  • If the journal entry is there but changes aren't applied: replay the journal on next mount. Fast (seconds, not minutes).
  • If the journal entry is there and changes are applied: commit the transaction. Done.

The journal guarantees atomicity of metadata updates: either all happen or none do.

There are three journaling modes in ext4 (configured with data= mount option):

  • writeback: only metadata is journaled. Data blocks are written at any time. Fastest, but a crash can leave a newly-extended file with garbage data from old disk contents. Default on many distros.
  • ordered (ext4 default): only metadata is journaled, but data blocks are written to disk before the journal commit. This ensures that after replay, any file a journal entry references contains either old or new valid data. Good balance.
  • journal: both metadata and data go through the journal. Safest, but writes everything twice. Used for databases or when data integrity is paramount.

The journal itself is just a reserved file (inode 8 on ext4). You can examine it: debugfs -R 'stat <8>' /dev/sda1.

// Demonstrate why fsync() matters for durability
// Without fsync, your 'successful' write may still be in page cache
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main(void) {
    int fd = open("/tmp/important.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) { perror("open"); return 1; }

    const char *data = "critical data\n";
    ssize_t written = write(fd, data, strlen(data));
    if (written < 0) { perror("write"); return 1; }

    // Without this, data may be lost on sudden power failure:
    if (fsync(fd) != 0) { perror("fsync"); return 1; }
    // fsync() flushes page cache → disk, AND waits for journal commit
    // on journaled filesystems. Only after fsync returns is data durable.

    // For directories (e.g. after rename/link), fsync the directory too:
    int dir = open("/tmp", O_RDONLY | O_DIRECTORY);
    fsync(dir);  // ensures the directory entry is durable
    close(dir);

    close(fd);
    printf("Data durably written.\n");
    return 0;
}

08The Superblock and File System Layout

Every file system has a superblock — the master control block that describes the file system as a whole. It is stored at a fixed, well-known location (block 1 for ext4, offset 1024 bytes from the start). If the superblock is corrupted, the entire file system is unmountable — so ext4 keeps backup copies in several block groups.

The superblock contains:

  • Magic number: identifies the FS type (ext4 magic = 0xEF53)
  • Block size (512, 1024, 2048, or 4096 bytes)
  • Total blocks and free blocks
  • Total inodes and free inodes
  • Block group size (blocks per group)
  • First data block and first inode number (inode 1 is reserved; inode 2 is the root directory)
  • Mount count and last mount time (used by fsck to decide when to check)
  • Feature flags: which optional features are enabled (extents, dir_index, 64-bit, etc.)
  • UUID: a 128-bit unique identifier for the file system (used in /etc/fstab for stable mounting)

The overall on-disk layout of an ext4 file system:

[Boot block][Superblock][Block group descriptors][Block groups...]

Each block group:
[Block bitmap][Inode bitmap][Inode table][Data blocks...]

You can read the superblock with tune2fs -l /dev/sda1 (for ext4) or dumpe2fs /dev/sda1. Understanding the superblock is the entry point to writing your own minimal file system or a user-space debugger.

# Read the ext4 superblock of a mounted partition
# (or a loop-mounted image)
tune2fs -l /dev/sda1 2>/dev/null | head -40
# Key fields:
# Filesystem magic number:  0xEF53
# Block count:              ....
# Free blocks:              ....
# Block size:               4096
# Inode count:              ....
# Inode size:               256
# Journal inode:            8
# First inode:              11
# Filesystem UUID:          xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Default mount options:    user_xattr acl
# Filesystem features:      has_journal ext_attr ... extents 64bit

# Read raw superblock bytes (offset 1024, length 1024)
dd if=/dev/sda1 bs=1 skip=1024 count=4 2>/dev/null | od -t x1
# First 2 bytes of the 56-byte inode count, then block count...
# Bytes at offset +56 (= 1024+56=1080): magic = 53 EF (little-endian)

09Mounting: Attaching File Systems to the Tree

Linux presents all storage as a single unified directory tree rooted at /. But different file systems (your root partition, a USB drive, a network share, a virtual /proc) are separate entities with their own superblocks. Mounting is the act of attaching one file system's root into a directory (the mount point) of an already-visible tree.

When you run mount /dev/sdb1 /mnt/usb:

  1. The kernel reads the superblock of /dev/sdb1 to identify the FS type.
  2. It calls that FS's mount() operation, which allocates an in-memory struct super_block.
  3. The kernel associates the USB root inode with the directory inode at /mnt/usb.
  4. During path resolution, when the VFS reaches /mnt/usb, it detects a vfsmount record and switches to the USB file system's root inode for further traversal.

This switch is transparent to programs. ls /mnt/usb/photo.jpg resolves just like any other path — the VFS layer handles the transition.

Mount namespaces (introduced in Linux 3.8) allow different processes to have different views of the mount tree. Containers use this: a container might see / as a minimal overlay file system with tmpfs mounts, while the host sees the real root. This is foundational to Docker and Kubernetes.

Bind mounts (mount --bind /some/dir /other/dir) attach a directory to a second location in the tree — no new block device involved. Useful for containers and chroot jails.

Special file systems are pseudo-file systems backed by kernel memory, not block devices:

  • proc: exposes process information (/proc/PID/maps, /proc/cpuinfo)
  • sysfs: exposes kernel objects and their attributes (/sys/block/sda/size)
  • tmpfs: RAM-backed, volatile storage (/tmp, /run)
  • devtmpfs: populated by the kernel with device nodes (/dev)

You can see all mounts with cat /proc/mounts or findmnt --tree.

// Create and mount a tmpfs programmatically (requires CAP_SYS_ADMIN)
// Demonstrates the mount() syscall directly
#include <sys/mount.h>
#include <sys/stat.h>
#include <stdio.h>
#include <unistd.h>

int main(void) {
    // Create mount point
    mkdir("/tmp/mymount", 0755);

    // mount(source, target, fstype, flags, data)
    // For tmpfs, source is ignored (conventionally "tmpfs")
    int r = mount("tmpfs", "/tmp/mymount", "tmpfs",
                  MS_NODEV | MS_NOSUID,
                  "size=10m,mode=1777"); // 10 MiB, sticky like /tmp
    if (r != 0) {
        perror("mount (need root)");
        return 1;
    }
    printf("Mounted tmpfs at /tmp/mymount\n");
    // Verify: cat /proc/mounts | grep mymount

    // Cleanup
    umount("/tmp/mymount");
    rmdir("/tmp/mymount");
    return 0;
}

10The VFS: Linux's File System Abstraction

Linux supports dozens of file systems: ext4, btrfs, XFS, tmpfs, NFS, FAT, NTFS, overlayfs, and more. Programs use the same system calls (open, read, write, stat) regardless of which FS the file lives on. This is possible because of the Virtual File System (VFS) — a generic file-system interface layer in the kernel.

VFS defines a set of abstract objects and operations tables (essentially vtables, like C++ virtual methods implemented manually in C):

  • struct super_block: represents a mounted file system. Has struct super_operations (e.g., alloc_inode, write_inode, sync_fs).
  • struct inode: the in-memory inode. Has struct inode_operations (e.g., lookup, create, link, mkdir, rename, permission).
  • struct file: represents an open file descriptor. Has struct file_operations (e.g., read, write, mmap, llseek, ioctl).
  • struct dentry: the dentry cache entry, linking a name to an inode. Has struct dentry_operations.

When you implement a file system, you fill in these structs with function pointers that do the real work for your storage format. The VFS calls your functions through these pointers without knowing anything about your on-disk format.

This design enables remarkable flexibility:

  • /proc/PID/maps is a 'file' whose read implementation generates content dynamically from kernel memory — no disk involved.
  • An NFS read sends an RPC over the network.
  • An ext4 read reads from the page cache, filling misses from disk via the bio layer.

All three use the same read(2) system call from userspace. The VFS is the indirection layer that makes this possible. When you write a kernel module implementing a simple file system, you are implementing these operation structs.

The kernel source for VFS is in fs/ (the core is fs/inode.c, fs/dcache.c, fs/namei.c). Reading fs/ramfs/inode.c is the canonical 'simplest possible VFS file system' tutorial — it is under 300 lines.

// Sketch of how a minimal FS fills in VFS operation tables
// (Simplified from fs/ramfs/inode.c in the Linux kernel)

// inode operations for a directory in our minimal FS
static const struct inode_operations myfs_dir_inode_ops = {
    .create  = myfs_create,  // called for open(O_CREAT)
    .lookup  = simple_lookup, // VFS helper: walk dcache
    .link    = simple_link,
    .unlink  = simple_unlink,
    .mkdir   = myfs_mkdir,
    .rmdir   = simple_rmdir,
    .rename  = simple_rename,
};

// file operations for reading/writing regular files
static const struct file_operations myfs_file_ops = {
    .read_iter  = generic_file_read_iter,  // page-cache read
    .write_iter = generic_file_write_iter, // page-cache write
    .mmap       = generic_file_mmap,
    .llseek     = generic_file_llseek,
    .fsync      = noop_fsync,  // no disk, nothing to sync
};

// superblock operations: how to allocate/destroy our inode objects
static const struct super_operations myfs_super_ops = {
    .statfs       = simple_statfs,
    .drop_inode   = generic_drop_inode,
    .show_options = myfs_show_options,
};

// This is what VFS calls when you 'mount -t myfs none /mnt'
static int myfs_fill_super(struct super_block *sb, void *data, int silent) {
    sb->s_op    = &myfs_super_ops;
    sb->s_magic = MYFS_MAGIC; // your unique magic number
    // ... allocate root inode, set sb->s_root ...
    return 0;
}

11The Page Cache: The FS and Memory Working Together

Reading and writing files would be unbearably slow if every operation hit the disk. The kernel's page cache is the solution: it keeps recently-accessed file contents in RAM as 4 KiB pages, indexed by (inode, page_offset).

When you read(fd, buf, 4096) for a page that is in cache, the kernel copies from RAM — microseconds. A cache miss causes a page fault into the file system, which submits a bio (block I/O request) to the block layer, which issues the hardware read — milliseconds. The loaded page stays in cache and is evicted by the LRU (Least Recently Used) algorithm when RAM pressure demands it.

When you write(fd, buf, 4096), the kernel writes into the page cache and marks the page dirty. The actual disk write happens later, during a writeback flush triggered by:

  • fsync() or fdatasync() (explicit, synchronous)
  • sync or sync_file_range() (explicit)
  • The pdflush/writeback kernel threads (periodic: every ~5 seconds, or when dirty memory exceeds a threshold)

This write-back caching is why writes feel instant but data may not be on disk yet. It is also why fsync() is critical for databases, WAL files, and anything that must survive a crash.

Memory-mapped files (mmap) make the page cache even more direct: the file's pages are mapped into the process's virtual address space. A read is a memory load; a write is a store that marks the page dirty. This is how many databases, JVMs, and the dynamic linker work — ld.so maps shared libraries rather than read()-ing them.

The page cache is also shared between processes: if two processes mmap the same file, they map the same physical pages. This is how shared memory between processes works at the page-cache level.

// Demonstrate mmap for file I/O — faster than read() for large files
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdio.h>
#include <unistd.h>

int main(void) {
    int fd = open("/etc/passwd", O_RDONLY);
    struct stat sb;
    fstat(fd, &sb);  // use fd, not path — avoids TOCTOU
    size_t len = sb.st_size;

    // Map the file into our address space (shared with page cache)
    char *addr = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);  // fd no longer needed; mapping keeps the pages alive

    if (addr == MAP_FAILED) { perror("mmap"); return 1; }

    // Now addr[0..len-1] is the file contents, accessed like an array
    // First access to each page triggers a page fault -> kernel loads from disk
    size_t lines = 0;
    for (size_t i = 0; i < len; i++)
        if (addr[i] == '\n') lines++;
    printf("%zu lines in /etc/passwd\n", lines);

    // Give the kernel a hint we'll access sequentially
    // madvise(addr, len, MADV_SEQUENTIAL);  // optional optimization

    munmap(addr, len);
    return 0;
}

Key insights

  • ✦An inode contains everything about a file except its name — names live in directories. This indirection is what makes hard links and atomic rename() possible.
  • ✦The VFS is a set of operation tables (vtables in C). Writing a Linux file system means filling in these tables — the kernel calls your code through function pointers without knowing your format.
  • ✦Journaling guarantees metadata consistency after a crash in seconds, not the 30+ minutes that fsck would take on a large disk. Understanding the three journaling modes (writeback/ordered/journal) is essential for reasoning about data durability.
  • ✦Delayed allocation (delalloc) is why ext4 typically produces files in a single contiguous extent even when written byte-by-byte: the physical block assignment is deferred until flush time, when the full size is known.
  • ✦The page cache sits between every file read/write and the disk. write() is asynchronous by default — data may not be on disk until fsync(). This is the most common source of data-loss bugs in systems software.
  • ✦Mount namespaces make containers possible: a container process has a different view of the mount tree than the host, achieved without copying any data.
  • ✦Block groups (ext4) and allocation groups (XFS) are the key to performance: the allocator keeps a file's inode and data in the same group, making sequential reads physically sequential on disk.

Common pitfalls

  • ⚠Assuming write() guarantees durability. It writes to the page cache, not disk. Always fsync() before considering data safe after a crash.
  • ⚠Confusing hard links and symlinks. Hard links share an inode and cannot cross file system boundaries; symlinks are path strings and can. Deleting the 'original' of a symlink creates a dangling link; deleting one hard link never removes the data while other links exist.
  • ⚠Running out of inodes on a full file system. `df -i` shows inode usage. A disk can have free space but zero free inodes, causing 'No space left on device' errors that baffle newcomers.
  • ⚠TOCTOU (Time of Check to Time of Use) races: checking a path with access()/stat() then opening it is not atomic. Always open() first and fstat() the file descriptor.
  • ⚠Expecting rename() to be atomic across file systems. rename() is atomic only within a single file system (it is a single metadata operation). Cross-device rename (across mount points) is not possible — cp+rm is required and is not atomic.
  • ⚠Confusing 'blocks' in st_blocks (always counted in 512-byte units regardless of file system block size) with file system blocks. st_blksize is the preferred I/O unit, not the allocation unit.
  • ⚠Not syncing the directory after creating or renaming a file. Even if the file data is fsync'd, the directory entry may not be durable. Sync both the file and its parent directory for full durability.

Practice

  1. 01
    easy

    Build a minimal ext4 image from scratch. Use `dd` to create a 50 MiB file, `mkfs.ext4` to format it, mount it on a loop device, create some files and directories, then use `dumpe2fs` and `debugfs` to inspect the block groups, inode table, and the raw directory entries of a directory you created. Write down the inode number of the root directory and of one file you created, then verify them with `ls -i`.

  2. 02
    easy

    Write a C program that creates a file, writes data to it, calls fsync(), then immediately reads /proc/self/fdinfo/<fd> and /proc/<pid>/maps to observe the file descriptor state. Then create a hard link to the same file, verify both names share an inode number via stat(), delete the original name, and confirm the data is still accessible through the hard link. Print the link count at each step.

  3. 03
    medium

    Implement a minimal user-space file system using FUSE (Filesystem in Userspace). It should support: a root directory, the ability to create regular files, write up to 64 KiB to each file (store in malloc'd memory), and read them back. Use libfuse3. Your FS should implement at minimum: getattr, readdir, open, read, write, and create operations. Mount it, test it with standard tools (cp, cat, ls -la), and unmount it cleanly.

  4. 04
    hard

    Simulate and observe journaling behavior. On an ext4 filesystem, use `dd` to write random data to a file, then use `debugfs -R 'dump <8> /tmp/journal.bin' /dev/your-device` to extract the journal (inode 8). Write a Python script that parses the journal binary to find transaction superblocks (magic 0xC03B3998) and print the block numbers they cover. Compare before/after a large file write.

  5. 05
    hard

    Write a kernel module that implements a tiny in-memory file system (call it 'tinyfs') mounted as `mount -t tinyfs none /mnt/tiny`. It should support: a root directory, creating files with touch, writing a string to a file with echo, and reading it back with cat. Base it on fs/ramfs/inode.c from the kernel source. Compile it as a loadable module, insmod it, run your tests, then rmmod it. Include a Makefile.

Resources

📕Operating Systems: Three Easy Pieces — Persistence section
bookfree

Chapters 39-45 cover file system concepts from first principles with outstanding clarity and humor. The best free textbook for this topic.

📄Linux Virtual File System Overview (kernel.org docs)
docsfree

The authoritative reference for VFS objects and operations. Read this before writing your first kernel FS module.

📄Ext4 Disk Layout (kernel.org docs)
docsfree

Exact on-disk structure of every ext4 data structure. Invaluable for writing debuggers or understanding what dumpe2fs shows.

📕Linux Kernel Development, 3rd edition
bookpaidRobert Love — Addison-Wesley. Chapter 13 covers the VFS.

The best single-volume introduction to kernel internals. Chapter 13 on VFS is concise and accurate.

📕Understanding the Linux Kernel, 3rd edition
bookpaidBovet & Cesati — O'Reilly.

Goes deeper than Love on VFS, page cache, and block I/O. Dense but comprehensive.

✎LWN.net: Filesystem coverage
articlefree

Search for 'ext4', 'btrfs', 'VFS', 'page cache' — LWN has the best narrative coverage of every significant FS development since 2000.

📄man7.org: open(2), stat(2), mount(2), mmap(2)
docsfree

Every system call used in this module has a man page here. Read the NOTES and ERRORS sections — they contain crucial caveats.

🔧Bootlin (Free Electrons) Linux Kernel source browser
toolfree

Cross-referenced kernel source. Browse fs/ramfs/, fs/ext4/, fs/namei.c side-by-side with struct definitions. Essential for reading FS code.

📕The Design and Implementation of the 4.4BSD Operating System
bookpaidMcKusick et al — Addison-Wesley. Chapter 6-7.

The BSD FFS (Fast File System) introduced block groups and rotationally-aware allocation. Foundational to ext2/ext4 design.

Checkpoints — you can now…

  • ✓You can explain what an inode is, list its fields, and describe why filenames are not stored in inodes — and what that means for hard links.
  • ✓You can use stat(), ls -i, debugfs, and dumpe2fs to inspect inode numbers, link counts, block groups, and extent maps on a real Linux system.
  • ✓You can explain the three ext4 journaling modes (writeback/ordered/journal), describe which writes each protects, and write a C program that correctly uses fsync() to ensure durability.
  • ✓You can trace a path like /usr/bin/python3 through the VFS layer step by step, naming the kernel data structures (dentry, inode, super_block) touched at each stage.
  • ✓You can explain what happens during a mount() system call and why mount namespaces enable container isolation.
  • ✓You can describe delayed allocation, explain why it produces fewer, larger extents than eager allocation, and verify this with filefrag -v on a freshly-written file.
  • ✓You can read fs/ramfs/inode.c in the kernel source and explain how each operation table (super_operations, inode_operations, file_operations) connects to VFS layer calls.
  • ✓You can write and compile a minimal FUSE file system that supports create, read, write, and readdir, and mount it with standard Linux tools.