Initial commit: Schunix as it stands before the SchOS rename
An x86-64 operating system built from xv6, now largely rewritten: SMP, a copy-on-write root filesystem (SchFS), PCI/USB/virtio drivers, a TTF rasteriser, and a Linux-numbered syscall ABI. No history before this point -- the tree was never under version control. This commit exists so the rename that follows has something to undo to.
This commit is contained in:
commit
d0f7acf9eb
275 changed files with 51417 additions and 0 deletions
666
docs/architecture/kernel-history.md
Normal file
666
docs/architecture/kernel-history.md
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
# Kernel history — what broke, and what the fix actually was
|
||||
|
||||
Not a design document. This is the record of bugs that were expensive to find
|
||||
and whose causes are not visible from the code that fixed them, plus the few
|
||||
subsystem write-ups (bootloader, boot speed, naming) that have nowhere better
|
||||
to live.
|
||||
|
||||
It is worth keeping because every entry here is a thing that looked like a
|
||||
different problem than it was.
|
||||
|
||||
> **Where the old design plan went.** Sections 1-6 of this file used to hold a
|
||||
> "Segregated Kernel" design: a Ring 0 Alpha / Ring 0 Beta split with driver
|
||||
> modules isolated by CR3 switching, from an `IDEA.txt` that no longer exists.
|
||||
> None of it was ever implemented, and it is superseded by
|
||||
> [schapi.md](schapi.md) — a macOS-shaped system answers driver isolation with
|
||||
> userspace drivers and a personality boundary, not a bespoke in-kernel ring
|
||||
> split. It also referenced a Zig toolchain that is now clang/LLVM. Removed
|
||||
> rather than left to be mistaken for a plan.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fixed: boot didn't reliably reach a working shell
|
||||
|
||||
Investigated 2026-07-05, before phase 1 started (predates this design, not
|
||||
caused by it). Originally manifested as `ksh` (mksh) crashing on every
|
||||
launch, apparently inside musl's mallocng allocator (`get_meta`,
|
||||
`src/malloc/mallocng/meta.h`, deliberately executing `hlt` on detecting
|
||||
"corrupted" heap metadata — a `trap 13` GPF at a fixed rip). It wasn't a
|
||||
musl bug. Six real kernel bugs were found and fixed across three
|
||||
investigation passes, and mksh boots and runs cleanly with all of them in
|
||||
place — the calloc assertion was a symptom of real memory/pointer-handling
|
||||
bugs downstream in the kernel, not anything musl or mallocng did wrong.
|
||||
|
||||
Fixed:
|
||||
- `copyuvm()` (`kernel/arch/x86_64/vm.c`, used by `fork()`) never copied
|
||||
`mmap()`-backed `proc->vma[]` regions into the child — only `[0, sz)`.
|
||||
Fixed via new `copy_vma_range()`, called from `fork()` (`kernel/proc/proc.c`).
|
||||
- `kmem.freelist` (`kernel/core/kalloc.c`) could get corrupted by a wild
|
||||
write elsewhere and would silently hand back a bad pointer, panicking far
|
||||
from the actual cause. Added a canary check (`check_run()`) at the top of
|
||||
both `kalloc()`/`kfree()` so corruption panics immediately at the point
|
||||
it's touched, with a clear message.
|
||||
- No TLB invalidation existed anywhere except the full `lcr3` reload on
|
||||
context switch — `mappages()`/`vm_unmap_range()` never called `invlpg`.
|
||||
Fixed by adding `invlpg()` (`include/x86.h`) to both functions.
|
||||
- **`MSR_STAR` was misconfigured** (`syscallinit()`, `kernel/arch/x86_64/vm.c`):
|
||||
`SYSRETQ` computes the returned `cs`/`ss` as `STAR[63:48]+16`/`+8`, which
|
||||
requires the *compat* 32-bit code selector (`SEG_UCODE32`) in that field —
|
||||
the kernel was putting the real 64-bit `USER_CS` there instead. Every
|
||||
`sysretq` silently loaded an invalid `cs` (`0x3b`, pointing at the TSS
|
||||
descriptor)/`ss` pair; QEMU's TCG doesn't validate segment descriptors on
|
||||
`SYSRET`, so this went unnoticed until a hardware interrupt forced a
|
||||
strictly-validated `IRETQ` with that bogus `cs` still loaded, which
|
||||
correctly `#GP`'d.
|
||||
- `sys_mmap`'s non-`MAP_FIXED` path (`kernel/sys/sys_mem.c`) assigned a fixed
|
||||
8MB virtual-address window per `vma[]` slot regardless of the requested
|
||||
length. A single allocation over 8MB (e.g. `realloc()` growth past that
|
||||
size) mapped straight into a neighboring live mapping and hit
|
||||
`mappages()`'s `panic("remap")`. Replaced with a real bump allocator
|
||||
(`proc->mmap_next`, `include/proc.h`), so every mapping gets its own
|
||||
disjoint, exactly-sized range regardless of size.
|
||||
- **`uva2ka()`** (`kernel/arch/x86_64/vm.c`) returned only the page-aligned
|
||||
base address of a user pointer's page, silently dropping the in-page byte
|
||||
offset. Direct callers (`sys_ioctl`'s `TIOCGPGRP`/`TCGETS`, `sys_getcwd`,
|
||||
signal masks, `times()`, `sysinfo()`, ...) read/wrote near the wrong
|
||||
address whenever the pointer wasn't page-aligned — i.e. almost always.
|
||||
Fixed at the source so every caller benefits.
|
||||
- **`fetchint`/`fetchaddr`/`fetchstr`/`argptr`** (`kernel/sys/syscall.c`)
|
||||
rejected any address `>= proc->sz`, wrongly assuming every valid user
|
||||
pointer lives in the heap. Stack addresses and `mmap()`'d buffers (used
|
||||
constantly by real programs, e.g. path-resolution buffers) live above
|
||||
`proc->sz` and were being rejected outright. Fixed to rely solely on
|
||||
`uva2ka()`'s page-table validation.
|
||||
|
||||
Bringing in toybox (see "Coreutils" below) surfaced four more, all fixed:
|
||||
- Linux syscall 58 (`vfork`) wasn't in the syscall-ABI translation table at
|
||||
all (`kernel/sys/linux_abi.c`) — every external command spawn from a
|
||||
program that prefers `vfork()` over `fork()` failed outright. Mapped to
|
||||
the existing `fork()` (a fully correct, if slightly slower, substitute).
|
||||
- `sys_stat`/`fstat`/`fstatat` (`kernel/sys/sys_fs.c`) filled in Schunix's
|
||||
own native `struct stat` layout regardless of the caller — musl/toybox
|
||||
parse a fixed Linux ABI byte layout instead, so `st_mode` was garbage and
|
||||
tools like `ls` couldn't tell files from directories, or executables from
|
||||
non-executables. Added `struct linux_stat` + `stati_linux()`, gated on
|
||||
Linux-ABI mode (`proc->syscall_map`).
|
||||
- `sys_open`/`sys_openat` required `omode == O_RDONLY` *exactly* to open a
|
||||
directory, but `opendir()`-style calls legitimately OR in `O_DIRECTORY`/
|
||||
`O_CLOEXEC` alongside it. Fixed to check just the access-mode bits
|
||||
(`O_ACCMODE`, added to `include/fcntl.h`).
|
||||
- `sys_getdents64` read one `struct dirent` (16 bytes) at a time — a full
|
||||
`bmap()`+`bread()` per slot. Directories here are hash-indexed (see
|
||||
`tree_search()`/`tree_expand()` below), so a directory with a few dozen
|
||||
real entries can have a size in the hundreds of KB, nearly all empty
|
||||
slots — this made listing such a directory look hung (it wasn't a
|
||||
deadlock, just thousands of redundant single-slot disk reads). Rewrote it
|
||||
to read a full block at a time and resolve each entry's type via
|
||||
`iget(inum)` (exported from `fs.c`, it was `static`) instead of
|
||||
re-searching the whole directory by name with `dirlookup()` for every
|
||||
entry. That rewrite's first version introduced a **new** self-deadlock:
|
||||
the "." entry's inode number equals the directory's own, and `iget()`
|
||||
correctly hands back the *same, already-locked* `struct inode*` — calling
|
||||
`ilock()` on it again sleeps forever on a lock the process already holds.
|
||||
Fixed by special-casing `xde->inum == ip->inum` (we already know it's a
|
||||
directory, no need to re-lock at all).
|
||||
|
||||
A synthetic stress test (interleaved malloc/free, realloc growth crossing
|
||||
`MMAP_THRESHOLD`, calloc, large allocations) runs to completion with zero
|
||||
crashes, and mksh now boots straight to an interactive prompt, runs external
|
||||
programs, pipes, and `ls`/`mkdir`/`grep` without issue.
|
||||
|
||||
## 2. Naming standardization
|
||||
|
||||
Schunix standardizes on exactly one name per role, regardless of the
|
||||
underlying implementation: the shell is always installed and exec'd as
|
||||
`/bin/sh` (currently mksh, built from `ports/mksh/`), and PID 1 is always
|
||||
`/bin/init`. `cmd/init/main.c`'s exec loop only ever tries `/bin/sh` — no
|
||||
ksh/mksh/dash/zsh/other-name fallbacks. dash was tried first
|
||||
(BSD-licensed, minimal) while the boot crash above was still open, then
|
||||
replaced with mksh once the underlying kernel bugs were fixed and dash's
|
||||
lack of interactive features (history, completion, line editing) made it
|
||||
feel unusably bare for day-to-day use; the dash port's source tree was left in
|
||||
place rather than deleted, in case it's useful again (e.g. as a `/bin/sh`
|
||||
POSIX-strict target distinct from an interactive login shell).
|
||||
|
||||
## 3. Coreutils
|
||||
|
||||
`ports/toybox/` (0BSD-licensed, https://landley.net/toybox/) provides `ls`,
|
||||
`cat`, `cp`, `mv`, `rm`, `mkdir`, `grep`, `sed`, `find`, `chmod`, `date`,
|
||||
`sort`, and more — see the `APPLETS` list in `ports/toybox/Makefile`. It builds
|
||||
as a single multicall binary; `tools/mkfs.c` doesn't understand symlinks or
|
||||
hardlink dedup, so `ports/toybox/Makefile`'s `install` target copies the built
|
||||
binary once per applet name into `sysroot/bin/` rather than symlinking
|
||||
(costs disk space per applet — currently ~44MB total — instead of one
|
||||
shared inode, but needed no mkfs changes).
|
||||
|
||||
## 4. Two-stage bootloader + PSOGM splash
|
||||
|
||||
`boot/` used to be a single BIOS boot sector doing everything (VBE mode
|
||||
set, A20, GDT, protected-mode switch, multiboot kernel scan/load) — silent,
|
||||
and packed to within 4 bytes of the hard 510-byte MBR limit, so there was
|
||||
no room to add so much as a print statement.
|
||||
|
||||
Split into two stages, the standard way real bootloaders solve this:
|
||||
|
||||
- **Stage 1** (`boot/bootasm.S`, still the literal MBR sector, still
|
||||
≤510 bytes) does exactly one thing: raw IDE PIO (LBA28) reads
|
||||
`STAGE2_SECTORS` (16) sectors starting at disk sector 1 into memory at
|
||||
`0x7E00`, then jumps there. Nothing else fits, nothing else is needed.
|
||||
(Caught a real bug writing this: `outb $0x1F7,%al`-style immediates
|
||||
silently truncate to 8 bits — ports 0x1F2-0x1F7 are all > 0xFF and need
|
||||
the `%dx` register form, or you're hitting the wrong I/O port entirely.)
|
||||
- **Stage 2** (`boot/stage2.S` + `boot/stage2main.c`, loaded at `0x7E00`,
|
||||
occupies disk sectors 1-16) does everything stage 1 used to skip straight
|
||||
to, plus a boot banner (BIOS teletype, real mode, before the VBE mode
|
||||
switch — note this only reaches the video device, not serial, so it
|
||||
won't show up in a `-serial file:...`-captured log), a splash screen, a
|
||||
real-time loading bar, and an F8 boot menu (all described below). Not
|
||||
size-constrained the same way as stage 1 — `tools/padstage2.pl` zero-pads
|
||||
it out to a fixed `STAGE2_SECTORS*512` (8192) bytes so the kernel's
|
||||
on-disk starting sector stays predictable regardless of stage2's actual
|
||||
compiled size, and errors out (like `tools/sign.pl` does for stage 1) if
|
||||
it ever grows past that budget instead of silently corrupting the layout.
|
||||
|
||||
The kernel now starts at disk sector 17 instead of 1 (`schunix.img`'s `dd
|
||||
... seek=17` in the root `Makefile`, and the equivalent `+(1+STAGE2_SECTORS)`
|
||||
in `stage2main.c`'s `readseg()` — all three places that encode
|
||||
`STAGE2_SECTORS` have to agree, or the kernel won't be found on disk at
|
||||
all). `boot/bootmain.c` no longer exists; its logic was split between
|
||||
`stage2.S` (real-mode/protected-mode setup) and `stage2main.c` (splash +
|
||||
the unchanged multiboot-header-scan-and-load logic).
|
||||
|
||||
The VBE mode info block itself lives at `0x30000`, not the classic
|
||||
single-stage bootloader's `0x9000` — stage2's own code now runs from
|
||||
`0x7E00` up to nearly `0x9000` once the boot-menu/progress-bar logic is
|
||||
included, and a block at `0x9000` gets partially overwritten by stage2's
|
||||
own tail end (found this the hard way: the kernel's `LFB:` cprintf started
|
||||
reporting a different wrong value on every boot, because it was reading
|
||||
whatever stage2 code/data now happened to occupy that address).
|
||||
`kernel/dev/vga.c`'s `vga_init()` has to agree on `0x30000` too, since it
|
||||
independently re-reads mode fields from the same block.
|
||||
|
||||
Splash/progress bar/boot menu rendering is a small standalone pixel
|
||||
renderer in `stage2main.c` (not the kernel's `vga.c` — paging isn't set up
|
||||
yet, so the LFB physical address from the VBE block is used as a plain
|
||||
pointer) reusing the kernel's own font data (`include/font.h`) directly.
|
||||
It fills the framebuffer with PSOGM's purple (matching
|
||||
`kernel/core/console.c`'s panic screen) and draws "SCHUNIX" centered, then:
|
||||
|
||||
- **Real-time loading bar**: `readsect()` (in `stage2main.c`, not the
|
||||
kernel) calls `progress_tick()` after every 512-byte disk read, updating
|
||||
an actual pixel-filled bar against the real total byte count — first a
|
||||
small one while scanning for the multiboot header, then a second,
|
||||
properly-sized one once the header's found and the true kernel size is
|
||||
known, labeled "Loading kernel...". Not simulated/timed — it reflects
|
||||
real bytes read off disk.
|
||||
- **F8 boot menu**: `wait_for_f8()` busy-polls the keyboard controller
|
||||
(`inb(0x64)`/`inb(0x60)`, raw scancodes — real-mode BIOS keyboard calls
|
||||
aren't available once we're in protected mode) for about a second after
|
||||
the splash appears. F8 (scancode `0x42`) drops into `boot_menu()`: three
|
||||
options (boot normal, boot verbose, reboot to firmware setup) navigated
|
||||
with up/down arrows and Enter, plus a "detected drives" panel from
|
||||
`ata_present()` — a standard floating-bus presence check (select the
|
||||
drive, write a pattern to two registers, read it back) on the primary
|
||||
IDE channel's master/slave (the only channel this project ever actually
|
||||
puts drives on; QEMU's IDE emulation answers the same test
|
||||
"successfully" on the unpopulated secondary channel too, so that channel
|
||||
isn't shown — it'd just be confidently wrong). "Reboot to firmware
|
||||
setup" pulses the keyboard controller's reset line
|
||||
(`outb(0x64, 0xFE)`, same technique as `kernel/sys/sys_proc.c`'s
|
||||
`sys_reboot()`); a real bootloader can't jump into BIOS/UEFI setup
|
||||
directly, so this is the closest honest equivalent — it gives the user
|
||||
a fresh POST during which their firmware's own setup-menu key works.
|
||||
- **Verbose boot**: picking "Boot Schunix (verbose)" writes a flag byte to
|
||||
`BOOT_VERBOSE_FLAG_ADDR` (`VBE_INFO_ADDR + 0x100`, i.e. just past the VBE
|
||||
block) before jumping to the kernel. `kernel/core/main.c`'s `kmain()`
|
||||
reads it into a global `boot_verbose` as its very first action and, if
|
||||
set, prints a line after every subsystem init call
|
||||
(`uartearlyinit`, `kinit1`, ..., `sinit`) — real per-step boot logging,
|
||||
not a canned message.
|
||||
|
||||
Two bugs worth remembering if this code gets touched again:
|
||||
|
||||
- **Keyboard extended-scancode handling**: `0xE0` (the prefix byte for
|
||||
arrow keys and friends) also has bit 7 set, the same bit used to detect
|
||||
key-*release* ("break") codes. A naive "skip anything with bit 7 set,
|
||||
then check for 0xE0" loop skips the 0xE0 byte itself before ever testing
|
||||
for it, so extended keys silently never register. The 0xE0 check has to
|
||||
come before the break-code check, not after.
|
||||
- **The kernel entry call is not a real C function call.** Both the
|
||||
original single-stage `bootmain()` and the new `stage2main()` do
|
||||
`((void(*)(uint32))entry)(fbaddr)` — normal cdecl, which pushes `fbaddr`
|
||||
on the stack. But `kernel/arch/x86_64/entry.S`'s `mboot_entry` reads its
|
||||
framebuffer argument out of **EAX** (a multiboot-style raw-register
|
||||
handoff, then `mov %eax,%ebp` / `mov %ebp,%ebx` / `mov %rbx,%rdi` carries
|
||||
it through the 32→64-bit transition into `kmain()`'s first argument).
|
||||
The cdecl call never puts anything in EAX — it "worked" for years purely
|
||||
because simple bootmain.c code happened, as a side effect of its own
|
||||
codegen, to leave the right value sitting in EAX right up until the
|
||||
`call` instruction. Adding the splash/menu/progress-bar code changed
|
||||
that codegen and broke it silently: the kernel booted fine and reported
|
||||
a plausible-looking but wrong `LFB:` address (garbage left over in EAX
|
||||
from unrelated prior code, different on every boot). Fixed by pinning
|
||||
the argument down explicitly with `asm volatile("jmp *%0" : : "r"(entry),
|
||||
"a"(fbaddr))` instead of hoping the compiler's register allocation
|
||||
cooperates. If this code moves around again, re-verify with a screendump
|
||||
taken *immediately* before the jump compared against the kernel's own
|
||||
`LFB:` log line — they silently diverging is exactly this bug.
|
||||
|
||||
## 5. `*at()` syscalls ignored/rejected real dirfds
|
||||
|
||||
`ls -l`/`ls -lR` (and, by the same code path, `find`/`du`/anything using
|
||||
toybox's `lib/dirtree.c`) would hang on `ls -l` and either hang or silently
|
||||
misbehave on recursive listings. Root cause: `kernel/sys/sys_fs.c`'s
|
||||
`*at()` syscalls (`openat`, `fstatat`, `unlinkat`, `renameat`, `linkat`,
|
||||
`symlinkat`, `mkdirat`, `mknodat`, `readlinkat`, `faccessat`) only handled
|
||||
two cases for their `dirfd` argument — `AT_FDCWD` (resolve relative to
|
||||
`proc->cwd`) or an absolute path (resolve from root, ignoring `dirfd`
|
||||
entirely). A **real** dirfd with a **relative** path — e.g.
|
||||
`fstatat(open("/bin", O_RDONLY), "ls", &st, ...)`, the standard, efficient
|
||||
way any directory-tree walker avoids rebuilding full path strings for every
|
||||
entry — was either rejected outright (`fstatat`, `unlinkat`, ...) or
|
||||
silently ignored in favor of `proc->cwd` (`openat`, which never checked at
|
||||
all — worse than rejecting, since it can silently resolve the *wrong*
|
||||
file if cwd happens to contain a same-named entry).
|
||||
|
||||
Fixed by adding a real dirfd-relative path resolver: `kernel/fs/fs.c`'s
|
||||
`namex()` was refactored into `namex_from(struct inode *start, ...)`,
|
||||
parameterizing what used to be a hardcoded `idup(proc->cwd)`, with two new
|
||||
exported entry points, `nameifrom()`/`nameiparentfrom()`. `sys_fs.c` gained
|
||||
`at_dir(dirfd)` (resolves `AT_FDCWD` or a real fd to a starting inode) and
|
||||
`at_namei()`/`at_nameiparent()` built on top of it, plus a `createat()`
|
||||
(parameterizing `create()`'s hardcoded `nameiparent()` call the same way).
|
||||
Every `*at()` syscall now uses these instead of its own inline
|
||||
absolute-path/`AT_FDCWD`-only check.
|
||||
|
||||
**Known follow-on issue, not yet root-caused**: heavy `ls`/`ls -lR` use
|
||||
across many commands in one long-running shell session can eventually make
|
||||
a *later*, unrelated directory hang (reproduced with `ls -lR /var` after
|
||||
~10 prior commands in the same session; the identical command succeeds
|
||||
immediately on a fresh boot). Not an inode-table or file-table exhaustion
|
||||
— both `iget()` and `filealloc()` fail cleanly (panic / return 0
|
||||
respectively) when full, and neither fired. Smells like a lock or
|
||||
reference-count leak somewhere in the *at() paths above, or in toybox's
|
||||
own fd handling, that only manifests after enough accumulated opens/closes.
|
||||
Next step: instrument `itable`/`ftable` occupancy counts and watch them
|
||||
across a long `ls`-heavy session to see what's actually climbing.
|
||||
|
||||
## 6. Boot speed
|
||||
|
||||
Two cheap, real wins in `boot/stage2main.c`, no functional changes:
|
||||
|
||||
- `progress_tick()` (called once per 512-byte disk sector) used to trigger
|
||||
a full progress-bar redraw — border plus fill, a few thousand pixel
|
||||
writes — on *every* sector. For the ~330KB kernel image that's ~650 full
|
||||
redraws (the multiboot-header-scan phase adds another ~32) for a bar
|
||||
that only has 101 visually distinct states (0-100%). Split into
|
||||
`progress_border()` (drawn once, in `progress_start()`, since the border
|
||||
never changes) and `progress_fill()`, and `progress_tick()` now skips the
|
||||
redraw entirely unless the percentage actually changed. This was the
|
||||
single biggest chunk of the bootloader's own wall-clock time.
|
||||
- `fill_rect()` (the full-screen splash fill, 480,000 pixels at 800x600,
|
||||
run at least twice per boot) used to call `draw_pixel()` per pixel, which
|
||||
recomputes `g_fb + y*stride + x*4` from scratch every time. Now computes
|
||||
each row's base pointer once and indexes into it.
|
||||
- The F8 boot-menu wait was shortened from ~1s (`5000000` busy-loop
|
||||
iterations) to ~0.6s (`3000000`) — flat overhead on every boot whether
|
||||
or not the menu is wanted, so it's the most direct lever available
|
||||
without giving up the feature entirely.
|
||||
|
||||
## 7. `*at()` syscalls, real signal delivery, and mksh job control (in progress)
|
||||
|
||||
`ls -l`/`ls -la` hanging outright turned out to be the same root cause
|
||||
described in section 11 above (dirfd-relative path resolution) — fixed
|
||||
and verified extensively (plain `ls`, `ls -l`, `ls -la`, `ls -lR`, piped
|
||||
output, all confirmed working repeatedly).
|
||||
|
||||
A *second*, deeper issue was found investigating a follow-up report
|
||||
(`cd` with no `HOME` set, then `cd var`, then `ls` hanging): mksh's
|
||||
interactive job control (`ports/mksh (mksh R59c source): jobs.c`) blocks in
|
||||
`sigsuspend()` waiting for `SIGCHLD`, expecting its own handler
|
||||
(`j_sigchld()`) to run, reap the child via `waitpid(-1, &status, WNOHANG)`,
|
||||
and update its internal job-state tracking. This kernel had **no real
|
||||
signal delivery at all** — `sys_rt_sigaction()` was a no-op stub, so no
|
||||
handler was ever invoked for anything, and `sigsuspend()` just slept out
|
||||
one tick and returned `-EINTR` unconditionally. mksh's wait loop had no
|
||||
way to ever learn the child had exited, hanging forever.
|
||||
|
||||
Real signal delivery was implemented from scratch:
|
||||
- `struct proc` (`include/proc.h`) gained `sig_handler[32]`/
|
||||
`sig_restorer[32]` (populated by `sys_rt_sigaction()`,
|
||||
`kernel/sys/sys_sig.c`), `saved_sigtf` (stashed pre-signal trapframe),
|
||||
and `in_signal` (re-entrancy guard). `allocproc()` zeroes these for a
|
||||
freshly claimed ptable slot (they weren't before — a reused slot
|
||||
silently inherited a previous, unrelated process's handler addresses,
|
||||
which crashed the *next* process to occupy that slot the moment
|
||||
anything tried to delivver a signal to it). `fork()` copies handler/
|
||||
restorer/mask from parent to child (POSIX semantics) but not pending
|
||||
signals.
|
||||
- `kernel/proc/proc.c`'s `exit()` sets `SIGCHLD` pending on the parent.
|
||||
- `kernel/sys/syscall.c`'s `syscall()` calls `deliver_signals(tf)` in its
|
||||
tail, right before returning to user mode: if there's a pending,
|
||||
unblocked signal with a registered handler, it redirects the trapframe
|
||||
to jump to the handler (`rdi`=signal number per SysV ABI, `rip`=handler)
|
||||
instead of resuming the interrupted code, having first stashed the
|
||||
original trapframe in `proc->saved_sigtf`.
|
||||
- The return address is **not** `sig_restorer[sig]` — checked, `SA_RESTORER`
|
||||
is not defined anywhere in this musl build, so `sigaction()` never fills
|
||||
in a restorer; it's always 0, and pushing 0 as a return address crashes
|
||||
with `rip=0` the instant the handler function returns. Instead,
|
||||
`deliver_signals()` injects a tiny 7-byte trampoline (`mov eax,15;
|
||||
syscall` — `rt_sigreturn`) directly onto the user's own stack, below the
|
||||
current `rsp` (clear of the SysV red zone), and points the return
|
||||
address at that — the standard technique for when the C library doesn't
|
||||
supply its own restorer.
|
||||
- `sys_rt_sigreturn()` restores `*proc->tf` from `proc->saved_sigtf`,
|
||||
clearing `in_signal`.
|
||||
|
||||
**A real bug found and fixed along the way**: the first version of
|
||||
`sys_rt_sigaction()` translated the user's `struct sigaction` pointer
|
||||
*once* via `uva2ka()` and then did a bulk `memset`+copy across the whole
|
||||
~152-byte structure. `uva2ka()` only guarantees the *page containing that
|
||||
address* is mapped, not everything up to some further offset — when the
|
||||
user's pointer landed close enough to a page boundary, the write ran past
|
||||
the mapped page into whatever physical page happened to be next in kernel
|
||||
address space (P2V is a flat offset, so "next page" there has nothing to
|
||||
do with the user's actual next page), corrupting unrelated kernel memory.
|
||||
This crashed every freshly `exec`'d `/bin/sh` almost immediately, since
|
||||
mksh calls `sigaction()` during its own startup. Fixed by only touching
|
||||
the two individual 8-byte fields actually needed (`sa_handler` at offset
|
||||
0, `sa_restorer` at offset 144), each through its own `uva2ka()` call.
|
||||
|
||||
`sys_waitpid()`/`sys_wait4()` (`kernel/sys/sys_proc.c`) were also rewritten
|
||||
on top of a new `waitpid(pid, status, options)` in `kernel/proc/proc.c`
|
||||
(refactored from the old any-child-only, always-blocking `wait()`, which
|
||||
is now just `waitpid(-1, 0, 0)`): a specific `pid` (not just "any child"),
|
||||
`WNOHANG` (return 0 immediately instead of blocking), and — importantly —
|
||||
a genuinely optional status pointer. The old code ran a NULL status
|
||||
pointer through `argptr()`, which rejects NULL/unmapped addresses outright
|
||||
(page 0 is deliberately unmapped) — but `waitpid(pid, NULL, WNOHANG)` is
|
||||
completely standard, and is exactly what mksh's job control does.
|
||||
|
||||
**Status: signal delivery mechanically works** — verified directly (a
|
||||
debug build logged the handler being invoked with the correct trapframe
|
||||
and `sys_rt_sigreturn()` restoring cleanly, no crash, repeatable). **mksh's
|
||||
job control still doesn't reliably progress afterward**, including for
|
||||
plain non-piped foreground commands, not just the original piped-into-head
|
||||
case. Root cause not yet isolated. Leading suspects, in order of
|
||||
likelihood:
|
||||
- `sys_setpgid()`/`sys_getpgid()` are no-op stubs (`kernel/sys/sys_proc.c`)
|
||||
— `j_sigchld()` (`ports/mksh (mksh R59c source): jobs.c:1344-1348`) has an early-out
|
||||
keyed on `j->ppid == procpid`, which may never resolve the way mksh
|
||||
expects without real process-group tracking.
|
||||
- `getrusage(RUSAGE_CHILDREN, ...)` — called at the top of `j_sigchld()` —
|
||||
isn't verified to behave sanely under this kernel's implementation
|
||||
(`kernel/sys/sys_proc.c`'s `sys_getrusage()` only fills in `ru_utime`
|
||||
from the *calling* process's own tick count, not accumulated child
|
||||
time; `RUSAGE_CHILDREN` isn't distinguished from `RUSAGE_SELF` at all).
|
||||
- Possibly something in how `j_sigchld()` matches a reaped `pid` against
|
||||
its own `job_list` (`ports/mksh (mksh R59c source): jobs.c:1372-1385`) silently fails
|
||||
to find the job for reasons not yet traced.
|
||||
|
||||
This was left here deliberately rather than continuing to iterate blind:
|
||||
SIGCHLD delivery is left **enabled** (`kernel/proc/proc.c`'s `exit()`)
|
||||
since disabling it guarantees zero chance of mksh's wait loop ever exiting,
|
||||
whereas leaving it on at least gives the cases that don't hit the
|
||||
still-unknown remaining gap a chance to work. Next session should start
|
||||
by instrumenting `j_sigchld()`'s own control flow (or strace-equivalent
|
||||
logging in the kernel around `sys_setpgid`/`sys_getrusage`/`sys_waitpid`
|
||||
calls specifically while reproducing `cd; cd var; ls` or even simpler
|
||||
`ls -l /bin` followed by a second command) rather than re-deriving the
|
||||
signal-delivery mechanism, which is now confirmed solid.
|
||||
|
||||
---
|
||||
|
||||
## 8. Two ABI assumptions that only GCC was keeping true
|
||||
|
||||
Found when the build moved from GCC-in-WSL to clang-on-Windows. Neither is a
|
||||
clang bug; both are places where the code relied on GCC doing something it
|
||||
never promised, and both had been latent for years.
|
||||
|
||||
### `%fs:0` was being used as scratch — it is the TCB self-pointer
|
||||
|
||||
`syscall_entry` (`kernel/arch/x86_64/trapasm.S`) stashed `%rax` at `%fs:(0)`
|
||||
while it fetched `proc` to find the kernel stack. The comment called it a
|
||||
"per-CPU scratch slot". It is not: the x86-64 TLS ABI reserves the word *at*
|
||||
the thread pointer to hold the thread pointer, and a compiler may read it to
|
||||
materialise the TP before applying a variable's offset.
|
||||
|
||||
GCC compiles `__thread` access to a direct `%fs:-8` segment reference and never
|
||||
reads that word, so the clobber was invisible. clang at `-O0` emits
|
||||
`movq %fs:0, %rax; addq $-8, %rax; (%rax)` — reading back the syscall number
|
||||
this code had just stored. The first process's first syscall is
|
||||
`exec` (7), so `proc` resolved to address `7 - 8 = -1` and the kernel
|
||||
page-faulted recursively until the stack was gone.
|
||||
|
||||
Fixed by moving the scratch to `%fs:(8)`. Positive offsets are safe: the TLS
|
||||
block lives at negative offsets and `seginit` places the thread pointer 2048
|
||||
bytes into a 4096-byte page.
|
||||
|
||||
**How it was found** is the part worth copying next time. Three rounds of
|
||||
`cprintf` narrowed it to "the word is right at `mpmain`, wrong by the first
|
||||
trap" and no further — the instrumentation perturbed the timing enough to move
|
||||
the symptom. A hardware watchpoint through QEMU's gdb stub named the
|
||||
instruction on the first run:
|
||||
|
||||
```
|
||||
qemu ... -S -gdb tcp::26000
|
||||
gdb -batch -x tcp.gdb # hbreak mpmain; watch *(unsigned long*)kernel_fs_base
|
||||
```
|
||||
|
||||
`seginit` also now writes the self-pointer, which it never did. That is ABI
|
||||
-correct regardless and costs one store per CPU.
|
||||
|
||||
### `_start` read incoming registers with `register ... asm("rdi")`
|
||||
|
||||
`lib/glue.c` captured `argc`/`argv` from `%rdi`/`%rsi` using GCC local register
|
||||
variables. Both compilers only guarantee that binding for inline-asm operands;
|
||||
using it to pick up an incoming argument is undefined. clang ignored it and
|
||||
read two uninitialised stack slots:
|
||||
|
||||
```asm
|
||||
movl -0x4(%rbp), %eax # should have been %edi
|
||||
movq -0x10(%rbp), %rax # should have been %rsi
|
||||
```
|
||||
|
||||
Every program still ran. `init`, `shmtest` and `socktest` take no arguments and
|
||||
passed their entire suites; the only symptom in the whole system was
|
||||
`inputtest -n 6` ignoring its limit. Moved to `lib/start.S`, where reading
|
||||
incoming registers is expressible and correct — `exec` passes argc/argv in the
|
||||
SysV argument registers already, so `_start` calls `main` without touching
|
||||
them.
|
||||
|
||||
---
|
||||
|
||||
## 9. The boot looked like a hobby OS, and two things were why
|
||||
|
||||
Not a bug — a design problem, fixed by looking at a screenshot instead of the
|
||||
code.
|
||||
|
||||
**The display was the kernel log.** Boot printed `pci: 6 devices` and
|
||||
`vga: display controller 1234:1111, aperture fd000000 (16 MB)` to the screen
|
||||
the user is looking at. That single fact is most of what separates a hobby
|
||||
project from a product; every shipping system puts that behind a flag.
|
||||
|
||||
`cons_serial_only` already existed for panic, so the fix was to start it *set*.
|
||||
Everything still goes out the UART — which is where `tools/bootcheck.ps1` reads
|
||||
it from, so all five suites kept passing untouched — and `console_takeover()`
|
||||
clears it on the first `exec()`, the moment userspace genuinely starts.
|
||||
`vga_init` no longer clears the screen, so the splash survives the whole of
|
||||
boot rather than being wiped a few hundred milliseconds in.
|
||||
|
||||
**There were three unrelated designs.** The splash was deep purple with gold
|
||||
text, deliberately matching the *panic* screen. The console was cream with
|
||||
near-black text. So a healthy boot was dressed as a crash, then abruptly
|
||||
became an amber terminal. `include/theme.h` is now the one palette, and the
|
||||
rule is: **normal states are calm and dark; only failure is loud.** PSOGM keeps
|
||||
the purple entirely to itself, which is what makes it mean something.
|
||||
|
||||
The splash copy went from "Schunix Boot Manager / version 1.0 / Memory: 1023 MB
|
||||
/ Press F8 for boot menu..." to the product name, a rule in the accent colour,
|
||||
and the one hint that is actionable — moved to the bottom, where a hint
|
||||
belongs. No shipping OS names its bootloader on the splash.
|
||||
|
||||
### Three latent bugs it flushed out
|
||||
|
||||
Retheming meant `stage2main.c` included `types.h` properly for the first time,
|
||||
and the 32-bit build stopped compiling:
|
||||
|
||||
- `_Static_assert(sizeof(uint64) == 8)` fired. `uint64` was `unsigned long`,
|
||||
which is **4 bytes** in the `-m32` loader — the file even carried a local
|
||||
`e820_u64` workaround with a comment saying so. Now conditioned on
|
||||
`__SIZEOF_LONG__` rather than on the platform, which covers LP64, LLP64 *and*
|
||||
ILP32 correctly.
|
||||
- `x86.h`'s `stosq` and `xchg` use `rep stosq` and `xchgq`, which do not exist
|
||||
in 32-bit mode. They only ever compiled because `uint64` was silently
|
||||
narrowed. Now behind `#ifdef __x86_64__`.
|
||||
- The progress bar computed `(uint64)done * 100 / total`, a 64-bit divide that
|
||||
needs libgcc's `__udivdi3` — which a freestanding loader does not link. Now
|
||||
scaled to KiB and done in 32 bits.
|
||||
|
||||
All three had been sitting in a bootloader that worked fine, waiting for
|
||||
someone to make `uint64` mean what it says.
|
||||
|
||||
---
|
||||
|
||||
## 10. A full-screen program typed the whole session into the shell
|
||||
|
||||
Keys reached `/dev/input/event0` **and** the tty line discipline at the same
|
||||
time. That is correct for two consumers of one keyboard and completely wrong
|
||||
for a full-screen program: while it had the display, the shell behind it was
|
||||
quietly accumulating every keystroke, and the first Enter afterwards ran
|
||||
whatever had piled up. Arrow keys arrived as CP437 glyphs on the console for
|
||||
good measure.
|
||||
|
||||
The fix is the standard one and it is a *primitive*, not a patch: an exclusive
|
||||
grab, `EVIOCGRAB`, with Linux's ioctl number. While a device is grabbed its
|
||||
events still flow to `/dev/input/eventN` but both keyboard drivers withhold the
|
||||
decoded character from the console.
|
||||
|
||||
Three details that matter more than the feature:
|
||||
|
||||
- **The grab is released by the devsw `close` hook**, not by the program. A
|
||||
program that grabs the keyboard and then crashes would otherwise leave the
|
||||
console permanently deaf, with no way to type the command that fixes it.
|
||||
- **Modifier state is still tracked while grabbed** (`kbdgetc` keeps updating
|
||||
shift/caps and only skips the character). Otherwise releasing the grab would
|
||||
hand the line discipline a stuck Shift.
|
||||
- **The USB path updates `prev[]` before returning**, or the next report would
|
||||
look like every held key had just been pressed again.
|
||||
|
||||
It is also exactly what Schcom will need, which is why it lives in the input
|
||||
layer rather than in any one client.
|
||||
|
||||
### The near-miss worth remembering
|
||||
|
||||
The first attempt appeared to work: the harness passed, every assertion green.
|
||||
It had not worked at all — the ioctl call was never inserted into the client
|
||||
source, because a scripted string replacement silently failed to match. The
|
||||
`grep` that should have caught it printed three hits (a comment, a `#define`,
|
||||
and the *release* call) and was read as confirmation without counting what
|
||||
they were.
|
||||
|
||||
What actually caught it was reading the transcript rather than the verdict:
|
||||
four bare `#` prompts after quitting, which is precisely the symptom the change
|
||||
was supposed to remove. `input_grab()` now announces itself, so "did the grab
|
||||
happen" is never again a thing anyone has to infer.
|
||||
|
||||
---
|
||||
|
||||
## 11. `ps` and `top`, and why they never worked
|
||||
|
||||
/proc was a set of character devices: init ran `mknod("/proc/uptime", 6, 2)` and
|
||||
friends, so every file was a real inode on the root filesystem with a
|
||||
major/minor behind it. That is fine for a fixed list and cannot work at all for
|
||||
the thing /proc is mostly for -- one directory per process. You cannot mknod a
|
||||
directory for a process that does not exist yet and will be gone in a minute.
|
||||
`ps` calls `dirtree_flagread("/proc")`, got a listing with no numbers in it, and
|
||||
reported nothing. It was never a `ps` problem.
|
||||
|
||||
So /proc is now a real filesystem behind the same `struct fs_ops` vtable SchFS
|
||||
uses (`kernel/fs/procfs.c`, `PROCFS_DEV`), mounted by init with
|
||||
`mount("proc", "/proc", "proc", ...)`. Nothing is stored: directory listings
|
||||
are generated from the process table when asked, file contents when read.
|
||||
|
||||
Three things worth keeping in mind:
|
||||
|
||||
- **The split is deliberate.** `kernel/fs/procfs.c` owns the *namespace* (what
|
||||
exists); `kernel/dev/procfs.c` keeps the *generators* (what the global files
|
||||
say). They change for different reasons, so `proc_gen_global()` is the only
|
||||
thing that crosses between them.
|
||||
- **procfs never touches ptable.** `proc_snapshot()` copies what is needed out
|
||||
from under the lock, so a process that exits between the readdir that listed
|
||||
it and the open that reads it produces a stale line rather than a
|
||||
use-after-free.
|
||||
- **Sizes are reported as zero**, exactly as Linux does. A synthetic file has
|
||||
no length until it is generated, and generating every one on every `stat()`
|
||||
to fill in a number almost nothing reads would be absurd.
|
||||
|
||||
### What the suite caught
|
||||
|
||||
Marking every procfs inode read-only broke `echo s > /proc/sysrq`, and three
|
||||
USB checks failed on it within a minute of the change -- they drive the disk
|
||||
through sysrq. /proc is read-only *except* sysrq, which is the one deliberate
|
||||
write target, and the code now says so in one place.
|
||||
|
||||
### Still not right
|
||||
|
||||
`ps -o comm` prints `[init]` rather than `init`. That field comes from
|
||||
`/proc/<pid>/exe`, which Linux makes a **symlink** and toybox `readlink()`s;
|
||||
ours is a regular file, so the readlink fails and toybox brackets the name as
|
||||
it would for a kernel thread. Default `ps` and `top` output are unaffected.
|
||||
Fixing it needs symlink support in procfs, which belongs with the rest of the
|
||||
VFS work rather than as a one-off.
|
||||
|
||||
---
|
||||
|
||||
## 12. A test program crashed at random, and had been typing its own launch command
|
||||
|
||||
Symptom: a full-screen framebuffer program, launched with arguments, worked
|
||||
four times and then died with
|
||||
|
||||
pid 9 <prog>: trap 14 err 4 on cpu 0 rip 0x2269be addr 0x18--kill proc
|
||||
|
||||
`err 4` is a user-mode read of a non-present page and `addr 0x18` is a null
|
||||
pointer at offset 24. `llvm-nm` put `rip` inside the routine that opens the
|
||||
program's overlay view, which dereferences a pointer the program only fills in
|
||||
once a level is loaded -- at offset 0x18 on x86-64, because the three 64-bit
|
||||
words of a base struct sit in front of it. So the program had been asked to open
|
||||
that view before there was anything to show.
|
||||
|
||||
**The technique is the transferable part:** the fault address alone said
|
||||
"null pointer plus 24", which is nearly useless; `llvm-nm` on the faulting `rip`
|
||||
turned it into a function name, and the offset then identified the exact field.
|
||||
Reach for that before adding printfs to a program you did not write.
|
||||
|
||||
The cause was ours, and it was visible in the log the whole time: every run
|
||||
printed a burst of garbage bytes *before* the grab was taken, and the amount
|
||||
differed each run.
|
||||
|
||||
**`/dev/input/eventN` had no notion of being opened.** Events were queued from
|
||||
boot onwards whether or not anything was listening, into one 256-entry ring
|
||||
with one read cursor. When the program opened the device it inherited the entire
|
||||
backlog -- including the keystrokes of the command line that had launched it,
|
||||
which the shell had already consumed -- and replayed them as input. Whether that
|
||||
crashed depended on which keys happened to be sitting there, which is exactly
|
||||
the "sometimes" in the report.
|
||||
|
||||
Fixed by giving `struct devsw` an `open` hook to match `close`, and having
|
||||
`/dev/input`:
|
||||
|
||||
- drop the backlog on open (`r = w`) -- a reader starts from *now*;
|
||||
- only queue events while `opens > 0`, which is what Linux does: an evdev
|
||||
device with no clients does not accumulate.
|
||||
|
||||
Linux gets both for free by giving every open its own client queue. One shared
|
||||
ring plus "start from now" is the same answer for a single reader, which is the
|
||||
only case that exists today; per-client queues become necessary the moment
|
||||
Schcom and a client both want the keyboard.
|
||||
|
||||
### Also fixed
|
||||
|
||||
`cd` into a file reported "Operation not permitted". It was right to fail, but
|
||||
`sys_chdir` returned a bare `-1`, so the shell printed whatever errno happened
|
||||
to be lying around. It returns `-ENOTDIR` now, and `-ENOENT`/`-EFAULT` on the
|
||||
other two failure paths -- "Not a directory" sends you to the right place,
|
||||
"Operation not permitted" sends you looking at permissions.
|
||||
239
docs/architecture/schapi.md
Normal file
239
docs/architecture/schapi.md
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# SchAPI, and Schunix as a system
|
||||
|
||||
Status: **direction agreed, not yet implemented.** Nothing in this document
|
||||
changes what is being built this week; it changes what everything is being
|
||||
built *towards*. See §7 for what actually happens now.
|
||||
|
||||
---
|
||||
|
||||
## 1. The decision
|
||||
|
||||
Schunix stops being "a Unix-like kernel with a userland bolted on" and becomes
|
||||
a **system**: a kernel, a native API, and the services and applications that
|
||||
sit on it, designed together.
|
||||
|
||||
The model is NeXTSTEP/macOS, not Linux. Specifically:
|
||||
|
||||
- A kernel that provides mechanism, with **more than one personality** on top.
|
||||
- **POSIX/Unix is a personality, not the identity.** It stays, completely and
|
||||
seriously, because it is how you get software. It is not the thing programs
|
||||
are expected to be written against.
|
||||
- **SchAPI** is the native interface: higher-level, object-shaped, designed for
|
||||
the GUI and services rather than retrofitted onto 1970s process semantics.
|
||||
|
||||
Two systems are being borrowed from, and they are *different* systems. It is
|
||||
worth being precise about which, because they disagree:
|
||||
|
||||
| Borrowed | From | Why |
|
||||
|---|---|---|
|
||||
| Layered architecture, personalities, native API over a Unix substrate | macOS / NeXTSTEP | It is the only mainstream design that got a modern API onto a Unix base without abandoning either |
|
||||
| Permanent backward compatibility | **Windows** | macOS is emphatically *not* this — Apple dropped 32-bit apps, Carbon, kexts and OpenGL, each time on a deadline |
|
||||
|
||||
That mix is coherent, but it is a mix. Taking the architecture from Apple and
|
||||
the compatibility policy from Microsoft is a deliberate choice, and the rest of
|
||||
this document is mostly about making that choice survivable — because
|
||||
compatibility-forever is the single most expensive promise an OS can make, and
|
||||
Windows pays for it every day.
|
||||
|
||||
---
|
||||
|
||||
## 2. The one mechanism that makes the promise affordable
|
||||
|
||||
**The stable interface is a library, not the syscall table.**
|
||||
|
||||
This is the whole trick, and it is what lets macOS keep decade-old binaries
|
||||
running while freely renumbering its syscalls. On macOS, nothing may issue a
|
||||
syscall directly; you link `libSystem`. Apple therefore owes compatibility only
|
||||
at the library boundary, and the kernel stays editable forever.
|
||||
|
||||
Linux made the opposite choice — the syscall ABI *is* the contract, "we do not
|
||||
break userspace" — and consequently every syscall mistake ever made is still
|
||||
there, with a `2` or a `64` suffixed to the name of its replacement.
|
||||
|
||||
So, for Schunix:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ applications, SchWM, SchDE │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ SchAPI │ POSIX / Linux personality│ ← THE STABLE BOUNDARY
|
||||
├──────────────────┴───────────────────────────┤
|
||||
│ libschunix — the only thing that may syscall│
|
||||
├──────────────────────────────────────────────┤
|
||||
│ syscall table — PRIVATE, unstable, renumberable
|
||||
├──────────────────────────────────────────────┤
|
||||
│ kernel │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Rules that follow, and they are not negotiable if the promise is to hold:
|
||||
|
||||
1. **A program that issues `syscall` directly is unsupported.** It may break at
|
||||
any release. Only `libschunix` may.
|
||||
2. `libschunix` exports versioned symbols. An entry point's behaviour never
|
||||
changes; a changed behaviour is a new version of the symbol.
|
||||
3. The kernel may renumber, merge, split or delete syscalls at will, provided
|
||||
`libschunix` still presents the same surface.
|
||||
|
||||
This *reconciles* with the existing decision to adopt Linux syscall numbering
|
||||
(`docs/roadmap.md`, and the reason musl/toybox/mksh work at all). Linux
|
||||
numbering does not go away — it becomes the **Linux personality's** entry path,
|
||||
which is exactly what it already is in practice. It is a compatibility surface
|
||||
that happens to be entered via `syscall`, and it is frozen because *Linux*
|
||||
froze it, not because we did.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stability tiers
|
||||
|
||||
"Nothing can ever be removed" is only affordable if you are precise about what
|
||||
"anything" covers. Windows' real problem was never the documented API — it was
|
||||
that undocumented behaviour became load-bearing, and then it was too late.
|
||||
|
||||
Every public interface carries exactly one tier:
|
||||
|
||||
| Tier | Promise | May change |
|
||||
|---|---|---|
|
||||
| `stable` | Forever, from the release that introduced it | Never. Additions only. |
|
||||
| `provisional` | Until the next release | Freely |
|
||||
| `internal` | None | Freely, without notice |
|
||||
|
||||
**Everything in Schunix today is `provisional`.** The freeze starts at 1.0 and
|
||||
covers only what is marked `stable` at that moment. This is not a way of
|
||||
dodging the commitment — it is the only way to make it, because a promise made
|
||||
now would permanently enshrine, among other things, a `fcntl(F_SETFL)` that
|
||||
was a documented no-op until last week and a `struct file` whose `O_NONBLOCK`
|
||||
handling had never been exercised.
|
||||
|
||||
Nothing becomes `stable` by being old. It becomes `stable` by someone deciding
|
||||
it is right and writing that down.
|
||||
|
||||
---
|
||||
|
||||
## 4. `compat/` — what it actually means
|
||||
|
||||
The instinct is right: old interfaces should not litter the current tree. But
|
||||
moving *source* into a folder does not reduce the *runtime* cost, and the
|
||||
runtime cost is the one that hurts.
|
||||
|
||||
The rule that keeps compatibility cheap:
|
||||
|
||||
> **A compatibility entry point must be implementable as a translation into the
|
||||
> current API. If it cannot be, that is a defect in the current API — fix the
|
||||
> current API before freezing it.**
|
||||
|
||||
So `compat/` holds shims, not parallel implementations. A shim:
|
||||
|
||||
- lives in `compat/<release-it-was-current-in>/`,
|
||||
- is written in terms of today's API and nothing else,
|
||||
- is never called by new code,
|
||||
- carries its own tests, which are the only thing that proves the promise is
|
||||
still being kept.
|
||||
|
||||
When a shim *cannot* be expressed as a translation, that is the alarm. It means
|
||||
the new API lost information the old one carried, and it is far cheaper to
|
||||
notice that before 1.0 than to maintain two real implementations forever. That
|
||||
is precisely the trap Win32 fell into.
|
||||
|
||||
---
|
||||
|
||||
## 5. Taking from the BSDs
|
||||
|
||||
`src-trunk/` is a NetBSD checkout, and a FreeBSD one is being added alongside
|
||||
it. Both are reference material and parts bins; neither is built and neither
|
||||
may ever end up in an image.
|
||||
|
||||
**Two trees is not redundancy.** They are worth having together for three
|
||||
reasons that a single tree cannot give:
|
||||
|
||||
- Where an RFC is ambiguous — and TCP's are, constantly — two independent
|
||||
implementations agreeing is far stronger evidence than one implementation
|
||||
asserting.
|
||||
- Where one is licence-encumbered, the other may not be. NetBSD's
|
||||
`tcp_input.c` carries a WIDE Project advertising clause that Berkeley's
|
||||
rescission does not cover; FreeBSD's equivalent may well be 2-clause. See
|
||||
[../licensing.md](../licensing.md) §3.
|
||||
- They have opposite biases, and knowing which you are reading matters:
|
||||
|
||||
| | FreeBSD | NetBSD |
|
||||
|---|---|---|
|
||||
| Stack quality | The better stack in absolute terms — faster, more complete, more actively developed | Solid, conservative, less featureful |
|
||||
| Extractability | Welded to FreeBSD: `epoch(9)`, `uma`, `netisr`, VIMAGE/vnet, `if_t`, a rich locking vocabulary | Portability *is* the project's identity; assumes far less of its host kernel |
|
||||
| Use it as | The reference you check yourself against | The code you actually lift |
|
||||
|
||||
That table is the same lesson §1 of the roadmap already learned the hard way
|
||||
with FreeBSD's `sys/dev/usb`: 100k lines welded to newbus, where porting the
|
||||
USB stack means porting the device framework first. Networking is the same
|
||||
shape. Read FreeBSD, lift NetBSD, write Schunix.
|
||||
|
||||
### Licensing — read this before copying anything
|
||||
|
||||
NetBSD is not uniformly licensed: 2-clause, UC 3-clause and a minority of
|
||||
4-clause-with-advertising files all appear, and only the first two are
|
||||
comfortable. The per-template rules, the measured counts, and the one-line
|
||||
check to run before copying a file are in [../licensing.md](../licensing.md)
|
||||
§3. Do not copy from `src-trunk/` without reading it.
|
||||
|
||||
### What is actually worth taking
|
||||
|
||||
Ranked by value, not by size:
|
||||
|
||||
1. **`sys/net`, `sys/netinet` — the TCP/IP stack.** The largest single win
|
||||
available. Phase 2 of the roadmap wants ARP/IPv4/ICMP/UDP/TCP, and this is a
|
||||
mature, clean, readable, 2-clause-heavy implementation of exactly that.
|
||||
2. **`sys/kern/subr_pool.c` — the pool/slab allocator.** Schunix has `kalloc`
|
||||
and a page at a time. Every subsystem written since has invented its own
|
||||
fixed-size table to work around that.
|
||||
3. **`autoconf` — the driver framework** (`config(1)`, `cfdriver`/`cfattach`,
|
||||
`device_t`). This is the grown-up version of what `kernel/drivers/registry.c`
|
||||
and `kernel/dev/irq.c` are hand-rolling. Adopt the *model*; do not try to
|
||||
port the code wholesale.
|
||||
4. `sys/kern/subr_*` generally — rbtree, workqueue, condvar, kmem.
|
||||
|
||||
Low value: `lib/libc` (musl already fills that role), `sys/dev/usb` (we have a
|
||||
working xHCI stack written from the spec).
|
||||
|
||||
**Take designs before code.** Every file copied is a file to be understood,
|
||||
relicensed-in-notice, and maintained forever under the §3 promise.
|
||||
|
||||
---
|
||||
|
||||
## 6. What SchAPI is, concretely
|
||||
|
||||
Not yet designed. What is decided about it:
|
||||
|
||||
- It is the native interface; POSIX is available beneath it but not the idiom.
|
||||
- It is reached through `libschunix`, never by direct syscall.
|
||||
- It is designed *with* Schcom in mind, because a compositor and its clients
|
||||
are the first real users and their needs (IPC, shared buffers, event streams,
|
||||
object lifetimes) are what will shape it.
|
||||
- The IPC primitive comes first. Everything in a NeXT-shaped system rests on
|
||||
it, and unix domain sockets — already built and working — are the substrate,
|
||||
not the interface.
|
||||
|
||||
What is explicitly *not* decided: whether it is object-oriented in the Objective-C
|
||||
sense, what language it is expressed in, and whether it takes Mach's port model
|
||||
or something smaller. Those need their own document, written when Schcom has
|
||||
told us what it actually needs.
|
||||
|
||||
---
|
||||
|
||||
## 7. What changes now
|
||||
|
||||
Almost nothing, on purpose. The ordering does not change:
|
||||
|
||||
- **The native Windows build must boot first.** The clang-built kernel
|
||||
currently reaches `sinit` and dies on the first trap. A direction is worth
|
||||
nothing while the tree does not run.
|
||||
- Then the roadmap continues as written: network, GPU, fonts, Schcom.
|
||||
|
||||
What this document changes *today* is only this:
|
||||
|
||||
1. `src-trunk/` is reference material, excluded from every build and image.
|
||||
2. New interfaces are marked `provisional` from now on, so that §3 has
|
||||
something to work with at 1.0.
|
||||
3. When the network stack is written, NetBSD's is the starting point rather
|
||||
than a blank file — with the licence check in §5 applied per file.
|
||||
4. `libschunix` is created when there is a second personality to justify it.
|
||||
Until then, the *rule* stands even though the library does not exist yet:
|
||||
no new first-party program issues raw syscalls.
|
||||
403
docs/architecture/schfs.md
Normal file
403
docs/architecture/schfs.md
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
# SchFS — design
|
||||
|
||||
**Status: phases 1 and 2 are done.** The copy-on-write B-tree, the volume layer,
|
||||
the object layer, a working image writer and a checker all exist, and the kernel
|
||||
mounts a SchFS volume read-only at boot. Phase 3 (writes and transaction groups)
|
||||
is next. See §8 for exactly what is built and how to run its tests.
|
||||
|
||||
Supersedes the on-disk format described in `include/fs.h`. Keeps the name SchFS;
|
||||
the on-disk magic changes so v1 images are detected and rejected rather than
|
||||
silently misread.
|
||||
|
||||
The brief was "the best aspects of BeFS, ZFS and btrfs." Those three disagree
|
||||
with each other in places, so this document says explicitly what is taken from
|
||||
each and what is deliberately left behind.
|
||||
|
||||
## 1. What each one is actually good at
|
||||
|
||||
**ZFS — never be inconsistent.** Copy-on-write: no block that is part of the
|
||||
live tree is ever overwritten. An update writes new blocks, then atomically
|
||||
flips a single root pointer. A crash therefore lands on either the old tree or
|
||||
the new one — never a half-written mix. Consequence: **no fsck**, ever. Paired
|
||||
with end-to-end checksums where a block's checksum is stored in its *parent*
|
||||
pointer, not in the block itself, which makes the whole tree self-validating and
|
||||
catches misdirected writes and bit rot that a self-stored checksum cannot.
|
||||
|
||||
**btrfs — one data structure, reused everywhere.** btrfs has essentially one
|
||||
engine: a copy-on-write B-tree. Inodes, directory entries, extent maps, free
|
||||
space, and snapshots are all just different key types in that same tree. For a
|
||||
small team this is the single most important idea in the whole design: you write
|
||||
and debug *one* tricky data structure, then get five subsystems from it. Also
|
||||
takes extents with **reference counts**, which is what makes snapshots and
|
||||
reflink copies fall out almost for free.
|
||||
|
||||
**BeFS — the part that feels like a database, at a size one person can build.**
|
||||
BeFS is a 64-bit journaled filesystem whose distinguishing feature is B+tree
|
||||
**indexes over file attributes**, with a query interface on top. That is the
|
||||
"SQL-like" capability, and unlike WinFS it actually shipped. Critically, BeFS is
|
||||
also the right *scale* model: a small team, a readable codebase, and a book
|
||||
(Giampaolo, *Practical File System Design*, free online) that describes it
|
||||
end to end.
|
||||
|
||||
## 2. What is deliberately not taken
|
||||
|
||||
- **No ZFS storage pools / RAID-Z / vdevs.** Multi-disk redundancy is a large
|
||||
subsystem that solves a problem Schunix does not have yet.
|
||||
- **No btrfs-style rebalancing or online format conversion.** Enormous
|
||||
complexity, and it is the historical source of btrfs's worst bugs.
|
||||
- **No actual SQL.** No parser, no query planner, no relational engine. See §6 —
|
||||
the useful part of "database filesystem" is the *index*, not the language.
|
||||
WinFS spent 2003-2006 and a large team on the other interpretation and
|
||||
shipped nothing.
|
||||
- **No deduplication.** ZFS's dedup is famous for needing more RAM than anyone
|
||||
expects. Extent refcounts give reflink/snapshot benefits without it.
|
||||
|
||||
## 3. On-disk shape
|
||||
|
||||
```
|
||||
+--------------------+ block 0 boot block (untouched by the FS)
|
||||
+--------------------+ blocks 1..N uberblock array (128 slots, rotated)
|
||||
+--------------------+
|
||||
| CoW B-tree | one tree, many key types:
|
||||
| | (ino, INODE, 0) -> inode record
|
||||
| | (ino, DIRENT, hash) -> directory entry
|
||||
| | (ino, EXTENT, offset) -> extent mapping
|
||||
| | (ino, ATTR, nameid) -> extended attribute
|
||||
| | (idx, INDEX, key) -> attribute index entry
|
||||
+--------------------+
|
||||
| free space | extent-based, tracked as tree keys, not a bitmap
|
||||
+--------------------+
|
||||
```
|
||||
|
||||
**Uberblock array (from ZFS).** 128 slots written round-robin, each carrying a
|
||||
transaction number and a checksum. Mount scans all slots and takes the
|
||||
highest-numbered one that validates. This is what makes crash recovery
|
||||
"pick the newest good root" instead of "replay a journal."
|
||||
|
||||
**Everything is one B-tree (from btrfs).** Keyed by
|
||||
`(objectid, type, offset)`, 24 bytes. Adding a feature usually means adding a
|
||||
key type, not a new on-disk structure.
|
||||
|
||||
**Checksums live in the parent (from ZFS).** Each internal node stores, for
|
||||
every child pointer, that child's CRC32c. A block that is read is verified
|
||||
against the pointer that led to it. v1 already chose CRC32c and already has
|
||||
checksum fields in `dinode` and `superblock` — same algorithm, better placement.
|
||||
|
||||
### Widths — every v1 limit that has to die
|
||||
|
||||
| v1 | limit | v2 |
|
||||
|---|---|---|
|
||||
| `dirent.inum` is `ushort` | 65,535 files, total | `uint64` object ids |
|
||||
| `dinode.size` is `uint` | 4 GB max file | `uint64` |
|
||||
| `uint` block numbers, BSIZE 1024 | 4 TB volume | `uint64` blocks, 4096-byte |
|
||||
| `DIRSIZ` 14 | 14-char filenames | variable, up to 255 |
|
||||
| fixed hash table per dir | every dir ≥113 KB, capped at 119 KB | B+tree, pay per entry |
|
||||
| `addrs[NDIRECT+3]` | block-at-a-time | extents with refcounts |
|
||||
|
||||
Block size moves 1024 → 4096 to match modern media and make extents worthwhile.
|
||||
|
||||
## 4. What already exists and gets reused
|
||||
|
||||
Not starting from zero:
|
||||
|
||||
- **`struct schfs_extent`** is already defined in `include/fs.h` and unused.
|
||||
The extent concept is already sketched; v2 gives it refcounts and 64-bit
|
||||
fields.
|
||||
- **CRC32c checksum fields** already exist in `dinode` and `superblock`, and
|
||||
mkfs already computes them. Same algorithm, relocated into parent pointers.
|
||||
- **`kernel/fs/log.c`** is a working transaction log with `begin_op`/`end_op`
|
||||
call sites throughout the FS. Under CoW the log is not needed for consistency,
|
||||
but `begin_op`/`end_op` are exactly the right places to bracket a transaction
|
||||
group, so the *call sites* survive even though the implementation is replaced.
|
||||
- **The buffer cache** (`bio.c`) is format-agnostic and needs no change beyond
|
||||
the block-size constant.
|
||||
|
||||
## 5. Phasing
|
||||
|
||||
Each phase leaves a bootable system. Do not start phase 2 before phase 1 is
|
||||
solid — the B-tree is the load-bearing piece and everything else assumes it.
|
||||
|
||||
**Phase 1 — the B-tree, in userland first.** Write the CoW B-tree as a plain C
|
||||
library with a block-device abstraction, and test it on a *host* file with a
|
||||
fuzz/torture harness (random insert/delete/lookup, simulated crash at every
|
||||
write point, verify the tree always mounts). This is the one component where a
|
||||
subtle bug is unfixable later, and testing it in the kernel is far harder. It is
|
||||
also portable straight into `tools/mkfs`.
|
||||
|
||||
**Phase 2 — read-only mount. DONE.** New magic, uberblock scan, tree walk, inode
|
||||
and dirent key types. `tools/mkschfs` writes images; the kernel only reads them.
|
||||
The system boots from a ramdisk root (see `docs/layout.md` and the ramdisk work)
|
||||
so a broken disk cannot stop you from booting.
|
||||
|
||||
Went further than planned in two places, both cheaply: data blocks carry
|
||||
checksums too (a `CSUM` key type, as btrfs does — without it "end-to-end
|
||||
checksums" would cover only metadata, which is the part least likely to rot),
|
||||
and extents landed early because reserving a file's blocks in one go was
|
||||
simpler than not doing it.
|
||||
|
||||
Not yet done: SchFS files are not reachable through `open(2)`. The mount is
|
||||
observable through `/proc/schfs` and through the kernel API in `schfs_int.h`,
|
||||
but wiring it into `namei`/`readi` means the VFS work that phase 3 needs anyway.
|
||||
|
||||
**Phase 3 — writes, transaction groups. DONE.** Every `schfs_k*` call that
|
||||
changes anything is one transaction group, committed by writing a new uberblock
|
||||
before it returns. The full write API — create, link, unlink, rename, write at
|
||||
an arbitrary offset, truncate — lives in `objw.c` and is exercised by
|
||||
`tools/schfs_objtest.c`.
|
||||
|
||||
Two things went differently from the plan above, both for reasons worth
|
||||
recording.
|
||||
|
||||
*Free space is not tracked as tree keys.* Allocating from a free map that lives
|
||||
in the tree mutates the tree, which allocates, which changes the map; btrfs
|
||||
resolves that with a fixpoint loop and ZFS with log-structured space maps. SchFS
|
||||
sidesteps it: the committed tree already says exactly which blocks are live, so
|
||||
the map is **derived** by walking the tree once at mount and held only in RAM.
|
||||
It cannot disagree with the filesystem, because it is computed from it, and a
|
||||
crash raises no question at all. The cost is mount time proportional to metadata
|
||||
size, and a ceiling on volume size set by how much bitmap fits in memory. If
|
||||
mount time ever hurts, the map can be cached on disk purely as a hint,
|
||||
validated against the generation and discarded on a mismatch — an addition, not
|
||||
a format change. See `kernel/fs/schfs/space.c`.
|
||||
|
||||
*Data blocks needed a generation stamp too.* The B-tree already rewrites its own
|
||||
nodes in place within a group, since a block that no committed root can reach is
|
||||
nobody's fallback. Data blocks had no such marker, so every hundred-byte write
|
||||
copied a whole 4 KB block and split an extent record around it. `schfs_extent`
|
||||
gained a 64-bit `gen`, and the same rule now applies to file data.
|
||||
|
||||
**Phase 3½ — reachable through `open(2)`. DONE.** SchFS is a mounted filesystem:
|
||||
|
||||
```
|
||||
mkdir /mnt
|
||||
mount -t schfs /dev/hdb /mnt
|
||||
ls /mnt ; echo hi > /mnt/tmp/w ; cat /mnt/tmp/w ; mkdir /mnt/tmp/d
|
||||
```
|
||||
|
||||
The VFS grew a real dispatch layer rather than a set of `if (dev == SCHFS_DEV)`
|
||||
checks. `struct fs_ops` (include/file.h) is one table per filesystem, chosen by
|
||||
`iget()` from the device number and hung off the inode; v1's implementations
|
||||
keep their bodies and are simply named `v1_*`. Adding a third filesystem is a
|
||||
new table and nothing else. `kernel/fs/schfs/vfs.c` is SchFS's, and it is pure
|
||||
translation — object ids in, inodes out — because all the filesystem logic
|
||||
already lives below it.
|
||||
|
||||
Three things had to change on the way, none of them SchFS's fault:
|
||||
|
||||
- `struct inode.inum` and `.size` were 32-bit, capping objects at 2³² and files
|
||||
at 4 GiB on a filesystem that promises far more. Both are `uint64` now, as are
|
||||
`readi`/`writei` offsets and `struct file.off`.
|
||||
- `struct dirent` carries a 16-bit inum and a 14-byte name, so it cannot
|
||||
represent a SchFS entry at all. `getdents64` is now an fs_op: each filesystem
|
||||
emits the record stream itself, and `f->off` is whatever position it needs to
|
||||
resume. v1 walks its hash buckets; SchFS walks a key range and synthesises
|
||||
"." and "..", which it does not store.
|
||||
- Mounting is a substitution made during path resolution (`mount_add` /
|
||||
`mount_cross` in `fs.c`). Nothing on either disk records it. The reverse
|
||||
direction matters as much as the forward one: `..` at a mounted root has to
|
||||
leave the mount, and the filesystem underneath has no idea it is mounted
|
||||
anywhere.
|
||||
|
||||
Three long-standing bugs surfaced the moment real commands ran against it, all
|
||||
in the shared VFS rather than in SchFS:
|
||||
|
||||
- `include/fcntl.h` used xv6's open flags while every program in the userland
|
||||
sends Linux's. `O_APPEND` (0x400) landed on `O_TRUNC`, so `cmd >> file`
|
||||
*truncated* it; `O_TRUNC` (0x200) landed on `O_CREATE`, so `cmd > file` left
|
||||
whatever was past the new end in place. Nothing about either pointed at a
|
||||
header.
|
||||
- `sys_openat` silently dropped `O_TRUNC` and `O_APPEND` while `sys_open`
|
||||
honoured both, so the same redirection behaved differently depending on which
|
||||
entry point libc happened to call.
|
||||
- `rmdir` had no Linux syscall number, so every Linux-ABI program got ENOSYS.
|
||||
|
||||
**Phase 4 — extents with refcounts.** Enables reflink copies and snapshots
|
||||
nearly for free.
|
||||
|
||||
**Phase 5 — attribute index and query.** See below.
|
||||
|
||||
## 6. The "SQL" part, honestly
|
||||
|
||||
What you want from "works like SQL" is: *ask a question about a lot of files and
|
||||
get an answer fast, without walking every directory*. That is an **index**, not
|
||||
a query language. BeFS's design:
|
||||
|
||||
- Files carry typed attributes (`string`, `int`, `time`, …).
|
||||
- An attribute can be *indexed*: a dedicated B+tree keyed by attribute value,
|
||||
mapping value → object ids.
|
||||
- A query is a predicate over indexed attributes, evaluated by intersecting
|
||||
index ranges.
|
||||
|
||||
In SchFS this is just another key type — `(idx, INDEX, key)` — in the tree
|
||||
that already exists. That is the whole reason to build phases 1-4 the btrfs way:
|
||||
the "database filesystem" feature becomes a few hundred lines on top of
|
||||
machinery already written and debugged, rather than a separate engine.
|
||||
|
||||
A concrete `query()` syscall and a `find`-like tool come after that, and are
|
||||
small by comparison.
|
||||
|
||||
## 7. Honest cost
|
||||
|
||||
Phases 1-3 are roughly 3,000-4,000 lines and are the bulk of the work. Phase 1
|
||||
alone (B-tree + torture harness) is worth doing carefully and unhurriedly; every
|
||||
later phase compounds on it.
|
||||
|
||||
This is the largest single piece of work in Schunix. It is also entirely
|
||||
ordinary filesystem engineering with excellent published references
|
||||
(Giampaolo for BeFS; the btrfs design papers; the ZFS uberblock/checksum-in-
|
||||
parent scheme) — there is no research risk here, only volume.
|
||||
|
||||
**Do this before the installer.** The installer's entire job is to create and
|
||||
populate this format. Building it against v1 means building it twice.
|
||||
|
||||
## 7½. Limits
|
||||
|
||||
Three different things get called "the limit", and they are worth keeping apart:
|
||||
what the **on-disk format** can express, what **this implementation** can
|
||||
currently mount, and what the **rest of Schunix** can carry. The smallest one is
|
||||
what you actually hit.
|
||||
|
||||
### The format
|
||||
|
||||
| | limit | where it comes from |
|
||||
|---|---|---|
|
||||
| Volume | **64 ZiB** (2⁶⁴ × 4 KiB blocks) | `uint64` block numbers |
|
||||
| File | **16 EiB** (2⁶⁴ bytes) | `schfs_inode.size` |
|
||||
| Files per volume | **1.8 × 10¹⁹** | `uint64` object ids |
|
||||
| Hard links per file | **4.3 × 10⁹** | `uint32 nlink` |
|
||||
| Filename | **255 bytes** | `SCHFS_NAME_MAX` |
|
||||
| Entries per directory | no fixed limit | one tree key per name hash |
|
||||
| Extent | **64 ZiB** in one record | `uint64 nblocks` |
|
||||
| Tree depth | **8 levels** | `SCHFS_MAX_LEVEL` |
|
||||
| Block size | **4096 bytes**, fixed | `SCHFS_BLKSIZE`; a mismatch is refused at mount |
|
||||
| Transaction groups | **1.8 × 10¹⁹** | `uint64` generation, never reused |
|
||||
|
||||
Directory *depth* is unbounded on disk; path resolution is what limits it.
|
||||
|
||||
### This implementation
|
||||
|
||||
| | limit | where it comes from |
|
||||
|---|---|---|
|
||||
| Volume, mounted | **64 GiB** | the in-RAM allocation bitmap: `SCHFS_SPACE_PAGES` (512) pages × 32768 blocks |
|
||||
| Volume, via the buffer cache | **4 TiB** | `bio.c` addresses blocks with a `uint`, and one SchFS block spans four of them |
|
||||
| RAM per mount | **8 KiB per GiB** of volume | two bitmaps, one bit per block each |
|
||||
| Inline value in a leaf | **1016 bytes** | `SCHFS_MAX_INLINE` = a quarter of a node |
|
||||
| Colliding names in one directory | ~**60**, sharing a hash | the packed list for one hash must fit in one inline value |
|
||||
| Directory entries at 8-byte names | ~**42** per key, unlimited keys | as above |
|
||||
| Records per leaf / children per node | **127 / 84** | 4 KiB minus a 32-byte header |
|
||||
| Mount time | one pass over the metadata | the allocation map is derived, not stored |
|
||||
|
||||
The 64 GiB mount ceiling is the one that bites first, and it is one constant:
|
||||
raising `SCHFS_SPACE_PAGES` to 4096 makes it 512 GiB at 64 KiB of pointers per
|
||||
mount. It is set where it is because the page-pointer vector lives inside
|
||||
`struct schfs_vol`, which the kernel holds as static storage.
|
||||
|
||||
### The rest of Schunix
|
||||
|
||||
These are VFS and driver limits rather than SchFS ones:
|
||||
|
||||
- `MAXPATH` caps a path at **128 bytes**.
|
||||
- `NMOUNT` allows **8** mounts.
|
||||
- The IDE driver is 28-bit LBA: **128 GiB** per disk.
|
||||
- A single `read`/`write` call is capped at **4 GiB** (`n` is a `uint`); the
|
||||
file itself is not.
|
||||
|
||||
The two that used to be here — a 16-bit inode number in `struct dirent` and a
|
||||
32-bit `inum`/`size` in `struct inode` — are gone. `getdents64` is a per-
|
||||
filesystem operation now, so SchFS emits its own records with 64-bit inode
|
||||
numbers and names up to 255 bytes, and the inode carries 64-bit numbers and
|
||||
sizes throughout.
|
||||
|
||||
## 8. What exists now
|
||||
|
||||
```
|
||||
include/schfs.h on-disk format + compile-time layout assertions
|
||||
kernel/fs/schfs/btree.{c,h} the copy-on-write B-tree and its cursor
|
||||
kernel/fs/schfs/schfs_int.h volume + object layer interface
|
||||
kernel/fs/schfs/space.c allocation bitmap, derived from the tree at mount
|
||||
kernel/fs/schfs/vol.c uberblock ring: open, format, commit
|
||||
kernel/fs/schfs/obj.c read side: inodes, dirents, extents, path lookup
|
||||
kernel/fs/schfs/objw.c write side: create, link, unlink, rename, write
|
||||
kernel/fs/schfs/kmount.c kernel: block device, mount, transaction bracket
|
||||
kernel/fs/schfs/vfs.c the fs_ops table: SchFS behind open/read/write
|
||||
tools/mkschfs.c build an image from a directory tree
|
||||
tools/schfs_fsck.c verify, list, extract
|
||||
tools/schfs_test.c B-tree torture harness
|
||||
tools/schfs_objtest.c object layer: files, directories, space accounting
|
||||
tools/schfs_roundtrip.sh end-to-end filesystem test
|
||||
tools/mutate.sh mutation-tests the suites themselves
|
||||
tools/bootcheck.ps1 boots under QEMU and drives the shell
|
||||
```
|
||||
|
||||
Everything except `kmount.c` is compiled *both* into the kernel and into the
|
||||
host tools, so the writer and the reader cannot drift apart: there is one
|
||||
implementation of the format and nothing to disagree with.
|
||||
|
||||
### Running the tests
|
||||
|
||||
```
|
||||
make schfs-check all three suites
|
||||
make schfs-test B-tree only
|
||||
make schfs-objtest object layer only
|
||||
make schfs-image-test round-trip only
|
||||
```
|
||||
|
||||
`make schfs-check` after touching anything under `kernel/fs/schfs`.
|
||||
|
||||
The round-trip's oracle is the host's own filesystem: an image is built from a
|
||||
directory tree, extracted with a separate program, and `diff -r`'d against the
|
||||
original. It also asserts hard-link counts (which `diff` cannot see), checks
|
||||
that rebuilding produces byte-identical output, and runs the whole thing a
|
||||
second time with the name hash cut to 6 bits so the packed-dirent collision path
|
||||
— which a 64-bit hash would never reach — is actually exercised.
|
||||
|
||||
`schfs_objtest` runs against a memory-backed volume, so a full
|
||||
create/write/delete/remount cycle takes milliseconds. Its most important check
|
||||
is the least glamorous: after deleting everything, free space must be back to
|
||||
exactly what it was on an empty volume. Every other test in the suite still
|
||||
passes on a filesystem that leaks a block on every delete.
|
||||
|
||||
`tools/mutate.sh` injects six deliberate bugs and fails if any of them goes
|
||||
undetected. It is how the suites are kept honest; see the note in §9.
|
||||
|
||||
### Verifying on real hardware
|
||||
|
||||
```
|
||||
make ramroot root from RAM, so device 1 is free
|
||||
make build/schfs.img
|
||||
make qemu-schfs then, at the shell: cat /proc/schfs
|
||||
```
|
||||
|
||||
`.\run.ps1 -SchFS` does the same on Windows, and `tools\bootcheck.ps1 -SchFS`
|
||||
does it unattended and asserts on the transcript.
|
||||
|
||||
## 9. Things learned the hard way
|
||||
|
||||
Recorded because each one cost real time and none is obvious from the code.
|
||||
|
||||
- **The root's object id must be reserved.** `next_objectid` started at
|
||||
`SCHFS_FIRST_OBJECTID`, which *is* the root's id, so the first directory
|
||||
created silently became the root. It then contained itself, and every tree
|
||||
walk recursed until the stack ran out. `schfs_fsck --check` now detects
|
||||
directory loops explicitly rather than crashing.
|
||||
- **Cursor buffers alias.** A range callback gets a pointer into the cursor's
|
||||
block buffer, and almost every readdir caller immediately looks the entry up,
|
||||
which overwrites it. Directory iteration copies each entry out before calling
|
||||
back. Extracting a tree with directories reparented under their own children
|
||||
is what this looks like when it goes wrong.
|
||||
- **A 4096-byte block does not fit on a 4096-byte kernel stack.** The cursor
|
||||
exists precisely to avoid one block buffer per tree level, but
|
||||
`schfs_vol_open` still kept a whole uberblock as a local and blew the stack on
|
||||
the first boot. The kstack canary caught it cleanly.
|
||||
- **Extents only merge if you allocate the run first.** Writing an extent record
|
||||
allocates blocks for its own copy-on-write path, so allocating data one block
|
||||
at a time guarantees the blocks are never adjacent and every block needs its
|
||||
own extent. `schfs_file_reserve` claims the whole run up front; an 822 KB
|
||||
binary is one extent instead of 201.
|
||||
- **Freed blocks are reusable during a build.** Copy-on-write forbids reusing a
|
||||
released block because the previously committed tree still points at it — but
|
||||
a build has committed nothing. Recycling them cut the test image from 3596
|
||||
blocks to 374.
|
||||
- **Reproducibility dies from atime.** mkschfs reads every source file, which
|
||||
updates its atime, so copying atime into the image made two builds of the same
|
||||
tree differ. All three timestamps come from mtime.
|
||||
130
docs/drivers.md
Normal file
130
docs/drivers.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Drivers
|
||||
|
||||
How hardware gets found, claimed and used, and where drivers that are not part
|
||||
of the build will attach.
|
||||
|
||||
---
|
||||
|
||||
## 1. The device model
|
||||
|
||||
There is one list of everything attached, whatever bus found it:
|
||||
`include/device.h` and `kernel/dev/device.c`, readable at `/proc/devices`.
|
||||
|
||||
```
|
||||
name bus class driver device
|
||||
kbd0 platform input ps2kbd PS/2 keyboard
|
||||
mouse0 platform input ps2mouse PS/2 mouse
|
||||
uart0 platform other uart8250 16550 serial port
|
||||
ide0 platform storage ide ATA disk controller
|
||||
vga0 pci display vga display controller
|
||||
```
|
||||
|
||||
Before this there were two registries in `kernel/drivers/registry.c` — an array
|
||||
of PCI drivers and an array of USB drivers — with nothing that knew about both.
|
||||
That is sufficient for *binding* and useless for the three things wanted next.
|
||||
|
||||
**Enumeration.** "What is in this machine, and what claimed it" had no answer
|
||||
covering a USB disk and a PCI display at once. A device manager is exactly that
|
||||
question.
|
||||
|
||||
**Classes.** A compositor should ask for *the display* and not care whether that
|
||||
is the VBE framebuffer or virtio-gpu. Without a class every consumer hardcodes a
|
||||
driver, and adding the second driver means editing the consumers — which is how
|
||||
a framebuffer address ended up hardcoded in three places here once already.
|
||||
|
||||
**A boundary for drivers that are not compiled in.** They have to attach
|
||||
somewhere that is not an array fixed at link time.
|
||||
|
||||
Two vocabularies are kept deliberately separate: a device's **bus** is
|
||||
provenance (`platform`, `pci`, `usb`), its **class** is capability
|
||||
(`display`, `input`, `storage`, `net`, `bus`). A USB disk and a SATA disk are
|
||||
both `storage` despite sharing no numbering at all. Where a driver claims a
|
||||
device, the driver's opinion wins over the descriptor's: `struct usb_driver` has
|
||||
a `devclass` field separate from the USB interface class, because the descriptor
|
||||
says what the device *claims* to be and the driver knows what it *made* of it.
|
||||
|
||||
It is a flat list with a bus tag, **not** a device tree. Nothing yet needs to
|
||||
walk a hierarchy, and a tree nobody traverses is just a harder list. Hot-unplug
|
||||
would need more; the `in_use` flag is the hook.
|
||||
|
||||
Platform devices are registered in one place
|
||||
(`platform_register_devices()` in `registry.c`) rather than inside each driver's
|
||||
init, for the same reason the driver arrays are written out by hand: a list you
|
||||
can read beats a list assembled by side effects.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where this is going: a hybrid model
|
||||
|
||||
The intended shape is the NeXTSTEP/macOS one rather than Linux's — a device is
|
||||
an object with a class and an ops vtable, and consumers talk to the class.
|
||||
|
||||
Two things often get bundled together as "loadable drivers" and they are
|
||||
separable:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Loadable modules** | A stable driver ABI, drivers as files loaded at runtime (`.sys`, `.kext`). A linker/loader job on top of the model above. |
|
||||
| **Drivers out of kernel** | Drivers as user-space processes (macOS DriverKit, which is why kexts are being retired). An IPC and memory-mapping job; the `AF_UNIX` and shared-memory work it needs already exists. |
|
||||
|
||||
The second is more in the spirit of the SchAPI direction and is slower and
|
||||
larger. Neither is built yet. What exists is the attachment point they both
|
||||
need.
|
||||
|
||||
---
|
||||
|
||||
## 3. Linux drivers
|
||||
|
||||
**Decided 2026-08-08.** Linux drivers are not shipped with Schunix and never
|
||||
will be. The user may install one on their own machine, through a device
|
||||
manager or equivalent, and Schunix will load it.
|
||||
|
||||
The licensing reasoning matters and is worth stating exactly, because it is
|
||||
easy to get wrong in both directions:
|
||||
|
||||
- **Distribution is what triggers the GPL.** Shipping a GPL-2.0-only driver in
|
||||
an Apache-2.0 image is what cannot be done — see [licensing.md](licensing.md)
|
||||
§2. A user obtaining a driver themselves and loading it on their own machine
|
||||
is their act, not ours, and is not a distribution by us.
|
||||
- **Implementing a loader is not copying.** Writing support for a module format
|
||||
and a driver ABI from published documentation produces our own work. It is a
|
||||
clean-room implementation, and it stays one only if it is written from
|
||||
documentation rather than transcribed from Linux source. Reading Linux to
|
||||
understand *hardware* is fine; translating its code is not, whatever the
|
||||
identifiers say.
|
||||
|
||||
So the rule for this subsystem is the same as everywhere else in the tree: the
|
||||
loader is ours and Apache-2.0, what it loads is the user's business, and nothing
|
||||
GPL ever enters an image we build. `RAMDISK_EXCLUDE` and `NOTPORTS` are the
|
||||
existing mechanisms for keeping that true mechanically rather than by intention.
|
||||
|
||||
---
|
||||
|
||||
## 4. Adding a driver today
|
||||
|
||||
1. Write `kernel/drivers/<class>/<name>/<name>.c` exporting a
|
||||
`struct pci_driver` or `struct usb_driver`. One directory per driver, so a
|
||||
driver that grows a register header, a second source file or a firmware blob
|
||||
has somewhere to put it:
|
||||
|
||||
kernel/drivers/
|
||||
registry.c
|
||||
gpu/vga/ gpu/virtio_gpu/
|
||||
net/e1000/
|
||||
bus/xhci/
|
||||
usb/core/ usb/hid/ usb/storage/ usb/hub/
|
||||
|
||||
2. Declare it in `kernel/drivers/registry.c` and add it to the array.
|
||||
3. Add the object to `KERN_DRIVERS` in the `Makefile`.
|
||||
|
||||
Registration in the device model is automatic — `pci_bind()` and
|
||||
`usb_bind_all()` publish whatever they claim, so a driver cannot forget to. Set
|
||||
`devclass` on a USB driver, or the device lands in `other`.
|
||||
|
||||
The best source for a new driver is the **specification**, not another kernel:
|
||||
xHCI, AHCI, virtio, NVMe and the Intel datasheets are public documents with no
|
||||
licence on what you write from them. `kernel/drivers/bus/xhci/xhci.c` came from the spec, and so did
|
||||
`net/e1000/` and `gpu/virtio_gpu/`. Where a *table* of constants is genuinely
|
||||
faster to transcribe than to retype from a PDF -- device IDs, register offsets --
|
||||
taking it from a BSD tree is fine after the licence check, and goes in `NOTICE`.
|
||||
That is data; the logic stays ours.
|
||||
175
docs/layout.md
Normal file
175
docs/layout.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Repository layout
|
||||
|
||||
One rule holds the whole thing together:
|
||||
|
||||
> **A directory contains either hand-written source or generated output. Never both.**
|
||||
|
||||
Everything generated lives under `build/`. Delete `build/` and you lose nothing
|
||||
that can't be rebuilt (except upstream tarballs, which live in `dist/` and are
|
||||
deliberately kept out of `clean`).
|
||||
|
||||
```
|
||||
schunix/
|
||||
├─ boot/ Stage 1 MBR + stage 2 loader (splash, F8 menu, kernel load)
|
||||
├─ kernel/ The kernel, by subsystem: arch/ core/ dev/ fs/ proc/ sys/
|
||||
├─ include/ Headers shared by kernel, userland, and tools/mkfs
|
||||
├─ lib/ First-party userland runtime: _start glue, syscall stubs,
|
||||
│ the initcode blob embedded in the kernel, user.ld
|
||||
├─ cmd/ First-party programs — one directory per program:
|
||||
│ cmd/<name>/main.c → sysroot/bin/<name>
|
||||
├─ ports/ Third-party software — one directory per port.
|
||||
│ Contains ONLY a Makefile and optional patches/.
|
||||
├─ projects/ Things that run ON Schunix rather than things that ARE it:
|
||||
│ the compositor, window manager, desktop, and installer.
|
||||
│ Not part of the kernel build. See projects/README.md.
|
||||
├─ tools/ Host-side helpers, SOURCE ONLY: mkfs.c, the SchFS test
|
||||
│ suites, sign.pl, vectors.pl, and the PowerShell harnesses.
|
||||
│ Their binaries build into build/tools/ -- this directory
|
||||
│ used to hold both, which is the one rule this layout has.
|
||||
│ tools/loc.ps1 counts how much of the tree is first-party.
|
||||
├─ sysroot/ Staging for the disk image. etc/ is hand-maintained;
|
||||
│ bin/ lib/ sbin/ are populated by `make sysroot-install`.
|
||||
├─ dist/ Upstream tarball cache. Survives `make clean`.
|
||||
├─ Refrence/ A FreeBSD checkout (Refrence/freebsd-src), kept as reference
|
||||
│ material and a parts bin. Never built, never in an image.
|
||||
│ Read it when a specification is ambiguous; copy from it only
|
||||
│ after the per-file licence check in docs/licensing.md.
|
||||
│ A full clone is 2.1 GB and a previous NetBSD one was deleted
|
||||
│ for that reason. Only sys/dev, sys/net and sys/netinet have
|
||||
│ ever been read, so a sparse checkout is ~100 MB:
|
||||
│ git clone --filter=blob:none --sparse <url> Refrence/freebsd-src
|
||||
│ git -C Refrence/freebsd-src sparse-checkout set │ sys/dev sys/net sys/netinet
|
||||
├─ src-trunk/ A NetBSD checkout, kept as reference material and a parts
|
||||
│ bin. NOT built, NOT installed, and it must never reach an
|
||||
│ image. Copying from it is governed by the per-file licence
|
||||
│ check in docs/architecture/schapi.md §5 — NetBSD is not
|
||||
│ uniformly licensed.
|
||||
├─ docs/ Design docs
|
||||
└─ build/ ALL generated output (see below)
|
||||
```
|
||||
|
||||
## build/
|
||||
|
||||
```
|
||||
build/
|
||||
├─ boot/ bootblock, stage2
|
||||
├─ kernel/ object files, the linked kernel, kernel.asm
|
||||
├─ lib/ libc glue objects, initcode blob
|
||||
├─ cmd/ linked first-party programs
|
||||
├─ fs.img the filesystem image
|
||||
└─ ports/<name>/
|
||||
├─ src/ extracted upstream tarball
|
||||
├─ obj/ out-of-tree build directory
|
||||
└─ prefix/ install prefix (musl's headers and libs land here)
|
||||
```
|
||||
|
||||
## Adding your own program
|
||||
|
||||
```sh
|
||||
mkdir cmd/hello
|
||||
$EDITOR cmd/hello/main.c # include "types.h", "user.h", ...
|
||||
```
|
||||
|
||||
Then add `hello` to `CMDS` in the root `Makefile`. It gets linked against
|
||||
`lib/` and installed to `sysroot/bin/hello` by `make sysroot-install`.
|
||||
|
||||
## Adding a third-party port
|
||||
|
||||
```sh
|
||||
mkdir ports/foo
|
||||
$EDITOR ports/foo/Makefile # start with `include ../mk/port.mk`
|
||||
```
|
||||
|
||||
`port.mk` derives everything from the directory name — it gives you `$(SRCDIR)`,
|
||||
`$(BUILDDIR)`, `$(PREFIX)`, `$(DISTDIR)`, `$(SYSROOT)`, and `$(MUSL)` (the
|
||||
musl install prefix that every port links against). Follow the pattern in
|
||||
`ports/mksh/Makefile`: fetch tarball to `$(DISTDIR)`, extract to `$(SRCDIR)`,
|
||||
build into `$(BUILDDIR)`, install into `$(SYSROOT)`. `clean` removes
|
||||
`$(PORTDIR)` and nothing else.
|
||||
|
||||
`ports/Makefile` picks up the new directory automatically. If it links against
|
||||
musl, add `foo: musl` there so ordering is right.
|
||||
|
||||
Local modifications to upstream go in `ports/foo/patches/*.patch` and are
|
||||
applied after extraction — never by editing files under `build/`, which are
|
||||
wiped on every clean.
|
||||
|
||||
## Ramdisk root
|
||||
|
||||
```sh
|
||||
make # normal: root on the IDE disk (build/fs.img as hdb)
|
||||
make ramroot # root from a SCHFS image linked into the kernel
|
||||
```
|
||||
|
||||
`make ramroot` builds `build/fs-ram.img` (default 6000 blocks, override with
|
||||
`RAMDISK_BLOCKS=`) and links it into the kernel, so the system boots to a shell
|
||||
with **no data disk attached at all**. That is what makes destructive work on
|
||||
the real disk survivable — reformatting it, or bringing up a new on-disk format
|
||||
— and it is the first piece of the installer.
|
||||
|
||||
Use `make ramroot`, not `make RAMROOT=1`: the flag changes `CFLAGS`, which make
|
||||
cannot see, so objects compiled for one setting would be silently reused for
|
||||
the other and `ROOTDEV` would point at the wrong device. The target drops the
|
||||
compiled objects first. Switching back is the same — plain `make` after a
|
||||
`ramroot` build reuses stale objects, so run `rm -rf build/kernel build/lib
|
||||
build/cmd` first (or just `make clean`).
|
||||
|
||||
The image costs boot time: stage2 loads the kernel by PIO one sector at a time,
|
||||
so a 6 MB ramroot kernel is visibly slower to start than the 350 KB default.
|
||||
|
||||
**What may go in it is a licensing question, not only a size one.** The image is
|
||||
linked into the kernel binary, so anything in `sysroot/` when it is built becomes
|
||||
part of a distributable artifact. A file staged there under a licence that
|
||||
cannot be combined with Apache-2.0 does not merely ride along — it makes the
|
||||
kernel itself undistributable. `RAMDISK_EXCLUDE` in the Makefile is the list of
|
||||
paths kept out, and enforcing it in the build rather than relying on someone
|
||||
remembering to unstage something first is deliberate. It has been needed once
|
||||
already: a 33 MB test payload landed in `sysroot/` and both overflowed the image
|
||||
and would have gone into the kernel binary.
|
||||
|
||||
Three knobs, all in the Makefile, none of them `FSSIZE`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `RAMDISK_BLOCKS` | image size, 6000 (× 1 KiB `BSIZE`) |
|
||||
| `RAMDISK_NINODES` | inode table size, 2000 |
|
||||
| `RAMDISK_EXCLUDE` | sysroot-relative paths to omit, with their subtrees |
|
||||
|
||||
`RAMDISK_NINODES` is worth knowing about because the inode table is
|
||||
*preallocated*: mkfs's default of 12000 inodes cost 1501 of the 6000 blocks — a
|
||||
quarter of the image — to describe about 260 files. At 2000 it costs 251, and
|
||||
metadata overall fell from 1596 blocks to 345.
|
||||
|
||||
If `mkfs` reports the image is full, read its message: it now prints the
|
||||
metadata breakdown and names the right knob for whichever image overflowed. It
|
||||
used to say only "increase FSSIZE in param.h", which is a constant that has
|
||||
nothing to do with the ramdisk root and sends you to the wrong file.
|
||||
|
||||
## Two things that will bite you
|
||||
|
||||
**`ld -r -b binary` derives symbol names from the input file's path.** The
|
||||
kernel embeds two blobs this way (`initcode`, `entryother`), and the root
|
||||
Makefile has `--redefine-sym` lines mapping e.g.
|
||||
`_binary_build_lib_initcode_start` → `_binary_initcode_start`. Move either
|
||||
blob's build location and those symbol names change; update the Makefile to
|
||||
match or the kernel fails to link.
|
||||
|
||||
**`STAGE2_SECTORS` now lives in exactly one place: the root `Makefile`.** It used
|
||||
to be written out by hand in five — `boot/bootasm.S`, `boot/stage2main.c`'s
|
||||
`readseg()`, the `dd ... seek=` offset, and twice more inside `tools/mkiso.sh`
|
||||
(its stage2 size assertion and `-boot-load-size`). This note used to warn you to
|
||||
keep three of them in step and did not know about the other two.
|
||||
|
||||
Raising it from 24 to 48 for the boot logo demonstrated the problem: the `dd
|
||||
seek=25` offset stayed behind, so the kernel was written directly over stage2's
|
||||
own tail. Now the Makefile passes `-DSTAGE2_SECTORS` to both boot sources,
|
||||
derives the padding budget and the `seek=` offset from the same variable, and
|
||||
exports it to `mkiso.sh`. The `#define`s that remain in the two boot sources are
|
||||
`#ifndef`-guarded fallbacks so those files still read sensibly alone; the
|
||||
Makefile always wins.
|
||||
|
||||
Two things still fail the build rather than silently misbooting, and should stay
|
||||
that way: `tools/padstage2.pl` errors if stage2 overflows its budget instead of
|
||||
truncating it, and `boot/stage2main.c` static-asserts that stage2 cannot reach
|
||||
the multiboot kernel-search buffer at `0x10000` — which puts the hard ceiling at
|
||||
65 sectors, since stage2 loads at `0x7E00`.
|
||||
241
docs/licensing.md
Normal file
241
docs/licensing.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Licensing
|
||||
|
||||
The one page to check before copying anyone else's code into Schunix.
|
||||
|
||||
Referenced from `LICENSE`, `LICENSE.MIT` and `NOTICE`, all three of which
|
||||
pointed here long before this file existed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Schunix is Apache-2.0
|
||||
|
||||
Copyright Nexoniarz. See `LICENSE`.
|
||||
|
||||
Apache-2.0 was chosen over MIT/BSD for one reason that actually matters at OS
|
||||
scale: it contains an **express patent grant** and a patent-retaliation clause.
|
||||
MIT and BSD are silent on patents, which means a contributor can hand you code
|
||||
and later assert a patent over it. For a kernel that is intended to accept
|
||||
outside contributions, that silence is the expensive kind.
|
||||
|
||||
What that grant means in practice: Apache-2.0 §2 gives everyone a perpetual,
|
||||
worldwide, irrevocable licence to reproduce, modify, sublicense and distribute
|
||||
Schunix. Any additional restriction stated elsewhere — in a README, a website,
|
||||
or a policy — does not narrow it. The licence text is what holds up.
|
||||
|
||||
If restricted distribution is ever genuinely wanted, that is a licence change
|
||||
(a bespoke proprietary licence, or something like BUSL-1.1 with a conversion
|
||||
date), not a wording change, and it means giving up the Apache grant.
|
||||
|
||||
---
|
||||
|
||||
## 2. What can be copied in
|
||||
|
||||
| Licence | Verdict | Obligation |
|
||||
|---|---|---|
|
||||
| Public domain, 0BSD, CC0 | **Yes** | None |
|
||||
| MIT, ISC | **Yes** | Keep the notice |
|
||||
| BSD 2-clause | **Yes** | Keep the notice |
|
||||
| BSD 3-clause | **Yes** | Keep the notice; no endorsement claims |
|
||||
| BSD **4-clause** (advertising) | **Avoid** — see §3 | Notice *and* an acknowledgement in advertising materials |
|
||||
| Apache-2.0 | **Yes** | Keep NOTICE contents |
|
||||
| MPL-2.0 | Case by case | File-level copyleft; keep those files separate |
|
||||
| **GPL-2.0-only** | **No** | — |
|
||||
| **GPL-3.0 / LGPL-3.0** | **No** for linked code | — |
|
||||
|
||||
**Every "yes" above requires an entry in `NOTICE`.** That file is the project's
|
||||
attribution record and the thing that makes the permissive licences actually
|
||||
satisfied rather than merely tolerated.
|
||||
|
||||
### Why GPL-2.0-only is a hard no
|
||||
|
||||
Apache-2.0 imposes conditions GPLv2 treats as "further restrictions" under its
|
||||
§6 — principally the patent-retaliation term. The FSF and the ASF agree on
|
||||
this: **Apache-2.0 code and GPL-2.0-only code cannot be combined into one
|
||||
distributable work.** It is not a matter of taste or of how carefully the files
|
||||
are kept apart in the tree; it is that the combined binary cannot satisfy both
|
||||
licences at once.
|
||||
|
||||
GPL-3.0 fixed this in the other direction (Apache-2.0 is compatible *into*
|
||||
GPLv3), but that does not help: the result would have to ship under GPLv3, and
|
||||
Schunix does not.
|
||||
|
||||
**Practical consequence:** BSD drivers can be adopted. Linux drivers cannot,
|
||||
ever, no matter how convenient. This is why the USB stack was written from the
|
||||
specification rather than ported.
|
||||
|
||||
---
|
||||
|
||||
## 3. NetBSD in particular
|
||||
|
||||
`src-trunk/` is a NetBSD checkout kept as reference material and a parts bin
|
||||
(see [architecture/schapi.md](architecture/schapi.md) §5). NetBSD is **not
|
||||
uniformly licensed**, and the difference matters per file:
|
||||
|
||||
| Template | Status |
|
||||
|---|---|
|
||||
| 2-clause BSD — modern NetBSD Foundation files | **Fine** |
|
||||
| 3-clause, "Regents of the University of California" | **Fine.** Berkeley formally rescinded the advertising clause in 1999 |
|
||||
| 4-clause with the advertising clause, **non-UC** copyright holder | **Avoid.** Berkeley's rescission covers Berkeley's copyright, not other people's |
|
||||
|
||||
The last row is real, not hypothetical. Measured on this checkout:
|
||||
|
||||
```
|
||||
sys/kern 26 of 220 files carry the advertising clause
|
||||
sys/dev 500 of 3565
|
||||
lib/libc 101 of 1575
|
||||
sys/net 5 of 180 ← the TCP/IP stack is nearly clean
|
||||
```
|
||||
|
||||
4-clause is still permissive and still redistributable; the burden is an
|
||||
advertising obligation that accumulates quietly and is tedious to honour
|
||||
properly. Prefer a 2-clause or UC-Regents file. If a 4-clause file is genuinely
|
||||
the right answer, it goes into `NOTICE` with the full clause quoted.
|
||||
|
||||
Check before copying, not after:
|
||||
|
||||
```sh
|
||||
grep -l "All advertising materials" <file>
|
||||
```
|
||||
|
||||
### And check *whose* clause it is
|
||||
|
||||
A file can carry several licence blocks from several copyright holders. Finding
|
||||
the advertising clause is only half the check — Berkeley's rescission covers
|
||||
Berkeley's copyright and nobody else's, so the question is always *whose*
|
||||
clause you found.
|
||||
|
||||
Measured on `src-trunk`, the networking tree:
|
||||
|
||||
```
|
||||
sys/net 106 files, 5 with the advertising clause
|
||||
sys/netinet 121 files, 25 " " "
|
||||
sys/netinet6 51 files, 5 " " "
|
||||
```
|
||||
|
||||
Encouraging at first glance — the 25 in `sys/netinet` are almost all SCTP,
|
||||
DCCP and multicast routing, none of which Schunix needs. `ip_input.c`,
|
||||
`ip_output.c`, `udp_usrreq.c`, `if_arp.c`, `ip_icmp.c`, `if.c` and `route.c`
|
||||
are all UC 3-clause, i.e. fine.
|
||||
|
||||
**But `tcp_input.c` and `tcp_output.c` are not.** Their advertising clause
|
||||
belongs to the **WIDE Project** (the KAME IPv6 people), whose copyright sits at
|
||||
the top of the file; the UC block is a separate licence further down. Berkeley
|
||||
never spoke for WIDE, so the rescission does not reach it. The two most
|
||||
valuable files in the stack are the two encumbered ones.
|
||||
|
||||
That is not fatal — 4-clause is still permissive — but it is exactly the case
|
||||
where a **second BSD tree earns its disk space**: FreeBSD's TCP has been
|
||||
through a relicensing effort NetBSD's has not, so the same file there may well
|
||||
be 2-clause. Check it the same way before assuming either:
|
||||
|
||||
```sh
|
||||
grep -n "Copyright" <file> | head # who claims it
|
||||
grep -n -B6 "All advertising" <file> # whose clause is it
|
||||
```
|
||||
|
||||
### FreeBSD's TCP is clean where NetBSD's is not
|
||||
|
||||
Checked, 2026-08-08, exactly that way. Against FreeBSD `main`:
|
||||
`sys/netinet/tcp_input.c` contains **no advertising clause at all**. Its
|
||||
copyright holders are the UC Regents, Swinburne University, Lawrence Stewart,
|
||||
the FreeBSD Foundation and Juniper Networks -- all BSD-2 or BSD-3.
|
||||
|
||||
So if BSD networking code is taken, it must come from **FreeBSD, not NetBSD**,
|
||||
and the specific reason is TCP. This reverses the earlier assumption in
|
||||
`docs/roadmap.md` that NetBSD was the intended starting point; NetBSD remains
|
||||
the better-written reference to *read*, but FreeBSD is the one that can be
|
||||
copied from.
|
||||
|
||||
|
||||
### The third option: lwIP
|
||||
|
||||
Before porting either BSD stack, weigh **lwIP** against both. Verified
|
||||
2026-08-08 from its `COPYING`: 3-clause BSD, Swedish Institute of Computer
|
||||
Science, **no advertising clause anywhere in the file**. Compatible, one NOTICE
|
||||
entry, done.
|
||||
|
||||
The licence is the smaller half of the argument. The larger half is that
|
||||
FreeBSD's `sys/netinet` is not a library -- `tcp_input.c` alone is 122 KB and
|
||||
reaches into mbufs, `epoch(9)`, netisr, VNET, `uma(9)`, sockbufs, inpcb hash
|
||||
tables and FreeBSD's locking. Taking it means porting FreeBSD's kernel
|
||||
infrastructure first and finding the TCP somewhere underneath, which is the same
|
||||
conclusion `docs/roadmap.md` §1 already reached about `sys/dev/usb`.
|
||||
|
||||
lwIP was written for the case Schunix is in: a small operating system with a raw
|
||||
Ethernet driver and no infrastructure to speak of. Its port layer is a handful
|
||||
of files, it has no dependency on the host kernel's allocator or locking, and it
|
||||
brings ARP, IPv4, ICMP, UDP, TCP, DHCP and DNS.
|
||||
|
||||
### Fonts
|
||||
|
||||
Chosen 2026-08-09, both licences checked at source rather than assumed:
|
||||
|
||||
| Font | Where | Licence |
|
||||
|---|---|---|
|
||||
| Roboto | desktop, window manager, compositor | **Apache-2.0** |
|
||||
| JetBrains Mono | terminal, TTY | **SIL OFL 1.1** |
|
||||
|
||||
Roboto is the same licence as Schunix, so it is the easy case: a NOTICE entry
|
||||
and nothing else.
|
||||
|
||||
OFL is permissive and bundling is precisely what it is for, with two conditions
|
||||
worth knowing before anyone subsets or converts a font:
|
||||
|
||||
- **The font stays under OFL** even inside an Apache-2.0 system. It does not
|
||||
infect anything else; it just cannot be relicensed.
|
||||
- **It may not be sold on its own.** Shipping it as part of an OS is fine.
|
||||
- **Reserved Font Names.** JetBrains Mono declares *none* -- its copyright line
|
||||
names no reserved name -- so a modified version may keep the name. Most OFL
|
||||
fonts do reserve theirs, and then a derivative must be renamed.
|
||||
Pre-rasterising a font into bitmaps at build time **is** a modification, so
|
||||
this is not a theoretical point for this project.
|
||||
|
||||
---
|
||||
|
||||
## 4. Things that are deliberately not shipped
|
||||
|
||||
Some code is fine to *build and run locally* and impossible to *distribute*
|
||||
with Schunix. Keeping the distinction sharp is what stops it eroding:
|
||||
|
||||
- **`Refrence/`** — a FreeBSD checkout (`Refrence/freebsd-src`), kept as
|
||||
reference material and a parts bin. Never built, never in an image. The
|
||||
directory name is spelled as it is on disk; renaming it is a one-line change
|
||||
if it ever grates.
|
||||
- **`src-trunk/`** — a NetBSD checkout, same rules. Currently absent; it was
|
||||
deleted for disk space and can be re-fetched sparsely if needed.
|
||||
- **`dist/`** — upstream tarballs and other downloaded blobs. Cached so a
|
||||
rebuild never needs the network; not part of any image, and not all of it is
|
||||
redistributable.
|
||||
|
||||
The mechanisms that keep this from eroding, since good intentions have already
|
||||
failed once here:
|
||||
|
||||
- `NOTPORTS` in `ports/Makefile` — a port that lives in the tree but is never
|
||||
built by `make`.
|
||||
- `RAMDISK_EXCLUDE` in the root `Makefile` — paths kept out of the ramdisk root.
|
||||
This one matters more than it looks: the ramdisk image is linked *into the
|
||||
kernel binary*, so anything sitting in `sysroot/` when that image is built
|
||||
becomes part of a distributable artifact. A GPL-2.0 or non-redistributable
|
||||
file staged there does not merely ride along, it makes the kernel itself
|
||||
undistributable.
|
||||
|
||||
A previous acceptance test — a ported 1993 shooter, GPL-2.0 engine on
|
||||
non-redistributable shareware data — was carried under exactly these rules and
|
||||
has since been removed. It is worth recording *why* it was safe: it was never in
|
||||
`PORTS`, and its 33 MB of data was excluded from the ramdisk. It is equally
|
||||
worth recording that the safety depended on two separate opt-outs staying
|
||||
correct, which is a lot of standing risk for a test that had stopped finding
|
||||
bugs. See `docs/roadmap.md` Phase 7.
|
||||
|
||||
---
|
||||
|
||||
## 5. Specifications are not code
|
||||
|
||||
A published specification carries no copyright over an independent
|
||||
implementation of it. The xHCI, USB, PCI, fbdev and ELF specifications were all
|
||||
read and implemented directly, which is why those drivers have no licence
|
||||
entanglement at all despite equivalents existing in both Linux and BSD.
|
||||
|
||||
When a subsystem is small enough to write from the spec, that is usually the
|
||||
cheapest option in the long run — not because copying is hard, but because a
|
||||
copied file is a file to be understood, attributed and maintained forever.
|
||||
481
docs/roadmap.md
Normal file
481
docs/roadmap.md
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
# Roadmap
|
||||
|
||||
Written 2026-08-04, from the plan: USB, GPU, fonts, networking and sockets,
|
||||
then a compositor (Schcom), a window manager (SchWM) and a desktop (SchDE),
|
||||
plus an installer and a `.sip` package format.
|
||||
|
||||
This file records two things that are easy to lose: the *order* the work has
|
||||
to happen in, and the decisions that constrain it.
|
||||
|
||||
**Superseded in direction, not in order.** Schunix is now aimed at being a
|
||||
*system* rather than a Unix-like kernel with a userland attached: a NeXTSTEP/
|
||||
macOS-shaped layering, with POSIX kept complete but demoted from identity to
|
||||
personality, and a native API (**SchAPI**) above it. See
|
||||
[architecture/schapi.md](architecture/schapi.md). That decision changes what
|
||||
the phases below are building towards; it does not reorder them, and it does
|
||||
not start until the tree builds and boots natively again.
|
||||
|
||||
That assumption about §1 has since been revised. NetBSD's TCP/IP stack was the
|
||||
intended starting point; the per-file licence check found that NetBSD's
|
||||
`tcp_input.c` and `tcp_output.c` carry the WIDE Project's 4-clause advertising
|
||||
clause, which Berkeley's rescission does not reach. FreeBSD's equivalent is
|
||||
clean, and lwIP is cleaner still and a far smaller port. See Phase 2 below and
|
||||
docs/licensing.md §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where drivers can come from
|
||||
|
||||
**BSD: yes. Linux: no.** This is not a preference, it is a licence
|
||||
incompatibility, and it decides the whole driver strategy.
|
||||
|
||||
The reasoning — Apache-2.0's patent terms counting as "further restrictions"
|
||||
under GPLv2 §6, which is why the two cannot be combined and redistributed — is
|
||||
in [licensing.md](licensing.md), along with the per-licence table and the
|
||||
NetBSD-specific rules. Read it before copying anything. The short version:
|
||||
BSD-2 and BSD-3 code can be used directly with a `NOTICE` entry; GPL-2.0-only
|
||||
code can never be, at any distance.
|
||||
|
||||
There is a third source that is better than either: **the specifications**.
|
||||
xHCI, AHCI, virtio, NVMe, the Intel e1000 and i915 datasheets are all public
|
||||
documents with no licence attached to what you write from them. That is where
|
||||
`kernel/drivers/xhci.c` came from.
|
||||
|
||||
One caveat about "port it from BSD" as a plan, learned by looking: FreeBSD's
|
||||
`sys/dev/usb` is roughly 100k lines welded to newbus, devclass, `bus_dma`,
|
||||
callouts, taskqueues and FreeBSD's locking. Porting it means porting
|
||||
FreeBSD's device framework first and finding the USB stack somewhere
|
||||
underneath. NetBSD's is more portable and the same argument applies with a
|
||||
smaller multiplier. **BSD is far more valuable as the reference you check
|
||||
yourself against than as the code you paste.** Read it when the spec is
|
||||
ambiguous — which it is, often — and write your own.
|
||||
|
||||
Reading Linux for *understanding* is fine and normal. Translating a Linux
|
||||
driver function-by-function is not; the result is a derivative work whatever
|
||||
the variable names say.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ordering, and why it is not the order it was asked in
|
||||
|
||||
The requested order was USB → GPU → fonts → network → GUI → installer. That
|
||||
is a reasonable order by *interest*. By dependency it wants one change:
|
||||
|
||||
**Shared driver infrastructure comes before all of it.** USB, any GPU beyond
|
||||
the VBE framebuffer, and any NIC all need the same three things, none of
|
||||
which existed:
|
||||
|
||||
- a way to register an interrupt handler at runtime (`kernel/dev/irq.c`)
|
||||
- physically contiguous memory for rings and descriptors
|
||||
(`kernel/core/dma.c`)
|
||||
- PCI capability walking and MSI (`kernel/dev/pci.c`)
|
||||
|
||||
That work is done. It was the first step of USB, not a detour from it.
|
||||
|
||||
The other honest note: **USB is the item that does least for the GUI.** Under
|
||||
QEMU there is already a PS/2 keyboard and mouse, so USB buys real hardware
|
||||
support — which matters, but not for getting Schcom on screen. Sockets and
|
||||
raw input events are what actually block the compositor. USB is being done
|
||||
first because it was asked for first; the ordering below reflects that with
|
||||
the dependencies fixed.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plan
|
||||
|
||||
### Phase 1 — USB *(done, bar the boot path)*
|
||||
|
||||
- [x] Shared infrastructure: IRQ registration, DMA arena, PCI capabilities
|
||||
- [x] xHCI: reset, DCBAA, command ring, event ring, scratchpad
|
||||
- [x] Port enumeration, Enable Slot, Address Device, control transfers
|
||||
- [x] Device descriptors read from real devices (USB2 and USB3 paths)
|
||||
- [x] Configuration and interface descriptor parsing, `SET_CONFIGURATION`
|
||||
- [x] Interrupt endpoints, driven from the IRQ handler
|
||||
- [x] HID boot protocol: keyboard and mouse into the existing input paths
|
||||
- [x] Bulk endpoints, bulk-only transport, SCSI, a block device at
|
||||
`USBDISK_DEV` — a superblock reads back through `bread()`
|
||||
- [x] Hubs — route strings, hub slot contexts, per-port reset and power. A
|
||||
mouse behind a hub enumerates a tier deep, which is what proves it
|
||||
- [ ] Making a USB disk mountable and bootable (needs the root device to be
|
||||
selectable rather than compiled in)
|
||||
|
||||
### Phase 2 — Sockets, raw input, and networking
|
||||
|
||||
Do sockets **before** the network stack. **Unix domain sockets are what Schcom
|
||||
needs** — clients and compositor have to pass messages and share buffers, and
|
||||
a pipe cannot carry an shm handle. Unix sockets are also far simpler than
|
||||
TCP/IP, so they are the right thing to build the socket layer's shape around.
|
||||
|
||||
- [x] `socket`/`bind`/`listen`/`accept`/`connect`/`send`/`recv` syscalls, at
|
||||
their real Linux numbers — they were `NO_LINUX` stubs, so a musl program
|
||||
calling `socket()` never even reached the stub
|
||||
- [x] `AF_UNIX` SOCK_STREAM, `socketpair`, `shutdown`; names live in a table
|
||||
rather than the filesystem (see the comment at the top of
|
||||
`kernel/net/unix.c` for what that costs)
|
||||
- [x] Raw input events — `/dev/input/event0` and `event1`, Linux evdev layout,
|
||||
fed by the PS/2 keyboard and mouse and by USB HID. The console keeps its
|
||||
own path; both consumers see every key
|
||||
- [ ] `SCM_RIGHTS` for passing descriptors. Possibly unnecessary: an shm id is
|
||||
an integer and travels fine over a stream socket, so this waits until
|
||||
something actually needs to pass an open file
|
||||
- [x] e1000 driver — `kernel/drivers/net/e1000.c`, from Intel's public manual
|
||||
317453. MAC from EEPROM, transmit and receive rings, link detection.
|
||||
Run with `-netdev user,id=n0 -device e1000,netdev=n0`.
|
||||
**Verified to carry real traffic**: `echo n > /proc/sysrq` sends a
|
||||
hand-built ARP request and receives the gateway's reply, which exercises
|
||||
the descriptor formats, the DD handshake, the tail bookkeeping, the DMA
|
||||
addresses and the MAC filter in one exchange. That check came before any
|
||||
protocol stack on purpose -- a stack on an unproven driver fails in ways
|
||||
indistinguishable from its own bugs
|
||||
- [ ] ARP, IPv4, ICMP, UDP, then TCP. **Not written from scratch** -- decided
|
||||
2026-08-08. Writing TCP is the one part of this where doing it yourself is
|
||||
a bad trade: it is not the packet format that is hard, it is congestion
|
||||
control, retransmission, window management and twenty years of accumulated
|
||||
corner cases that only appear against a real peer.
|
||||
**Preferred base: lwIP** (BSD-3, no advertising clause, verified). It was
|
||||
written for exactly this situation -- a small OS with a raw Ethernet
|
||||
driver -- and its port layer is a handful of files.
|
||||
**If BSD instead: FreeBSD, not NetBSD.** NetBSD's `tcp_input.c` carries
|
||||
the WIDE Project's 4-clause advertising clause; FreeBSD's has none. But
|
||||
FreeBSD's `sys/netinet` is not a library -- it reaches into mbufs,
|
||||
epoch(9), netisr, VNET and uma(9), so taking it means porting FreeBSD's
|
||||
kernel infrastructure first. See docs/licensing.md §3.
|
||||
- [ ] `AF_INET` on top of the same socket layer
|
||||
|
||||
### Phase 3 — GPU
|
||||
|
||||
"Basic GPU driver" has a right answer and a wrong one. The wrong one is
|
||||
starting with Intel or AMD hardware: modern GPU drivers are enormous and most
|
||||
of the size is mode setting, power management and a command submission model
|
||||
that only pays off once there is something to submit.
|
||||
|
||||
- [x] **virtio-gpu**, 2D. `kernel/drivers/virtio_gpu.c`, written from the OASIS
|
||||
specification. Full path: GET_DISPLAY_INFO, RESOURCE_CREATE_2D,
|
||||
RESOURCE_ATTACH_BACKING, SET_SCANOUT, TRANSFER_TO_HOST_2D,
|
||||
RESOURCE_FLUSH. Bound as the display class ahead of the VBE framebuffer.
|
||||
Run with `-vga std -device virtio-gpu-pci`; keep `-vga std`, because the
|
||||
bootloader's splash goes through VBE and there is no virtio path that
|
||||
early
|
||||
- [ ] A `/dev/dri`-style device with mode setting, so resolution stops being
|
||||
whatever the bootloader asked VBE for
|
||||
- [ ] Double buffering and vblank — Schcom needs both to not tear
|
||||
- [ ] Only then: real hardware, and only 2D/blit acceleration
|
||||
|
||||
### Phase 4 — Fonts
|
||||
|
||||
This is a bigger job than it sounds and it should be sized honestly. A TTF/OTF
|
||||
rasteriser needs: `cmap`/`glyf`/`loca`/`head`/`hmtx` table parsing, quadratic
|
||||
béziers for TrueType outlines and *cubic* ones for OpenType/CFF, a scanline
|
||||
rasteriser with anti-aliasing, and a glyph cache.
|
||||
|
||||
The bigger structural problem is that the console assumes a fixed 8×16 cell
|
||||
grid throughout — `vga_putc`, the cursor, the ANSI erase operations. Variable
|
||||
width text is not a font change, it is a text-rendering layer.
|
||||
|
||||
- [x] **The text layer** — `include/text.h` + `kernel/dev/text.c`. Glyph
|
||||
metrics, a `struct surface` target so a compositor can draw off-screen,
|
||||
8-bit coverage with real blending, UTF-8, and `font_ops` so a rasteriser
|
||||
plugs in as a second provider. This was the actual blocker: the font was
|
||||
never the problem, the character-cell grid was.
|
||||
- [ ] Port the console onto it and delete the cell grid
|
||||
- [x] A TrueType rasteriser — `kernel/dev/ttf.c`, glyf outlines, composite
|
||||
glyphs, cmap format 4, anti-aliased scanline fill. Host harness at
|
||||
`tools/ttftest.c`
|
||||
- [x] A glyph cache — direct-mapped over ASCII into a 48 KB pool
|
||||
- [ ] Load a font from disk and point the console at it, deleting the cell grid
|
||||
- [ ] CFF/Type 2 charstrings, so OTF fonts work too (a separate `font_ops`
|
||||
provider; a bytecode interpreter, not an extension of the glyf path)
|
||||
- [ ] A rasteriser. `stb_truetype.h` is public domain and therefore usable
|
||||
as-is if wanted; writing one is also reasonable and more interesting
|
||||
- [ ] **The typefaces, decided 2026-08-09.** Both verified, not assumed:
|
||||
**Roboto** for the desktop, window manager and compositor --
|
||||
**Apache-2.0**, the same licence as Schunix, so it needs nothing beyond a
|
||||
NOTICE entry.
|
||||
**JetBrains Mono** for the terminal and TTY -- **SIL OFL 1.1**, and
|
||||
notably with *no* Reserved Font Name declared, so it may be modified and
|
||||
still called JetBrains Mono. That matters more than it sounds:
|
||||
pre-rasterising a font into bitmaps at build time is a modification, and
|
||||
an RFN would have forbidden keeping the name on the result.
|
||||
OFL's one real constraint is that the font may not be sold on its own and
|
||||
must stay under OFL; bundling it in an Apache-2.0 system is exactly the
|
||||
case it is written for.
|
||||
- [ ] Text layout (advance widths, kerning) as a library in `lib/`
|
||||
- [ ] The console keeps its fixed grid; proportional text belongs to the GUI
|
||||
|
||||
**Font licensing: public domain or very permissive only.** No SIL OFL if it
|
||||
can be avoided, no "free for personal use", no CC-BY-SA. The constraint is
|
||||
the same one that governs drivers (§1): a font file is redistributed inside
|
||||
every Schunix image, so its licence attaches to the image.
|
||||
|
||||
Safe choices, in rough order of usefulness:
|
||||
|
||||
- **CC0 / public domain** — best case, nothing to comply with.
|
||||
- **MIT / BSD / Apache-2.0 fonts** — fine, needs a `NOTICE` entry.
|
||||
- **SIL OFL** — usable but has a real string attached: a modified font must
|
||||
be renamed, and OFL fonts cannot be sold on their own. Workable, but read
|
||||
it before committing one.
|
||||
|
||||
Whatever is chosen, the licence goes in `NOTICE` **before** the file is
|
||||
committed, not after.
|
||||
|
||||
### Phase 5 — The GUI, as separate projects
|
||||
|
||||
See `projects/README.md`. Schcom, SchWM and SchDE live outside the kernel
|
||||
tree on purpose.
|
||||
|
||||
- [ ] **Schcom** — the compositor. Owns the framebuffer, composites client
|
||||
buffers, routes input. Needs: unix sockets, shm (done), raw input
|
||||
events, and a GPU driver with double buffering.
|
||||
- [ ] **SchWM** — window management on top of Schcom's protocol
|
||||
- [ ] **SchDE** — panel, launcher, settings
|
||||
|
||||
Raw input is the missing kernel piece nobody thinks of until it bites:
|
||||
keyboard events currently only arrive through the tty line discipline, cooked
|
||||
and line-buffered and delivered to one foreground process. A compositor needs
|
||||
key-down/key-up with modifiers and no line discipline at all — an evdev-style
|
||||
`/dev/input`.
|
||||
|
||||
### Phase 6 — Installer and `.sip`
|
||||
|
||||
`.sip` (Schunix Installation Package), the stated model being WIM in Windows
|
||||
PE: a *file-based* image rather than a block image, so one file can hold a
|
||||
whole filesystem, be applied to a partition of any size, and hold several
|
||||
images that share their common files.
|
||||
|
||||
The pieces for this mostly exist already. `tools/mkschfs.c` does content
|
||||
dedup with an `(st_dev, st_ino)` fast path; SchFS's B-tree is content
|
||||
addressed. A sensible `.sip` is: a header, a file index (path, metadata,
|
||||
content hash), and a blob store keyed by hash, with compression per blob.
|
||||
|
||||
- [ ] `.sip` format specification, written down before any code
|
||||
- [ ] `tools/mksip` — capture a directory tree into a `.sip`
|
||||
- [ ] Kernel or userspace apply path
|
||||
- [ ] An installation environment that boots from the ISO, partitions,
|
||||
applies the `.sip`, and installs the bootloader
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — The full-stack acceptance test *(served its purpose; removed)*
|
||||
|
||||
For a while this phase was a ported copy of a well-known 1993 shooter, kept as
|
||||
an acceptance test rather than a feature. The reasoning was sound and is worth
|
||||
keeping even though the test is gone: a large program that somebody else wrote,
|
||||
which exercises `/dev/fb0`, `/dev/input/event0`, `mmap`, the ELF loader,
|
||||
`gettimeofday` and a fixed-rate tic loop *simultaneously* and notices when any
|
||||
of them lie, is a much harsher test than anything written in-house — because it
|
||||
was not written against our bugs.
|
||||
|
||||
It found real ones, all of which are fixed and covered by narrower tests now:
|
||||
|
||||
- `fcntl(F_SETFL, O_NONBLOCK)` was a documented no-op, so the first read blocked
|
||||
forever and looked exactly like a hung renderer.
|
||||
- `/dev/input/eventN` replayed its backlog to each new opener, handing a program
|
||||
the keystrokes that had launched it as input.
|
||||
- Without `EVIOCGRAB`, a full-screen program and the shell behind it both read
|
||||
the same keystrokes.
|
||||
|
||||
It was removed once it stopped finding anything: the port was ~100k lines of
|
||||
third-party source under licences that can never ship in an Apache-2.0 image,
|
||||
and carrying it had a standing cost — 33 MB of non-redistributable data staged
|
||||
into `sysroot/`, which is linked into the kernel binary for a ramdisk-root
|
||||
build. That combination is a licensing accident waiting to happen, for a test
|
||||
that was passing every time.
|
||||
|
||||
**What replaced it:** `tools/bootcheck.ps1` with its `-Sock`, `-Evdev` and `-Ps`
|
||||
modes, plus `tools/splashshot.ps1` for the boot path. These are narrower and do
|
||||
not prove the whole stack composes, which is the honest cost of the removal.
|
||||
|
||||
**The lesson worth keeping** is the shape of the test, not the program: the next
|
||||
full-stack test should be something large that we did not write, driven to a
|
||||
real pixel-level assertion rather than a log line. `tools/splashshot.ps1`'s
|
||||
colour counting exists because a log-only harness once passed on a blank screen.
|
||||
|
||||
### Phase 8 — The system layer: `schinit`, firmware, and a crash channel
|
||||
|
||||
Three ideas parked here rather than started, because each one is a project and
|
||||
the phases above are the agreed order. Recorded so they are not re-derived from
|
||||
scratch later.
|
||||
|
||||
**`schinit`, a launchd-shaped init.** The point of copying launchd rather than
|
||||
sysvinit is not the config format, it is *demand launching*: launchd holds the
|
||||
listening socket or watches the path, and starts the job when something actually
|
||||
arrives. That inverts the dependency graph — jobs stop needing to declare what
|
||||
must start before them, because the thing they depend on answers on first use.
|
||||
Schunix already has the two pieces that makes possible: `AF_UNIX` sockets
|
||||
(`kernel/net/unix.c`) and a real `/proc`. What is missing is a supervisor that
|
||||
can hold a socket on a job's behalf and hand it over on exec, plus a job
|
||||
description format. Prerequisite for this being worth anything: `ps` and `top`
|
||||
must be able to enumerate processes, or the supervisor is unobservable.
|
||||
|
||||
**A crash screen you can work in, and a QR code channel out of it.** PSOGM
|
||||
(`kernel/core/panic.c`) currently renders and stops. The idea is two staged
|
||||
things, and the staging matters:
|
||||
|
||||
- *An interactive mode — "a small OS inside the crash".* This is the right
|
||||
framing and it is worth stating precisely, because it decides the design. At
|
||||
panic time the kernel is the only thing running, which is exactly why it can
|
||||
offer what a live system cannot: walk the stack, read another process's
|
||||
memory, dump page tables, inspect the process table — with no scheduler and no
|
||||
locking to respect, because nothing else can race you.
|
||||
|
||||
What makes it a *system* rather than a print statement is that it keeps the
|
||||
base machine's drivers. Concretely, the ones that work with interrupts off:
|
||||
the VGA/framebuffer (`vga_draw_pixel` is a plain memory write), the PS/2
|
||||
keyboard (pollable via port 0x60/0x64), and the serial port. Those three are
|
||||
a complete console. Everything else — IDE, USB, the timer, anything that
|
||||
sleeps or takes a sleeplock — is off limits, because the lock it needs may be
|
||||
precisely the one held by the CPU that just died.
|
||||
|
||||
So the shape is a **polled event loop over preallocated state**: no `kalloc`,
|
||||
no `sleep`/`wakeup`, no `acquire` on anything a live path might hold, one
|
||||
static command buffer, and a driver set restricted to the three above. That
|
||||
restriction is not a limitation to work around later; it is the invariant that
|
||||
makes the mode trustworthy when everything else is not. Anything that wants
|
||||
more should be answered by rebooting into a recovery environment instead.
|
||||
|
||||
A useful precedent to read but not copy: DDB (NetBSD/FreeBSD's in-kernel
|
||||
debugger) is exactly this, and its hard-won rule is the same one — a debugger
|
||||
that can deadlock is worse than no debugger.
|
||||
- *A QR code.* **Done.** `kernel/core/qr.c` (byte mode, EC level L, versions
|
||||
1–6, no allocation) and a card on the right of the crash screen. A panic
|
||||
report is worth nothing if it cannot leave a machine whose network stack is
|
||||
dead, and a phone camera is an out-of-band channel needing no driver on our
|
||||
side. Deliberately *not* a URL: it encodes the report itself and reaches
|
||||
nothing.
|
||||
|
||||
It ended up unconditional rather than behind Enter, which is better — it
|
||||
costs nothing to always draw, and requiring a keypress would mean the one
|
||||
case where input is least trustworthy is also the case where the report is
|
||||
hardest to retrieve. Verified end-to-end by `make crashshot && make crashqr`,
|
||||
which decodes the symbol out of the captured framebuffer.
|
||||
|
||||
**UEFI.** Not a feature so much as the point at which the current bootloader
|
||||
stops being extendable: it is 16-bit real mode, INT 10h VBE, and PIO ATA, none
|
||||
of which exist on a UEFI-only machine. The realistic shape is a *second*
|
||||
loader — an EFI application that gets the framebuffer from GOP, the memory map
|
||||
from `GetMemoryMap()`, loads the kernel off a FAT ESP, and enters at the same
|
||||
place `boot/stage2main.c` does — rather than a port of the existing one. Worth
|
||||
noting that the splash work just done survives that transition: it is a mask, a
|
||||
palette and a layout, none of which care how the pixels were found. What does
|
||||
not survive is every assumption in `stage2main.c` about where things live in low
|
||||
memory (see `docs/layout.md`).
|
||||
|
||||
### SchFS beyond Schunix
|
||||
|
||||
Three ideas parked 2026-08-08, in the order they should happen.
|
||||
|
||||
**An on-disk format version, and an upgrade path.** This one is not optional and
|
||||
gets harder every day it is postponed: the moment an image exists that someone
|
||||
cares about, changing the layout means being able to read the old one. The
|
||||
superblock already carries a version field and a CRC32c; what is missing is a
|
||||
*policy* — a documented rule for what a version bump means, a refusal to mount
|
||||
something newer than the driver understands (rather than misreading it), and an
|
||||
explicit upgrade step. Cheap now, expensive later, and it blocks the other two:
|
||||
a second implementation is only meaningful against a format that is pinned.
|
||||
|
||||
**A Linux implementation.** The realistic first form is **FUSE**, not a kernel
|
||||
module — it needs no GPL entanglement (`libfuse` is LGPL and used across a
|
||||
process boundary), it can reuse the read/write logic already in `tools/mkschfs`
|
||||
and `tools/schfs_fsck` rather than being written twice, and it turns "does our
|
||||
filesystem work" into something testable with `fsx` and `xfstests`, which is a
|
||||
far harsher examination than anything in this tree. A Linux *kernel* module is a
|
||||
different project and carries the licence problem that
|
||||
[licensing.md](licensing.md) describes.
|
||||
|
||||
**The missing features.** Worth listing honestly before adding any: no journal
|
||||
replay across an unclean shutdown beyond what CoW gives for free, no snapshots
|
||||
(despite the B-tree being copy-on-write, which is most of the work), no extended
|
||||
attributes, no sparse-file support, and no online `fsck`. Snapshots are the one
|
||||
where the existing design already pays for most of the cost.
|
||||
|
||||
---
|
||||
|
||||
## 3b. Known broken
|
||||
|
||||
Two things that are wrong *now*, both reported 2026-08-08 and both diagnosed
|
||||
rather than merely observed. Neither blocks the GUI work, which is why they are
|
||||
here and not in a phase.
|
||||
|
||||
### ~~SMP is broken~~ *(fixed 2026-08-08)*
|
||||
|
||||
**SMP works.** One, two and four processors all boot; every CPU reports in with
|
||||
`cpu%d: starting`, and `/proc/cpuinfo` lists them all. `smp_start_aps` in
|
||||
`kernel/core/main.c` is 1.
|
||||
|
||||
Five distinct bugs, each hidden behind the one before it, and all invisible
|
||||
under QEMU because it publishes only one processor in the legacy MP table.
|
||||
VMware published the real count, which is why the triple fault only ever
|
||||
appeared there.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| CPU enumeration | `mpinit()` read the 1994 MP table. `kernel/arch/x86_64/acpi.c` now reads the ACPI MADT. |
|
||||
| triple fault | `entry64high` had **no AP branch** — every AP took the BSP's stack and ran `kmain()` twice. Now tests `EBX == 1`. |
|
||||
| `panic("acquire")` | `seginit()` called `kalloc()` before `%fs` existed; kalloc locks, locking reads `cpu`. BSP now preallocates each AP's page. |
|
||||
| `panic("sched locks")` | `kernel_fs_base` was **one global** read by `trapasm.S` on every entry, so all CPUs shared one per-CPU block. Now `%gs:0`. |
|
||||
| null `proc` in `sleep()` | `cpu`/`proc` were `__thread` — per *thread*, so the compiler cached the thread pointer across `sched()`, but `%fs` is per *CPU*. Now `mycpu()`/`myproc()`. |
|
||||
| `#GP` in `syscall_entry` | The new per-CPU slots landed on `%fs:(8)`, the syscall scratch word — the same memory as `%gs:8`. Moved to +16 and +24. |
|
||||
|
||||
The `__thread` one is the lesson worth keeping: **`__thread` is wrong for
|
||||
per-CPU kernel data.** It means per thread, and the compiler is entitled to
|
||||
cache the thread pointer across calls on that basis. A kernel thread changes CPU
|
||||
at every `sched()`, so the assumption is false for exactly the variables that
|
||||
most need to be right. `include/proc.h` carries the disassembly that proves it.
|
||||
|
||||
Per-CPU block layout, shared with assembly and not to be reordered:
|
||||
|
||||
+0 self-pointer x86-64 TLS ABI (%fs:0)
|
||||
+8 syscall scratch trapasm.S parks %rsp here
|
||||
+16 struct cpu *
|
||||
+24 struct proc *
|
||||
|
||||
### ~~The kernel demands 224 MB of RAM~~ *(fixed 2026-08-08)*
|
||||
|
||||
`kernel/arch/x86_64/e820.c` reads the firmware's memory map and sets `phystop`
|
||||
from it. **The floor went from 224 MB to 32 MB**, and the kernel now sees all
|
||||
the memory a machine actually has: 512 MB in gives `MemTotal: 524160 kB`, where
|
||||
before it reported 224 MB whatever was installed.
|
||||
|
||||
Three details worth keeping:
|
||||
|
||||
- It takes the end of the usable region **containing the kernel**, not the
|
||||
highest usable address anywhere. A memory map has reserved holes in it, and
|
||||
`freerange()` walks straight from the end of the kernel image to `phystop`
|
||||
handing out every page between — so the global maximum would put firmware
|
||||
tables on the free list, failing later and elsewhere and looking like anything
|
||||
but a memory map problem.
|
||||
- The ceiling is **3 GB**, because `kvmalloc()` maps 0–4 GB as four 1 GB pages
|
||||
and marks the top one uncached for PCI BARs.
|
||||
- Below `PHYSTOP_MIN` it prints why and stops, rather than triple faulting in
|
||||
silence. At 24 MB: `e820: only 23 MB usable; Schunix needs at least 24 MB`.
|
||||
|
||||
The original text is kept below because the reasoning still applies to the SMP
|
||||
half of §3b, which is the same shape of bug: a constant nobody revisited.
|
||||
|
||||
### The kernel demanded 224 MB of RAM
|
||||
|
||||
**Symptom as reported:** the OS needs ~256 MB, which is absurd next to Windows 98
|
||||
or an early Linux.
|
||||
|
||||
**Diagnosis:** it is one constant. `PHYSTOP` in `include/memlayout.h` is
|
||||
`0xE000000` — 224 MB — inherited unchanged from xv6, and `main()` hands
|
||||
`P2V(PHYSTOP)` straight to `kinit1()`/`dma_init()`. The kernel therefore
|
||||
*assumes* that much physical memory exists and walks off the end of RAM if it
|
||||
does not. Measured 2026-08-08: boots at 224M and above, triple faults at 192M
|
||||
and below. Nothing is actually being *used* — the requirement is entirely
|
||||
fictional.
|
||||
|
||||
**The fix is already half-built.** `boot/stage2.S` collects a full E820 memory
|
||||
map in real mode and leaves it at `VBE_INFO_ADDR + 0x200` (count) and `+ 0x208`
|
||||
(24-byte entries); `boot/stage2main.c` already parses it to print detected RAM.
|
||||
**The kernel never reads it.** So: have `main()` take the top of the highest
|
||||
usable E820 region instead of the constant, clamp it to what the direct map
|
||||
covers, and keep `PHYSTOP` only as a fallback for the case where the firmware
|
||||
returned nothing. That should drop the floor to something in the tens of
|
||||
megabytes and is a contained change.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kept out of scope on purpose
|
||||
|
||||
- Anything that needs pushing or publishing. This work stays local.
|
||||
Loading…
Reference in a new issue