The VFS

Three concepts in the six-part treatment: the four objects, path lookup, and the I/O paths — buffered, direct, and io_uring.

The Virtual File System is the reason read() works identically on a file, a pipe, a socket, a device node, and /proc/cpuinfo. It is a set of function-pointer tables and a great deal of shared machinery around them, and it is the most reusable piece of design in the kernel.


Concept 1: The Four Objects

1. What problem it solves

"File" means four different things that people conflate: the bytes on disk, the name you used to get to them, the fact that you have it open at a particular position, and the filesystem it lives in. Each has a different lifetime and a different sharing rule.

   Two processes open the same file.
     - the same INODE                (one file on disk)
     - probably the same DENTRY      (if they used the same path)
     - DIFFERENT struct file          (different offsets, different flags)

   One process opens the same file twice.
     - the same inode, the same dentry, TWO struct files.

   `ln` creates a second name.
     - the same inode, TWO dentries.

   That is why there are four objects and not one.

2. Where it exists in the kernel

rg -n "struct file \{" -A 35 include/linux/fs.h
rg -n "struct inode \{" -A 50 include/linux/fs.h
rg -n "struct dentry \{" -A 30 include/linux/dcache.h
rg -n "struct super_block \{" -A 40 include/linux/fs.h
$EDITOR Documentation/filesystems/vfs.rst

3. Who owns each, and for how long

ObjectCreated byLives untilShared between
struct fileopen()The last close() of that fd (and its dups)Only via dup/fork
struct inodeThe filesystem, on first lookupEvicted from the inode cache under pressureEvery opener and every hard link
struct dentryPath lookupEvicted from the dcache under pressureEvery process using that path
struct super_blockmountumountThe whole system
cat /proc/sys/fs/dentry-state       # nr_dentry, nr_unused, age_limit, ...
cat /proc/sys/fs/inode-nr
ls -l /proc/self/fd/                # your struct files, as symlinks
sudo slabtop -o | grep -E 'dentry|inode_cache|ext4_inode'

4. The four ops tables

This is the architecture. Everything else is detail.

TableOnAnswers
file_operationsinode (i_fop), copied into each struct fileWhat can I do to this open file? read_iter, write_iter, mmap, fsync, unlocked_ioctl, poll
inode_operationsinode (i_op)What can I do to this name-space object? lookup, create, unlink, rename, symlink, getattr, permission
address_space_operationsinode->i_mapping->a_opsHow do I move this file's pages to and from the device? read_folio, writepages, dirty_folio, direct_IO
super_operationssuperblock (s_op)How do I manage this filesystem? alloc_inode, write_inode, evict_inode, sync_fs, statfs

The split between file_operations and inode_operations is the one people get wrong: inode_operations is about names and metadata; file_operations is about contents. unlink is an inode op because it removes a name. read is a file op because it moves bytes.

/* A minimal filesystem, structurally: */
static const struct super_operations myfs_sops   = { .alloc_inode = ..., .statfs = ... };
static const struct inode_operations myfs_dir_iops  = { .lookup = ..., .create = ... };
static const struct file_operations  myfs_file_fops = { .read_iter = ..., .write_iter = ... };
static const struct address_space_operations myfs_aops = { .read_folio = ..., .writepages = ... };

5. Experiment

CLAIM. The four objects have independent lifetimes, and you can observe each one separately.

METHOD.

# One inode, two dentries:
echo hello > /tmp/a; ln /tmp/a /tmp/b
stat -c '%i %h %n' /tmp/a /tmp/b        # same inode number, link count 2

# One inode, one dentry, two struct files with independent offsets:
exec 3</tmp/a; exec 4</tmp/a
head -c 2 <&3 ; echo
head -c 2 <&4 ; echo                    # both read from the START. Separate f_pos.
head -c 2 <&3 ; echo                    # fd 3 continues where IT left off
exec 3<&- 4<&-

# Two fds that SHARE a struct file:
exec 5</tmp/a; exec 6<&5                # dup, not open
head -c 2 <&5; head -c 2 <&6            # the second continues from the first
exec 5<&- 6<&-

PREDICT FIRST: for each of the three blocks, what does each head print? The dup case is the one that surprises people, and it is the reason struct file exists separately from the inode.

Then watch the caches:

cat /proc/sys/fs/dentry-state
find /usr -type f > /dev/null            # populate the dcache
cat /proc/sys/fs/dentry-state
sudo sh -c 'echo 2 > /proc/sys/vm/drop_caches'    # drop dentries and inodes
cat /proc/sys/fs/dentry-state

