Skip to content

VOL 03 / CH 13 / LESSON 01

13.1 Paths, inodes, and data blocks ​

The complete string /home/lin/report.txt is not stored on the file's data blocks. The filesystem resolves directory entries one component at a time, reaches a numbered file object, and then locates its data blocks. Paths belong to the namespace; storage devices do not understand the name "report."

The file system is responsible for linking names, permissions, byte ranges, and physical storage, while allowing processes to continue accessing objects through file descriptors. The most confusing aspect of this abstraction is that a filename is not the file itself, a file descriptor is not an inode, and file size does not equal the actual number of data blocks occupied. Separating these three concepts provides clear, traceable entities, essential for reasoning about crash recovery.

1. From Path to Device ​

For a typical local block-device filesystem, access crosses these layers; tmpfs and network filesystems have different lower-level paths:

text
Process: file descriptor, current directory
        ↓
VFS: a unified open/read/write/stat interface
        ↓
Specific filesystem: directory, inode, block mapping, free space
        ↓
Page cache and block I/O
        ↓
Device drivers, controllers, persistent media

The VFS provides a consistent interface for filesystems like ext4, XFS, tmpfs, and network file systems, yet this does not mean they have identical disk structures. For instance, "inode" is a key concept in Unix-style filesystems and within the VFS. However, the metadata organization on disk in a specific filesystem might not simply be a flat inode table.

2. Names Exist in the Directory ​

A directory maintains a mapping from names to filesystem objects. Path resolution begins at the root directory, the current working directory of the process, or a specified directory descriptor, then proceeds segment by segment:

text
/srv/app/config.json
 │   │        │
Root  Directory entry  Directory entry → Final object

The metadata of the final object typically includes:

  • File type and permissions;
  • Owner and group;
  • File size;
  • Timestamps;
  • Hard link count;
  • Mapping to data blocks or extents;
  • Filesystem-specific flags, extended attributes, and checksum information.

The inode itself generally does not store the file's unique name, because the same object can have multiple hard links. Removing a directory entry does not necessarily immediately destroy the object.

dentry and Caching ​

The Linux VFS uses a dentry to represent a name resolution relationship and caches recent lookup results. A dentry is an in-memory kernel object and should not be equated directly with a directory entry on disk. A cache hit can avoid rereading on-disk directories, although path components still need resolution; cache invalidation and consistency are managed jointly by the VFS and the specific filesystem.

Therefore, seeing stat again faster does not prove that the disk directory structure has changed, likely, the path and inode metadata are still cached in memory.

Hard links add a directory entry for an existing object within the same file system. Two names typically point to the same inode:

text
notes.txt ─┐
           ├──> inode 418 ──> data
draft.txt ─┘

Deleting one of the names only removes one naming relationship. A regular file is only eligible for deletion and data block reclamation when its link count reaches zero and no active references, such as open descriptors or memory mappings, keep it alive.

Symbolic links are another type of file whose content is a path that must be resolved at access time. If the target is moved or deleted, a symbolic link may become dangling; hard links, by contrast, remain valid regardless of whether the original name exists.

Hard links to directories are typically restricted to prevent the formation of difficult-to-manage cyclic directory structures. Ordinary hard links across file systems are not permitted because inode numbers are only meaningful within the scope of a specific file system.

4. File Descriptors Point to a Single Open State ​

open returns a small integer that is an index into the process's file descriptor table. This index typically maps through a layer of "open file description" to associate with a filesystem object:

text
Process fd 3 ─┐
               ├──> open file description ──> inode/vnode
Process fd 7 ─┘          │
                        ├─ current offset
                        └─ status flags

File descriptors generated by dup or fork can share the same open file description, and thus share the file offset. Two independent open calls typically result in distinct open states, even if they ultimately point to the same inode.

