File-System Implementation and Layout

Imagine a disk as one enormous array of numbered blocks. It has no labels, no names, no memory of what belongs where. Every file system must solve the same problem. It must turn that featureless array into a structured hierarchy. That hierarchy holds named, recoverable files and directories. This requires committing certain regions of the disk to fixed roles. There must be a place to boot from. There must be a place to describe the whole volume. There must be a place to track free space. There must also be a place to store metadata about every file. All of this happens long before any user data is written. This matters because layout decisions are made once, when a file system is first created. These decisions constrain everything for the life of that volume. They affect how fast the system boots. They affect how quickly it can locate a file. They affect how gracefully it survives a crash. They also affect how many disk seeks a single file open requires. Real file systems — ext4, NTFS, FFS, and their descendants — all wrestle with the same tension. That tension is between simplicity of layout and performance under real workloads. Their designs differ mainly in how cleverly they lay out these fixed regions. They also differ in how they cache them in memory. The big picture is a layered one. On the physical disk sits a small number of fixed, well-known regions. These include the boot block, the superblock, free-space structures, and inode (metadata) tables. The bulk of the disk is then devoted to actual file data. In memory, the operating system builds a parallel set of structures. These include a mount table, an in-memory superblock, and a buffer/page cache. These structures let it avoid touching the disk for every operation. They translate an on-disk layout that never changes into a mutable, cached view. That cached view changes constantly. This article explains exactly what lives in each on-disk region. It also explains what lives in each in-memory structure. It explains why the layout is organized the way it is. It explains how mounting stitches a raw partition into the directory tree. It also explains how the buffer cache turns slow disk I/O into fast memory operations. This happens while remaining crash-consistent. It builds directly on Directory Structure and Disk Scheduling Algorithms. It sets up the deeper treatment of Inodes and File Metadata and Allocation Methods for File Storage.

Key takeaways
  • A disk volume is divided into fixed regions: boot block, superblock, free-space map, inode/metadata table, and data blocks.
  • The boot block holds bootstrap code; only one partition's boot block is actually used at startup.
  • The superblock is the file system's "birth certificate" — block size, total blocks, free counts, and root inode location.
  • Mounting attaches a volume's directory tree into the global namespace via a mount table, translating pathnames across devices.
  • The buffer/page cache holds recently used disk blocks in memory, exploiting locality to avoid slow I/O.
  • Crash consistency (journaling, ordered writes) protects the superblock and metadata regions from corruption during power loss.
The core insight

A file system is a small set of fixed on-disk regions describing structure, plus in-memory caches that make using that structure fast without losing crash safety.

The idea

A file-system implementation is the concrete mapping between an abstract hierarchy of files and directories and the raw sequence of blocks a disk or SSD actually exposes. The disk itself has no notion of "files" — it is addressed by logical block number (LBA), a flat array of fixed-size sectors (traditionally 512 bytes, now often 4096-byte blocks). Every file system must therefore reserve, at formatting time (`mkfs`), specific regions of this address space for specific purposes, and this reservation scheme is called the on-disk layout. Four layout regions recur across nearly every classical file system. The boot block (or boot sector) occupies the first few blocks of a partition and holds a small bootstrap program executed by firmware at power-on; a volume need not be bootable, but the block's location is standardized. The superblock is a small, densely packed structure holding the file system's vital statistics: block size, total block count, free block count, count of inodes, and a pointer to the root of the directory tree. The free-space region tracks which blocks are currently unallocated, using a bitmap, a free list, or a variant. The inode table (or its equivalent, an MFT in NTFS) is a region holding fixed-size metadata records — one per file — that describe ownership, permissions, size, timestamps, and pointers to the file's data blocks; this leads directly into Inodes and File Metadata and Allocation Methods for File Storage. Parallel to this static, on-disk structure, the operating system maintains in-memory data structures that exist only while the system is running. The mount table records which device is attached at which pathname in the global namespace, enabling a single tree to span multiple physical or virtual volumes. The in-memory superblock is a cached, often augmented copy of the on-disk superblock, avoiding a disk read on every metadata query. The buffer cache (block-level) or page cache (unified with virtual memory) holds recently accessed disk blocks, since real workloads exhibit strong temporal and spatial locality — the same blocks, or their neighbors, are re-read and re-written far more often than a uniform-random access pattern would suggest.

