Skip to main content

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 Updated 42 min Intermediate

Diagram of a shell REPL loop reading from the UART, parsing, executing built-in commands and printing back to the terminal
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:

bios@confessions ~/purgatory-shctrl-L clear · ↑/↓ history · tab
PurgatoryOS booting...
Heap: ready
FS: mounted (ramdisk)
[sys] init: 7 syscalls registered (write/exit/getpid/yield/open/read/close)
Interrupts enabled.
Starting scheduler...
[sched] starting first task (pid=0)
Welcome to PurgatoryOS. Your heap is a disk.
sh: type `help` to see built-ins. This runs in your browser, no eret required.
root@purgatory:/home$ 
PurgatoryOS shell running in your browser. Try: ls /, cat /etc/motd, echo hi, cd /bin, /bin/hello, help.click to focus

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:

  1. The Hardware Plumbing (Kernel Space)

    • drivers/uart.c & include/drivers/uart.h: We’re adding uart_getc(). Just as uart_putc polls for a ready transmit buffer, this polls UARTFR.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.
  2. The Syscall Bridge

    We are expanding the syscall interface so the shell can “see” the world without having kernel privileges:

    • k_sys_read (in syscall.c): We’re adding a special case for File Descriptor 0 (stdin). When the shell asks to read from fd 0, the kernel calls uart_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.
  3. 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.
  4. Linker Magic

    • link.ld: We use the linker script to move kernel/shell.o out of the privileged kernel memory and into the .user_space section (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.

bios@confessions ~/memory-map · Physical address space after Post 13: the shell lives in the same Block 1 as the demo tasks, plus a dedicated load window for flat binaries
PL011 UART (MMIO) 0x0009000000 → 0x0009001000 (4 KB)
Block 0 · Kernel .text (C+Rust) 0x0040000000 → 0x0040011000 (68 KB)
Block 0 · .rodata / .data / .bss 0x0040011000 → 0x0040028000 (92 KB)
Block 0 · Boot stack (16 KB) 0x0040028000 → 0x004002C000 (16 KB)
Block 1 · .user_space (shell + user.S) 0x0040200000 → 0x0040202000 (8 KB)
Block 1 · Ramdisk inodes (~1 KB) 0x0040202000 → 0x0040202800 (2 KB)
Block 1 · Flat-binary load window (64 KB) 0x0040210000 → 0x0040220000 (64 KB)
Block 1 · PCBs + kstacks + ustack + heap 0x0040220000 → 0x0040302000 (904 KB)
PL011 UART (MMIO)
Block 0 · Kernel .text (C+Rust)
Block 0 · .rodata / .data / .bss
Block 0 · Boot stack (16 KB)
Block 1 · .user_space (shell + user.S)
Block 1 · Ramdisk inodes (~1 KB)
Block 1 · Flat-binary load window (64 KB)
Block 1 · PCBs + kstacks + ustack + heap
unmapped

The Workflow:

  1. The Shell finds an executable in the ramdisk.
  2. It issues a sys_read to copy those bytes from the Rust heap into the Exec Window at 0x40210000.
  3. 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 memcpy to 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 history or cwd need to read the shell’s local variables.
  • Simplicity: Calling into our Rust-based VFS to walk a BTreeMap might 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.

bios@confessions ~/drivers/uart/uart.c: additions
#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:

  1. Manual Echo: The PL011 hardware does not automatically show you what you’ve typed. The shell must manually sys_write every character back to the terminal. If you forget this, the terminal will stay blank even as the shell “reads” your input.
  2. 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.
  3. 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:

  1. Prefixing: If the path doesn’t start with /, it’s relative. We prefix it with the current sh_cwd.
  2. Concatenation: We combine the prefix and the user’s input into a temporary buffer.
  3. 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 loop while (opos > 1 && out[opos - 1] != '/') is what ensures sh_cwd behaves 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 no readlink here. Real POSIX path resolution is incredibly elaborate. By building this “half-baked” version, you start to appreciate why the vfs_lookup code 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_SIZE and SH_LOAD_ADDR are 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 need argc or argv here 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):