This explains several behaviors:

  • After a path is unlinked, already-open descriptors remain readable and writable;
  • lseek modifies the offset within the open state, not the global cursor stored in the inode;
  • File descriptor numbers can be reused; logging only "fd=5" is insufficient to identify a long-lived object;
  • File lock ownership and release semantics depend on lock type, and cannot be inferred solely from the inode graph.

5. Hands-On: An Open File Survives Name Removal ​

Removing a filename deletes one directory entry. A process that already opened the file still holds an open file description and can continue accessing the same file object. This experiment separates a name from open state in the terminal.

Save as inode-links.c. This program creates a temporary file and hard link, checks device and inode identity, then removes both names. The open descriptor still reads the content. Short I/O, EINTR, and failure cleanup are handled separately:

c
#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

static int write_all(int fd, const void *buffer, size_t length) {
    const unsigned char *cursor = buffer;
    while (length > 0) {
        ssize_t written = write(fd, cursor, length);
        if (written > 0) {
            cursor += (size_t)written;
            length -= (size_t)written;
        } else if (written < 0 && errno == EINTR) {
            continue;
        } else {
            if (written == 0) errno = EIO;
            return -1;
        }
    }
    return 0;
}

static int read_exact(int fd, void *buffer, size_t length) {
    unsigned char *cursor = buffer;
    while (length > 0) {
        ssize_t received = read(fd, cursor, length);
        if (received > 0) {
            cursor += (size_t)received;
            length -= (size_t)received;
        } else if (received < 0 && errno == EINTR) {
            continue;
        } else {
            if (received == 0) errno = EIO;
            return -1;
        }
    }
    return 0;
}

int main(void) {
    char original[] = "/tmp/atlas-file-XXXXXX";
    char hard_link[128];
    static const char message[] = "still reachable\n";
    char buffer[sizeof message] = {0};
    int original_exists = 0, link_exists = 0;
    int fd = mkstemp(original);
    if (fd < 0) {
        perror("mkstemp");
        return EXIT_FAILURE;
    }
    original_exists = 1;

    int name_length = snprintf(hard_link, sizeof hard_link, "%s.link", original);
    if (name_length < 0 || (size_t)name_length >= sizeof hard_link) {
        errno = ENAMETOOLONG;
        goto failure;
    }
    if (write_all(fd, message, sizeof message - 1) != 0
            || link(original, hard_link) != 0) goto failure;
    link_exists = 1;

    struct stat original_stat, link_stat;
    if (fstat(fd, &original_stat) != 0 || stat(hard_link, &link_stat) != 0)
        goto failure;
    if (original_stat.st_dev != link_stat.st_dev
            || original_stat.st_ino != link_stat.st_ino
            || original_stat.st_nlink != 2) {
        errno = EIO;
        goto failure;
    }
    printf("same inode: yes, links: %lu\n",
           (unsigned long)original_stat.st_nlink);

    if (unlink(original) != 0) goto failure;
    original_exists = 0;
    if (unlink(hard_link) != 0) goto failure;
    link_exists = 0;
    if (lseek(fd, 0, SEEK_SET) < 0
            || read_exact(fd, buffer, sizeof message - 1) != 0) goto failure;
    printf("%s", buffer);
    if (fflush(stdout) == EOF) goto failure;
    if (close(fd) != 0) {
        fd = -1;
        goto failure;
    }
    return EXIT_SUCCESS;

failure:
    {
        int error = errno;
        if (fd >= 0) close(fd);
        if (link_exists) unlink(hard_link);
        if (original_exists) unlink(original);
        errno = error;
        perror("inode example");
        return EXIT_FAILURE;
    }
}
bash
cc -std=c17 -O2 -Wall -Wextra -Werror inode-links.c
./a.out
# same inode: yes, links: 2
# still reachable

This is also a common reason why disk space doesn't immediately return after deleting large log files: processes still hold references to the deleted file. Although the name is gone from the directory, the object hasn't reached the point where it can be reclaimed.