How it works

Formatting (`mkfs`) is the process that writes the initial, empty layout onto a raw partition. Conceptually it performs the following steps. ``` FORMAT(partition, block_size): n_blocks := size(partition) / block_size

# 1. Reserve boot block(s) write_zero_or_bootstub(partition, block 0)

# 2. Reserve and initialize superblock sb.magic := FS_MAGIC sb.block_size := block_size sb.total_blocks := n_blocks sb.inode_count := choose_inode_count(n_blocks) sb.free_block_count := n_blocks - reserved_blocks(sb) sb.free_inode_count := sb.inode_count - 1 # root uses one sb.root_inode := ROOT_INODE_NUMBER sb.state := CLEAN write(partition, SUPERBLOCK_OFFSET, sb) write_backup_superblock_copies(partition, sb) # crash resilience

# 3. Initialize free-space structure (bitmap example) for b in range(n_blocks): bitmap[b] := (b in reserved_regions(sb)) ? USED : FREE write(partition, sb.bitmap_offset, bitmap)

# 4. Initialize inode table for i in range(sb.inode_count): inode_table[i] := EMPTY_INODE inode_table[ROOT_INODE_NUMBER] := make_root_directory_inode() write(partition, sb.inode_table_offset, inode_table)

# 5. Write root directory's data block (".", "..") root_block := new_directory_block(ROOT_INODE_NUMBER, ROOT_INODE_NUMBER) write(partition, allocate_from(bitmap), root_block) ```

Mounting links a formatted volume into the live directory tree so ordinary path lookups can cross into it transparently. ``` MOUNT(device, mount_point_path): sb := read_superblock(device) assert sb.magic == FS_MAGIC # sanity check if sb.state != CLEAN: run_consistency_check(device) # e.g. fsck / journal replay in_memory_sb := load_into_memory(sb) # cached superblock mp_inode := resolve_path(mount_point_path) # must be an existing directory assert is_directory(mp_inode) assert not already_a_mount_point(mp_inode) entry := { device, root_inode: sb.root_inode, mount_point: mp_inode } mount_table.add(entry) mp_inode.mark_as_mount_point(device) # lookups here now redirect ``` Once mounted, every pathname lookup consults the mount table at each directory boundary: ``` RESOLVE_PATH(path): cur := current_root_or_cwd_inode() for component in split(path): if cur.is_mount_point(): device := mount_table.lookup(cur) cur := read_inode(device, device.root_inode) # cross into new FS cur := lookup_entry_in_directory(cur, component) if cur is null: error("no such file or directory") return cur ``` Finally, the buffer/page cache intercepts every block read and write, turning most I/O into memory operations: ``` READ_BLOCK(device, block_number): if cache.contains(device, block_number): cache.mark_recently_used(device, block_number) return cache.get(device, block_number) else: data := disk_read(device, block_number) # goes through I/O scheduler if cache.is_full(): victim := cache.evict_lru() if victim.dirty: disk_write(victim.device, victim.block_number, victim.data) cache.insert(device, block_number, data) return data WRITE_BLOCK(device, block_number, data): cache.insert_or_update(device, block_number, data, dirty=True)

# deferred write-back, or immediate flush for metadata in journaled FS ```

Figure
On-disk layout of a partition, and the in-memory structures it feeds
On-disk partition layout Boot block Super- block Free-space bitmap Inode table (metadata) Data blocks (file contents, directory blocks) mount() / read_superblock() loads structures into memory In-memory (kernel) structures In-memory superblock cache free counts, block size, root inode ptr Mount table /dev/sda1 → / /dev/sdb1 → /home tmpfs → /tmp Buffer / page cache (LRU-ish) blk 401 clean blk 402 dirty blk 88 clean blk 5001 dirty blk 12 clean blk 900 clean blk 91 dirty blk 13 clean dirty blocks flushed to disk on sync(), eviction, or journal commit eviction → disk_write() → obeys Disk Scheduling policy

Complexity & correctness

