aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: 85a01d2497f17a04fad0550dea8000c33ea9eef1 (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
# 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.

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)

- 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   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 (PROG_SH)

init `exec`s a tiny shell: **prompt → read a line (with echo) → match a command
→ `fork`+`exec`+`wait`**. Commands live in a table (`worker`, `hello`). Runs
entirely over the headless serial console:

```
$ hello
hello world!
$ worker
worker!
$ foo
?
$
```

### 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

```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 (`c/`):

- `c/gbos.h` — the userland API (`writes`, `readc`, `nl`, `getpid`, `sexit`).
- `c/libc.s` — syscall wrappers (asxxxx asm). Each saves
  `BC/DE/HL` around `rst $30`, since the trap clobbers them but SDCC expects
  them preserved.
- `c/crt0.s` — entry: `call main`; then `exit(0)`. Linked first so `_start` is
  the `$4000` entry.
- `c/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 (`c/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 (`c/libc.c`, linked into every program): `putu`
(print decimal), `atou` (parse decimal), `argv_parse` (tokenize into argc/argv).

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:

```c
char *argv[8];
unsigned char argc = argv_parse(argv, 8);   /* args one two -> argc=3 */
```

### CLI tools (in `c/`)

| 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`.

```
$ 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
```

Each tool is a separate `c/<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()`.

## Filesystem

A tiny **RAM filesystem** lives in **WRAMX (`$D000-$DFFF`, 4 KiB)** — always
mapped in DMG mode and simultaneously accessible with a process's `$A000` data
bank, so the kernel copies between file and process memory with no bounce
buffer. 8 fixed slots of 512 bytes: `name[8]` + `len[2]` + `data[502]`. Seeded
with a `readme` at boot.

Syscalls: `open` (4), `close` (5), `getb` (12), `putb` (13, byte in `E`),
`list` (14), `remove` (15). Libc: `open`/`close`/`fgetc`/`fputc`/`flist`/
`fremove` (`c/libc.s`).

```
$ save notes
gbos has a filesystem now
$ cat notes
gbos has a filesystem now
$ wc notes
1 5 26
$ ls
readme
notes
$ rm notes
```

> **ABI note:** the syscall trap indexes its jump table with `A`, so syscall
> args go in `B`/`DE`/`E` (never `A`) and results come back in `A`. That's why
> `putb` takes its byte in `E`.

## Roadmap

- [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
- [ ] more tools (`rev`, `grep`, `tail`), shell redirection (`>`/`|`)
- [ ] battery-backed files (move the FS to cart SRAM), directories
- [ ] 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()`
```