Virtual File System
Let us see how Linux unifies different filesystem types such as ext4, tmpfs, procfs, and many others into a single abstraction layer and handles filesystem operations through it. If you have followed my notes or explored Linux internals before, you have probably encountered structures such as struct file and struct inode, especially in kernel exploitation. However, we do not usually ask where these structures actually come from, how they are created, and how a file on disk eventually gets represented by a generic struct inode inside the kernel. We will see that in detail here. Trust me, you will get a whole different perception towards filesystems after read this section. I usually think of Filesystem directories has something that are present just like that in my HDD/SSD. But, it turns out that every directories and files are mere structs in memory.
We start with one of the earliest important functions executed by the Linux kernel:
Source: init/main.c:972
start_kernel()
located in init/main.c. There are two important function calls here related to the VFS:
Source: init/main.c:1031 and init/main.c:1157
vfs_caches_init_early();
vfs_caches_init();
The former initializes some early VFS infrastructure and lookup tables, while the latter initializes the actual VFS caches and subsystems.
For example:
Source: fs/dcache.c:3510-3520
void __init vfs_caches_init(void)
{
filename_init();
dcache_init();
inode_init();
files_init();
files_maxfiles_init();
mnt_init();
bdev_cache_init();
chrdev_init();
}
Let’s look at inode_init():
Source: fs/inode.c:2657-2665
void __init inode_init(void)
{
/* inode slab cache */
inode_cachep = kmem_cache_create("inode_cache",
sizeof(struct inode),
0,
(SLAB_RECLAIM_ACCOUNT |
SLAB_PANIC |
SLAB_ACCOUNT),
init_once);
}
The familiar kmem_cache_create() appears here. This creates a dedicated SLAB cache from which generic struct inode objects can later be allocated. This does not immediately create inodes; rather, it creates the infrastructure required to allocate and initialize them when the kernel needs them.Similarly, other VFS-related caches and subsystems are initialized inside vfs_caches_init().
Importantly, the mount subsystem is initialized through:
Source: fs/dcache.c:3517 (call site inside vfs_caches_init())
mnt_init();
Before looking at mounts, however, let us first see how Linux resolves a pathname such as:
/etc/passwd
and then connect this process to why mounting is necessary.
Pathname Lookup and Dentries
Consider:
open("/etc/passwd", O_RDONLY);
Linux does not look up /etc/passwd as one complete string. Instead, it resolves the path component by component:
/ → etc → passwd
This is where struct dentry comes in.
A dentry, or directory entry, represents a name within a filesystem hierarchy. It connects a pathname component to its corresponding inode.
Conceptually:
dentry("passwd")
│
▼
inode
For /etc/passwd, the lookup roughly follows:
dentry("/")
│
▼
dentry("etc")
│
▼
dentry("passwd")
│
▼
inode
The kernel first checks the dentry cache. If the dentry is already cached, it can continue the lookup without asking the underlying filesystem.
If it is not cached, the VFS invokes the filesystem-specific lookup operation. For example, ext4 knows how to search its own on-disk structures and eventually provides the information required to create or obtain the corresponding generic VFS objects.
So the basic flow is:
pathname component
│
▼
dentry lookup
│
├── cache hit
│
└── cache miss
│
▼
filesystem-specific lookup
│
▼
inode
At this point, it may seem like dentries and inodes are enough. However, they only allow us to walk within a filesystem tree.
Consider:
/
├── etc
├── home
└── proc
What happens when / belongs to an ext4 filesystem, but /proc is actually another filesystem : procfs? Remember that /proc is not a conventional filesystem directory in linux, rather a in memory representation of the system state maintained by linux. Therefore, Linux maintains /proc in seperate filesystem.
While resolving:
/proc/cpuinfo
Linux starts in one filesystem:
ext4: /
but eventually needs to continue the lookup inside another filesystem:
procfs: /
This transition is what mounting solves. A mount essentially connects one filesystem tree to a location inside another filesystem tree.
Conceptually:
root filesystem
/
│
proc
│
│ mount
▼
procfs root
│
cpuinfo
Therefore, during pathname lookup, Linux needs to know not only:
Which dentry am I currently at?
but also:
Which mounted filesystem am I currently inside?
This is why pathname resolution is conceptually based on:
mount + dentry
The dentry tells us where we are inside a filesystem, while the mount tells us which filesystem tree we are currently walking.
Now that we understand why dentries alone are not enough, we can finally look at how Linux creates and manages mounts, starting from:
Source: fs/namespace.c:6244 (mnt_init() definition)
mnt_init();
Rootfs is simply an initially empty, memory-backed virtual filesystem starting at /. It gives the kernel a temporary root filesystem during early boot, so that at a later stage, the real filesystem such as an ext4 filesystem on a disk can be mounted as the actual root filesystem.
Right after this, start_kernel() creates a kernel thread called kernel_init, which eventually becomes responsible for starting the first userspace process, PID 1.
However, before starting PID 1, the kernel makes sure that the initramfs has been completely unpacked from its CPIO archive into the initially empty rootfs at /. This is necessary because the early init executable, such as /init, may need to be executed directly from the initramfs filesystem.
The relevant code from kernel_init_freeable(), which is called by kernel_init(), is:
Source: init/main.c:1660-1688
kunit_run_all_tests();
wait_for_initramfs();
console_on_rootfs();
/*
* check if there is an early userspace init. If yes, let it do all
* the work
*/
int ramdisk_command_access;
ramdisk_command_access = init_eaccess(ramdisk_execute_command);
if (ramdisk_command_access != 0) {
if (ramdisk_execute_command_set)
pr_warn("check access for rdinit=%s failed: %i, ignoring\n",
ramdisk_execute_command, ramdisk_command_access);
ramdisk_execute_command = NULL;
prepare_namespace();
}
integrity_load_keys();
wait_for_initramfs() ensures that the initramfs has finished being unpacked. After this, rootfs contains the files from the CPIO archive, such as /init, /bin/sh, and potentially /dev/console.
The kernel then checks whether an early userspace init specified by rdinit= exists. If it does not, prepare_namespace() is called to find and mount the real root filesystem.
Source: init/do_mounts.c:457-495
void __init prepare_namespace(void)
{
...
wait_for_device_probe();
md_run_setup();
if (saved_root_name[0])
ROOT_DEV = parse_root_device(saved_root_name);
initrd_load();
if (root_wait)
wait_for_root(saved_root_name);
mount_root(saved_root_name);
devtmpfs_mount();
if (init_pivot_root(".", ".")) {
pr_err("VFS: Failed to pivot into new rootfs\n");
return;
}
if (init_umount(".", MNT_DETACH)) {
pr_err("VFS: Failed to unmount old rootfs\n");
return;
}
}
The important part is:
Source: init/do_mounts.c:483 (call site inside prepare_namespace())
mount_root(saved_root_name);
This identifies the root device specified by the kernel command line, for example:
root=/dev/nvme0n1p2
Linux then mounts the filesystem found on that partition at a temporary location:
/root
So at this point we have:
temporary rootfs
/
┌──────────┼──────────┐
│ │ │
/init /dev /root
│
▼
real ext4 filesystem
The old initramfs files have not been erased yet.
Then:
Source: init/do_mounts.c:486 (call site inside prepare_namespace())
init_pivot_root(".", ".");
changes the process’s root so that the real filesystem becomes /:
NEW /
│
ext4
│
┌───────────┼───────────┐
│ │ │
/etc /bin /home
The old temporary rootfs is still around temporarily, but it is no longer the main /.
Finally:
Source: init/do_mounts.c:490 (call site inside prepare_namespace())
init_umount(".", MNT_DETACH);
detaches the old rootfs from the mount tree.
So the initramfs contents are not copied into the disk filesystem, nor are they overwritten one-by-one. The entire old root filesystem is simply detached from the mount tree and the real filesystem takes its place as /.
This is why the initramfs can safely contain /init even though the final system will also have its own /init or /sbin/init: the initramfs and the real root are two different filesystem instances, and the mount/pivot operation changes which one is visible as /
Pathlookup, File read/write and structs creation
So far, everything is setup completely by the kernel. However, remember that after the mount, the kernel does not all of a sudden loan every files to memory and provide a inode struct to it. It behaves in a lazy load way. If a file is read or write, only then the necessary structs is created in memory for that corresponding file path. “/etc/passwd” for instance.
For example, consider:
/etc/passwd
When a process opens this file, the kernel performs pathname lookup. It walks the path one component at a time: first /, then etc, and finally passwd.
For each component, Linux first checks the dentry cache. If the required dentry is already cached, it can reuse it. If not, the filesystem’s ->lookup() operation is called. For ext4, ext4_lookup() searches the directory information on disk and obtains the inode information for the requested file.
The kernel then creates/populates an in-memory struct inode using the inode_cachep slab and associates it with the corresponding dentry.
At the end of the lookup, the kernel has a struct path identifying the file:
Source: include/linux/path.h:8
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
};
The dentry represents passwd, while the mount tells the kernel which filesystem instance it belongs to. The dentry is associated with the file’s struct inode.
Reading the file
Once the path has been resolved, the actual file read uses the inode to determine where the file’s data is located in the filesystem.
For /etc/passwd, ext4 uses the inode’s filesystem-specific information to find the file’s data blocks. Those blocks are read from the storage, normally through the page cache if available or fetched from disk when in demand. The kernel therefore does lazy loading rather than creating an in-memory struct inode for every file on the disk during mount.
struct file, FD table, read()/write()/open()
so far we have:
pathname → dentry → inode
But userspace doesn’t directly work with an inode. When we call:
int fd = open("/etc/passwd", O_RDONLY);
Linux creates a **struct file** representing this particular open instance and then gives userspace an integer (fd).
A simplified struct file looks like (real definition, with every field, is at include/linux/fs.h:1255):
struct file {
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
loff_t f_pos;
unsigned int f_flags;
...
};
The important members are:
**f_path**— contains thevfsmountand dentry of the opened file.**f_inode**— points to the inode of the file.**f_op**— points to the operations that should be used for this file (read,write,mmap,ioctl, etc.).**f_pos**— current position in the file.**f_flags**— flags used when opening the file, such asO_RDONLY.
After opening /etc/passwd, conceptually:
struct file
├── f_path
│ ├── mnt → ext4 mount
│ └── dentry → "passwd"
│ │
│ └── d_inode → struct inode
│
├── f_inode → struct inode
├── f_op → ext4 file operations
├── f_pos → 0
└── f_flags → O_RDONLY
The inode itself represents the file and its filesystem metadata. The struct file represents this particular open instance of that file.
Turning struct file * into an FD
Userspace cannot receive a kernel pointer such as:
struct file *
Instead, Linux gives the process a small integer:
fd = 3
Each process has a files_struct:
Source: include/linux/sched.h:1197 (files field in task_struct), include/linux/fdtable.h:38 (files_struct)
struct task_struct {
...
struct files_struct *files;
...
};
struct files_struct {
...
struct fdtable *fdt;
};
The fdtable contains an array of pointers to struct file:
Source: include/linux/fdtable.h:26
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd;
unsigned long *close_on_exec;
unsigned long *open_fds;
...
};
Conceptually, this looks like:
process
│
└── files_struct
│
└── fdtable
│
└── fd[]
│
├── [0] → struct file *
├── [1] → struct file *
├── [2] → struct file *
└── [3] → struct file * → /etc/passwd
Therefore:
The FD is just an index into the process’s FD table.
If open() returns 3, it means:
current->files->fdt->fd[3]
points to the struct file representing the opened file.
What happens during read(3, ...)?
Now suppose:
read(3, buf, 100);
The kernel does not start pathname lookup again. That work was already done by open().
Instead, it uses the FD:
3
↓
FD table
↓
fd[3]
↓
struct file *
The kernel now has the struct file:
struct file
├── f_path
├── f_inode → passwd inode
├── f_op → filesystem operations
├── f_pos → current position
└── f_flags
Now, the kernel can use f_op vtable and get necessary function pointers and execute them.
The Wrap
Wow, that was a long travel deep into Linux kernel starting right from the main function in kernel. To wrap up, At this point we should know about these four different things:
struct dentry
Represents the name/location of a file in the directory hierarchy.
dentry
├── d_name → "passwd"
├── d_parent → dentry for "etc"
└── d_inode → passwd inode
struct inode
Represents the file itself and its metadata.
inode
├── permissions
├── owner
├── size
├── timestamps
├── i_op
├── i_fop
└── ...
struct file
Represents one particular open instance of that file.
file
├── f_path
├── f_inode
├── f_op
├── f_pos
└── f_flags
FD
The integer userspace uses to refer to that struct file.
fd = 3
↓
fdtable[3]
↓
struct file *
One Last Time : Complete flow
For:
int fd = open("/etc/passwd", O_RDONLY);
read(fd, buf, 100);
the important flow is:
open("/etc/passwd")
│
▼
pathname lookup
│
▼
dentry
│
▼
inode
│
▼
struct file
│
▼
fd table[3]
│
▼
fd = 3
read(3, ...)
│
▼
fd table[3]
│
▼
struct file
│
├── f_inode
│
├── f_pos
│
└── f_op
│
▼
filesystem read
That’s all for now, take rest for a while 🙂