Skip to main content

Chapter 12 · Operating Systems

Build a Simple Filesystem: Turning the Heap Into a Ramdisk

Build an in-memory tree filesystem for PurgatoryOS in no_std Rust using Box<Node>, BTreeMap<String, NodeId>, and Vec<u8> on top of Post 11's allocator. Add three new syscalls (open, read, close), a per-task file descriptor table, and a working VFS layer that the shell in Post 13 can type against.

By 27 min Intermediate

Diagram of a ramdisk filesystem tree with bin, docs and README nodes backed by a file table stored in the kernel heap
On this page

At the end of the last post, I promised the filesystem would be the hardest design decision in the series: write it in C to match the rest of the kernel, or in Rust to take advantage of the alloc crate we just bolted on. I picked Rust, and the reason isn’t ideology. A filesystem is mostly bookkeeping, and BTreeMap<String, NodeId>, Box<Node>, and Vec<u8handle> do that bookkeeping for you. Writing the same thing in C means writing a hash table, a string type, and a growable byte buffer from scratch, and every one of those is a doorway to the exact class of bug Post 11 just sealed shut. This is kind of the payoff post for the detour we took through the toolchain. If we’d kept the C allocator, this post would be three times as long, and half the code would be plumbing.

So, the kernel heap that Rust took over in the previous post is about to become a disk. Not a real one because there’s no block device, no SD card, no NVMe controller in our QEMU virt machine and no driver to talk to one if there were. What we’re building is closer to Linux’s ramfs or tmpfs: a filesystem whose “storage” is just chunks of the kernel’s own memory, threaded together into the shape of a directory tree. When you boot PurgatoryOS after this post, the heap doesn’t just hold PCBs and stacks anymore; it holds /bin/sh, /etc/motd, and the first real files with real fds that the shell in the next post will type against.

The scope stays deliberately narrow. There’s no write(2) to a file yet (user tasks still only write to stdout/the UART), no mkdir, no unlink, no permissions that do anything. What there is, by the end of the post, is enough of a VFS to seed a file tree at boot, walk a path from / to a leaf, open that leaf, read its bytes into a user buffer, and close the descriptor. Three new syscalls (open, read, close) and one new Rust module (rust/src/fs.rs) cover all of it. xv6’s filesystem serves as the reference for this post, but ours will be much simpler than Linux’s because we don’t need its superblock, log, or bitmap. Our “disk” is 100% heap, so the whole block layer collapses.


What We’re Building Today

By the end of this post, kernel_main boots as before and then hands control to a short Rust call that populates a ramdisk in the heap with a handful of files. Two user tasks, still running at EL0, issue open("/etc/motd", O_RDONLY), read the bytes with read(fd, buf, len), print them via sys_write, and close(fd). The UART shows the same counter output as before, plus two new lines: the message-of-the-day from /etc/motd and the first few bytes of /bin/sh (which, for now, is just a placeholder).

Here’s the ramdisk you end up with at boot, laid out exactly as the Rust seed routine builds it. Click any node to inspect its inode number, backing heap pointer, and (for files) a preview of the Vec<u8> it’s holding:

bios@confessions ~/ramdisk · PurgatoryOS ramdisk at boot: seeded from rust/src/fs.rs
ramdisk treeclick to inspect
inspectorBox<Node>
path/etc/motd
kindRegular file
inode#7
mode-rw-r--r--
size41 B
addr0x0000000040202690

Message of the day: the first real file a user task will ever read from this kernel.

Vec<u8> preview41 B
Welcome to PurgatoryOS. Your heap is a disk.
purgatory$open("/etc/motd", O_RDONLY)VFS · open
fd3inode#7size41 BkindRegular filebacking0x0000000040202690 · Box<Node>
Directory
Regular file
Executable
Mount point

Four new or heavily-modified files make this happen, and (encouragingly) link.ld does not change at all. The ramdisk is just more heap, so the linker already has the bytes reserved:

  • rust/src/fs.rs: this is the filesystem itself. Node, Vfs, path resolution, the global VFS: Mutex<Vfs>, and a small seed() that constructs the initial tree.
  • rust/src/ffi.rs: four new extern "C" shims: fs_init, fs_open, fs_read, fs_close, all on top of the kmalloc/kfree pair we already had.
  • include/kernel/syscall.h & kernel/syscall.c: three new syscall numbers (SYS_OPEN=5, SYS_READ=6, SYS_CLOSE=7) and their kernel handlers, which mostly just forward into the Rust FFI layer.
  • include/kernel/scheduler.h & kernel/scheduler.c: a per-task file descriptor table. Eight slots, inside the PCB. This is where we actually start using the “process” part of a “process control block”.
  • user/user.S: hree more svc stubs: sys_open, sys_read, sys_close. These calls are there so the EL0 code can actually issue them.
  • kernel/main.c: We need to add fs_init(); between rust_heap_init() and scheduler_init(). Plus two tasks that now read a file before yielding.