Time complexity of the core operations. Reading the superblock at mount time is `O(1)` — it lives at a fixed, known offset, so no search is required. Path resolution costs `O(d)` disk/cache accesses, where `d` is the path depth, since each directory component requires one lookup (and, at mount boundaries, one redirect through the mount table, itself `O(1)` with a hash-based implementation). Checking or updating free-space status for one block is `O(1)` with a bitmap, since block number `b` maps directly to bit `b` of the bitmap — no traversal needed. Finding a free block among `n` total blocks is `O(n)` in the worst case with a naive bitmap scan, though real systems maintain per-group free counts or a free list so an actual free block is found in `O(1)` amortized time. Buffer cache lookup is `O(1)` with a hash table keyed by (device, block number); LRU eviction is `O(1)` with a doubly linked list plus hash map, or `O(log n)` if implemented with a heap-based clock approximation.

Space complexity. The superblock is a small, fixed-size structure independent of volume size — typically under a few kilobytes, so `O(1)` relative to disk size. The free-space bitmap requires 1 bit per block, so `O(n / 8)` bytes for `n` blocks — for a 1 TB disk with 4 KB blocks, `n ≈ 2.68×10^8` blocks, giving roughly 32 MB of bitmap, a small fraction of total capacity. The inode table's size is fixed at format time as a design tradeoff: too few inodes and the file system runs out of "file slots" even with free disk space remaining; too many wastes space permanently, since the region cannot easily shrink later, foreshadowing the deeper inode-design tradeoffs in Inodes and File Metadata.

Correctness argument for the buffer cache. The cache must maintain the invariant that any read returns the most recently written value for that block, from the perspective of processes using the file system (read-your-writes consistency), even though the on-disk copy may be stale. This holds because every `READ_BLOCK` and `WRITE_BLOCK` funnels through the same cache lookup keyed by `(device, block_number)`; a write always updates (or inserts) the cached entry before any subsequent read can observe it, and there is no code path that reads directly from disk while bypassing an existing cache entry. The invariant that matters for crash correctness is different and weaker: after an uncontrolled power loss, the on-disk state must be recoverable to some consistent file-system state, not necessarily the most recent one. This is why metadata writes (superblock, bitmap, inodes) are typically write-ordered or journaled — the system enforces that a transaction's intent record hits disk before the data it describes is considered committed, so a crash mid-write leaves the log inconsistent (detectable and replayable) rather than the file system silently corrupted.

Tradeoffs. A fixed-size inode table (classic UFS/ext2 style) gives `O(1)` inode lookup by number but caps the maximum file count at format time. A dynamic metadata structure (NTFS's Master File Table, ext4's flexible inode allocation) trades a small lookup overhead for elastic capacity. Write-back caching (deferring disk writes) dramatically improves throughput but risks losing recent writes on crash; write-through caching sacrifices throughput for durability. Bitmaps are compact and support `O(1)` status checks, but a naive linear scan for a free block degrades under fragmentation — production systems mitigate this with grouped allocation and cached "hint" pointers, which is exactly the subject of Allocation Methods for File Storage.

Worked example

Trace mounting a small ext2-like volume with 128 blocks (block size 1 KB) at `/mnt/data`, then opening the file `/mnt/data/notes.txt`.

Step 0 — layout on disk (fixed at format time).

RegionBlocksContents
Boot block0unused bootstrap stub
Superblock1magic=0xEF53, total_blocks=128, block_size=1024, inode_count=32, free_blocks=94, root_inode=2
Free bitmap2128 bits, one per block
Inode table3–632 inodes, 128 bytes each (4 blocks)
Root dir data7entries: `.` → 2, `..` → 2, `notes.txt` → 9
File data8–127includes inode 9's data at block 40

Step 1 — `MOUNT("/dev/sdb1", "/mnt/data")`.

ActionResult
Read block 1`sb.magic == 0xEF53` ✓, state = CLEAN, no fsck needed
Load in-memory superblockcached copy: free_blocks=94, root_inode=2
Resolve `/mnt/data`existing empty directory, inode 55 on root device
Add mount table entry`{device: sdb1, root_inode: 2, mount_point: inode 55}`
Mark inode 55flagged as a mount point

Step 2 — `RESOLVE_PATH("/mnt/data/notes.txt")`.

