# gbos A tiny **Unix-flavored microkernel for the Game Boy Color** (MBC5 cartridge). Not Linux, not FUZIX — a from-scratch cooperative kernel that borrows the V7 *process model* (separate read-only text + per-process data, a syscall trap, round-robin scheduling) and squeezes it into the GBC's banked, MMU-less memory. This is a **scaffold**, but a *working* one: it assembles into a valid 32 KiB ROM, brings up a process table, and round-robin context-switches between two demo tasks that each `write()` a byte and `yield()`. Verified end-to-end in the `~/dev/gbc` emulator (headless serial console) — it streams `ABABAB...`. ## Why this shape (the hardware reality) - No MMU, no memory protection. "Unix" here = the API/process model, not isolation. - CPU only ever sees **64 KiB**; RAM is banked. - **Kernel code lives in ROM** (`$0000-$3FFF`, up to 8 MiB via MBC5) — free, read-only. - **Program text lives in ROM banks** (`$4000-$7FFF`) — the V7 "pure text" idea. - **Per-task RW state** (data/bss/heap/stack) lives in one **8 KiB cart-RAM bank** (`$A000-$BFFF`, MBC5, up to 128 KiB / 16 banks). - The context switch swaps banks, so it **must** run from **HRAM** (never banked) and never touch a swappable stack mid-swap. ## Memory map ``` $0000-$3FFF ROM0 kernel core (rst/IRQ vectors, init, sched, syscalls) - fixed $4000-$7FFF ROMX current task TEXT (read-only program code) $8000-$9FFF VRAM graphics (unused so far) $A000-$BFFF SRAM current task DATA/BSS/HEAP/STACK (MBC5 RAM bank) $C000-$CFFF WRAM0 kernel globals + kernel stack (never swapped) $D000-$DFFF WRAMX per-process u-area (SVBK bank) (reserved, not used yet) $FF80-$FFEE HRAM context-switch trampoline (111 bytes) $FFEF-$FFF2 HRAM bank shadows + switch target ``` ## Process control block (`include/gbos.inc`) `STATE, PID, SP, ROMB(16b text bank), RAMB(data bank), WRAMB(u-area), PARENT, EXIT` — 10 bytes. `MAX_PROCS = 8`. ## Syscall ABI User loads `C` = syscall number, args in `DE`/`B`/`HL`, then `rst $30`. Return in `A` (and `B` for `wait`). **The trap clobbers `BC`/`DE`/`HL`** (it uses `HL` to index the dispatch table), so userland must save any pointer it needs across a syscall (`push hl` / `pop hl`). Libc syscall stubs handle this for C. | # | name | status | |---|---------|--------| | 0 | exit | ✅ zombie + reparent orphans + wake waiter | | 1 | fork | ✅ copies the 8 KiB bank, child returns 0 | | 3 | write | ✅ `DE`=buf `B`=len → serial console | | 6 | exec | ✅ `B`=program id → maps ROM text bank, jumps in | | 7 | wait | ✅ blocks; reaps a zombie child → `A`=pid `B`=code | | 8 | getpid | ✅ | | 11| yield | ✅ cooperative switch | | 2 | read | ✅ blocking console input (1 byte) via external-clock serial | | 4,5,9,10 | open/close/kill/brk | ⛔ `ENOSYS` | ### The shell (PROG_SH) init `exec`s a tiny shell: **prompt → read a line (with echo) → match a command → `fork`+`exec`+`wait`**. Commands live in a table (`worker`, `hello`). Runs entirely over the headless serial console: ``` $ hello hello world! $ worker worker! $ foo ? $ ``` ### Process lifecycle (exit / wait / reaping) The classic Unix zombie/reap dance, adapted to banked memory: - **`exit(code)`** sets `PS_ZOMBIE` + status, **reparents** any children to init (pid 1), **wakes** the parent if it's blocked in `wait()`, then schedules away forever. Its resources are freed by the reaper, not here. - **`wait()`** scans for a `PS_ZOMBIE` child. Found → **reap**: free its cart-RAM bank (back to the allocator) and its PCB slot, return pid + code. Children exist but none dead → set self `PS_BLOCKED` and yield, retry on wake. No children → return `$FF` (`ECHILD`). - The scheduler skips non-`READY` procs; `FindNextReady` returns carry when nothing is runnable so `yield` just keeps the caller running (idle). - **Cart-RAM banks are a free list** (`wBankUsed` bitmap): `AllocRamBank` / `FreeRamBank`. Verified by running 6 workers through only 3 child banks (`w2w3w4w5w6w7`) — each reaped bank is recycled by the next `fork`. Lifecycle demo (`tasks.asm`): init forks 3 workers, each `exec`s PROG_WORKER (prints `w`, exits with its pid), init `wait`s and reaps all three → `wwwR2R3R4!`. ### exec (`sys_exec`, src/proc.asm + programs.asm) Where the text-in-ROM model pays off. Programs are linked to run from the ROMX window (`$4000-$7FFF`) and stored in their own ROM banks (`programs.asm`). `exec(B=program id)`: 1. looks up `ProgramTable[B]` = (ROM bank, entry), 2. sets `PROC_ROMB` and maps the bank live into `$4000-$7FFF`, 3. resets the stack to the top of the task's (already mapped) cart-RAM bank, 4. `jp`s to the entry — it never returns. Proof it's really bank-driven: both demo programs are ORG'd at the **same** address `$4000` in **different** banks (02 and 03). The parent `exec`s `PROG_PING`, the child `exec`s `PROG_PONG`, and the output `1212...` (2000/2000, zero garbage) can only happen if each process executes its own bank via `PROC_ROMB`. A fuller exec would also copy `.data` from ROM and zero `.bss`. ### fork (`sys_fork`, src/proc.asm) The interesting syscall. It enters on the parent's user stack (which lives in the `$A000-$BFFF` cart-RAM window), so before it can swap that window it **switches to a kernel stack in WRAM0**. Then it: 1. allocates a free PCB slot + a fresh cart-RAM bank (bump allocator), 2. copies the parent's whole 8 KiB bank → the child bank, 256 bytes at a time through a WRAM bounce buffer (only one cart-RAM bank is visible at once), 3. plants a **switch-in frame** in the child at `Uafter-8` (`Uafter` = parent SP at entry): the copied return address is already there, so it just zeroes the saved `hl/de/bc/af` slots — the child resumes at the post-`fork` PC with `A=0`, 4. fills the child PCB (shared ROM text bank, new RAM bank, parent as PPID), 5. restores the parent stack and returns the child pid. Verified: `init` forks a child (P/C alternate on serial); nested forks yield three independent processes with distinct pids (`123123...`). ## The context switch (`src/hram.asm`) The crux. `hSwitchTo(DE=&incomingPCB)`: 1. push full register context onto the **outgoing** task's stack 2. save `SP` into the outgoing PCB (WRAM0, always mapped) 3. program incoming banks: **RAM bank → SVBK → ROM text bank** 4. load `SP` from the incoming PCB (its banks are now mapped) 5. pop context, `ret` into the incoming task Runs from HRAM so changing `SVBK`/RAM-bank never pulls the rug out from `PC` or the stack. New tasks are bootstrapped with a fake frame (`ProcSetupStack`) so the first switch-in `ret`s straight to their entry point. ## Build ```sh make # -> gbos.gb (RGBDS: rgbasm/rgblink/rgbfix) make clean ``` Console output goes to the **serial port** (`tty.asm`). Easiest way to watch it is the project emulator's headless serial console: ```sh ~/dev/gbc/build/gbc gbos.gb --headless --uncapped # prints ABABAB... ``` A real gbos would render to VRAM; serial is the zero-VRAM debug channel. ### Bring-up bugs already found & fixed (kept as cautionary tales) - **Stack vs. BSS clear:** the kernel stack (`SP=$D000`) grows down into `$CxFF`, so zeroing all of WRAM0 wiped the live return address. `ClearKernelRAM` now clears only `$C000-$CBFF`, leaving the stack region alone. - **Syscall ABI vs. dispatch:** `SyscallTrap` originally used `DE` to index the jump table, clobbering the `write()` buffer pointer passed in `DE`. Dispatch now indexes via `A`/`HL` only, preserving `DE`/`B` for the handler. ## Writing programs in C gbos programs can be written in **C**, compiled with **SDCC** (the `sm83` port). This makes porting tiny Unix CLI tools realistic. The toolchain (`c/`): - `c/gbos.h` — the userland API (`writes`, `readc`, `nl`, `getpid`, `sexit`). - `c/libc.s` — syscall wrappers (asxxxx asm). Each saves `BC/DE/HL` around `rst $30`, since the trap clobbers them but SDCC expects them preserved. - `c/crt0.s` — entry: `call main`; then `exit(0)`. Linked first so `_start` is the `$4000` entry. - `c/build.sh` — compiles a `.c` with SDCC's **native** toolchain (`sdasgb` + `sdldgb`), links code at `$4000`, and extracts the ROM-bank blob. The Makefile INCBINs it and registers it as a program. ```c #include "gbos.h" void main(void) { writes("hello from C on gbos!", 21); nl(); writes("my pid is ", 10); { char d = getpid() + '0'; writes(&d, 1); } nl(); } ``` ### libc `writes`, `putc`, `puts`, `nl`, `strlen`, `readc` (returns `EOF`=4 at end of input), `getargs` (this program's argument string), `getpid`, `sexit`. Arguments: the shell splits the command line at the first space and leaves `"cmd\0args\0"` at `$A000`; the child inherits it through `fork`, and `getargs()` returns the args. ### CLI tools (in `c/`) | tool | what it does | |------|--------------| | `echo` | print its arguments (`echo hello world`) | | `cat` | copy stdin to stdout until EOF (Ctrl-D / pipe end) | | `uname`| print the system name | | `pid` | print the process's pid (decimal) | | `true` / `false` | exit 0 / 1 | | `chello` | the C "hello" demo | ``` $ uname gbos sm83 (Game Boy Color) $ echo C tools on a Game Boy C tools on a Game Boy $ pid 5 $ cat hi from cat hi from cat ``` Each tool is a separate `c/.c`, built to a ROM-bank blob, INCBIN'd, and registered in the program table + shell command table (`src/programs.asm`). **Toolchain gotchas found the hard way:** SDCC's `--asm=rgbds` mode mis-orders instructions (emits `ld [hl],a` before the `ld hl,sp+0` that sets the pointer) — so we use SDCC's native asxxxx path and INCBIN the blob instead. String literals with embedded control chars are also mangled in rgbds mode; C programs pass explicit lengths and emit newlines via `nl()`. ## Roadmap - [x] `fork`: copy parent's 8 KiB RAM bank → free bank, child returns 0 - [x] `exec`: point `PROC_ROMB` at a program in a ROM bank, reset stack, enter - [x] `exit` / `wait` / zombie reaping + cart-RAM bank recycling (free list) - [ ] `exec` refinement: copy `.data` from ROM + zero `.bss` for RW globals - [x] a real shell program: `fork`+`exec`+`wait` driven from the serial console - [x] C toolchain: SDCC (sm83) + libc shim, C programs run as gbos processes - [x] grow libc (`puts`/`putc`/`strlen`/`getargs`/EOF) + args via the shell - [x] CLI tools in C: `echo`, `cat`, `uname`, `pid`, `true`, `false` - [x] Makefile builds all C programs (a `CBLOBS` list) - [ ] more tools (`wc`, `head`, `rev`), a real `argc/argv`, a RAM filesystem - [ ] Preemptive scheduling: real context save in `TimerISR` → `hSwitchTo` - [ ] Use the `$D000-$DFFF` u-area (SVBK) for per-process kernel state / kstack - [ ] `brk`/heap allocator inside the task bank (heap up, stack down, collision = ENOMEM) - [ ] A filesystem in remaining cart SRAM/flash (minix/v7-ish), paged 8 KiB at a time - [ ] Swap whole tasks to cart flash when RAM banks are exhausted (UZI-style) - [ ] VRAM console + keyboard/joypad `read()` ```