The physical address space is unchanged from the previous post, except for the shape of what’s inside the heap. The ramdisk does not move any symbols; it’s just a half-dozen extra Box allocations competing with PCBs and stacks for the same 1 MB pool:

bios@confessions ~/memory-map · Physical address space after Post 12: ramdisk occupies ~1 KB of the heap
PL011 UART (MMIO) 0x0009000000 → 0x0009001000 (4 KB)
Block 0 · Kernel .text (C+Rust) 0x0040000000 → 0x004000F000 (60 KB)
Block 0 · .rodata / .data / .bss 0x004000F000 → 0x0040028000 (100 KB)
Block 0 · Boot stack (16 KB) 0x0040028000 → 0x004002C000 (16 KB)
Block 1 · .text.user / .rodata.user 0x0040200000 → 0x0040202000 (8 KB)
Block 1 · Ramdisk inodes (~1 KB) 0x0040202000 → 0x0040202800 (2 KB)
Block 1 · PCBs + stacks + other heap 0x0040202800 → 0x0040302000 (1022 KB)
PL011 UART (MMIO)
Block 0 · Kernel .text (C+Rust)
Block 0 · .rodata / .data / .bss
Block 0 · Boot stack (16 KB)
Block 1 · .text.user / .rodata.user
Block 1 · Ramdisk inodes (~1 KB)
Block 1 · PCBs + stacks + other heap
unmapped

