Chapter 13 · Operating Systems
Writing a Shell in C: A REPL Over the UART
Give PurgatoryOS a prompt. Add uart_getc and a line-discipline buffer, parse commands on top of Post 12's ramdisk, dispatch built-ins (ls, cat, echo, cd, pwd, help, clear), and load /bin/hello as a flat binary at a fixed address. This is the payoff post, where readers finally type into their own kernel.
By Nathan Updated 42 min Intermediate
On this page
Our last post ended with an open question: when the shell types the name of a program,
what does “run it” mean? An ELF loader? A flat binary at a fixed address? A lookup
table of built-ins compiled into the kernel image? I promised we’d pick one in this post,
and I’m going to cheat by picking two. The shell in this post dispatches a command in
exactly one of two ways. If the token you typed matches a name in its built-in table
(ls, cat, echo, cd, pwd, help, clear, history), it calls the corresponding C function directly.
If not, it walks /bin and, if it finds an executable there, loads its bytes into a fixed load
address and jumps to them. That is it. There is no fork, no exec syscall, and no ELF parser.
That design decision is not laziness; it’s the exact hybrid the Thompson shell
in Unix V1 used before Bourne came along and formalised the whole thing, and it buys us a working
prompt in about 250 lines of C.
The payoff is what this post is really about. By the end of it, make run drops you into a
blinking cursor on a kernel you built from 0x40000000 up, reading bytes off the same PL011
UART we wrote to in Post 4, executing commands against the same ramdisk we seeded in Post 12,
returning through the same svc/eret boundary we drew in Post 10. Twelve posts of plumbing,
to handle the one prompt.
In the shell below, you can already try out what we’re building today. Every command below
is wired to the same ramdisk. We already built the ls which walks the BTreeMap<String, NodeId>,
cat calls the same read() Rust function, /bin/hello runs through the flat-binary path the code
below describes:
If you’ve gotten here from Post 1 without skipping, you’ve written every line underneath what will make today’s post possible.
What We’re Building Today
Today, we transition our OS from a hardcoded demo to an interactive environment. To achieve this, we are implementing a Read-Eval-Print Loop (REPL) that lives entirely in User Space (EL0). To keep the shell “pure” (interacting only via syscalls), we need to bridge the gap between hardware interrupts and user-level execution. Here is the breakdown of the architectural changes:
-
The Hardware Plumbing (Kernel Space)
drivers/uart.c&include/drivers/uart.h: We’re addinguart_getc(). Just asuart_putcpolls for a ready transmit buffer, this pollsUARTFR.RXFE(Receive FIFO Empty). This is the only place in the entire OS that touches the PL011’s receive registers.kernel/main.c: We are officially promoting the shell to PID 0. We’re swapping our old test tasks for a single task_create(shell_main). The shell is now our “init” process.
-
The Syscall Bridge
We are expanding the syscall interface so the shell can “see” the world without having kernel privileges:
k_sys_read(insyscall.c): We’re adding a special case for File Descriptor 0 (stdin). When the shell asks to read from fd 0, the kernel callsuart_getc()and hands back the byte.SYS_READDIR: A brand new syscall (ID 8). This allows the shell to ask the Filesystem (via our Rust FFI) what files exist so it can perform path resolution.
-
The User-Space shell
kernel/shell.c: This is the heart of today’s post. It contains the tokenizer, command dispatch table, and the logic to resolve paths.- Crucial Constraint: The shell has no access to
drivers/uart.h. It is a “pure” user program; every byte it receives or prints must travel through an svc instruction.
-
Linker Magic
link.ld: We use the linker script to movekernel/shell.oout of the privileged kernel memory and into the.user_spacesection (Block 1). This ensures that when the CPU is in EL0, it has the permissions to execute the shell’s code, while the rest of the kernel remains protected.
Before we dive into the code, we need to clarify how the shell executes programs. Because our MMU configuration (from Post 6) strictly limits where EL0 can execute code, we are using a classic Unix V1-style execution model.
The Workflow:
- The Shell finds an executable in the ramdisk.
- It issues a
sys_readto copy those bytes from the Rust heap into the Exec Window at 0x40210000. - The Shell jumps to that address.
We perform this copy because tfromt heap (where the ramdisk lives) is not mapped as executable for User Space. By copying the data iinbless” the code fas an or execution.
Why Built-ins + Flat Binary (and Not ELF, and Not Fork)
Modern shells follow a famous pattern: while (read_line) { fork(); exec(); wait(); }.
Our shell follows the spirit of that loop, but replaces the complex mechanics of fork and
exec with a much simpler “Copy and Jump” strategy. Here is why we are taking the path of
least resistance for Post 13:
In a standard Unix system, fork() requires a complete clone of the Process Control Block
(PCB) and a Copy-on-Write (CoW) MMU policy. Neither of these exists in our kernel yet.
Implementing a robust fork is an entire project in itself; for now, we want a shell that
works, not a kernel that is a clone of xv6.
A “real” exec call is a massive undertaking. It involves:
- Parsing the ELF (Executable and Linkable Format) headers.
- Mapping load segments into virtual memory.
- Setting up the auxiliary vector for the stack.
Instead of all that plumbing, we treat executables as Flat Binaries. We don’t parse headers;
we just copy the raw machine code. It’s the “Minimum Viable Program” approach: if you put
code at a specific address and point the Program Counter at it, it will run. If you eventually
want to move to a real loader, you only have to change the ten lines inside our sh_exec_flat
function.
We will lean on what we already have: a working Filesystem and a Scheduler that can run an EL0 task forever.
- The “Exec”: We use
memcpyto move bytes from the ramdisk to a fixed address (0x40210000). - The “Jump”: We call it via a simple function pointer:
((void(*)(void))0x40210000)().
This isn’t a “fake” shell; it’s just a simple implementation of the same core idea used by
the Unix V1 shell in 1971 before mmap and complex paging existed.
In shells like bash, some commands (like cd) must be built-ins because an external program
cannot change the parent shell’s working directory. We follow this pragmatic “cheat”
for three reasons:
- Speed: No syscall overhead for simple tasks.
- Access to State: Commands like
historyorcwdneed to read the shell’s local variables. - Simplicity: Calling into our Rust-based VFS to walk a
BTreeMapmight take 20 lines of C code if done externally, but it’s trivial if handled as a built-in.
By using this “Built-ins + Flat Binary” design, we get a functional, interactive environment without getting bogged down in the heavy engineering of a POSIX-compliant loader… yet.
Adding uart_getc
The PL011 has 16-byte RX and TX FIFOs. In Post 4 we only cared about transmission, so we
polled UARTFR.TXFF (bit 5, “TX FIFO full”) and wrote to UARTDR. Now we need the receive side,
which is the same idea with the bits flipped: poll UARTFR.RXFE (bit 4, “RX FIFO empty”), and
when it’s clear, read a byte from the low 8 bits of UARTDR. The PL011 Technical Reference Manual
lays out both flags and the fact that UARTDR is bidirectional. It reads return received
data, writes queue transmit data. This is exactly the trick that makes
memory-mapped I/O so elegant for RS-232 UARTs:
the register that you write to and the register that you read from have the same address.
#include "drivers/uart.h"
/* Previous uart_init / uart_putc / uart_puts / kprint unchanged ─── */
/* uart_getc: blocking read of one byte from the PL011 RX FIFO.
*
* Polls UARTFR.RXFE (bit 4) until the FIFO is non-empty, then reads
* the next byte out of UARTDR's low byte. The upper 24 bits of
* UARTDR encode framing/parity/overrun errors; for Post 13 we just
* mask them off and leave error handling to a future post.
*
* This is a polling read. It will burn 100% of a CPU while there's
* no input: fine for us because the shell is the *only* running
* EL0 task and the timer IRQ still preempts anything we need. When
* we add RX IRQs, the shell task will block on a ring buffer
* instead, and this function goes away.
*/
char uart_getc(void) {while (UART_REG(UARTFR) & UARTFR_RXFE)
; /* spin until a byte arrives */
return (char)(UART_REG(UARTDR) & 0xFF);
}
The matching header change in two lines. First we need to add the new RXFE
flag bit, and second we need to add the function prototype. Previously we only
defined UARTFR_TXFF (bit 5) and UARTFR_BUSY (bit 3) because we only ever
transmitted; bit 4 (“Receive FIFO empty”) has been there in the PL011 TRM
the whole time, we just never asked it anything:
/* include/drivers/uart.h: additions */
/* UARTFR bits (extending Post 4's definitions) */
#define UARTFR_RXFE (1 << 4) /* Receive FIFO empty */
char uart_getc(void);
Bit 4 is set when the RX FIFO has nothing in it; cleared the moment a byte
lands. uart_getc spins on this bit being set and returns the moment it clears.
It is crucial to understand that even though uart_getc is now defined in the driver,
the shell never actually touches it. Because the shell is an EL0 (User Space) program
and the PL011 MMIO window at 0x09000000 is strictly mapped as EL1-only (Kernel Space),
any attempt by the shell to read the hardware register directly would result in a
fatal processor exception.
Instead, we have plumbed the driver into the existing sys_read path to create a clean hardware
abstraction. When the shell’s line discipline logic executes sys_read(0, &c, 1), it triggers a
software interrupt that drops the CPU into the kernel. The kernel’s k_sys_read function then
intercepts the request, identifies that File Descriptor 0 (stdin) is being targeted, and calls
uart_getc() on the shell’s behalf.
This design is the primary reason for having a syscall layer in the first place: the shell remains a “pure” user program, blissfully unaware that there is a physical UART on the other side of the interface. It simply asks for a byte from the kernel and waits for the answer, maintaining the security and portability of the system.
Plumbing sys_read for stdin
Up until now, k_sys_read has been strictly literal: it looked up a file descriptor in the task’s
table, found a corresponding NodeId, and asked the filesystem for data. However, following the
Unix tradition, File Descriptor 0 (stdin) is a “magic” handle. It doesn’t point to a file on the
ramdisk; it points to whatever the kernel defines as the standard input source. In our case, that
source is the UART. We implement this by intercepting the syscall before it ever reaches the VFS
logic. With just a few lines of C and an early return, we turn a generic read request into a direct
hardware query:
static int64_t k_sys_read(int fd, char *user_buf, uint64_t len) {
if (user_buf == NULL || len == 0) return 0;
/* fd 0 = stdin: blocking read one character from the UART. */
if (fd == 0) {
user_buf[0] = uart_getc();
return 1;
}
if (fd < 0 || fd >= MAX_OPEN_FILES) return EBADF;
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;
}
This if (fd == 0) block is our entire stdin implementation. You’ll notice we
return exactly one byte even if the caller requested more. While a production-grade
read(2) would buffer input in the kernel to satisfy larger requests, our shell only
ever asks for one character at a time. We are deferring the complexity of kernel-side
buffering for the future, where we’ll actually need it for piped input.
There is also a subtle detail regarding memory safety. In this specific implementation,
we write user_buf[0] = uart_getc() directly. This works because the shell’s user stack
currently lives in Block 1, which our MMU configuration marks as EL1-writable. In a
more restricted production kernel, we would route this through copy_to_user to ensure
we aren’t accidentally overwriting kernel memory. For now, this “fast path” keeps the
code readable, but it marks the exact spot where we will eventually need to add
access_ok() checks once we introduce per-task page tables.
Adding the sys_readdir Syscall
To implement the ls command, the shell needs to list the children of a directory.
This requires crossing two major boundaries: the EL0/EL1 privilege gap (the shell
cannot access kernel memory) and the C/Rust language barrier (our VFS metadata
lives in a Rust BTreeMap). Following the pattern established earlier, the shell
will speak svc, and the kernel will delegate the heavy lifting to our Rust FFI.
First, we expand our syscall registry in include/kernel/syscall.h. We add a new
entry to the enum and provide the prototype that the shell will use:
/* include/kernel/syscall.h: additions */
typedef enum {
SYS_WRITE = 1,
SYS_EXIT = 2,
SYS_GETPID = 3,
SYS_YIELD = 4,
SYS_OPEN = 5,
SYS_READ = 6,
SYS_CLOSE = 7,
SYS_READDIR = 8, /* ← new */
SYS_MAX
} syscall_nr_t;
int64_t sys_readdir(int fd, char *buf, uint64_t cap);
The kernel-side handler performs the standard due diligence: validating the file
descriptor and ensuring the user buffer is valid. Just like k_sys_read, we use a
kernel bounce buffer. We do this for hygiene; we don’t want our Rust VFS code directly
touching raw pointers from User Space.
/* kernel/syscall.c: k_sys_readdir */
extern int64_t fs_readdir(uint32_t id, char *dst, uint64_t cap);
static int64_t k_sys_readdir(int fd, char *user_buf, uint64_t cap) {
if (fd < 0 || fd >= MAX_OPEN_FILES) return EBADF;
if (user_buf == NULL || cap == 0) return EINVAL;
pcb_t *me = current_task_pcb();
if (me->fds[fd].node_id == 0) return EBADF;
/* Bounce through a kernel buffer, same hygiene as k_sys_read. */
char kbuf[256];
uint64_t chunk = cap > sizeof(kbuf) ? sizeof(kbuf) : cap;
int64_t n = fs_readdir(me->fds[fd].node_id, kbuf, chunk);
if (n < 0) return n;
copy_to_user(user_buf, kbuf, chunk);
return n;
}
Finally, we register the handler in our dispatch table. By now, this table-plus-enum boilerplate is a familiar routine with the one that mirrors how the Linux syscall ABI for AArch64 fo manages its own expansion.
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,
[SYS_READ] = (syscall_fn)k_sys_read,
[SYS_CLOSE] = (syscall_fn)k_sys_close,
[SYS_READDIR] = (syscall_fn)k_sys_readdir,
};
Our syscall_init function includes a bounds check that will catch the “classic” mistake
of adding an enum entry but forgetting the table row, preventing a null-pointer jump at
runtime. We will implement the matching fs_readdir FFI shim and the Vfs logic in the ls
Built-in section, where we can see the code actually in action.
Line Discipline: Reading a Whole Line
Our UART driver gives us one byte at a time, but a shell needs a line: a
sequence of characters terminated by the Enter key. In a full-blown Unix
system, this is handled by the kernel’s “line discipline” (like Linux’s N_TTY),
which manages complex control signals like ^C to kill a process or ^R for history
search. For our shell, we are implementing a “Minimum Viable Line Discipline.”
We focus on the two most critical behaviors: terminating the line and handling backspaces.
A subtle quirk of computing history is that different terminals send different
codes for the backspace key. BSD historically favored 0x7f (DEL), while GNU/Linux
systems often use 0x08 (BS). To be compatible with both, our shell accepts either
code. When a backspace is detected, we perform the “three-byte dance” used by
readline: we echo \b \b. This moves the cursor back, overwrites the character
with a space, and moves the cursor back again to visually erase it for the user.
Before we look at the input loop, let’s look at the top of kernel/shell.c. Note
that we only include freestanding headers (stdint.h and stddef.h). These are the
only standard headers a kernel can safely rely on. Crucially, you won’t see
drivers/uart.h here. The shell is a user program; it has no business knowing the
UART exists. It only knows about syscall.h.
/* kernel/shell.c: top of file */
#include <stdint.h> /* uint64_t, int64_t, uint32_t */
#include <stddef.h> /* NULL, size_t */
#include "kernel/syscall.h" /* sys_open / sys_read / sys_close / */
/* sys_write / sys_readdir */
#include "kernel/shell.h" /* shell_main prototype */
/* ── File-scope constants ────────────────────────────────────────────────── */
#define SH_LINEBUF_SIZE 128
#define SH_MAX_ARGS 8
#define SH_LOAD_ADDR ((void *)0x0000000040210000UL)
#define SH_LOAD_SIZE (64 * 1024)
/* ── File-scope state ────────────────────────────────────────────────────── */
/* Tracks the current directory. Updated by sh_cmd_cd, read by sh_cmd_ls. */
static char sh_cwd[64] = "/home";
/* Forward declaration for the execution path. */
static int sh_exec_flat(const char *name);
The SH_LOAD_ADDR is the one piece of “leaked” knowledge the shell needs. It must agree
with our linker script on where the executable window lives. We also keep sh_cwd at the
file scope (K&R style) so all built-in commands like ls can access it without passing
extra pointers around.
Here is how we turn raw sys_read calls into a buffered line. Every byte travels from
hardware to kernel to user-space and back again.
/* kernel/shell.c: output helpers and line-input routine */
/* ── Pure-computation helpers (no kernel calls) ─────────────────────────── */
static uint64_t sh_strlen(const char *s) {
uint64_t n = 0;
while (s[n]) n++;
return n;
}
static int sh_streq(const char *a, const char *b) {
while (*a && *b && *a == *b) { a++; b++; }
return *a == *b;
}
/* ── I/O helpers: every byte the shell emits goes through SVC ──────────── */
static void sh_puts(const char *s) {
sys_write(1, s, sh_strlen(s));
}
static void sh_write(const char *buf, uint64_t len) {
sys_write(1, buf, len);
}
/* sh_readline: read a single \n-terminated line from stdin.
*
* Returns the length of the line (not including the newline). Mutates
* `buf` in place; does NOT NUL-terminate, caller decides whether to
* add the null or pass (buf, len) to a length-taking API like fs_open.
*
* Handles:
* - '\r' or '\n': line terminator. Echo '\n' and return.
* - '\b' (0x08) and DEL (0x7f): backspace. Echo \b \b and drop last char.
* - printable ASCII (0x20..0x7e): append and echo.
* - anything else: silently dropped. Control chars and UTF-8 hi bytes
* don't belong in a kernel shell yet.
*
* The shell is at EL0 and never touches the UART itself, every read is
* sys_read(0, ...) and every write is sys_write(1, ...). The kernel's
* k_sys_read special-cases fd=0 to call uart_getc(); k_sys_write calls
* uart_putc() in a loop. As far as sh_readline knows, "stdin" is just a
* fd that returns one byte at a time.
*/
static int sh_readline(char *buf, int max) {
int pos = 0;
for (;;) {
char c;
sys_read(0, &c, 1); /* blocking single-byte read */
if (c == '\r' || c == '\n') {
sys_write(1, "\n", 1);
return pos;
}
if (c == 0x08 || c == 0x7f) { /* backspace / DEL */
if (pos > 0) {
pos--;
sys_write(1, "\b \b", 3); /* visually erase one glyph */
}
continue;
}
if (c >= 0x20 && c <= 0x7e) { /* printable ASCII */
if (pos < max - 1) {
buf[pos++] = c;
sys_write(1, &c, 1); /* local echo */
}
continue;
}
/* Silently drop everything else for now, ^C, tab, arrow keys. */
}
}
Three things worth calling out:
- Manual Echo: The PL011 hardware does not automatically show you what
you’ve typed. The shell must manually
sys_writeevery character back to the terminal. If you forget this, the terminal will stay blank even as the shell “reads” your input. - Escape Sequences: If you hit the arrow keys, you might see stray
characters like
[A]. These are multi-byte VT100 escape sequences. We aren’t decoding them yet to keep the code short, so they are currently treated as unprintable and dropped. - Buffer Safety: Once we hit
max - 1, we simply stop accepting input. A “real” shell might beep at you, but for our purposes, we just stop the buffer from overflowing.
Tokenising and Dispatch
Once we have a full line of text, the shell must break it down into something
the CPU can understand. This involves two steps: tokenizing the string into an
argument vector (argv) and dispatching that vector to the correct function.
While advanced shells like xv6 build a complex Abstract Syntax Tree (AST) to
handle pipes and redirection, our shell is more streamlined. We use a flat
argv array; a simple list of pointers to the words in your command.
This function performs an “in-place” split. Instead of allocating new memory,
it scans the input string, replaces whitespace with NUL terminators (\0), and
stores the starting address of each word in the argv array.
/* sh_tokenise: split `line` into argv[], in place. */
static int sh_tokenise(char *line, char *argv[]) {
int argc = 0;
char *p = line;
while (*p && argc < SH_MAX_ARGS) {
/* Skip leading whitespace and "zap" it with NUL bytes */
while (*p == ' ' || *p == '\t') *p++ = '\0';
if (*p == '\0') break;
/* Save the pointer to the start of the token */
argv[argc++] = p;
/* Scan to the end of the current token */
while (*p && *p != ' ' && *p != '\t') p++;
}
return argc;
}
Rather than using a giant switch statement (which becomes unreadable as your OS
grows) we use a dispatch table. This is a list of command names paired with their
corresponding function pointers. This approach scales effortlessly; adding a new
command like whoami or uptime in a future post is as simple as adding one line
to this table.
/* Command dispatch table. Every built-in returns 0 on success. */
typedef int (*sh_builtin_fn)(int argc, char *argv[]);
static const struct {
const char *name;
sh_builtin_fn fn;
} sh_builtins[] = {
{ "help", sh_cmd_help },
{ "ls", sh_cmd_ls },
{ "cat", sh_cmd_cat },
{ "echo", sh_cmd_echo },
{ "pwd", sh_cmd_pwd },
{ "cd", sh_cmd_cd },
{ "clear", sh_cmd_clear },
{ NULL, NULL }, /* Sentinel to mark the end */
};
When you hit Enter, the shell follows a specific hierarchy of execution. It checks its internal built-ins first, then looks for external files, and finally gives up if nothing is found.
static void sh_dispatch(int argc, char *argv[]) {
if (argc == 0) return;
/* 1. Try to find a built-in command */
for (int i = 0; sh_builtins[i].name != NULL; i++) {
if (sh_streq(argv[0], sh_builtins[i].name)) {
sh_builtins[i].fn(argc, argv);
return;
}
}
/* 2. Not a built-in. Try to run /bin/<command> as a flat binary. */
if (sh_exec_flat(argv[0]) == 0) return;
/* 3. Failure: Show the standard error message */
sh_puts("sh: ");
sh_puts(argv[0]);
sh_puts(": command not found\n");
}
chitecture Notes
- No-Std Reliability: sh_streq is our own two-line strcmp implementation, and sh_puts is a small wrapper around sys_write. Since we are in a no-std environment, we build these fundamentals from scratch to ensure the shell remains independent of heavy C libraries.
- Scalability: By using a table, we are following the same design pattern used by Bash. It keeps the dispatcher logic clean while making the shell easy to extend as we add more features in the future.
The ls Built-in: Our First Real Use of fs_readdir
We have already set up the sys_readdir syscall on the C side. Now, we need to
implement the actual logic in Rust. This requires two pieces of code: a method on
our Vfs struct to gather file names, and an FFI shim to make that method callable from C.
The real work happens in rust/src/fs.rs. Because our Vfs nodes and NodeKind enum
are private, only a method inside this file can “see” the BTreeMap that holds our
directory entries. We’ve chosen a simple wire format for the data: a single buffer
containing NUL-terminated strings (name1\0name2\0). This is significantly cheaper
than passing an array of pointers across the FFI, as it avoids any new memory
allocations in the kernel.
/* rust/src/fs.rs: new method on Vfs */
impl Vfs {
/// Copy the names of directory entries into `dst` as a sequence
/// of NUL-terminated strings. Returns the count of names written.
pub fn readdir(&self, id: NodeId, dst: &mut [u8]) -> i64 {
let node = match self.nodes.get(&id) {
Some(n) => n,
None => return -1, // Not found
};
let children = match &node.kind {
NodeKind::Directory(c) => c,
NodeKind::File(_) => return -1, // Not a directory
};
let mut off: usize = 0;
let mut n: i64 = 0;
for (name, _child_id) in children.iter() {
let bytes = name.as_bytes();
/* If it doesn't fit, we stop (silent truncation) */
if off + bytes.len() + 1 > dst.len() { break; }
dst[off..off + bytes.len()].copy_from_slice(bytes);
dst[off + bytes.len()] = 0; // The NUL terminator
off += bytes.len() + 1;
n += 1;
}
n
}
}
The shim in ffi.rs acts as a translator. It converts raw C pointers into
safe Rust slices and manages the lock on the global VFS instance. By keeping
the NodeKind enum private to the filesystem, we ensure the FFI layer doesn’t
depend on the internal shape of a directory. This means we can swap a BTreeMap
for a HashMap later without changing a single line of FFI code.
/* rust/src/ffi.rs: the FFI bridge */
#[unsafe(no_mangle)]
pub extern "C" fn fs_readdir(id: u32, dst_ptr: *mut u8, cap: usize) -> i64 {
if dst_ptr.is_null() || cap == 0 { return 0; }
let _g = IrqGuard::new();
// SAFETY: caller promises (dst_ptr, cap) is a valid kernel buffer
let dst = unsafe { core::slice::from_raw_parts_mut(dst_ptr, cap) };
VFS.lock().readdir(id, dst)
}
Finally, we implement the ls command in our shell. Notice the pattern: the shell never touches a NodeId. It uses a path string, gets a file descriptor from the kernel, reads the names into a buffer, and immediately closes the descriptor.
/* kernel/shell.c: sh_cmd_ls */
static int sh_cmd_ls(int argc, char *argv[]) {
char resolved[128];
const char *arg = (argc > 1) ? argv[1] : sh_cwd;
/* Normalize the path (handles "..", ".", and relative paths) */
if (sh_resolve_path(arg, resolved, sizeof(resolved)) < 0) {
sh_puts("ls: path too long\n"); return -1;
}
int64_t fd = sys_open(resolved, sh_strlen(resolved));
if (fd < 0) {
sh_puts("ls: "); sh_puts(resolved); sh_puts(": Not found\n");
return -1;
}
char buf[256];
int64_t n = sys_readdir(fd, buf, sizeof(buf));
sys_close(fd);
if (n < 0) { sh_puts("ls: not a directory\n"); return -1; }
/* Walk the NUL-separated names and print them */
int off = 0, emitted = 0;
while (off < (int)sizeof(buf) && emitted < (int)n) {
sh_puts(&buf[off]);
sh_puts(" ");
off += (int)sh_strlen(&buf[off]) + 1; // Jump past string + NUL
emitted++;
}
sh_puts("\n");
return 0;
}
This “Open-Read-Close” flow is the blueprint for almost every command in the
shell. Whether it’s cat reading file contents or cd moving through the tree,
the shell remains a client of the kernel, using syscalls to peek into the VFS.
Path Resolution: How cd .. Survives
One technical detail we glossed over earlier: our kernel is quite literal. The
Rust Vfs::resolve method we built in Post 12 expects an absolute path. It starts
at the root and splits on /. It has no concept of a “current working directory,”
nor does it know what . or .. mean. If you typed cd .., and we passed that string
directly to the kernel, sys_open would look for a file literally named .. in the
root directory and return ENOENT (Not Found). To make the shell feel like a real
Unix environment, the shell must perform path normalization in pure C before every
sys_open call.
This function transforms a messy user string into a clean, absolute path the kernel can understand. It follows a three-step process:
- Prefixing: If the path doesn’t start with
/, it’s relative. We prefix it with the currentsh_cwd. - Concatenation: We combine the prefix and the user’s input into a temporary buffer.
- Collapsing: We walk through the components, “popping” the previous directory whenever
we encounter
..and ignoring .or empty segments (like//).
/* Resolve `path` against sh_cwd into `out` (absolute, normalized).
* Handles relative paths, ".", and "..". Returns length, or -1 on overflow. */
static int sh_resolve_path(const char *path, char *out, int out_size) {
char tmp[128];
int n = 0;
/* For relative paths, start from the current working directory. */
if (path[0] != '/') {
const char *p = sh_cwd;
while (*p && n < (int)sizeof(tmp) - 1) tmp[n++] = *p++;
if (n == 0 || tmp[n - 1] != '/') {
if (n < (int)sizeof(tmp) - 1) tmp[n++] = '/';
}
}
/* Append the input path. */
while (*path && n < (int)sizeof(tmp) - 1) tmp[n++] = *path++;
tmp[n] = '\0';
/* Normalize components into `out`, handling "." and "..". */
int opos = 0;
out[opos++] = '/';
const char *p = tmp;
while (*p == '/') p++; /* skip leading slashes */
while (*p) {
const char *start = p;
while (*p && *p != '/') p++;
int clen = (int)(p - start);
while (*p == '/') p++; /* skip repeated slashes */
if (clen == 0 || (clen == 1 && start[0] == '.')) continue;
if (clen == 2 && start[0] == '.' && start[1] == '.') {
/* Go up: strip the last path component from out. */
if (opos > 1) {
opos--;
while (opos > 1 && out[opos - 1] != '/') opos--;
}
continue;
}
/* Append separator (skip for root). */
if (opos > 1) {
if (opos >= out_size - 1) return -1;
out[opos++] = '/';
}
for (int i = 0; i < clen; i++) {
if (opos >= out_size - 1) return -1;
out[opos++] = start[i];
}
}
out[opos] = '\0';
return opos;
}
Writing this logic from scratch reveals a few subtle “gotchas” that full-scale kernels like Linux handle behind the scenes:
- The “Pop” Logic: Correctly handling
..is surprisingly tricky. You can’t just delete a fixed number of characters. You have to “pop” characters until you hit the previous / or the beginning of the string. The loopwhile (opos > 1 && out[opos - 1] != '/')is what ensuressh_cwdbehaves correctly. - Silent Cleaning: Notice that repeated slashes (e.g.,
//bin////hello) are handled naturally. The tokenizer simply skips over empty components, meaning the user can be a bit “messy” with their typing without causing an error. - The MVP Scope: There are no symlinks, no
~expansion, and noreadlinkhere. Real POSIX path resolution is incredibly elaborate. By building this “half-baked” version, you start to appreciate why thevfs_lookupcode in the Linux kernel is so significant.
Without sh_resolve_path, every cd .. or relative cat README would fail. It is,
line-for-line, the most tedious code in the shell, but it is the invisible glue
that makes a filesystem feel “navigable.”
The Flat-Binary Exec Path
This is where we implement a meaningful “cheat.” In a mature OS, loading a program
involves parsing headers and mapping memory pages. For our shell, we are using the
simplest possible execution model: we find a file in /bin, copy its raw bytes into
a fixed “load window,” and point the CPU at the first byte.
We have reserved a 64 KB window at 0x40210000 (defined as SH_LOAD_ADDR). This address
is significant because our MMU configuration from earlier posts specifically marks
this block as EL0-executable. When the shell jumps to this address, the CPU fetches
instructions just as it would for any other function; no privilege change or complex
eret dance required.
The logic in sh_exec_flat performs a dual role. Because our ramdisk currently contains
both raw text files (like hello, world!) and binary stubs, the loader has to decide
whether to print the data or execute it.
/* kernel/shell.c: the "loader" */
static int sh_exec_flat(const char *name) {
char path[64];
/* Construct the full path: /bin/<name> */
int pos = sh_snprintf(path, sizeof(path), "/bin/%s", name);
int64_t fd = sys_open(path, (uint64_t)pos);
if (fd < 0) return -1; // Not found in /bin
/* Copy the binary into the executable window */
int64_t n = sys_read(fd, (char *)SH_LOAD_ADDR, SH_MAX_SIZE);
sys_close(fd);
if (n <= 0) return 0;
/* The "String or Code?" Branch:
* If the first byte is printable ASCII, it's a text file, just print it.
* If it's a non-printable byte (like 0x7f), treat it as machine code. */
char first = ((char *)SH_LOAD_ADDR)[0];
if (first >= 0x20 && first < 0x7f) {
sh_write((char *)SH_LOAD_ADDR, (uint64_t)n);
return 0;
}
/* Otherwise, jump to the load address.
* We cast the address to a function pointer and call it. */
void (*entry)(void) = (void (*)(void))SH_LOAD_ADDR;
entry();
return 0;
}
This “is it ASCII?” check is a temporary hack. If we tried to “jump”
into the string hello, world!, the CPU would attempt to execute the
character ‘h’ as an instruction, leading to an immediate illegal
instruction exception.
We should replace this with a proper “Magic Number” system. But for
now, this branch allows us to use /bin/hello as a test case for both
the filesystem and the loader’s ability to pull data into the execution
window.
To prove the “jump” path actually works without writing a full compiler
backend yet, we seed our ramdisk with a “stub” for /bin/sh.
// /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);
Because this stub starts with 0x7f (the ELF magic byte), our shell will
identify it as a binary and attempt to execute it. It will crash almost
immediately (since there is no real code after the header), but that is
exactly what we want to see. A “clean” crash in the EL0 exception handler
proves that the shell successfully handed control to the new “program.”
This is the “Flat Binary, Same Address Space” model. It is the absolute simplest way to run a program: Copy + Jump. It works because the Shell and its children share the same EL0 view of memory. When we introduce per-task page tables in the next post, this function is where the most dramatic changes will occur, as we move from a shared window to isolated memory spaces.
The Header: include/kernel/shell.h
The line buffer, the tokenizer, the dispatch table, and the flat-binary
loader, basically everthing we have built so far lives exclusively inside
kernel/shell.c. By marking those symbols as static, we ensure they are
invisible to the rest of the system.
The kernel has no business reaching into the shell’s internals. Its only job is to kick-start the shell as the “init” process, which is why our header is intentionally minimal:
/* include/kernel/shell.h
*
* Public interface to the in-kernel REPL.
*
* Everything else about the shell stays private to kernel/shell.c.
* That includes the line discipline, the tokeniser and the built-in table.
*
* From Silicon to Shell, Post 13: Building the Shell
*/
#ifndef KERNEL_SHELL_H
#define KERNEL_SHELL_H
/*
* shell_main is the entry point for the shell task.
*
* kernel_main registers it as an EL0 task. It runs an endless read and
* eval loop:
*
* 1. Read a line from stdin, which is sys_read on fd 0.
* 2. Split that line into tokens.
* 3. Dispatch it through the built-in table, or load it from /bin.
*
* It never returns.
*/
void shell_main(void);
#endif /* KERNEL_SHELL_H */
The power of this header lies in what it omits:
- Implementation Details: Constants like
SH_LINEBUF_SIZEandSH_LOAD_ADDRare absent. This follows the standard Linux kernel header convention: public headersprovide function prototypes, while “magic numbers” remain private. If we ever add a script-running feature in a future post, we’ll expose the function, but keep the internal buffers hidden. - Arguments for
shell_main: In a mature OS, the init process receives an environment (envp) from the kernel. Our shell, however, boots into a clean slate and waits for its first command from the UART. We don’t needargcorargvhere yet; when we introduce a real loader in Post 14, this signature will be the first thing to evolve.
With the header defined, we simply add the include to kernel/main.c. This allows us to replace any loose extern declarations with a clean, compiler-checked interface.
/* kernel/main.c: one new include */
#include "kernel/shell.h"
By keeping the interface this narrow, we ensure that the shell remains a modular component. It is an EL0 task that happens to be the first one to run, but from the kernel’s perspective, it’s just another function pointer to be scheduled.
The Shell Loop
After all the plumbing is connected, the actual shell loop is remarkably brief. Including the welcome banner, it is only about fifteen lines of code. This is the Read Eval Print heart of the system that we have been building toward since Post 1.
void shell_main(void) {
char line[SH_LINEBUF_SIZE];
char *argv[SH_MAX_ARGS];
sh_puts("\nWelcome to PurgatoryOS 0.13.\n");
sh_puts("Type `help` for built-ins, or /bin/hello for the payoff.\n\n");
for (;;) {
sh_puts("root@purgatory:");
sh_puts(sh_cwd);
sh_puts("$ ");
int n = sh_readline(line, SH_LINEBUF_SIZE);
line[n] = '\0';
int argc = sh_tokenise(line, argv);
sh_dispatch(argc, argv);
}
}
You will notice there are no special __attribute__ tags on shell_main. In
earlier versions, I manually placed every shell function into a specific user
space section. This was error prone because if you forgot one function, the OS
would crash the moment the CPU tried to execute a privileged page from EL0.
The cleaner solution is to handle this at the file level in link.ld. We
explicitly exclude shell.o from the kernel’s .text section and pull it
into .user_space (Block 1). This ensures the entire file is mapped as EL0
executable automatically.
/* link.ld: exclude shell.o from kernel sections, include in .user_space */
.text : {
KEEP(*(.text.boot))
KEEP(*(.text.vectors))
*(EXCLUDE_FILE(*user.o *main.o *shell.o) .text
EXCLUDE_FILE(*user.o *main.o *shell.o) .text.*)
}
/* (similar EXCLUDE_FILE on .rodata, .data, .bss above) */
.user_space : {
_user_start = .;
build/kernel/shell.o(.text .text.*)
build/kernel/shell.o(.rodata .rodata.*)
build/kernel/shell.o(.data .data.*)
build/kernel/shell.o(.bss .bss.*)
build/user/user.o(.text .text.*)
_user_end = .;
}
If we forget this exclusion, the first call to sh_readline would trigger a
permission fault synchronous exception. The MMU would see an EL0 process trying
to fetch code from a kernel only page and kill the task immediately.
The last piece of the puzzle is updating kernel/main.c. We are officially retiring
our demo tasks and replacing them with a single call to task_create(shell_main).
The shell is now our init process (PID 0). It is the first consumer of every system
we have built, including the scheduler, the syscall table, the Rust heap, and
the filesystem.
/* kernel/main.c: The new boot sequence */
void kernel_main(void) {
// ... MMU, GIC, Timer, and Heap init ...
fs_init();
kprint("FS: mounted (ramdisk)\n");
scheduler_init();
syscall_init();
/* The payoff: Start the shell instead of hardcoded demos */
task_create(shell_main);
kprint("Starting scheduler...\n");
scheduler_start();
}
Deleting those old demo tasks is incredibly satisfying. They were the scaffolding we used to prove the kernel worked. The shell is the actual building. It exercises the entire stack in a way that hardcoded loops never could by putting a human in the loop.
A Register Snapshot: Right After sys_read Returns
The whole reason the shell works is that when sys_read(0, &c, 1) returns, the byte
sits in c (one byte off the EL0 stack), x0 holds the return value 1, the program counter
is back in sh_readline at EL0, and the PL011’s RX FIFO has one fewer byte in it. Here’s
GDB’s view at the first keystroke of the first session, just after I typed h (0x68):
›The shell is an EL0 program. Every byte it reads or writes crosses the SVC boundary, sys_read(0, &c, 1) is the entire stdin implementation as far as user space is concerned. The kernel's k_sys_read handler is where the actual uart_getc() poll happens, and it stays at EL1 throughout.
Booting It
The moment of truth arrives when we run make run. The log output shows the kernel initializing each subsystem we have built over the last thirteen posts, but instead of stopping at a hardcoded demo, it hands the reins to you.
When you type /bin/hello and see the output on your screen, you are witnessing the entire OS stack in motion.
Every character in that “hello, world!” string originated as a
Vec<u8> allocated inside the Rust heap. It was pulled into existence
by sys_read, copied into the shell’s executable load window, and then
emitted byte by byte to the PL011 data register.
You typed the command, the kernel read the hardware interrupt, the shell
parsed the string, and the dispatcher executed the logic. The result came
back through the exact same sys_write path we used for our demo tasks in Post 12.
The core invariant we have preserved since Post 4 remains intact: every
single byte the kernel emits is ultimately a managed write to the UART at
0x09000000. The only difference now is that you are the one deciding which
bytes get sent.
What Broke (And Why)
Building a shell from scratch is a lesson in reinterpreting bytes whose origins you have forgotten. Every bug I encountered occurred at a protocol boundary, usually because I tried to take a shortcut across an abstraction layer. Here are the three primary issues that surfaced and how they were resolved.
-
The Carriage Return Trap
My initial version of
sh_readlinetreated\r(Carriage Return) as a character to be ignored while waiting for a\n(Newline) to terminate the line. When testing in Minicom, the shell simply sat idle because QEMU’s emulated terminal sends\rwithout a following \n.- The Fix: I updated the logic to treat
\rand\nidentically. This mirrors the POSIX TTYICRNLflag, which translates carriage returns to newlines on input. It is a bit of 40-year-old legacy behavior that still matters for modern terminal emulation.
- The Fix: I updated the logic to treat
-
Stale Pointers in the Tokenizer
The first version of
sh_tokenisekept tokens pointing directly into theline[]buffer. However, becausesh_readlinereused that same buffer for the next command, the pointers became stale. The first command worked, but the second command would print tokens from the first becauseargv[0]still pointed to memory that had been overwritten.- The Fix: I ensured that
sh_readlinezeroes out theline[]buffer on entry and thatsh_tokeniseis called immediately after every read. I also moved the buffer away from being astaticglobal to prevent multi-pass pointer corruption.
- The Fix: I ensured that
-
Jumping into ASCII
The most dramatic failure happened in the execution path. Originally, I assumed every file in
/binwas machine code and jumped straight to the load address. When I tried to run/bin/hellothe CPU tried to execute the character ‘h’ (0x68). On AArch64, this decodes to an undefined instruction.- The Fix: I implemented the “printable ASCII” check. If the first byte of a file is a
standard character (between
0x20and0x7e), the shell simply prints the file. If it is a non-printable byte like0x7f(the ELF magic number), it takes the jump. This is a temporary hack that I will replace in Post 14 with a proper “magic number” header.
- The Fix: I implemented the “printable ASCII” check. If the first byte of a file is a
standard character (between
Beyond the immediate bugs, I had to significantly restructure the code to honor the privilege boundaries I had established.
- Enforcing the UART Boundary: I initially had the shell calling
uart_getc()directly because it was a “kernel task.” This bypassed the syscall layer entirely. I fixed this by removing the UART driver headers fromshell.cand forcing all I/O throughsys_read(0, ...)andsys_write(1, ...). The shell now only communicates with the kernel via syscall.h. - The
sys_readdirRequirement: I tried to let the shell see the internalNodeIdof a directory to make listing files easier across the FFI. This leaked internal kernel state into user space. I resolved this by building a properSYS_READDIRsyscall that handles the lookup inside the kernel and bounces the results through a safe buffer. - Path Resolution as a Necessity: I originally thought
sh_resolve_pathwas a luxury. However, without it, commands likecd ..failed because the VFS had no literal node named...I had to ship a 50-line C implementation to normalize paths before they ever reach the kernel. - Linker Granularity: I started by adding
__attribute__((section(".text.user")))to every function in the shell. It was tedious and I eventually forgot to add it to a new function, causing a permission fault. The fix was deleting the attributes and usingEXCLUDE_FILEin the linker script to move the entire shell.o file into user space at once. - The ELF Stub: To test the execution path properly, I replaced the text placeholder for
/bin/shwith a 32-byte ELF header stub. This ensuressh_exec_flattakes the “jump” path. Even though the jump fails because there is no real code, it proves the kernel successfully hands control to the load address and cleanly kills the task when it hits invalid memory.
The common thread in these changes is a commitment to the abstraction. Each of these fixes replaced a “short” three-line hack with a proper boundary crossing, ensuring the shell remains a true user-level program.
What’s Next
That is the end of From Silicon to Shell. Thirteen posts ago the machine did nothing but execute a reset vector; it now boots, maps its own address space, schedules tasks, serves syscalls, mounts a filesystem and hands you a prompt.
If you want to keep going with PurgatoryOS, the three most rewarding next steps are:
- A real
execpath. Replacesh_exec_flatwith an ELF64 loader that parses program headers and maps segments properly, then addforkso the shell can wait on a child instead of jumping into it. - Per-process address spaces. Right now every task shares one set of page tables. Giving each process its own TTBR0_EL1 turns the permission model from a comment into something the hardware enforces.
- A block device and a persistent filesystem. The ramdisk disappears on reset. A virtio-blk driver plus a simple on-disk format is the natural sequel to Post 12.
If instead you want to keep going down a different path: the next series, From Source to Machine Code, builds a compiler from scratch, lexer to code generation. It ends where this series began, emitting the AArch64 instructions this kernel has been executing all along.
Sources
- Thompson shell - Wikipedia, history of the original Unix shell and the fork+exec+wait model every Unix shell since has built on. The “user-level program that reads commands, forks, execs, waits” sentence in the middle of the article is the one-line description of what this post’s shell is a stripped-down version of.
- Unix shell - Wikipedia, a broader history of the shell family, including rc, csh, ksh, bash, zsh. Worth reading for the sense of how many different design decisions have been made on top of the same read-eval loop since 1971.
- PrimeCell UART (PL011) Technical Reference Manual - ARM DDI 0183, the canonical spec for
UARTDR,UARTFR.RXFE,UARTFR.TXFF, and the 16-byte RX/TX FIFOs. The paragraph onUARTDR’s upper error bits is the one that made me write the& 0xFFmask inuart_getc. - ARM’s PL011 UART - Mike Krinkin, the clearest practical write-up of using the PL011 on QEMU from a bare-metal kernel. The section on polling RXFE directly informed
uart_getc’s shape. - The Linux Keyboard and Console HOWTO: Delete and Backspace, the definitive reference on why
0x7fvs0x08for backspace is still a live problem, and why every shell handles both. TheICRNLandECHOsections explain the subset of N_TTY behaviour our line discipline skipped. - How Unix erases things when you type a backspace - Hacker News discussion, Chris Siebenmann’s post (and the thread under it) on the
\b \bthree-byte dance, and why it’s still the right way to erase a character even on modern terminals. This is whatsh_readlineemits on backspace. - xv6: a simple, Unix-like teaching OS (Cox & Kaashoek, MIT 6.1810), chapter on
user/sh.cis the direct ancestor of this post’s design. xv6’s version parses a richerstruct cmdAST for pipes and redirects; ours is a flatargv. Reading the xv6 shell alongside is the quickest way to see what we left out. - Lab: Simple xv6 shell - MIT 6.828, the teaching exercise that walks students through writing a shell on top of xv6’s syscalls. Same shape as this post, focused on the parsing and exec layers.
- The Architecture of Open Source Applications - The Bourne-Again Shell (Chet Ramey), a shell author’s tour of Bash’s internals, including the
builtins[]table that oursh_builtins[]is a twelve-entry subset of. Worth reading for how far a dispatch table scales before it stops being the right structure. - GNU Bash Reference Manual - Shell Builtin Commands, the formal spec for why
cd,echo, and friends are built in: speed, access to internal state, and things you literally can’t do from a child process. All three reasons are why our built-in table exists. - AAPCS64 - ARM Procedure Call Standard, the spec that makes
sh_exec_flat’s((void(*)(void))addr)()cast valid C on AArch64.externfunction pointers follow AAPCS64, which means x0–x7 hold arguments, x30 holds the return link, and a flat binary that ends withretreturns cleanly. - Linux syscall ABI for AArch64 - Chromium OS docs, the “x8 is the syscall number, x0–x7 are arguments,
svc #0is the trap” convention we’ve been using since Post 10. Our shell issuessys_open,sys_read,sys_close,sys_writethrough this ABI. - GNU Readline, the library almost every serious shell uses for line editing, history, and completion. Our
sh_readlineis what you get when you strip readline down to its two simplest features. If you want history and arrow-key handling in the C shell, readline is the thing to reach for, though porting it to a kernel is a non-trivial project. - Unix Shell: History and Trivia - Oil Shell blog, Andy Chu’s chronologically ordered tour of every mainstream Unix shell, including the ones that lost. Puts the design decisions in this post into a broader context of what’s been tried and discarded.
- The Birth of the Bash Shell - opensource.com, the origin story of Bash, with useful context on why shells are what they are. Lightweight reading compared to the above, but the section on “reading a line, eval, print, loop” is exactly what
shell_mainimplements. - Procedure Call Standard for the Arm 64-bit Architecture, still the reference for why a flat binary can
retout ofsh_exec_flat’s caller and have the shell resume cleanly. Caller-saved x0–x18, callee-saved x19–x28, and x30 as the link register are the three rules that keep us honest.
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.