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 Nathan 27 min Intermediate
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:
Message of the day: the first real file a user task will ever read from this kernel.
Welcome to PurgatoryOS. Your heap is a disk.
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 globalVFS: Mutex<Vfs>, and a small seed() that constructs the initial tree.rust/src/ffi.rs: four newextern "C"shims:fs_init,fs_open,fs_read,fs_close, all on top of thekmalloc/kfreepair 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 addfs_init(); betweenrust_heap_init()andscheduler_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:
The ramdisk consumes about 1 KB of heap across the nine nodes, plus 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, everyBTreeMap::insertthat grows the underlying B-tree, everyVec::fromgoes through the global allocator we wired up in the previous post.BTreeMaphandles its own rebalancing, per thealloc::collections::BTreeMapdocs, , 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-trackingstrdup, 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.
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):
- 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 becomefd1, andsys_writewill gate on it. - 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 anO_RDWRpath,O_CREATto create new files, and anO_APPENDto set the initial offset. In the next post, we will add O_CREAT, but the others are out of scope for the series. pathcrosses 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 astrnlen_userscan first. Linux actually does both withgetname_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:
›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
resolveusedpath.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 toNodeId::rootand"/"resolve toNodeId::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 thenfs_readwould panic because the NodeId was a file, not a directory, and the match arm inread()returnedNoneinstead ofSome(0). The fix was the.filter(|s| !s.is_empty())you see in the listing above. POSIX’spathconftreats 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_readin my first version passeduser_bufdirectly intofs_readinstead of bouncing throughkbuf. Because the FFI lock insidefs_readis only for kernel-side state, and because the RustVec<u8>::copy_from_slicehappily wrote into a user address that happened to map through Block 1 (at EL0-writable permission), everything looked fine untiltask_breturned fromsys_readto find its stack had been overwritten by its own MOTD. The symptom was ablinto garbage on the very next function call, which QEMU flagged as an EL0 synchronous exception with a nonsense ESR_EL1, andtask_kill_currentkilled 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 ink_sys_readabove, 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’scopy_from_user/copy_to_useris 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.
alloccrate - 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 everyNode. TheDropimpl is why a futureunlink()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 theBorrow<str>impl that let me writeBTreeMap::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/stdsplit. Chapter-sized, worth re-reading now that Post 12 is actually usingallocend-to-end. - Heap Allocation - Writing an OS in Rust (Philipp Oppermann), Oppermann’s walkthrough of enabling
Box/Vecin 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 ourk_sys_open/k_sys_readdeliberately 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 letlibpurgatory_rs.aexportfs_openas a plain C symbol. Unchanged from Post 11 but more exercised. spincrate - docs.rs: theMutexwe wrapVFSin. Same lock type as Post 11’s allocator, chosen for the same reason: it works inno_std, it doesn’t allocate, and under our single-core / IRQ-masked assumptions it never actually spins.
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.