diff options
| author | user <user@clank> | 2026-07-18 20:41:37 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-07-18 20:41:37 +0200 |
| commit | 546d2f36f309a0d8ac88360361b6fcfc0167eef5 (patch) | |
| tree | aedcff087aeb83f4e386cccf4e34829e5955f06d | |
| parent | tools: gbdemo - scripted, screen-synced demo driver (+ the README demo) (diff) | |
| download | gbos-546d2f36f309a0d8ac88360361b6fcfc0167eef5.tar.gz gbos-546d2f36f309a0d8ac88360361b6fcfc0167eef5.tar.xz gbos-546d2f36f309a0d8ac88360361b6fcfc0167eef5.zip | |
docs: concise README (networking + demo gif up front); internals.md
README is now pitch -> proof (real capture gif) -> quick start -> docs
pointers, with the network stack and gbhub/gbjoin/gbtype getting
explicit billing. The full technical breakdown (memory model, syscall
ABI, fork/exec/context switch, FS layout, toolchain war stories) moves
to docs/internals.md, plus previously-undocumented sections on the
network stack and the terminal/OSK.
| -rw-r--r-- | README.md | 413 | ||||
| -rw-r--r-- | docs/internals.md | 239 |
2 files changed, 308 insertions, 344 deletions
@@ -1,368 +1,93 @@ # 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. +A tiny **Unix-flavored OS for the Game Boy Color** — a from-scratch cooperative +microkernel with fork/exec, a shell with pipes, a persistent filesystem, a +color terminal on the LCD, and a **real TCP/IP stack over the link cable**. +It boots, gets a DHCP lease, pings, resolves DNS, fetches web pages, and hangs +out on IRC. On a 1997 handheld (well, an emulated one). -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) +*Real capture: cold boot → DHCP lease → `ping` → an IRC session, typed live +into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).* -- 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. +## What's in the box -## Memory map +- **Processes, the V7 way**: `fork`/`exec`/`wait`/`exit`, zombies + reaping, + background jobs (`spin &`), `ps`, `kill` — round-robin cooperative + scheduling across banked memory with an HRAM context switch. +- **A shell** (written in C, running *on* the GB) with I/O redirection + (`>` `<`) and pipes (`|`), argument passing, and a cwd-aware prompt. +- **Networking, for real**: the kernel owns SLIP over the link port, IPv4, + ICMP, UDP and TCP; userland gets BSD-ish sockets via one syscall. On top: + `dhcp` (boot-time lease), `ping`, `nslookup` (real DNS), `wget` (HTTP), + `chat`, and a colorized **IRC client** (`irc HOST [NICK]`, `/join` and + friends, SELECT for the keyboard). +- **A network to plug into**: `tools/gbhub` is a virtual switch + DHCP server + + NAT gateway — spawn a fleet of Game Boys that ping *each other* and reach + the real internet; `tools/gbjoin` attaches an interactive GB to it, and + `tools/gbtype` types into any GB's shell from the host. +- **40×18 color terminal** on the LCD (4×8 font, two chars per tile, ANSI-ish + colors) + an **on-screen keyboard** on the window layer (SELECT toggles it). +- **Persistent filesystem** on battery-backed cart RAM: inodes, nested + directories, per-process cwd — survives power-off. +- **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~30 + tools ship in `usr/` (`cat`, `wc`, `head`, `ls`, `uptime`, …). Adding a + program is one `.c` file + a table entry. -``` -$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 (`usr/sh.c`) - -init `exec`s the shell (written in C). It reads a line, tokenizes it, and runs -commands with **I/O redirection and pipes**: - -``` -$ echo hello > note # redirect stdout to a file -$ cat note -hello -$ wc < note # redirect stdin from a file -1 1 6 -$ cat readme | wc -l # pipe (via a temp file) -2 -$ echo one two three | wc -1 3 14 -``` - -Operators must be space-separated (`cat readme > out`). Command names are -resolved by the kernel (`sys_lookup` + `NameTable`). - -**How I/O routing works.** Each process has a `stdin`/`stdout` fd in its PCB -(default `$FF` = console). `read`/`write`/`putc` route through the kernel -(`KGetc`/`KPutc`): console when the fd is `$FF`, otherwise the filesystem -(`getb`/`putb`). The shell forks a child, the child `setin`/`setout`s to open -files, then `exec`s — so the program's I/O lands in the file. A pipe -`a | b` is run as `a > __pipe ; b < __pipe ; rm __pipe`. - -### 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 +## Quick start ```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 (`usr/`): - -- `usr/gbos.h` — the userland API (`writes`, `readc`, `nl`, `getpid`, `sexit`). -- `usr/libc.s` — syscall wrappers (asxxxx asm). Each saves - `BC/DE/HL` around `rst $30`, since the trap clobbers them but SDCC expects - them preserved. -- `usr/crt0.s` — entry: `call main`; then `exit(0)`. Linked first so `_start` is - the `$4000` entry. -- `usr/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 - -Syscall wrappers (`usr/libc.s`): `writes`, `putc`, `puts`, `nl`, `strlen`, `readc` -(returns `EOF`=4 at end of input), `getargs` (this program's argument string), -`getpid`, `sexit`. C helpers (`usr/libc.c`, linked into every program): `putu` -(print decimal), `atou` (parse decimal), `argv_parse` (tokenize into argc/argv). +make # -> gbos.gb (needs RGBDS + SDCC) -Arguments: the shell splits the command line at the first space and leaves -`"cmd\0args\0"` at `$A000`; the child inherits it through `fork`, `getargs()` -returns the raw arg string, and `argv_parse()` tokenizes it: +# solo, in the sl0pboy emulator (~/dev/gbc): +~/dev/gbc/build/sl0pboy --sixel 3 --chrome gbos.gb -```c -char *argv[8]; -unsigned char argc = argv_parse(argv, 8); /* args one two -> argc=3 */ +# networked — hub in one terminal, Game Boys in others: +sudo tools/gbhub --daemon # virtual net + DHCP + NAT (needs root) +tools/gbjoin # a windowed GB joins 10.0.0.0/24 +tools/gbtype 'ping 10.0.0.3' # or type into it from the host ``` -### CLI tools (in `usr/`) - -| tool | what it does | -|------|--------------| -| `echo` | print its arguments (`echo hello world`) | -| `cat` | print a file (`cat readme`) or copy stdin to stdout | -| `wc` | count lines/words/chars of a file or stdin (`wc -l readme`) | -| `head` | first *n* lines of a file or stdin (`head -n 5 readme`) | -| `ls` | list files | -| `save` | read a line from stdin into a file (`save notes`) | -| `rm` | delete a file (`rm notes`) | -| `args` | argc/argv demo (`args one two three`) | -| `uname`| print the system name | -| `pid` | print the process's pid (decimal) | -| `true` / `false` | exit 0 / 1 | -| `chello` | the C "hello" demo | - -Options use `hasflag`/`optval` in libc: `wc -l`/`-w`/`-c`, `head -n N`. +On the Game Boy: **SELECT** toggles the on-screen keyboard, d-pad + **A** +types, **START** sends the line. ``` -$ echo C tools on a Game Boy -C tools on a Game Boy -$ args one two three -argc=3 -argv[0]=one -argv[1]=two -argv[2]=three -$ wc (input: "hello world\nfoo\n") -2 3 16 -$ head 2 (input: alpha/beta/gamma/delta) -alpha -beta +/# uname -a +gbos sm83 (Game Boy Color) +/# ping 10.0.0.1 +PING 10.0.0.1 + reply from 10.0.0.1 seq=1 + ... +-- 4/4 received +/# irc 10.0.0.1 sl0pboy +-!- registering as sl0pboy (SELECT = keyboard) ``` -Each tool is a separate `usr/<name>.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()`. - -## Processes & jobs +## The demo GIF -Every process has a **pid** (1–255, monotonic) and a parent. `getpid`, `wait`, -and a full lifecycle (`fork`/`exec`/`exit`/reaping) already existed; on top of -that: - -- **`ps`** lists live processes (`pid state command`); state is `R`eady, - `B`locked (in `wait`), or `Z`ombie. The PCB records the running program id - (`PROC_PROG`) so `ps` can show names. -- **`kill <pid>`** terminates a process (`sys_kill`): marks it a zombie, - reparents its children to init, wakes a `wait`-blocked parent. **pid 1 - (init/the shell) is immortal** — `kill 1` is refused. -- **Background jobs:** `cmd &` runs without waiting; the shell prints `[pid]` - and reaps finished jobs (non-blocking `reap`), printing `[pid done]`. - -``` -$ spin & # a process that just yields forever -[2] -$ ps -1 B sh -2 R spin -3 R ps -$ kill 2 -[2 done] -$ ps -1 B sh -5 R ps -``` +The GIF above is produced by a scripted, self-syncing demo driver: +`tools/gbdemo` runs a step file (`demo/readme.gbd`), injects console text and +button events at the right moments — syncing on the *actual screen contents* +(the terminal's WRAM shadow, read over the emulator's debug socket) rather +than timers — while the emulator records every frame, then renders the GIF: -## Filesystem - -A small **block-based, persistent filesystem** on battery-backed cart RAM -(`src/blk.asm` + `src/fs.asm`). Layout on the 32 KiB "disk" (256-byte blocks): - -``` -block 0 superblock (magic + version) -block 1 bitmaps (free blocks + free inodes) -blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs) -blocks 4.. data blocks +```sh +sudo tools/gbhub --daemon # network up (once) +tools/gbdemo demo/readme.gbd # inside tmux; writes demo/gbos-demo.gif ``` -- **Inodes + nested directories.** A directory is a file of 16-byte entries - (inode# + name), each carrying `.`/`..`. Files are up to **2 KiB** (8 direct - blocks); **32** inodes, 16 entries per directory. -- **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths - (absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`, - inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt. -- **Bounce buffer.** Only `read_block`/`write_block` touch cart-RAM banking; - everything else works on WRAM buffers. The bitmap + inode table are cached in - WRAMX; data/dir blocks stream through a one-block cache. -- **Persistent.** Formats on first boot (magic/version check), then survives - power-off via the emulator's `.sav`. -- Same syscalls as before (`open`/`getb`/`putb`/`list`/`remove`) so the tools - (`cat`/`ls`/`save`/`rm`/`wc`/`head`) are unchanged. +Edit `demo/readme.gbd` to choreograph your own (verbs: `type`, `slowtype`, +`key`, `waitfor`, `record`, `gif`, …). -``` -$ save notes -gbos has a real filesystem now -$ cat notes -gbos has a real filesystem now -$ mkdir docs -$ cd docs -/docs$ save note -hi -/docs$ cd / -$ cat docs/note -hi -$ ls docs -note -$ wc notes # survives a reboot -1 5 31 -``` +## Docs -## Roadmap +- [docs/internals.md](docs/internals.md) — the full technical breakdown: + memory model, syscall ABI, fork/exec/context-switch internals, the network + stack, the filesystem layout, C toolchain notes, war stories, roadmap. +- [docs/link-drop-investigation.md](docs/link-drop-investigation.md) — a + networking bug hunt, preserved. -- [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) -- [x] `wc`, `head`, and an `argc/argv` demo (`args`) + `argv_parse` in libc -- [x] option parsing (`hasflag`/`optval`): `wc -l/-w/-c`, `head -n N` -- [x] a RAM filesystem (WRAMX) + `ls`/`cat`/`save`/`rm`; `wc`/`head` read files -- [x] shell in C with redirection (`>`/`<`) and pipes (`|`, via a temp file) -- [x] per-process stdin/stdout routing (`KGetc`/`KPutc`, `setin`/`setout`) -- [ ] more tools (`rev`, `grep`, `tail`), true concurrent pipes -- [~] persistent filesystem rewrite (block-based, cart SRAM), directories: - - [x] **stage 1**: block device (`blk.asm`) via a WRAM bounce buffer - - [x] **stage 2**: inodes + root directory; variable-size files (<=2 KiB), - bitmap allocators; 16 files / 32 inodes; persists across reboots - - [x] **stage 3**: nested directories, path resolution, per-process cwd, - `cd`/`mkdir`/`ls <dir>` — the whole tree persists across reboots -- [ ] 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()` -``` +Built and tested against the [sl0pboy](../gbc) emulator; runs on anything that +emulates a CGB + MBC5 cartridge with battery RAM. No MMU, no protection, no +regrets. diff --git a/docs/internals.md b/docs/internals.md new file mode 100644 index 0000000..c1007d1 --- /dev/null +++ b/docs/internals.md @@ -0,0 +1,239 @@ +# gbos internals + +The deep technical breakdown that used to live in the README. The short story +is in [../README.md](../README.md); this is the long one. + +## 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 terminal (BG) + on-screen keyboard (window) +$A000-$BFFF SRAM current task DATA/BSS/HEAP/STACK (MBC5 RAM bank) +$C000-$CFFF WRAM0 kernel globals + kernel stack (never swapped) +$D000-$DFFF WRAMX terminal shadow, FS caches (per-process u-area reserved) +$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. + +Core calls: `exit fork read write exec wait getpid yield kill` plus console, +filesystem (`open getb putb list remove mkdir chdir`), process +(`ps-info reap setin setout`), time (`uptime sleep`) and networking +(`ssend srecv net` — see below) families. + +### The shell (`usr/sh.c`) + +init `exec`s the shell (written in C). It reads a line, tokenizes it, and runs +commands with **I/O redirection and pipes**: + +``` +$ echo hello > note # redirect stdout to a file +$ cat note +hello +$ wc < note # redirect stdin from a file +1 1 6 +$ cat readme | wc -l # pipe (via a temp file) +2 +``` + +Operators must be space-separated (`cat readme > out`). Command names are +resolved by the kernel (`sys_lookup` + `NameTable`). + +**How I/O routing works.** Each process has a `stdin`/`stdout` fd in its PCB +(default `$FF` = console). `read`/`write`/`putc` route through the kernel +(`KGetc`/`KPutc`): console when the fd is `$FF`, otherwise the filesystem +(`getb`/`putb`). The shell forks a child, the child `setin`/`setout`s to open +files, then `exec`s — so the program's I/O lands in the file. A pipe +`a | b` is run as `a > __pipe ; b < __pipe ; rm __pipe`. + +### 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`. + +### 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`. + +### 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. + +## 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. + +## The network stack (`src/socket.asm`, `src/net.asm`) + +The LCD terminal + on-screen keyboard freed the link port from console duty, +so **the link port is the network interface**. The kernel owns everything from +the wire up: byte-level link transfers (`net.asm`), SLIP framing, IPv4 + +checksums, ICMP (inbound echo is auto-answered while anything pumps RX), UDP, +and a small TCP (SYN/ACK state machine, MSS 200, single in-flight segment). +Userland sees one `SYS_NET` syscall: `socket / bind / connect / send / recv / +recv_nb / close` driven by a request block — no program touches SLIP or IP. + +Userland networking is plain C on top of that: `dhcp` (DISCOVER→ACK, then +`net_setip()`), `resolve.h` (dotted-quads + DNS A queries to 1.1.1.1), +`netd` (background RX pump so pings are answered), `ping`, `nslookup`, `wget` +(HTTP GET over kernel TCP), `irc` (a full client: channels, /join /msg /me, +nick colors, flood-safe rendering) and `chat`. + +Bytes that arrive **outside** SLIP frames land in a console ring instead — +that's how the host can type into the shell remotely (`tools/gbtype`). + +On the host side, `tools/gbhub` is a virtual switch/router: it spawns (or +accepts, `tools/gbjoin`) several emulator instances, leases each an IP via +DHCP (10.0.0.2+), routes GB↔GB traffic, floods broadcasts, and NATs the rest +out a TUN device to the real internet. `tools/gateway.py` is the older +single-GB gateway (echo/http/chat/irc bridge modes). + +## Filesystem + +A small **block-based, persistent filesystem** on battery-backed cart RAM +(`src/blk.asm` + `src/fs.asm`). Layout on the 32 KiB "disk" (256-byte blocks): + +``` +block 0 superblock (magic + version) +block 1 bitmaps (free blocks + free inodes) +blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs) +blocks 4.. data blocks +``` + +- **Inodes + nested directories.** A directory is a file of 16-byte entries + (inode# + name), each carrying `.`/`..`. Files are up to **2 KiB** (8 direct + blocks); **32** inodes, 16 entries per directory. +- **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths + (absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`, + inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt. +- **Bounce buffer.** Only `read_block`/`write_block` touch cart-RAM banking; + everything else works on WRAM buffers. The bitmap + inode table are cached in + WRAMX; data/dir blocks stream through a one-block cache. +- **Persistent.** Formats on first boot (magic/version check), then survives + power-off via the emulator's `.sav`. + +## The terminal & keyboard (`src/term.asm`, `src/osk.asm`) + +- **40×18 color terminal on the CGB LCD**: a 4×8 font packs two characters per + background tile; tiles are line-bound so scrolling rewrites one tilemap row, + not the screen. ANSI-ish color escapes give per-cell fg/bg (see `usr/ansi.c`, + the IRC client's nick colors). An ASCII shadow of the screen lives in WRAM + (`TERM_BUF`, `$D600`) — which is what `tools/gbdemo` reads to sync demos. +- **On-screen keyboard** on the window layer: SELECT toggles it, d-pad + A + types, START sends the line. The terminal underneath is never disturbed + (window enable is one LCDC bit). + +## Writing programs in C + +gbos programs are written in **C**, compiled with **SDCC** (the `sm83` port): + +- `usr/gbos.h` — the userland API; `usr/libc.s` — syscall stubs that preserve + `BC/DE/HL` around `rst $30`; `usr/libc.c` — helpers (`putu`, `atou`, + `argv_parse`, `hasflag`/`optval`, `msleep`). +- `usr/crt0.s` — entry: `call main`, then `exit(0)`; linked first so `_start` + is the `$4000` entry. +- `usr/build.sh` — compiles with SDCC's **native** toolchain (`sdasgb` + + `sdldgb`), links code at `$4000`, extracts a ROM-bank blob; the Makefile + INCBINs it and registers it in the program + shell command tables + (`src/programs.asm`). + +Arguments: the shell leaves `"cmd\0args\0"` at `$A000`; the child inherits it +through `fork`, `getargs()` returns the raw string, `argv_parse()` tokenizes. + +**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 the 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()`. + +## Bring-up bugs 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`. +- **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. +- **TCP field offsets vs. 8-bit adds:** socket-struct accessors do `add SK_x` + as an 8-bit immediate, so any field offset ≥256 silently truncates and lands + *inside* the RX buffer — keep TCP state ahead of `SK_RXBUF`. +- **Console ring vs. burst injection:** one `net_pump` drains a whole serial + burst, so the 64-byte console ring must hold a full injected command line. + +## Roadmap + +- [ ] `exec` refinement: copy `.data` from ROM + zero `.bss` for RW globals +- [ ] more tools (`rev`, `grep`, `tail`), true concurrent pipes +- [ ] 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) +- [ ] Swap whole tasks to cart flash when RAM banks are exhausted (UZI-style) |
