Files, Inodes, and the Virtual Filesystem Interface
LESSON
Files, Inodes, and the Virtual Filesystem Interface
By the end of this lesson, you will be able to...
Trace
openandreadfrom a pathname or descriptor to kernel file state.Distinguish a file descriptor, an open-file object, an inode, and a directory entry.
Review a minimal VFS boundary and predict whether two reads share an offset.
Idea in one sentence: A filesystem interface works because it gives names, per-open state, persistent object metadata, and storage-specific operations different jobs instead of putting them all in “the file.”
logger executes:
int fd = open("/var/log/app.log", O_RDONLY);
read(fd, buf, 4);
It looks like fd is the file. Then a second process opens the same path and gets another integer. If the integers were the file, what happens to the cursor? If the pathname were the file, what happens after the pathname has been resolved? If the inode alone were the file, where would the read offset and access mode live for each open?
Those questions force a useful split. A small kernel needs at least a process-local handle, an object representing one open instance, persistent metadata for the underlying object, and a way to translate names through directories. A virtual filesystem interface (VFS) is the boundary that lets open, read, and close use those roles without making every syscall know how a particular backing filesystem stores blocks.
Core Insight
The tempting design is one record called file that stores a name, data blocks, permissions, offset, and every process's handles. It works for one process and one filesystem. It breaks as soon as two processes open the same object with different offsets, a pipe is exposed through a descriptor but has no pathname, or a second filesystem needs different lookup and read code.
Use separate objects with separate ownership:
| Object | What it answers | Typical lifetime |
|---|---|---|
file descriptor (fd) |
“Which open object does this process mean?” | until the process closes that table entry or exits |
| open-file object | “What are this open's mode, offset, and reference count?” | until every descriptor referring to this open is closed |
| inode or vnode-like object | “What persistent object is this, and how can its filesystem operate on it?” | cached while referenced; its durable metadata outlives opens |
| directory entry | “Within this directory, which name refers to which object?” | while that name-to-object link exists; it may also be cached |
Plain meaning:
The pathname is an address written on an envelope. An inode is the building's record. An open-file object is one visitor's bookmark and access badge. A descriptor is the number on that visitor's coat check ticket.
In this scenario:
fd == 3 is meaningful only in logger's descriptor table. It points to an open-file object whose offset may be 0. A second open of the same pathname can create a second open-file object, also referring to the same inode, but with its own offset.
Technical name:
The process table maps an integer file descriptor to an open file description (called an open-file object here). The object representing filesystem metadata is often an inode. A production VFS commonly also uses a dentry to represent a name in a directory. A teaching kernel can use a simpler in-memory directory lookup while keeping the same responsibility split.
The Promise of a Small VFS
The syscall layer should make one promise: after a pathname resolves, ordinary operations work through a stable file-like interface. A regular disk file, a directory, a device, or a pipe can present operations such as:
lookup(parent, name) -> object or error
open(object, flags) -> open-file object
read(open-file object, destination, count) -> bytes or error
write(open-file object, source, count) -> bytes or error
close(open-file object) -> release one reference
This is deliberately small. The VFS owns common policy and routing: descriptor allocation, per-open mode, offset updates, reference accounting, and dispatch to the object's operations. The backing filesystem owns its representation: directory records, inode layout, logical-block mapping, and later the cache and writeback policy in lesson 011.
The boundary is not a claim that every object behaves identically. Reading a directory may return entries, reading a pipe may block, and reading a device may invoke a driver. The promise is narrower: the syscall layer can route a file-like request through one controlled interface, with each object type declaring the operations it supports.
A Naive Design Loses Per-Open State
Suppose a kernel stores one mutable offset inside the inode:
struct inode {
int size;
int offset; // naive: shared by every open
...
};
logger reads four bytes and changes inode.offset from 0 to 4. Later, reporter opens the same path expecting to start at byte 0, but it starts at byte 4. The two processes have accidentally shared a cursor just because they share the persistent object.
Moving the offset into the open-file object fixes the normal open case:
struct open_file {
struct inode *node;
int offset;
int readable;
int writable;
int refcount;
};
Now two calls to open create two open-file objects and two offsets. But duplicating a descriptor with dup should usually create a second descriptor to the same open-file object, so those duplicated descriptors intentionally share an offset. That is not a special exception; it is evidence that “descriptor” and “open” are different objects.
The object split also makes ownership visible. open_file.node holds a reference to the inode. Each descriptor pointing to the open file raises its refcount. close(fd) removes just that process-table entry; it releases the inode only when the final open-file reference disappears. The lifetime rules from lesson 005 and synchronization rules from lesson 009 apply here too.
Worked Trace: open then read
Assume logger has an empty descriptor slot and /var/log/app.log contains the bytes KERN.... The process asks to read the first four bytes.
Input: open("/var/log/app.log", O_RDONLY) followed by read(3, user_buf, 4).
1. open crosses the syscall boundary
As in lesson 008, the kernel copies or safely reads the user pathname and validates flags before changing kernel state. It begins at the process root or current working directory and resolves one component at a time:
root -- "var" --> inode for /var
/var -- "log" --> inode for /var/log
/var/log -- "app.log" --> inode for the regular file
Each directory lookup turns a name in a parent directory into a child object. A production VFS may cache this name-to-object relation as dentries; the teaching-kernel model only needs the same logical transition.
2. The kernel creates per-open state and a process handle
The resolved inode says what the persistent object is: regular file, directory, device, or another supported kind. The kernel checks that O_RDONLY is allowed, increments the inode reference, creates:
open_file A: node = app.log inode, offset = 0, readable = true, refcount = 1
Then it selects an unused entry in logger.files[] and stores a pointer to open_file A there. Suppose the selected index is 3.
Intermediate state: logger.files[3] -> open_file A -> app.log inode; the return value is 3. No pathname needs to be parsed on each later read.
3. read(3, user_buf, 4) routes through that open
The kernel first checks that descriptor 3 exists in logger and that open_file A.readable is true. It reads from the inode at open_file A.offset == 0. The filesystem-specific read implementation maps the requested byte range to its stored representation; the next lesson will follow that mapping through cached blocks and device I/O.
If four bytes are available, the filesystem supplies KERN. Only after the read result is known does the kernel advance open_file A.offset: 0 -> 4, subject to the object's locking and short-read/error rules. It copies the four bytes to the validated user buffer and returns 4.
Output: the process sees KERN, and its next sequential read starts at byte 4.
4. A second open does not inherit the first cursor
reporter now executes open("/var/log/app.log", O_RDONLY). It resolves the same inode but receives open_file B with offset = 0. Its descriptor might also be integer 3, because descriptor numbers are process-local.
logger.files[3] -> open_file A (offset 4) -> app.log inode
reporter.files[3] -> open_file B (offset 0) -> app.log inode
Naive contrast: storing the offset in the inode would make both reads begin at the same shared cursor. Storing descriptor 3 globally would make unrelated processes collide. The three-level graph prevents both errors.
So far, the pathname gave us a route, the descriptor gave one process a handle, the open-file object gave one open its mutable state, and the inode identified the underlying object. The VFS boundary makes these responsibilities explicit before we add the lower storage path.
Trade-offs and Limits of the VFS Boundary
A minimal VFS buys a stable syscall path and lets a kernel add another filesystem or file-like object without duplicating descriptor logic. It also makes debugging clearer: an invalid descriptor is a process-table problem; a wrong offset is an open-file problem; a bad name lookup is a directory problem; wrong data mapping is an inode or storage problem.
The trade-off is explicit: the split buys a stable interface and independent per-open state, but it costs extra objects, references, locking, and error paths. Name lookup may need caches and invalidation. Sharing an open-file object through dup requires its offset updates to be synchronized. Supporting many object types grows the operation table and makes unsupported operations explicit errors.
The boundary does not solve storage durability. A successful write may have changed an in-memory cache but not reached the device. It also does not make permissions optional: access checks occur during lookup and open according to the kernel's policy, and later operations must respect the open mode. You can see the boundary when a bug crosses layers: a correct fd with stale data is not a descriptor bug; it is evidence to inspect cache or writeback behavior next.
Common Confusions
Confusion: “A descriptor is the inode number.”
Why it is tempting:
Both are small identifiers associated with a file.
Better model:
An fd indexes one process's descriptor table and can refer to a pipe or device as well as a regular file. An inode identifies filesystem object metadata. The mapping between them passes through an open-file object.
Confusion: “Two opens of the same pathname must share the cursor.”
Why it is tempting:
They reach the same underlying bytes.
Better model:
Two independent open calls normally create two open-file objects, so their offsets are independent. Two descriptors produced by dup reference one open-file object and therefore share its offset.
Confusion: “The pathname is checked once, so it is permanent identity.”
Why it is tempting:
The user supplies a pathname and receives a descriptor from it.
Better model:
A pathname is a directory lookup request. After resolution, the open object holds a reference to the underlying object; later read(fd, ...) uses the descriptor graph, not a new pathname search each time.
Confusion: “VFS means every backend has identical behavior.”
Why it is tempting:
The top-level calls have uniform names.
Better model:
VFS standardizes routing and common object roles. It dispatches to type- or filesystem-specific operations, which may reject an operation or implement different waiting and storage behavior.
Check: logger calls open twice on the same pathname. It reads 10 bytes through the first descriptor. Where should the second descriptor's initial offset be stored, and what value should it have?
Think first, then reveal.
Answer: In a second open-file object, initialized to 0. Both open-file objects refer to the same inode, but separate open calls need separate per-open state. This differs from dup, which adds another descriptor to the existing open-file object.
Check: A program successfully opens a file, then calls read(fd, ...) one thousand times. Why should the kernel not redo pathname lookup on every call?
Think first, then reveal.
Answer: The descriptor already identifies a validated open-file object, which holds a reference to the resolved inode and the current offset. Repeating lookup wastes work and could change the meaning if directory names change; descriptor operations should route through the open state.
Practice: review a minimal file-object graph
Design the structures for a teaching kernel that supports regular files, a read-only device, open, read, close, and dup.
Draw a graph for this sequence:
fd1 = open("/notes.txt", O_RDONLY)
fd2 = dup(fd1)
fd3 = open("/notes.txt", O_RDONLY)
read(fd1, ..., 5)
Use this rubric:
| Criterion | A good answer shows |
|---|---|
| Descriptor mapping | fd1 and fd2 are entries in one process table; fd3 is another entry |
| Per-open state | fd1 and fd2 share one offset; fd3 has a distinct offset beginning at 0 |
| Persistent object | both open-file objects refer to the same inode for /notes.txt |
| Ownership | closing one descriptor does not release the inode while another open-file reference remains |
| VFS boundary | regular-file and device reads can use common routing while dispatching to different read operations |
Resources
- [DOC] Linux kernel: Overview of the Virtual File System — Focus: inspect the distinct VFS roles of file, inode, dentry, and operation objects.
- [BOOK] MIT 6.1810 xv6 book, File system chapter — Focus: trace the smaller teaching-kernel path from descriptors through files and inodes.
- [COURSE] MIT 6.1810 xv6 filesystem reading — Focus: inspect
file.c,sysfile.c,fs.c, andbio.cas separate implementation boundaries.
Key Takeaways
- A file descriptor is a process-local handle, not a pathname or a persistent filesystem object.
- An open-file object owns per-open state such as offset and mode; an inode represents the underlying object and its metadata.
- Pathname lookup maps names through directories once; later descriptor operations route through the resolved open object.
- A small VFS centralizes routing and common policy while leaving data layout and device behavior to the backing implementation.