6. File Size, Blocks, and Extents ​

Files present applications with a continuous byte sequence, but physical blocks need not be contiguous. The file system must map logical offsets to actual storage locations.

Early Unix-style designs relied on direct, indirect, and multi-level indirect block pointers. Modern file systems also commonly use extents, describing a range with a "logical start, physical start, and continuous length." For large, contiguous files, extents are more compact than tracking each block individually.

The specific layout is part of the file system's implementation details. You cannot assume that an older ext series inode's "12 direct pointers" represents a fixed mapping used by all ext4 files today; ext4 often uses extent trees, and the interpretation of the same fields within an inode depends on flags and format variants.

Sparse Files ​

If a logical range has no allocated physical blocks, reading it returns zeros, this is known as a hole. Thus:

text
Logical size: 1 GiB
Actual allocation: a few data blocks

File size reported by stat, allocated space reported by du, and device free space answer different questions. If a copy tool doesn't recognize holes, it may expand a sparse file into a file that actually consumes 1 GiB of space.

7. Directory Operations and Atomicity ​

When rename replaces an existing target in the same filesystem, that target is not briefly absent because of the replacement; lookup reaches the old or new object. Separate lookups of source and target do not form an atomic snapshot, and both names may briefly refer to the new object. However, it does not automatically persist file contents to stable storage, nor does it combine modifications across multiple files into a single transaction.

Cross-file-system moves generally cannot be completed atomically via rename. Tools degrade to a copy-then-delete pattern. In such cases, a partial failure might leave the source file intact and the destination file in an incomplete state. Applications must account for this semantic difference in their error handling.

Creation, linking, renaming, and deletion all modify the namespace. If an application needs to ensure that a new name persists after a power loss, it must follow the file and directory synchronization protocol discussed in the next section.

8. Caching Makes "Files on Disk" Vague ​

A read path might hit page cache and never touch the underlying device at all. The write path typically first updates page cache, with the kernel later coalescing and writing those changes back to disk. Memory-mapped files follow a similar caching and dirty page mechanism, though synchronization boundaries are expressed using interfaces like msync and fsync.

Direct I/O, synchronous open flags, and memory mapping each have distinct alignment, visibility, and persistence rules. They are not a simple "bypass all caching and it's automatically safe" switch. Before choosing any of these options, one must clearly define:

  • Whether page cache bypass is actually required;
  • Whether the operation will be mixed with regular buffered I/O;
  • What constitutes completion notification, does it mean data has reached the page cache or disk?
  • How metadata and directory updates are synchronized;
  • What state is visible after a crash: old version, new version, or partial updates?

9. What Happens When Capacity Is Exhausted ​

The fact that "there is free space on the device" does not guarantee that the current write will succeed. Failure may arise from:

  • Exhaustion of data block or metadata space;
  • Reaching limits on inode or object count;
  • User or project quotas;
  • A read-only remount;
  • Device I/O errors;
  • Sparse files requiring actual allocation only upon subsequent writes;
  • COW snapshots retaining old blocks, reducing the expected amount of reclaimable space.

Errors may only surface at write, fsync, or close. Reliable programs must validate the entire commit path, rather than open alone.

10. Summary ​

The file system separates paths, open states, and stored objects into distinct layers:

  • Directory entries map names to objects, and inodes typically do not store the unique file name;
  • File descriptors point to a specific open state; even after a name is deleted, the descriptor may keep the object alive;
  • Hard links share the same underlying object, while symbolic links store a path that needs to be resolved;
  • Logical byte ranges are mapped to storage via block pointers or extents;
  • Sparse files decouple logical size from actual allocated space;
  • The page cache separates the completion of system calls from actual device persistence.

The next section covers crash recovery, discussing logs, copy-on-write, checksums, and how applications can safely replace a file.

Check the Storage Interfaces and Protocols ​

Built with VitePress | Software Systems Atlas