6. Failure mode

MistakeSymptom
Storing per-open state on the inodeTwo openers corrupt each other. It belongs in file->private_data.
Putting a contents operation in inode_operationsIt does not compile, and then you discover the split
Not implementing evict_inodeThe filesystem leaks inodes; slabtop grows without bound
Assuming a dentry implies the file existsNegative dentries record non-existence
Holding a struct file reference foreverThe filesystem cannot be unmounted
Forgetting .owner = THIS_MODULE in file_operationsrmmod while open → jump into freed text

Concept 2: Path Lookup

1. What problem it solves

open("/usr/lib/x86_64-linux-gnu/libc.so.6") must resolve seven components, each requiring a directory read, a permission check, and possibly a symlink traversal — and it happens millions of times a second on a busy machine, mostly for paths that were resolved a microsecond ago.

Path lookup is one of the most heavily optimized paths in the kernel, and the optimization is visible in its structure.

2. Where it exists in the kernel

rg -n "link_path_walk|path_lookupat|walk_component" fs/namei.c | head
rg -n "lookup_fast|__d_lookup_rcu" fs/namei.c fs/dcache.c | head
$EDITOR Documentation/filesystems/path-lookup.rst    # one of the best docs in the tree

3. The two walks

   RCU-WALK (the fast path)
     - no locks, no reference counts, no atomic operations
     - reads the dcache under rcu_read_lock()
     - uses a per-dentry SEQLOCK to detect that something changed
     - if ANYTHING is unexpected -- a miss, a symlink, a permission
       question it cannot answer, a mount point -- it BAILS OUT
     - "unlazy_walk()" converts to ref-walk and retries from there

   REF-WALK (the slow path)
     - takes references and locks as it goes
     - can call into the filesystem for a real lookup
     - always works

The performance of the whole system depends on RCU-walk succeeding, which is why the dcache is such a large piece of machinery and why negative dentries matter:

   NEGATIVE DENTRY: a dentry with d_inode == NULL.
     It records that "libfoo.so does not exist in this directory".
     Without it, every failed lookup would hit the filesystem again.
     A program searching ten library paths for one file creates nine
     negative dentries and then finds them cached forever after.
sudo bpftrace -e 'kprobe:link_path_walk { @[comm] = count(); }'
cat /proc/sys/fs/dentry-state              # column 2 is nr_unused, largely negative
sudo perf stat -e 'syscalls:sys_enter_openat' -- ls -R /usr >/dev/null 2>&1

4. Experiment

CLAIM. The dcache dominates path-lookup cost, and you can measure both states.

METHOD.

# Cold: drop dentries and inodes (2), not the page cache.
sudo sh -c 'echo 2 > /proc/sys/vm/drop_caches'
time find /usr -type f -name 'nothing-matches' 2>/dev/null

# Warm: the dcache is now populated.
time find /usr -type f -name 'nothing-matches' 2>/dev/null

# And negative dentries specifically:
sudo sh -c 'echo 2 > /proc/sys/vm/drop_caches'
cat /proc/sys/fs/dentry-state
for i in $(seq 20000); do stat "/tmp/does-not-exist-$i" 2>/dev/null; done
cat /proc/sys/fs/dentry-state

PREDICT FIRST: the cold/warm ratio for find. And: does stating 20,000 non-existent files increase nr_dentry? By how much?

5. Failure mode

MistakeSymptom
A d_op callback that sleepsRCU-walk cannot call it; every lookup falls back to ref-walk and the system slows down measurably
A filesystem whose lookup is slowEvery cold path resolution pays; users experience it as "the first access is slow"
Creating and deleting millions of filesNegative dentries accumulate until the shrinker runs
Assuming a path is stableIt can be renamed under you; that is what the seqlock detects
Resolving a user path without LOOKUP_* flagsSymlink and .. traversal you did not intend — a security bug

Concept 3: The I/O Paths

1. What problem it solves

There are three ways to move bytes between a file and a program, and they exist because they make genuinely different trades.

2. Where they exist in the kernel

