13.1 Paths, inodes, and data blocks
The doorplate at the Atlas Archive reads /home/lin/report.txt, but inside the storage room there's no box labeled with a complete path. Only directory entries, numbered file objects, and a set of data blocks are present. Paths belong to the namespace; storage devices don't 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
A typical file access operation traverses these layers:
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 mediaThe 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:
/srv/app/config.json
│ │ │
Root Directory entry Directory entry → Final objectThe 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 avoids redundant directory traversals; 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.
3. Hard Links and Symbolic Links
Hard links add a directory entry for an existing object within the same file system. Two names typically point to the same inode:
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 processes are still holding an open reference to it.
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:
Process fd 3 ─┐
├──> open file description ──> inode/vnode
Process fd 7 ─┘ │
├─ current offset
└─ status flagsFile 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
unlink, already-open descriptors remain readable and writable; lseekmodifies 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 Observation: Removing a Doorplate, the File Remains Open
Removing a filename from a directory is like taking down a plaque from a hallway in an archive, once the door is opened, the process still holds an open file description and can continue accessing the same data. This experiment separates the concept of a "name" from the "open state" in the terminal.
The following program creates a file and a hard link, then removes both names. As long as the file descriptor remains open, the content remains readable:
#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 char *cursor = buffer;
while (length > 0) {
ssize_t written = write(fd, cursor, length);
if (written > 0) {
cursor += written;
length -= (size_t)written;
} else if (written < 0 && errno == EINTR) {
continue;
} else {
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 fd = mkstemp(original);
if (fd < 0) {
perror("mkstemp");
return EXIT_FAILURE;
}
if (snprintf(hard_link, sizeof(hard_link), "%s.link", original)
>= (int)sizeof(hard_link)
|| write_all(fd, message, sizeof(message) - 1) != 0
|| link(original, hard_link) != 0) {
perror("setup");
close(fd);
unlink(original);
return EXIT_FAILURE;
}
struct stat original_stat;
struct stat link_stat;
if (stat(original, &original_stat) != 0
|| stat(hard_link, &link_stat) != 0) {
return EXIT_FAILURE;
}
printf("same inode: %s, links: %lu\n",
original_stat.st_ino == link_stat.st_ino ? "yes" : "no",
(unsigned long)original_stat.st_nlink);
if (unlink(original) != 0 || unlink(hard_link) != 0
|| lseek(fd, 0, SEEK_SET) < 0
|| read(fd, buffer, sizeof(message) - 1)
!= (ssize_t)(sizeof(message) - 1)) {
perror("read after unlink");
close(fd);
return EXIT_FAILURE;
}
printf("%s", buffer);
return close(fd) == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}cc -std=c17 -O2 -Wall -Wextra inode-links.c
./a.out
# same inode: yes, links: 2
# still reachableThis 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:
Logical size: 1 GiB
Actual allocation: a few data blocksThe size of stat, and the allocated space seen by du, and the available space on the device 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
Within the same file system, rename typically provides atomic name transitions: concurrent lookups see either the old name relationship or the new one, not a partial directory entry. 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.