Kelick Tech Hub

Build, learn & keep up — tech, Linux & sports news included.

C++ Virtual File System Project

Advanced•2 weeks•inodes, trees, persistence

A filesystem inside a file: directories, files, and blocks you manage yourself.

Share on X

What you will build

Create a disk image in one host file, then implement the filesystem on top of it: an inode table, a directory tree, block allocation, and a mini shell (mkdir, cd, ls, cat, rm) to use it. It's the capstone of systems projects — operating-system concepts made tangible.

What you will learn

Feature checklist

Starter code

#include <array>
#include <cstring>
#include <string>
#include <vector>

const int BLOCK = 512, NBLOCKS = 1024, NINODES = 128;

struct Inode {
    bool used = false;
    bool isDir = false;
    int size = 0;
    int blocks[12];          // direct blocks (12 * 512 = 6 KB max file)
    Inode() { std::memset(blocks, -1, sizeof(blocks)); }
};

struct DirEntry { char name[28]; int inode; };  // -1 = empty slot

class VFS {
    std::array<Inode, NINODES> inodes_;
    std::vector<bool> blockFree_ = std::vector<bool>(NBLOCKS, true);
    int cwd_ = 0;            // inode 0 = root directory
public:
    int allocBlock() {
        for (int i = 0; i < NBLOCKS; ++i)
            if (blockFree_[i]) { blockFree_[i] = false; return i; }
        return -1;           // disk full
    }
    int allocInode(bool dir) {
        for (int i = 0; i < NINODES; ++i)
            if (!inodes_[i].used) {
                inodes_[i] = Inode{};
                inodes_[i].used = true; inodes_[i].isDir = dir;
                return i;
            }
        return -1;
    }
    // resolve(path): walk DirEntry lists from root or cwd_
};

Full walkthrough

Step 1: The disk is one file, divided into blocks

A VFS formats a single host file (say 4 MiB) as a grid of fixed blocks (4 KiB each → 1024 blocks). Layout: block 0 = superblock (magic, block size, counts), then a block bitmap (which blocks are free), an inode table region, then data blocks. Every offset in the system is blockNumber × blockSize — one multiplication is the whole storage engine's addressing scheme.

Step 2: Inodes: metadata separated from content

struct Inode { bool used; bool isDir; uint32_t size; uint32_t blocks[12]; } — an inode describes a file but is not the file: it points at up to 12 data blocks (12 × 4 KiB = 48 KiB max file — a stated limit, fine for learning). struct DirEntry { char name[28]; uint32_t inode; } — directories are just files whose content is DirEntry records. That 'directories are files' insight is the Unix idea, arriving in your own code.

Step 3: allocBlock() and allocInode(): the resource heart

allocBlock scans the bitmap for a clear bit, sets it, returns the index; freeBlock clears it. allocInode does the same over the inode table. These two functions are the ONLY places resources are granted, so 'deleting reclaims space' reduces to: free the inode's blocks in the bitmap, free the inode, remove the directory entry. No leaks are possible anywhere else because nowhere else can allocate.

int VFS::allocBlock() {
    for (int i = 0; i < numBlocks; ++i)
        if (!bitmap[i]) { bitmap[i] = true; return i; }
    return -1;  // disk full
}

Step 4: Path resolution with . and ..

Split '/docs/notes/a.txt' into components and walk from the root inode: for each component, search the current directory's entries for the name, move to that inode; '.' stays, '..' goes to the parent (store parent links or track the walk stack — the stack is simpler and always consistent). Resolution failure at any hop = 'no such file or directory', naming the component that failed.

Step 5: Commands as thin wrappers: mkdir, touch, write, cat, ls, rm

Each shell command resolves paths then does one thing: mkdir allocates an inode + a block holding '.' and '..' entries; write copies bytes into the file's blocks (allocating as it grows, refusing past 12 blocks or a full disk); cat streams blocks back in inode order; ls prints a directory's entries with sizes; rm frees everything via the allocators' mirrors. The REPL is the mini shell's loop with built-ins only.

Step 6: Persistence is the default state

The host file IS the disk: every mutation writes through to it (fseek/fwrite on the ofstream, or memory-map it), and reopening the image re-reads the superblock and tables. There is no save command because there is nothing outside the disk to save TO — run the program twice and ls in the second run proves the first run's work survived.

Step 7: Test the resource lifecycle

Format an image, mkdir /docs, write a 10 KiB file (3 blocks), check free blocks dropped by exactly the inode's usage (3 data + directory effects), cat it back byte-identical, write until the disk reports full honestly, rm the file and confirm the free count returns EXACTLY to the post-format number — any discrepancy is a block leak with your name on it.

Common mistakes

Compile and run

g++ -std=c++17 -o vfs vfs.cpp

Sample run

$ ./vfs
No image found — format a new one? (y/n):
> y
Formatted disk.img: 1024 blocks x 4 KiB (4 MiB) · 1019 blocks free
vfs:/$ 
> mkdir docs
vfs:/$ 
> cd docs
vfs:/docs$ 
> write hello.txt
> Hello from inside a virtual disk.
> This file lives in blocks, not in a real folder.
> .
Wrote hello.txt — 1 block used (1017 free)
vfs:/docs$ 
> ls
hello.txt   83 bytes
> cat hello.txt
Hello from inside a virtual disk.
This file lives in blocks, not in a real folder.
> cd ..
> exit

Try it in your browser

No browser demo is included for this project. Build the C++ version below — that is the real program.

Related Advanced projects

← All 60 projects · Classic site (Learn & Sandbox)

Download Fake GPS