Stepcur inodeAction
startroot device's inode 2 (`/`)—
component "mnt"inode 40 (dir)lookup "mnt" in root dir block
component "data"inode 55lookup "data"; inode 55 is a mount point
mount redirectsdb1's inode 2mount_table.lookup(55) → cross device
component "notes.txt"inode 9lookup in sdb1's root dir block (block 7)
donereturn inode 94 total lookups + 1 redirect = 5 steps, matches `O(d)` with d = depth 4

Step 3 — reading the file's first block through the cache.

Cache state beforeActionCache state after
empty`READ_BLOCK(sdb1, 40)` missesdisk_read(40) issued; block loaded, marked clean
{40: clean}second read of same blockcache hit, `O(1)`, no disk access
{40: clean}`WRITE_BLOCK(sdb1, 40, new_data)`{40: dirty} — deferred write-back
{40: dirty}cache pressure evicts block 40disk_write(40) flushed first, then evicted

This trace shows the three costs in practice: mounting is a handful of fixed reads independent of volume size, path resolution costs one step per component plus a redirect at each mount boundary, and repeated access to the same block after the first miss becomes free until eviction forces a flush.

Common pitfalls & traps

PitfallWhy it happensConsequence
Treating the in-memory superblock as always in sync with diskFree counts are updated in memory first for speedA crash before flush can make disk report stale free-space counts
Forgetting to write backup superblock copiesSingle-copy layout looks simplerOne bad sector destroys the whole file system's metadata
Off-by-one in bitmap-to-block mappingConfusing "bit index" with "block number offset from partition start"Wrong block marked free/used; silent data corruption
Unmounting while dirty pages remain in cache`umount` invoked without a flushData loss on removable media or power cycling
Assuming inode table size can grow laterFixed-size table chosen without headroomFile system runs out of inodes with disk space still free
Race between two processes allocating the same free blockFree-bitmap check-then-set not atomicTwo files silently share and corrupt the same data block

Pioneers & history

The layout conventions described here trace back to early Unix file systems, whose designers had to solve superblock placement, free-list management, and crash recovery with far less memory and far slower disks than modern systems provide, motivating decisions — such as small fixed metadata regions and replicated superblocks — that persist in file systems today.

Origins · 1 minDennis Ritchie and how this was discovered
DR
Dennis Ritchie
1941–2011 · Bell Labs, co-creator of Unix and the Unix file system model

Dennis Ritchie, with Ken Thompson, designed the original Unix file system, establishing the superblock/inode/data-block layout and the hierarchical mount model that essentially every subsequent file system — from BSD's FFS to Linux's ext family — inherited and refined.

History
1969
— Thompson and Ritchie build the first Unix file system on a PDP-7, establishing inodes and a flat block layout.
1978
— Version 7 Unix formalizes the superblock, inode table, and free-list layout still recognizable today.
1984
— Berkeley's Fast File System (FFS) introduces cylinder groups, replicated superblocks, and locality-aware allocation.
1994
— the ext2 file system for Linux generalizes FFS-style layout with block groups and flexible inode allocation.
2008
— ext4 adds journaling-integrated metadata and extent-based allocation, foreshadowing modern crash-consistent designs.

Where this matters

