1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
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)
|