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)setsPS_ZOMBIE+ status, reparents any children to init (pid 1), wakes the parent if it's blocked inwait(), then schedules away forever. Its resources are freed by the reaper, not here.wait()scans for aPS_ZOMBIEchild. Found → reap: free its cart-RAM bank (back to the allocator) and its PCB slot, return pid + code. Children exist but none dead → set selfPS_BLOCKEDand yield, retry on wake. No children → return$FF(ECHILD).- The scheduler skips non-
READYprocs;FindNextReadyreturns carry when nothing is runnable soyieldjust keeps the caller running (idle). - Cart-RAM banks are a free list (
wBankUsedbitmap):AllocRamBank/FreeRamBank. Verified by running 6 workers through only 3 child banks (w2w3w4w5w6w7) — each reaped bank is recycled by the nextfork.
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):
- looks up
ProgramTable[B]= (ROM bank, entry), - sets
PROC_ROMBand maps the bank live into$4000-$7FFF, - resets the stack to the top of the task's (already mapped) cart-RAM bank,
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:
- allocates a free PCB slot + a fresh cart-RAM bank (bump allocator),
- 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),
- 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 savedhl/de/bc/afslots — the child resumes at the post-forkPC withA=0, - fills the child PCB (shared ROM text bank, new RAM bank, parent as PPID),
- restores the parent stack and returns the child pid.
The context switch (src/hram.asm)
The crux. hSwitchTo(DE=&incomingPCB):
- push full register context onto the outgoing task's stack
- save
SPinto the outgoing PCB (WRAM0, always mapped) - program incoming banks: RAM bank → SVBK → ROM text bank
- load
SPfrom the incoming PCB (its banks are now mapped) - pop context,
retinto 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/lstake paths (absolute/a/bor relativea/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_blocktouch 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 whattools/gbdemoreads 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 preserveBC/DE/HLaroundrst $30;usr/libc.c— helpers (putu,atou,argv_parse,hasflag/optval,msleep).usr/crt0.s— entry:call main, thenexit(0); linked first so_startis the$4000entry.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.ClearKernelRAMnow clears only$C000-$CBFF. - Syscall ABI vs. dispatch:
SyscallTraporiginally usedDEto index the jump table, clobbering thewrite()buffer pointer passed inDE. Dispatch now indexes viaA/HLonly. - TCP field offsets vs. 8-bit adds: socket-struct accessors do
add SK_xas an 8-bit immediate, so any field offset ≥256 silently truncates and lands inside the RX buffer — keep TCP state ahead ofSK_RXBUF. - Console ring vs. burst injection: one
net_pumpdrains a whole serial burst, so the 64-byte console ring must hold a full injected command line.
Roadmap
- [ ]
execrefinement: copy.datafrom ROM + zero.bssfor RW globals - [ ] more tools (
rev,grep,tail), true concurrent pipes - [ ] Preemptive scheduling: real context save in
TimerISR→hSwitchTo - [ ] Use the
$D000-$DFFFu-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)