Every general-purpose operating system relies on exactly this layered layout-plus-cache design. Linux's ext4, XFS, and Btrfs each define their own superblock format and free-space structure (a bitmap for ext4, B-trees for XFS and Btrfs) but share the mount-table and page-cache abstractions in the kernel's Virtual File System (VFS) layer. Windows' NTFS replaces the fixed inode table with an extensible Master File Table but still reserves a boot sector and a fixed-offset "file system descriptor" analogous to a superblock. Database systems and container runtimes rely on the mount table's flexibility to overlay union file systems (as in Docker's overlay2) and bind-mount volumes without duplicating data. Embedded and mobile systems tune buffer/page cache sizes aggressively because RAM is scarce, while cloud block-storage systems (Amazon EBS-backed volumes, network file systems) replicate the superblock across nodes to survive single-machine failure — a direct descendant of the "backup superblock" idea from 1970s Unix. Solid-state drives complicate the classic layout further, since the flash translation layer beneath an SSD (see Solid-State Drives (SSDs)) remaps logical block numbers to physical flash pages, meaning the file system's tidy on-disk layout is itself an abstraction over another layer of indirection and wear-leveling below it.

Ask the tutor: How does journaling change the write-ordering rules for the superblock, bitmap, and inode table during a single file-creation transaction?

Why does the boot block matter if most partitions are not bootable?

The boot block's location is standardized across nearly all file systems so that firmware and bootloaders can find bootstrap code at a fixed offset regardless of which file system a partition uses. Even when a partition holds no bootable code, the block is still reserved (often left zeroed) so the layout stays consistent with bootable partitions. This uniformity lets tools like disk partitioners, boot managers, and rescue utilities treat the first blocks of any partition the same way without needing to first identify the file system type, simplifying low-level tooling considerably.

What's the difference between the on-disk superblock and the in-memory superblock?

The on-disk superblock is a static structure written once at format time and updated occasionally afterward; it survives power loss because it lives on persistent storage. The in-memory superblock is a cached, working copy loaded during mounting, updated frequently (free-block counts change on every allocation) to avoid a disk write for every metadata change. The kernel periodically flushes the in-memory version back to disk — via `sync()`, a timer, or journal commit — so the two stay approximately consistent, with the gap between them being exactly the window of vulnerability that crash-consistency mechanisms are designed to close.

Why use a bitmap for free space instead of a linked free list?

A bitmap gives O(1) status checks for any specific block and is extremely compact — roughly 1 bit per block, a tiny fraction of disk capacity. A linked free list, by contrast, threads free blocks together via pointers stored inside the free blocks themselves, which historically saved memory on very small machines but makes bulk operations (like reporting total free space, or finding a nearby free block for locality) slower, since the list must be walked. Modern systems overwhelmingly prefer bitmaps or tree-based structures (as in XFS's B+-trees) because memory is cheap and O(1) random access matters far more than saving a few bits.

How does mounting handle a path that crosses two different file systems?

The directory where a device is mounted (the mount point) is flagged in its parent file system, and the mount table maps that flagged inode to the mounted device's root inode. During path resolution, whenever lookup reaches a flagged mount-point inode, the resolver consults the mount table and substitutes the target device's root inode before continuing, effectively splicing one directory tree into another. This makes the switch transparent to applications: a path like `/mnt/data/notes.txt` looks like an ordinary path even though the last two components live on an entirely different physical device with its own superblock and inode numbering.

Does the buffer cache violate durability guarantees?

By itself, yes — a write acknowledged to an application may exist only in the cache and be lost on power failure, which is why the cache alone is not a durability mechanism. Real systems combine caching with explicit synchronization primitives: `fsync()` forces a specific file's dirty pages to disk, `sync()` flushes the whole cache, and journaling file systems write a compact log entry describing pending metadata changes before applying them, so a crash mid-write can be detected and replayed or rolled back on the next mount. The cache optimizes the common case; journaling and explicit flushes protect the rare but critical case of an interrupted write.

Why is the inode count fixed at format time in older file systems?

Classic designs like ext2 pre-allocate a fixed-size inode table sized as a fraction of total blocks, because doing so lets any inode number be located with simple arithmetic — `O(1)` lookup with no indirection needed. The cost is inflexibility: if a volume ends up storing far more small files than the formatter anticipated, the file system can report "no space left on device" even while gigabytes of data blocks sit unused, purely because inode slots ran out. Later designs (ext4's flexible allocation, NTFS's growable MFT, most modern log-structured and B-tree-based systems) trade a small amount of indirection for the ability to grow metadata capacity as needed.

How does this layout interact with SSDs differently than with spinning disks?

The layout described here — boot block, superblock, bitmap, inode table, data — is defined in terms of logical block numbers, and the file system reasons about locality and seek cost as if those numbers corresponded to physical position, which is exactly true on a spinning disk. On an SSD, the flash translation layer remaps logical block numbers to physical flash pages continuously for wear-leveling, so the file system's careful placement (e.g., grouping an inode near its data blocks) no longer guarantees physical proximity. File systems tuned for SSDs therefore de-emphasize seek-minimizing placement and instead focus on minimizing write amplification and aligning writes to erase-block boundaries.

Before this

Where this leads