bios@confessions ~/registers · EL0, inside sh_readline, just after `svc #0` (sys_read) returns x0=1 and *user_buf=0x68 ('h')
x0 0x0000000000000001 Return value of sys_read: 1, meaning one byte was placed in the user's buffer. The byte itself ('h', 0x68) sits at *user_buf, on the EL0 stack one slot below sp.
x8 0x0000000000000006 SYS_READ. The shell loaded x8 with the syscall number before `svc #0`; the kernel's syscall_dispatch indexed syscall_table[6] to land in k_sys_read, which special-cased fd=0 and called uart_getc(). We're back at EL0 because eret restored CurrentEL from SPSR_EL1 along with PC.
x19 0x0000000040201FC0 The address of `line[]` on the shell's user stack. Callee-saved; the syscall path didn't touch it.
sp (SP_EL0) 0x0000000040201FB0 Shell's user stack. The single-byte `c` in sh_readline lives at sp + 0x0F.
pc 0x0000000040200218 The instruction right after `svc #0` in the user.S sys_read wrapper, a `ret` that pops back into sh_readline's body. This is the EL0 offset into .user_space at which the shell resumes.
CurrentEL 0x0000000000000000 EL0. We are user space. The UART read happened at EL1 inside k_sys_read, the shell never touched 0x09000000 directly, and the MMU still maps that page EL1-only.
PSTATE.DAIF 0x0000000000000000 All interrupts unmasked. The timer IRQ can still preempt the shell mid-loop and round-robin to another task, except there are no other tasks.

›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.

  1. The Carriage Return Trap

    My initial version of sh_readline treated \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 \r without a following \n.

    • The Fix: I updated the logic to treat \r and \n identically. This mirrors the POSIX TTY ICRNL flag, 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.
  2. Stale Pointers in the Tokenizer

    The first version of sh_tokenise kept tokens pointing directly into the line[] buffer. However, because sh_readline reused 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 because argv[0] still pointed to memory that had been overwritten.

    • The Fix: I ensured that sh_readline zeroes out the line[] buffer on entry and that sh_tokenise is called immediately after every read. I also moved the buffer away from being a static global to prevent multi-pass pointer corruption.
  3. Jumping into ASCII

    The most dramatic failure happened in the execution path. Originally, I assumed every file in /bin was machine code and jumped straight to the load address. When I tried to run /bin/hello the 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 0x20 and 0x7e), the shell simply prints the file. If it is a non-printable byte like 0x7f (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.

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 from shell.c and forcing all I/O through sys_read(0, ...) and sys_write(1, ...). The shell now only communicates with the kernel via syscall.h.
  • The sys_readdir Requirement: I tried to let the shell see the internal NodeId of a directory to make listing files easier across the FFI. This leaked internal kernel state into user space. I resolved this by building a proper SYS_READDIR syscall 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_path was a luxury. However, without it, commands like cd .. 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 using EXCLUDE_FILE in 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/sh with a 32-byte ELF header stub. This ensures sh_exec_flat takes 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 exec path. Replace sh_exec_flat with an ELF64 loader that parses program headers and maps segments properly, then add fork so 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 on UARTDR’s upper error bits is the one that made me write the & 0xFF mask in uart_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 0x7f vs 0x08 for backspace is still a live problem, and why every shell handles both. The ICRNL and ECHO sections 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 \b three-byte dance, and why it’s still the right way to erase a character even on modern terminals. This is what sh_readline emits on backspace.
  • xv6: a simple, Unix-like teaching OS (Cox & Kaashoek, MIT 6.1810), chapter on user/sh.c is the direct ancestor of this post’s design. xv6’s version parses a richer struct cmd AST for pipes and redirects; ours is a flat argv. 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 our sh_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. extern function pointers follow AAPCS64, which means x0–x7 hold arguments, x30 holds the return link, and a flat binary that ends with ret returns cleanly.
  • Linux syscall ABI for AArch64 - Chromium OS docs, the “x8 is the syscall number, x0–x7 are arguments, svc #0 is the trap” convention we’ve been using since Post 10. Our shell issues sys_open, sys_read, sys_close, sys_write through this ABI.
  • GNU Readline, the library almost every serious shell uses for line editing, history, and completion. Our sh_readline is 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_main implements.
  • Procedure Call Standard for the Arm 64-bit Architecture, still the reference for why a flat binary can ret out of sh_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.
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.