aboutsummaryrefslogtreecommitdiffstats
path: root/docs/internals.md
blob: f6300253cc9a6626197a27a8fba18d44e04dd0b4 (plain) (blame)
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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# 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), `nc` (raw TCP both ways: interactive, or `-s` to
pump stdin and print the reply), `irc` (a full client: channels, /join /msg
/me, nick colors, flood-safe rendering) and `chat`.

**Listening** (`net_op_listen` / `net_op_accept`): a listener stays a listener.
When a SYN arrives, `tcp_spawn_conn` clones a *new* socket from it (local port
+ owning pid), and `accept` hands that socket to the application - so a server
holds several clients at once, which is the whole point of `ircd`. Closing a
client socket leaves the listener untouched. `accept` never blocks: one RX pump
per call, like `recv_nb`. If the socket table is full the SYN is dropped, and
the client's retransmit is taken once a slot frees.

That makes **demux a four-tuple problem**: one local port now hosts a listener
plus a socket per client. `net_find_tcp` scans for an established socket
matching (local port, peer IP, peer port) and only falls back to a LISTENing
socket if none matches. Matching on local port alone - what it used to do -
hands every client's segments to whichever socket sits earliest in the table.
`tcp_peer_is_current` does the peer comparison and **preserves BC**, because
the scan keeps its socket index there.

**Receive-path invariants** (all three were bugs once, and each one is
invisible until a peer sends more than a toy reply):

- `recv`/`recv_nb` deliver `min(RXLEN, caller maxlen)` bytes. Copying the
  whole segment regardless of the caller's buffer size corrupts the calling
  process — a 200-byte segment into a 96-byte buffer smashes its stack.
  A short read keeps the tail in `RXBUF` for the next call.
- `tcp_buffer_data` **appends** at `RXBUF + RXLEN`, and a draining `recv`
  resets `RXLEN` to 0 (it is the append offset). A segment that does not fit
  is dropped *without* advancing `rcv_nxt`, and re-ACKed, so the peer
  retransmits when there is room. Overwriting unread bytes instead is silent
  data loss the application cannot detect.
- A **FIN is only end-of-stream when it arrives in order**. Accepting a FIN
  that sits past a gap marks the socket `TCP_DONE`, and `recv` then reports
  EOF at the hole — truncating the transfer just before the last segments.

`tcp_close` FINs and then pumps for the peer's FIN/ACK, but **stops as soon as
it arrives**, and in any case after **~250 ms of real time**. Both halves were
bugs. Lingering in `TCP_FINWAIT` made a server drop the next client's SYN, so
every request after the first paid a SYN retransmit (1.1s → 0.1s per request).
And the budget used to be counted in *pumps* (8 × 8000 ≈ 30s of wall clock when
the peer never answers): a well-behaved peer closes in milliseconds so nobody
noticed, until `ircd` refused a client that stayed connected and `close()` froze
the daemon - and every other client with it - for half a minute.

A server must also **drain the request it was sent**. Reading only the request
line leaves the rest unacknowledged: the client retransmits headers for a
minute, never sends its FIN, and its dead connection keeps arriving at the port
long after the server re-listens. One 1.4 KB request stalled every following
connection for 30-120s.

Sockets are owned by the pid that created them (`SK_OWNER`) and released by
`SockReleaseOwner` on exit *and* on kill — the table has only 4 entries, and a
killed `irc`/`nc`/`httpd` used to hold its slot until reboot.

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`).
`sys_pollcon` pumps the link itself before draining that ring, so a program
that never touches the network (`more`) still sees injected keys, and the
Game Boy keeps answering pings while it sits at a `--More--` prompt.

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, nlink, size, 12 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 **3 KiB**
  (`NDIRECT` = 12 direct blocks, the whole tail of the inode); **32** inodes,
  16 entries per directory. `sys_getb`/`sys_putb` **bounds-check the block
  index** - they did not, so a file that grew past the pointers simply kept
  walking into the *next inode*, reading its fields as block numbers. A file
  could round-trip fine (reads and writes aliased identically) and only show
  the damage once something else touched the neighbouring inode.
- **One-block data cache.** Two files open at once thrash it: `cp` of a 3 KiB
  file alternates read/write blocks per byte and takes ~25s. Correct, slow.
- **Open modes** are read / write / append. There is no seek syscall, so
  `O_APPEND` (open at EOF) is the only way to add to a file - which is what
  makes `>>`, and therefore building a multi-line script on the device,
  possible at all.
- **`sys_stat` / `sys_fsstat`** back `ls -l` and `df`. The syscall trap needs
  HL for its dispatch table, so a call taking both a path and an output
  buffer passes a request block, the shape SYS_NET already uses.
- **`/bin` is synthetic.** It is not on the disk: `dir_find` resolves "bin" in
  the root to `BIN_INO`, and names inside it through the kernel's own
  NameTable, so `ls /bin` shows exactly what the shell can run - one table,
  one truth. A program image is inode `BIN_FILE|progid`, `stat`s as a 16 KiB
  file (a program IS its ROM bank) and reads through `rom_getb`, which maps
  that bank, takes the byte, and maps the CALLER'S text bank back before
  returning - the caller is itself running from `$4000`. The whole tree is
  read-only: creating, removing or writing inside `/bin` fails.
  NB every `and BIN_FILE` test destroys A, so the inode has to be reloaded
  before `inode_ptr`; forgetting that once made `ls -l` stat inode 0 and
  report every file as an empty non-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.

**Scripts.** `sh` is the same program interactively and as a script runner: it
asks `isatty()` (SYS_ISATTY: is stdin the console?) and, when stdin is a file,
drops the prompt and the echo. EOF ends that shell - only pid 1 on the console
treats EOF as "power the machine off", which is why a script used to shut the
Game Boy down when it reached the end. init runs `/rc` at boot if it exists.

**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)