aboutsummaryrefslogtreecommitdiffstats

gbos internals

The deep technical breakdown that used to live in the README. The short story is in ../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 execs 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/setouts to open files, then execs — 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. jps 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 execs PROG_PING, the child execs 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 rets 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 TimerISRhSwitchTo
  • [ ] 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)