rg -n "vfs_read\b|vfs_write\b" -A 25 fs/read_write.c | head -40
rg -n "filemap_read\b|generic_perform_write" mm/filemap.c | head
rg -n "iomap_dio_rw|iomap_file_buffered_write" fs/iomap/*.c | head
ls io_uring/

3. The three

BufferedDirect (O_DIRECT)io_uring
Goes through the page cacheYesNoEither
Syscall per I/OYesYesNo — a shared ring
Alignment requirementsNoneOffset, length, and buffer must be block-alignedDepends on the op
ReadaheadYesNoFor buffered ops
Write returns whenData is in the page cacheThe device has it (but maybe in its cache)On completion, via the ring
Best forAlmost everythingApplications with their own cache (databases)High IOPS, many concurrent ops

Buffered is the default and usually right: the cache absorbs re-reads, readahead hides latency, and writes are batched.

Direct hands the pages to the device with no copy and no caching. It is often slower — you gave up readahead and caching — and it is right when the application caches better than the kernel can, which for a database is often true.

io_uring is not a third caching policy; it is a different submission model. Two shared ring buffers, one for submissions and one for completions, so a program can queue a thousand operations and reap them with no syscall at all in the best case.

   io_uring:
     userspace                        kernel
     ─────────                        ──────
     write SQE into the SQ ring
     (optionally) io_uring_enter() ──▶ pick up SQEs
                                       submit them
     ...work happens asynchronously...
     read CQE from the CQ ring   ◀──── post completions

     With SQPOLL, a kernel thread polls the SQ ring and the fast path
     involves NO SYSCALL AT ALL.
rg -n "struct io_uring_sqe \{" -A 30 include/uapi/linux/io_uring.h
rg -n "IORING_OP_" include/uapi/linux/io_uring.h | head -30
ls io_uring/
$EDITOR Documentation/ 2>/dev/null; find Documentation -iname '*io_uring*'

4. Experiment

CLAIM. Buffered, direct, and io_uring have measurably different profiles, and "direct is faster" is usually false.

METHOD.

dd if=/dev/urandom of=/tmp/io bs=1M count=512 status=none

# Buffered, cold and warm:
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
dd if=/tmp/io of=/dev/null bs=4k 2>&1 | tail -1     # cold buffered
dd if=/tmp/io of=/dev/null bs=4k 2>&1 | tail -1     # warm buffered

# Direct, cold and "warm" (there is no warm -- that is the point):
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
dd if=/tmp/io of=/dev/null bs=4k iflag=direct 2>&1 | tail -1
dd if=/tmp/io of=/dev/null bs=4k iflag=direct 2>&1 | tail -1

# With a larger block size, which changes the answer:
dd if=/tmp/io of=/dev/null bs=1M iflag=direct 2>&1 | tail -1

# And the syscall counts:
sudo perf stat -e 'syscalls:sys_enter_read' -- dd if=/tmp/io of=/dev/null bs=4k 2>/dev/null

PREDICT FIRST: rank the five dd invocations by throughput before running. Most people put bs=4k iflag=direct too high and warm buffered too low.

If fio is available, the comparison that actually matters:

fio --name=buffered --rw=randread --bs=4k --size=512M --filename=/tmp/io --ioengine=psync
fio --name=direct   --rw=randread --bs=4k --size=512M --filename=/tmp/io --ioengine=psync --direct=1
fio --name=uring    --rw=randread --bs=4k --size=512M --filename=/tmp/io --ioengine=io_uring --direct=1 --iodepth=32

5. Failure mode

MistakeSymptom
O_DIRECT with unaligned buffers or offsets-EINVAL, and the alignment requirement is device-dependent
Assuming O_DIRECT means durableIt bypasses the page cache, not the device's write cache. You still need fsync or FUA.
Benchmarking buffered I/O without dropping cachesYou measured memcpy
Using O_DIRECT with small block sizesYou pay full device latency per I/O with no readahead
Using io_uring and blocking on every completionYou reimplemented synchronous I/O with more machinery
Assuming io_uring operations are orderedThey are not, unless you link them (IOSQE_IO_LINK)
Mixing buffered and direct I/O on one fileCoherency is mostly handled and the corners are sharp. Do not.

Validation / Self-check

  1. Name the four VFS objects and give, for each, its lifetime and what it is shared between.
  2. Two processes open() the same path. Which objects are shared and which are not?
  3. Two fds from dup(). Same question. Why does the answer differ?
  4. Name the four ops tables and the question each answers.
  5. Why is unlink an inode operation and read a file operation?
  6. Describe RCU-walk and ref-walk, and name three things that force a bail-out.
  7. What is a negative dentry and what does it make cheap? Show the counter that proves they exist.
  8. Why does a d_op callback that sleeps hurt system-wide performance?
  9. Compare buffered, direct, and io_uring on four axes.
  10. Why is O_DIRECT often slower, and when is it right?
  11. Does O_DIRECT make a write durable? Explain precisely.
  12. What are the two rings in io_uring, and what does SQPOLL remove?

Next: The Block Layer — how a page becomes a device command.