The ramdisk consumes about 1 KB of heap across the nine nodes, plus  100~100 bytes of file contents. Nothing fancy; a real kernel’s root filesystem would dwarf this by six orders of magnitude. The important property is that the Box<T>, Vec<T>, and BTreeMap<K, V>](https://doc.rust-lang.org/alloc/index.html) get this for free from the global allocator we wired up in the previous post. Every one of those allocations is a kmalloc under the hood, and every drop is a kfree.


Why “Tree in Memory” and Nothing Else

In this series on building PurgatoryOS, we left two doors open for this post: a ramdisk tree (what we’re building) and a FAT-like block layout. The FAT approach is a better teaching tool for how real on-disk filesystems work, and I’d recommend reading xv6’s filesystem chapter if that’s what you want. But none of it buys us anything if we want to end with a working shell. Our “disk” has no blocks, sectors, fragmentation, or latency. Adding the fiction of blocks on top of heap memory would just be a ceremony.

Linux’s ramfs docs put it more plainly than I can:

ramfs is a very simple filesystem that exports Linux’s disk caching mechanisms (the page cache and dentry cache) as a dynamically resizable RAM-based filesystem.

The important phrase there is “exports the caches as a filesystem”. Linux’s ramfs is essentially just the data structures the kernel already keeps around to speed up other filesystems, with a thin shim that makes them addressable by path. That’s close to what we’re doing: the heap is already there, and Rust’s BTreeMap<String, NodeId> is already a (much simpler) dentry cache. We add a Node type, seed a handful of instances at boot, and we’re done.

The other consideration is the Linux VFS layer:

The Virtual File System (also known as the Virtual Filesystem Switch) is the kernel software layer that provides the filesystem interface to userspace programs. It also provides an abstraction within the kernel which allows different filesystem implementations to coexist.

We will write a tiny VFS with exactly one filesystem implementation, no pluggability, because the point of a VFS is precisely that user tasks don’t care whether they’re talking to ramfs, ext4, or FAT. sys_open takes a path, sys_read takes an fd. The kernel side hides the rest. Keeping the open/read/close entrypoints honest now means Post 13’s shell won’t need to change a line if, one day, we plug in a second filesystem.


The Data Model: rust/src/fs.rs

Here’s the whole filesystem, in Rust that assumes we have Box, Vec, String, and BTreeMap from the alloc crate (which we do), because the Embedded Rust Book’s no_std chapter walked through exactly this split last post: core is always there, alloc is available once you wire up a #[global_allocator]. Everything in this file compiles with nothing but alloc and spin.

// rust/src/fs.rs
use alloc::boxed::Box;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use core::slice;
use spin::Mutex;

/// Integer handle for a filesystem node. The kernel's equivalent of
/// an inode number. Zero is reserved; #1 is always the root.
pub type NodeId = u32;

/// Every filesystem object is a Node. Directories hold a name→NodeId
/// map; files hold a byte vector. No permissions yet; no timestamps;
/// no symlinks. Add those as separate posts if the series goes longer.
pub enum NodeKind {
    Directory(BTreeMap<String, NodeId>),
    File(Vec<u8>),
}

pub struct Node {
    pub id:    NodeId,
    pub name:  String,
    pub kind:  NodeKind,
}

/// The whole filesystem state. Single-rooted, all in-memory, one
/// BTreeMap<NodeId, Box<Node>> owning every node in the tree.
///
/// `next_id` is a monotonically-increasing counter. We never reuse
/// inode numbers. That keeps a stale fd from accidentally referring
/// to a new file if the old one is unlinked (not that we support
/// unlink yet).
pub struct Vfs {
    nodes:   BTreeMap<NodeId, Box<Node>>,
    next_id: NodeId,
}

impl Vfs {
    pub const fn empty() -> Self {
        Vfs {
            nodes:   BTreeMap::new(),
            next_id: 2,   // root is always #1
        }
    }

    /// Allocate a fresh NodeId. Never returns 0.
    fn alloc_id(&mut self) -> NodeId {
        let id = self.next_id;
        self.next_id += 1;
        id
    }

    /// Install the root directory (#1). Called exactly once from
    /// seed(); panics if called a second time.
    fn make_root(&mut self) {
        assert!(!self.nodes.contains_key(&1), "root already exists");
        let root = Node {
            id:   1,
            name: String::from("/"),
            kind: NodeKind::Directory(BTreeMap::new()),
        };
        self.nodes.insert(1, Box::new(root));
    }


    /// Create a directory named `name` inside `parent`. Returns
    /// its new NodeId.
    fn mkdir(&mut self, parent: NodeId, name: &str) -> NodeId {
        let id = self.alloc_id();
        let dir = Box::new(Node {
            id,
            name: String::from(name),
            kind: NodeKind::Directory(BTreeMap::new()),
        });
        self.nodes.insert(id, dir);
        self.link_into(parent, name, id);
        id
    }

    /// Create a file named `name` inside `parent` containing `data`.
    fn mkfile(&mut self, parent: NodeId, name: &str, data: &[u8]) -> NodeId {
        let id = self.alloc_id();
        let file = Box::new(Node {
            id,
            name: String::from(name),
            kind: NodeKind::File(Vec::from(data)),
        });
        self.nodes.insert(id, file);
        self.link_into(parent, name, id);
        id
    }

    /// Add an existing NodeId into `parent`'s children map under `name`.
    /// Invariant violation (parent not a dir, name already present) is
    /// a panic: seed() is the only caller right now and seed() is correct.
    fn link_into(&mut self, parent: NodeId, name: &str, child: NodeId) {
        let p = self.nodes.get_mut(&parent)
            .expect("link_into: parent NodeId missing");
        match &mut p.kind {
            NodeKind::Directory(children) => {
                children.insert(String::from(name), child);
            }
            NodeKind::File(_) => {
                panic!("link_into: parent is not a directory");
            }
        }
    }


    /// Walk a slash-separated path from the root. Returns the NodeId
    /// of the leaf, or None if any component is missing or not a dir
    /// on the way down.
    pub fn resolve(&self, path: &str) -> Option<NodeId> {
        let mut cur: NodeId = 1;                // start at /
        for comp in path.split('/').filter(|s| !s.is_empty()) {
            let node = self.nodes.get(&cur)?;
            let children = match &node.kind {
                NodeKind::Directory(c) => c,
                NodeKind::File(_)      => return None,
            };
            cur = *children.get(comp)?;
        }
        Some(cur)
    }

    /// Copy up to `buf.len()` bytes starting at `offset` from the file
    /// at `id`. Returns the number of bytes copied, or None if `id` is
    /// not a file.
    pub fn read(&self, id: NodeId, offset: usize, buf: &mut [u8]) -> Option<usize> {
        let node = self.nodes.get(&id)?;
        let data = match &node.kind {
            NodeKind::File(v) => v,
            NodeKind::Directory(_) => return None,
        };
        if offset >= data.len() { return Some(0); }
        let n = core::cmp::min(buf.len(), data.len() - offset);
        buf[..n].copy_from_slice(&data[offset..offset + n]);
        Some(n)
    }
}

/// The one true filesystem. A Mutex because, just like the allocator,
/// reentrancy across a timer IRQ would race on next_id / nodes.
pub static VFS: Mutex<Vfs> = Mutex::new(Vfs::empty());

/// Build the initial ramdisk. Called once from fs_init() via FFI.
pub fn seed() {
    let mut fs = VFS.lock();
    fs.make_root();

    let bin  = fs.mkdir(1, "bin");
    let etc  = fs.mkdir(1, "etc");
    let home = fs.mkdir(1, "home");

    // /bin entries
    let elf_stub = [
        0x7f, b'E', b'L', b'F',  2, 1, 1, 0,  0, 0, 0, 0,  0, 0, 0, 0,
           2, 0, 0xb7, 0,        1, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0,
    ];
    fs.mkfile(bin,  "sh",       &elf_stub);
    fs.mkfile(bin,  "hello",    b"hello, world!\n");

    // /etc entries
    fs.mkfile(etc,  "motd",     b"Welcome to PurgatoryOS. Your heap is a disk.\n");
    fs.mkfile(etc,  "hostname", b"purgatory\n");

    // /home entries
    fs.mkfile(home, "README",   b"This is /home. Post 13's shell will cd here on start.\n");
}

There are two things here that are worth reading twice:

  • The first is that every allocation like Box::new, every BTreeMap::insert that grows the underlying B-tree, every Vec::from goes through the global allocator we wired up in the previous post. BTreeMap handles its own rebalancing, per the alloc::collections::BTreeMap docs, , and asks our allocator for exactly as many bytes as it needs at exactly the moment it needs them. Contrast this with the C version that would have required a hash table implementation, a length-tracking strdup, and a reallocating byte buffer.
  • The second is resolve. Path resolution is the hardest-to-get-right function in every real filesystem, and it is two lines in safe Rust: split on /, look up each component in the current node’s children map, return the leaf. That’s it. No recursion, no allocation, no explicit loop termination condition beyond “when the iterator runs out.” The only reason Linux’s path resolution is complicated is that it deals with symlinks, mount points, permission checks, and caching; we have none of that. If you really want to appreciate what we’re skipping, have a look at the Linux VFS docs’ section on pathname lookup

NodeKind is the one design choice I want to flag. You could split Node into DirNode and FileNode, and store them in separate BTreeMaps. The enum is nicer because match forces you to handle both cases. If we add a Symlink(String) variant later, the compiler will tell us every place we need to update. This is the “make illegal states unrepresentable” argument in miniature. Rust doesn’t make you write this style; it just rewards you for it.


The FFI Surface: Five Functions

The C side will call into Rust exactly five times across the whole post: fs_init once at boot, fs_open / fs_read / fs_close per syscall, and fs_readdir is reserved for when we build the shell and want to do an ls. Everything else stays on the Rust side of the line.

bios@confessions ~/rust/src/ffi.rs (additions)
use crate::fs::{VFS, seed as fs_seed};
use core::slice;
 
/// C entry point: build the initial ramdisk. Called from kernel_main
/// once, right after rust_heap_init().
#[unsafe(no_mangle)]
pub extern "C" fn fs_init() {
  let _g = IrqGuard::new();
  fs_seed();
}
 
/// C entry point: resolve `path` to a NodeId. Returns 0 for "not found",
/// anything > 0 is the NodeId. C callers will treat the NodeId as an
/// opaque u32: only fs_read / fs_close do anything with it.
#[unsafe(no_mangle)]
pub extern "C" fn fs_open(path_ptr: *const u8, path_len: usize) -> u32 {
  if path_ptr.is_null() || path_len == 0 { return 0; }
  let _g = IrqGuard::new();
 
  // SAFETY: the caller promises (path_ptr, path_len) is a valid byte
  // range in the *kernel* address space. Post 12 copies user bytes
  // into a kernel buffer before calling this; that copy is in C.
  let bytes = unsafe { slice::from_raw_parts(path_ptr, path_len) };
  let path  = match core::str::from_utf8(bytes) {
      Ok(s)  => s,
      Err(_) => return 0,
  };
 
  VFS.lock().resolve(path).unwrap_or(0)
}
 
/// C entry point: copy bytes from `id` at `offset` into the kernel
/// buffer `dst_ptr`. Returns the number of bytes copied, or -1 on a
/// type error (e.g. reading a directory).
#[unsafe(no_mangle)]
pub extern "C" fn fs_read(
  id:       u32,
  offset:   usize,
  dst_ptr:  *mut u8,
  dst_len:  usize,
) -> i64 {
  if dst_ptr.is_null() || dst_len == 0 { return 0; }
  let _g = IrqGuard::new();
 
  // SAFETY: same story as fs_open: the caller promises `dst_ptr` is
  // a valid kernel-side buffer of length `dst_len`. The user's buffer
  // was already copied-out of by the C syscall handler.
  let dst = unsafe { slice::from_raw_parts_mut(dst_ptr, dst_len) };
  match VFS.lock().read(id, offset, dst) {
      Some(n) => n as i64,
      None    => -1,
  }
}
 
/// C entry point: close a NodeId. For now this is a no-op at the VFS
/// layer: the per-task fd table is what's actually "closed" in C.
/// Exported for symmetry and so Post 13's pipe support can hang off it.
#[unsafe(no_mangle)]
pub extern "C" fn fs_close(_id: u32) -> i64 {
  0
}

Five functions, under 60 lines total. Compare this to what sys_open looks like inside Linux: multi-thousand-line fs/namei.c, dentry-cache traversal, RCU, mount-point resolution, capability checks. Our version is allowed to be short because our filesystem is allowed to be a toy. One discipline we are keeping from Linux is that every piece of user-supplied data (the path pointer, the destination buffer) is validated at the kernel boundary, and the Rust side assumes everything it sees is already kernel-trusted.


The Kernel Side: Three New Syscalls

We need to extend the syscall table with three new entries and a small per-task fd table. The table diff is trivially small:

/* include/kernel/syscall.h: additions only */

typedef enum {
    SYS_WRITE  = 1,
    SYS_EXIT   = 2,
    SYS_GETPID = 3,
    SYS_YIELD  = 4,
    SYS_OPEN   = 5,     /* new */
    SYS_READ   = 6,     /* new */
    SYS_CLOSE  = 7,     /* new */
    SYS_MAX
} syscall_nr_t;

#define EMFILE   -24    /* too many open files */
#define ENOENT   -2     /* path not found */
#define ENAMETOOLONG -36

The per-task fd table lives inside the PCB. Eight slots are plenty for now because it’s enough for stdin/stdout/stderr and five opens on top of them. They are also small enough to zero-initialise inline without slowing task_create. In the next post, we will push this higher, but that is also where we need it for shell pipes:

/* include/kernel/scheduler.h: additions */

#define MAX_OPEN_FILES  8

/* An open-file slot. `node_id` is the Rust-side NodeId we got from
 * fs_open(); `offset` is the per-fd read cursor. A node_id of 0 means
 * "slot is free". */
typedef struct {
    uint32_t node_id;
    uint64_t offset;
} fd_entry_t;

typedef struct pcb {
    /* ... existing fields from Post 9 / 10 ... */
    fd_entry_t fds[MAX_OPEN_FILES];     /* new */
} pcb_t;

The handlers themselves are three short C functions that forward into the Rust FFI layer, with the usual EL0 ↔ EL1 argument-copying dance in the middle:

/* kernel/syscall.c: additions */

/* Imported from libpurgatory_rs.a */
extern uint32_t fs_open(const char *path, uint64_t len);
extern int64_t  fs_read(uint32_t id, uint64_t off, char *dst, uint64_t len);
extern int64_t  fs_close(uint32_t id);

/* copy_from_user: bring `len` bytes from the user-space `src` into the
 * kernel-allocated `dst`. In Post 12 we trust EL0 pointers to be valid
 *: Post 13's MMU work hardens this with access_ok(). */
static int64_t copy_from_user(char *dst, const char *src, uint64_t len) {
    for (uint64_t i = 0; i < len; i++) dst[i] = src[i];
    return (int64_t)len;
}

static int64_t copy_to_user(char *dst, const char *src, uint64_t len) {
    for (uint64_t i = 0; i < len; i++) dst[i] = src[i];
    return (int64_t)len;
}

#define PATH_MAX 128

static int64_t k_sys_open(const char *user_path, uint64_t len) {
    if (len == 0 || len >= PATH_MAX)       return ENAMETOOLONG;
    if (user_path == NULL)                 return EINVAL;

    /* Copy the path into a kernel buffer before calling Rust, we do
     * not want a user-space pointer held by the Rust allocator lock. */
    char path[PATH_MAX];
    copy_from_user(path, user_path, len);
    path[len] = '\0';

    uint32_t id = fs_open(path, len);
    if (id == 0) return ENOENT;

    /* Allocate a free slot in the current task's fd table. fd 0/1/2 are
     * reserved for stdin/stdout/stderr even though we don't use them yet. */
    pcb_t *me = current_task_pcb();
    for (int fd = 3; fd < MAX_OPEN_FILES; fd++) {
        if (me->fds[fd].node_id == 0) {
            me->fds[fd].node_id = id;
            me->fds[fd].offset  = 0;
            return fd;
        }
    }
    return EMFILE;
}

static int64_t k_sys_read(int fd, char *user_buf, uint64_t len) {
    if (fd < 0 || fd >= MAX_OPEN_FILES) return EBADF;
    if (user_buf == NULL || len == 0)   return 0;

    pcb_t *me = current_task_pcb();
    fd_entry_t *slot = &me->fds[fd];
    if (slot->node_id == 0) return EBADF;

    /* Again: bounce through a kernel buffer. Keeps the Rust side from
     * ever touching EL0-writable memory. 256 B is a generous chunk for
     * Post 12's demo files; a real read(2) would loop. */
    char kbuf[256];
    uint64_t chunk = len > sizeof(kbuf) ? sizeof(kbuf) : len;

    int64_t n = fs_read(slot->node_id, slot->offset, kbuf, chunk);
    if (n < 0) return n;

    copy_to_user(user_buf, kbuf, (uint64_t)n);
    slot->offset += (uint64_t)n;
    return n;
}

static int64_t k_sys_close(int fd) {
    if (fd < 0 || fd >= MAX_OPEN_FILES) return EBADF;

    pcb_t *me = current_task_pcb();
    if (me->fds[fd].node_id == 0) return EBADF;

    fs_close(me->fds[fd].node_id);
    me->fds[fd].node_id = 0;
    me->fds[fd].offset  = 0;
    return 0;
}

/* And add to syscall_table (indexed by syscall number) */
static syscall_fn syscall_table[SYS_MAX] = {
    [SYS_WRITE]  = (syscall_fn)k_sys_write,
    [SYS_EXIT]   = (syscall_fn)k_sys_exit,
    [SYS_GETPID] = (syscall_fn)k_sys_getpid,
    [SYS_YIELD]  = (syscall_fn)k_sys_yield,
    [SYS_OPEN]   = (syscall_fn)k_sys_open,    /* new */
    [SYS_READ]   = (syscall_fn)k_sys_read,    /* new */
    [SYS_CLOSE]  = (syscall_fn)k_sys_close,   /* new */
};

Three things to flag against POSIX’s open(2):

  1. We always return the lowest available fd ≥ 3. POSIX requires “the lowest file descriptor not currently open for the process”. On a real Unix, that’s fd 0 (stdin) if unused, then 1 (stdout), then 2. We reserve 0/1/2 purely by convention; nothing in our kernel actually uses them as anything yet. When we introduce the shell, stdout will naturally become fd 1, and sys_write will gate on it.
  2. No O_RDONLY / O_WRONLY / O_CREAT flags. Every open is read-only; every file exists. POSIX’s open() takes a flag bitfield; we ignore it. A real implementation would also need an O_RDWR path, O_CREAT to create new files, and an O_APPEND to set the initial offset. In the next post, we will add O_CREAT, but the others are out of scope for the series.
  3. path crosses the kernel boundary as (ptr, len), not a NUL-terminated string. This is defensible: the user can lie about length, but they can also lie about NUL-termination. (ptr, len) lets us bounds-check once and move on; NUL-termination forces the kernel to perform a strnlen_user scan first. Linux actually does both with getname_flags, which copies the user path into a kernel page and NUL-terminates it as it goes. However, Linux uses a lot more code than we need.

The copy_from_user / copy_to_user functions are placeholders. In a “real” kernel, they’d gate on a proper access_ok() check that inspects the user’s page table to make sure the bytes are actually mapped EL0-accessible. We do not yet have page-per-task isolation; all user tasks share the same Block 1 mapping, so there is no distinction between kernel and user memory for this post. The MMU hardening we will do in the next post is where access_ok earns its keep. I mark it here so future-me (and you) remember it’s a documented sin, and not a forgotten one.


The User Side: Three More svc Stubs

The EL0 wrappers in user/user.S are intentionally boring. Each one is six instructions: set x8, drop into the kernel, come back. The kernel does all the interesting work:

/* user/user.S: additions */

.global sys_open
.global sys_read
.global sys_close

sys_open:
    /* x0 = path ptr, x1 = path len */
    mov x8, #5          /* SYS_OPEN */
    svc #0
    ret

sys_read:
    /* x0 = fd, x1 = buf ptr, x2 = len */
    mov x8, #6          /* SYS_READ */
    svc #0
    ret

sys_close:
    /* x0 = fd */
    mov x8, #7          /* SYS_CLOSE */
    svc #0
    ret

Same pattern as Post 10’s sys_write / sys_exit / sys_getpid / sys_yield, and the same AAPCS64 calling convention already does all the work of getting args into x0–x7.

We will also refactor task_a such that it can “open a file, read it, print it”. This shows that we have the basis to start building our shell.

/* Move functions to a user-code section (Executable) */
__attribute__((section(".text.user")))
static void task_a(void) {
    char buf[64];
    int64_t fd = sys_open("/etc/motd", 9);   

    if (fd < 0) {
        sys_write(1, "open failed\n", 12);
        sys_exit(1);
    }

    int64_t n = sys_read(fd, buf, sizeof(buf));
    if (n > 0) sys_write(1, buf, (uint64_t)n);
    sys_close(fd);

    while (1) {
        sys_write(1, msg_a, sizeof(msg_a) - 1);
        sys_yield();
    }
}

Three new syscalls, one user-side function, and the task now depends on a filesystem being live when it runs.


A Register Snapshot: the Moment sys_open Returns

This is the register-level view showing the EL1 side immediately before eret sends us back to EL0 with the new fd in x0. I pulled the values out of GDB with layout regs after the first /etc/motd open. task_a is about to resume at the mov w0, w0 right after its bl sys_open:

bios@confessions ~/registers · Inside el0_sync_handler, just before eret: fd=3 about to be returned to task_a
x0 0x0000000000000003 Return value: the fd the kernel just allocated. 3 because 0/1/2 are reserved. Will overwrite the saved x0 in the exception frame, then restore into EL0's x0 on eret.
x8 0x0000000000000005 SYS_OPEN. Kept in the saved frame for posterity; the user side will clobber it on the next syscall.
x1 0x0000000040200100 Still holds the user path pointer ("/etc/motd") the user passed. The kernel copied out of it; the user retains the string.
x2 0x0000000000000009 Length = 9 bytes (len of '/etc/motd').
SP_EL0 0x0000000040202FF0 User stack pointer: task_a's stack, untouched by the kernel. When eret runs, execution resumes with this SP in effect.
SP_EL1 0x000000004002FBB0 Kernel stack. Holds the saved-register frame from SAVE_REGS; will be reset on LOAD_REGS + eret.
ELR_EL1 0x00000000402000E8 User-space return address: the instruction after `bl sys_open` in task_a. Written by the hardware on svc, consumed by eret.
SPSR_EL1 0x0000000000000000 PSTATE to restore: EL0t, all interrupts unmasked. Exactly what task_a had when it issued svc.
PSTATE.EL 0x0000000000000001 Currently EL1. The next eret drops us to EL0.

›Nothing here is filesystem-specific, it's the same boundary geometry we set up in Post 10. The filesystem is just the current thing on the C/Rust side of the fence; the ISA doesn't care whether x0 came out of a BTreeMap walk or a UART write.


Updating Our Build

The good news is that our build infrastructure already handles the Rust side of this. cargo build picks up rust/src/fs.rs automatically because lib.rs is where we mod fs;, and libpurgatory_rs.a relinks on any .rs change. The only thing we need is for lib.rs to know the new module exists:

// rust/src/lib.rs: one new line

#![no_std]
#![feature(alloc_error_handler)]

extern crate alloc;

mod allocator;
mod fs;          // ← new
mod ffi;

/* ...panic handler and alloc_error_handler unchanged... */

And kernel_main, we need to add fs_init() between heap init and the scheduler, because the seed routine allocates, and we need the allocator to live first:

/* kernel/main.c: one new line */

extern void fs_init(void);   /* new, provided by libpurgatory_rs.a */

void kernel_main(void) {
    mmu_init();
    uart_init();
    kprint("PurgatoryOS booting...\n");

    exceptions_init();
    kprint("Exceptions: ready\n");

    gic_init();
    kprint("GIC: ready\n");

    timer_init(500);
    kprint("Timer: armed\n");

    rust_heap_init();
    kprint("Heap: ready\n");

    fs_init();                      /* ← new */
    kprint("FS: mounted (ramdisk)\n");

    scheduler_init();
    syscall_init();
    kprint("Syscalls: ready\n");

    task_create(task_a);
    task_create(task_b);

    asm volatile("msr daifclr, #2");
    kprint("Interrupts enabled.\n");

    kprint("Starting scheduler...\n");
    scheduler_start();
}

The Makefile itself needs zero changes. The Rust library is still a single staticlib target; the C filesystem handlers compile into the usual kernel object list; the linker pulls in libpurgatory_rs.a in the same way it did in Post 11. This is the kind of refactor that pays for itself.


Booting It

make run should look almost identical to how it did before when booting. There should be one new line (“FS: mounted”) and two new UART bursts at the top of the task output. One per task, as each reads its seed file for the first time:

“Welcome to PurgatoryOS. Your heap is a disk.” is the first output this kernel has ever produced that wasn’t hardcoded into its .text. Every byte of that line lived briefly inside a Vec<u8> behind a Box<Node> inside a BTreeMap<NodeId, Box<Node>> behind a Mutex<Vfs>, then was copied into a kernel stack buffer by k_sys_read, then copied out to task_a’s user buffer, then walked byte-by-byte into the PL011 UART’s data register by k_sys_write. Five data structures, two privilege crossings, one FFI boundary, and the shape you got out the other side is a string. This is, I think, the smallest amount of work an operating system can do and still be called one.


What Broke (And Why)

Two bugs, which both took me about an afternoon to fix, are worth talking about.

  • The path-split iterator subtlety. My first version of resolve used path.split('/') and matched every component, including empty ones. That made "//etc//motd" resolve correctly (because the empty components were re-looked up in the current directory, which succeeded), but it also made "" resolve to NodeId::root and "/" resolve to NodeId::root. This is actually what you want for /, but it meant a stray trailing slash on any file path (“open(‘/etc/motd/’)”) would silently succeed and then fs_read would panic because the NodeId was a file, not a directory, and the match arm in read() returned None instead of Some(0). The fix was the .filter(|s| !s.is_empty()) you see in the listing above. POSIX’s pathconf treats trailing slashes specifically as “this must be a directory”, and I should have copied that rule instead of letting the filter swallow it, but at this stage, silently treating the stray slash as equivalent to not having it is fine for a ramdisk.

  • The copy_to_user call that corrupted task_b’s stack. Our old sin comes home to roost. sys_read in my first version passed user_buf directly into fs_read instead of bouncing through kbuf. Because the FFI lock inside fs_read is only for kernel-side state, and because the Rust Vec<u8>::copy_from_slice happily wrote into a user address that happened to map through Block 1 (at EL0-writable permission), everything looked fine until task_b returned from sys_read to find its stack had been overwritten by its own MOTD. The symptom was a bl into garbage on the very next function call, which QEMU flagged as an EL0 synchronous exception with a nonsense ESR_EL1, and task_kill_current killed the task. I spent twenty minutes trying to debug the scheduler before realising the corrupted register values spelt “Welcome to PurgatoryO” in ASCII.

    The fix was the kbuf[256] you see in k_sys_read above, and a reminder note to myself: any time a Rust function gets a pointer from the syscall layer, that pointer should already be kernel-owned. A real kernel’s copy_from_user / copy_to_user is the canonical answer to this class of bug, and it’s what we’ll wire up properly in Post 13’s hardening pass.


What’s Next

The heap is now a filesystem, and the kernel has three syscalls for opening, reading, and closing files. That is just enough to build a shell. In the next post, we will do exactly that: a read-eval loop over the UART, parse a command, walk /bin, open the right executable, and run it. “Run it” is the one piece that still needs scaffolding; we don’t have an exec loader yet, and getting one working over a ramdisk is where the real interesting design decisions live. Should /bin/hello be an ELF? Should it be a flat binary at a fixed load address? Should we embed user programs in the kernel image and open them by name without actually loading them, letting ls and cat resolve the filesystem path first? In the next post, we will pick one.

And one thing that isn’t on the roadmap for now is persistence. Nothing we’ve written survives a reboot. fs_init() rebuilds the tree from scratch every boot, and the demo files are compiled into the kernel image. If you want the files in the OS to survive a reboot, we need to read an initramfs archive from memory or a disk image. If you want to try that extension, Linux’s initramfs docs are the right place to start.


Sources

  • Overview of the Linux Virtual File System - Linux Kernel Documentation, the canonical VFS reference. The clean split between superblock / inode / file / dentry is the mental model every filesystem on Linux is shaped by, and the right starting point if you want a more realistic version of what we wrote.
  • ramfs, rootfs and initramfs - Linux Kernel Documentation, the Linux doc that comes closest to what PurgatoryOS’s ramdisk actually is: a filesystem whose storage is “exported caches”, i.e. the heap.
  • tmpfs - Linux Kernel Documentation, the slightly-richer cousin of ramfs. Worth reading to see exactly which features we left off (swap, size limits, xattrs, configurable user/group).
  • Pathname lookup - Linux Kernel Documentation, an essay-length explanation of how real-world path resolution is so much harder than our path.split('/') (symlinks, mounts, RCU walk, permission checks, namespaces). Mandatory reading if you plan to extend this post beyond a ramdisk.
  • xv6: a simple, Unix-like teaching operating system (Cox & Kaashoek, MIT 6.1810), the reference teaching filesystem. Chapter 8 walks through a full block-based filesystem with a log, bitmap, and inode table. The closest thing to the “FAT-lite” design we considered for this post.
  • POSIX open(2), read(2), close(2), the specs the user-visible syscalls in this post are attempting to imitate. Worth reading for the edge cases we explicitly skipped: O_CREAT, O_APPEND, partial reads, EINTR.
  • POSIX open() - Open Group Base Specifications, the formal specification, one step more authoritative than the man page. The “lowest-numbered unused fd” rule lives here.
  • alloc crate - Rust standard library, the documentation for the crate that just became useful. The top-level description (“a smaller subset of std that contains all the necessary definitions to handle heap allocation”) is exactly what we’re leaning on.
  • BTreeMap - alloc::collections::btree_map, the ordered map we use for directory children. Log-n lookup, log-n insertion, iteration in key order. The Rust team’s internal BTreeMap case study is a good read on why they picked a B-tree over a hash map.
  • Box<T> - alloc::boxed, the single-owner heap allocation we use for every Node. The Drop impl is why a future unlink() will be a one-liner.
  • Vec<T> - alloc::vec, the growable byte buffer that backs every file’s contents. Our files are small enough that the initial capacity is never exceeded, but the infrastructure is all there for append-mode writes in Post 13.
  • String - alloc::string, the owned-string type we use for node names. Notable for the Borrow<str> impl that let me write BTreeMap::get(path_component: &str) without any extra allocation (see What Broke).
  • no_std - The Embedded Rust Book, still the single best explanation of the core / alloc / std split. Chapter-sized, worth re-reading now that Post 12 is actually using alloc end-to-end.
  • Heap Allocation - Writing an OS in Rust (Philipp Oppermann), Oppermann’s walkthrough of enabling Box/Vec in a kernel is the prior art for Post 11’s allocator and the directly adjacent work for Post 12’s data structures.
  • copy_from_user / copy_to_user - Linux Kernel API, the canonical reference for the user-pointer safety pattern our k_sys_open / k_sys_read deliberately imitates (with a TODO to harden in Post 13). The idea that every user pointer touched by the kernel is copied through a checked boundary, even if the kernel currently runs in the same address space, is the prerequisite for every real OS’s syscall safety.
  • AAPCS64 - ARM Procedure Call Standard, still the spec that makes all of this work without a byte of glue between C and Rust. x0–x7 carry the syscall args; x8 carries the syscall number; extern "C" in Rust produces the same prologue/epilogue GCC does.
  • The Rust Reference - External blocks (extern "C"), spec for the ABI strings and #[no_mangle] attribute that let libpurgatory_rs.a export fs_open as a plain C symbol. Unchanged from Post 11 but more exercised.
  • spin crate - docs.rs: the Mutex we wrap VFS in. Same lock type as Post 11’s allocator, chosen for the same reason: it works in no_std, it doesn’t allocate, and under our single-core / IRQ-masked assumptions it never actually spins.
Subscribe / Stdin

Get the next part

New parts of From Silicon to Shell: Building an OS for ARM and More land in your inbox the day they publish. No spam, unsubscribe in one click.

We send your address to Kit, our email provider, and ask you to confirm by email before anything is sent. See our Privacy Statement.