docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.
A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.
So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.
schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.
Three corrections found on the way, all load-bearing:
* projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
* The root README said "No TCP/IP" months after the stack landed.
* history/kernel.md sec.7 was marked "in progress" on a problem that process
groups solved.
Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
215 lines
10 KiB
Markdown
215 lines
10 KiB
Markdown
# Boot
|
||
|
||
Firmware to the first shell prompt. This is the shortest path through the whole
|
||
system, which makes it the best thing to read first.
|
||
|
||
The loader's side of the hand-off is specified in
|
||
[`boot/README.md`](../../boot/README.md) — that file is the contract, and this
|
||
one is the narrative.
|
||
|
||
---
|
||
|
||
## 1. Firmware
|
||
|
||
**UEFI only.** There was a BIOS path and it was removed: everything since
|
||
roughly 2012 boots UEFI, and CSM — the firmware-side BIOS emulation that let
|
||
legacy loaders run — has been going away since 2020. The cost of keeping it was
|
||
not the code but the tax on everything it touched, and the asymmetry is total. A
|
||
BIOS loader lives in 16-bit real mode and calls the firmware through software
|
||
interrupts; UEFI hands a loader long mode with paging already on and a table of
|
||
function pointers.
|
||
|
||
The loader is `boot/uefi/`, a 64-bit PE application built by the same clang
|
||
install as everything else (`--target=x86_64-unknown-windows`). It imports
|
||
neither gnu-efi nor EDK II — the handful of structures it uses were written from
|
||
the specification, which is both a smaller dependency and no licence question.
|
||
|
||
## 2. The loader
|
||
|
||
Six steps, in this order, and the order is load-bearing:
|
||
|
||
1. Find the kernel on the volume this image was loaded from — `\schos\kernel`,
|
||
or `\kernel`.
|
||
2. Load its ELF `PT_LOAD` segments to their physical addresses.
|
||
3. Record the framebuffer geometry from the Graphics Output Protocol.
|
||
4. Translate `GetMemoryMap()` into the 24-byte E820-shaped entries
|
||
`kernel/arch/x86_64/e820.c` already parses — not a second format for the
|
||
kernel to learn.
|
||
5. `ExitBootServices`.
|
||
6. Build the page tables the kernel's 64-bit entry expects, and jump.
|
||
|
||
**Nothing allocates after step 5 and nothing calls a firmware service after it,
|
||
because there are none.** Everything the jump needs is computed before.
|
||
|
||
### The hand-off block
|
||
|
||
One page of low memory at `0x30000`, defined once in
|
||
[`include/schboot.h`](../../include/schboot.h) and included by both the loader
|
||
and `entry.S` so the two cannot disagree.
|
||
|
||
| Offset | What |
|
||
|---|---|
|
||
| `+0x000` | VBE-shaped mode info: LFB address, stride, width, height, depth |
|
||
| `+0x100` | Verbose-boot flag |
|
||
| `+0x101` | Boot medium (`1` = CD; `0` = not stated) |
|
||
| `+0x200` | E820 entry count, then up to 40 × 24-byte entries |
|
||
| `+0x600` | ACPI RSDP physical address, or 0 |
|
||
|
||
Two of these exist because of specific failures worth knowing about.
|
||
|
||
**The RSDP is passed explicitly** because the kernel's own search scans the EBDA
|
||
and `0xE0000`–`0x100000`, which is a BIOS convention. UEFI publishes the RSDP in
|
||
its configuration table and is under no obligation to leave a copy anywhere;
|
||
OVMF does not. Without it the kernel finds no MADT, so no IOAPIC, so no
|
||
interrupt routing — and the machine boots to a prompt on polled serial output
|
||
and then ignores every keystroke, which looks like a broken console rather than
|
||
a missing table.
|
||
|
||
**The E820 cap is 40 entries, not 32.** Real UEFI firmware produces far more
|
||
descriptors than QEMU's, and the space was already there.
|
||
|
||
### The entry point
|
||
|
||
The kernel publishes a `sch64_header` — magic `SCH64BOO`, 8-byte aligned within
|
||
the first 16 KB, followed by a 64-bit physical entry address — found by
|
||
scanning, exactly as a multiboot loader scans for its own magic.
|
||
|
||
It exists because the firmware has *already* put the machine in long mode.
|
||
Dropping back to protected mode to re-enter through the 32-bit multiboot path
|
||
is a mode switch whose failure mode is a triple fault with no console. The
|
||
32-bit entry is still present in `entry.S` and now has no caller.
|
||
|
||
## 3. `kmain()`
|
||
|
||
`kernel/core/main.c`. The ordering here is not arbitrary and several steps are
|
||
where they are because of a bug:
|
||
|
||
```
|
||
e820_init() memory map first — PHYSTOP is a variable, not a constant
|
||
kinit1() the allocator, given everything below the DMA arena
|
||
dma_init() the arena, carved off the TOP of physical memory
|
||
kvmalloc() the kernel page tables
|
||
acpi_init() CPU enumeration from the MADT; mpinit() only as fallback
|
||
lapicinit() tvinit() seginit() ioapicinit()
|
||
irq_init() before any driver that registers a handler
|
||
inputinit() before the keyboard and mouse start producing events
|
||
consoleinit() uartinit() mouseinit()
|
||
platform_register_devices(fb) the fixed furniture of a PC, plus the framebuffer
|
||
pci_init() enumerate, and bind drivers
|
||
pinit() binit() fileinit() sockinit()
|
||
time_init() after the timer runs, before userspace
|
||
ideinit() ramdiskinit()
|
||
startothers() the other processors
|
||
kinit2() sinit() the rest of memory, then PID 1
|
||
netinit() after sinit — it starts a kernel thread
|
||
mpmain() enter the scheduler; never returns
|
||
```
|
||
|
||
**The DMA arena is taken before `kalloc` gets the rest.** A device needs
|
||
physically contiguous memory and `kalloc`'s free list makes no such promise —
|
||
two pages from it are adjacent only by chance. Taking it first is the only way
|
||
to be sure the region exists.
|
||
|
||
**ACPI is asked before the legacy MP table**, and getting this backwards is
|
||
invisible under QEMU. Modern firmware publishes a complete MADT *and* an MP
|
||
table containing one processor, so asking the older interface first means never
|
||
seeing more than one CPU. VMware published the real count, which is why the
|
||
resulting triple fault only ever appeared there.
|
||
|
||
**`main()` names no display driver.** Whichever driver owns the framebuffer the
|
||
loader left running publishes it with `gfx_attach_scanout()`, and the console
|
||
and crash screen draw on that. See [graphics.md](graphics.md).
|
||
|
||
### Memory sizing
|
||
|
||
`PHYSTOP` is a variable read from the firmware's map, not a constant. It used to
|
||
be `0xE000000` — 224 MB — inherited from xv6, and it was not a limit on what the
|
||
kernel used but an **assertion that exactly that much RAM existed**. Less and the
|
||
allocator walked off the end of memory; more and it was invisible.
|
||
|
||
Three details survive that fix:
|
||
|
||
- It takes the end of the usable region **containing the kernel**, not the
|
||
highest usable address anywhere. A memory map has reserved holes, and freeing
|
||
a single range from the kernel's end to a global maximum puts firmware tables
|
||
on the free list — failing later, elsewhere, looking like anything but a
|
||
memory map problem.
|
||
- The usable regions are *also* kept as a list, and `kinit1()` frees each one.
|
||
One range ending at a top address is what a BIOS map looks like and what UEFI
|
||
never does: OVMF drops an ACPI NVS region at `0x800000`, so the run containing
|
||
the kernel ends at 8 MB and everything above the hole was invisible.
|
||
- 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. The floor is 24 MB, and below it the
|
||
kernel prints why and stops rather than triple faulting in silence.
|
||
|
||
## 4. SMP
|
||
|
||
`startothers()` wakes each application processor through the local APIC, pointing
|
||
it at a trampoline copied to `0x7000`. Each AP runs `mpenter()` → `mpmain()`,
|
||
reports `cpu%d: starting`, and enters the scheduler.
|
||
|
||
This was broken from the project's beginning and is fixed. Five distinct bugs,
|
||
each hidden behind the one before it — the details are in
|
||
[../history/kernel.md](../history/kernel.md), and the one worth carrying
|
||
forward is in [kernel.md](kernel.md#per-cpu-state): **`__thread` is wrong for
|
||
per-CPU kernel data.**
|
||
|
||
APs are counted on the boot processor *after* each one reports in, so the count
|
||
cannot include a processor that started and died.
|
||
|
||
## 5. The first process
|
||
|
||
`sinit()` builds PID 1 by hand: a page directory, `initcode` mapped at 0, 32
|
||
pages of stack, a trapframe pointing at user mode, and session and process group
|
||
both set to 1.
|
||
|
||
It does **not** mount the root filesystem, and cannot: mounting reads blocks,
|
||
`bread()` sleeps, and sleeping needs a process context. So the mount happens in
|
||
`forkret()`, the first time any process is scheduled — guarded by a `static int
|
||
first`.
|
||
|
||
That is also where PID 1's working directory is re-pointed at the real root,
|
||
and the reason is worth keeping:
|
||
|
||
> `sinit()` sets `cwd = namei("/")` before any filesystem is mounted. The old
|
||
> v1 root survived that because `iget(ROOTDEV, ROOTINO)` is two compile-time
|
||
> constants and touches no disk. SchFS has no constant to offer — the root
|
||
> object id comes off the uberblock — so that early `namei("/")` builds an inode
|
||
> from an unmounted volume. Nothing complains, because absolute paths never
|
||
> consult cwd: `exec("/bin/init")` works fine. It is the first *relative* lookup
|
||
> that fails, and in init that is `open("console")`. No console means no fd
|
||
> 0/1/2, so every shell it spawns exits immediately and init respawns it
|
||
> forever. The symptom is a silent boot that reads like a hang and is not one.
|
||
|
||
`initcode` then execs `/bin/init`, and [init.md](init.md) takes over.
|
||
|
||
## 6. What the screen shows
|
||
|
||
**The display is not the kernel log.** `cons_serial_only` starts *set*: every
|
||
boot message goes out the UART and nothing goes to the framebuffer. The loader's
|
||
splash therefore survives the whole of boot rather than being wiped a few
|
||
hundred milliseconds in, and `console_takeover()` clears the flag on the first
|
||
`exec()`, when userspace genuinely starts.
|
||
|
||
Nothing is lost — the serial line is where `bootcheck` reads from and where you
|
||
want the messages when something is wrong — and the F8 verbose boot never sets
|
||
the flag, so "show me everything" still works.
|
||
|
||
---
|
||
|
||
## What it does not do
|
||
|
||
- **No BIOS path.** A machine without UEFI does not boot SchOS.
|
||
- **The root device is compiled in.** Booting from a USB disk needs the root
|
||
device to be selectable, which it is not; the USB mass-storage driver works
|
||
and a superblock reads back through `bread()`, but nothing can mount it.
|
||
- **`make ramroot` is the only way to boot with no data disk.** It links a
|
||
filesystem image into the kernel binary, which costs boot time and carries a
|
||
licensing constraint — see [../licensing.md](../licensing.md) §4.
|
||
- **The 32-bit multiboot entry is dead code.** It is left in place deliberately:
|
||
deleting kernel entry code is its own piece of work with its own way of
|
||
failing silently.
|
||
- **`make qemu-uefi` under OVMF reports less memory than the machine has** in
|
||
some configurations. The loader's map is correct; the fix went in with the
|
||
region list above, and any remaining case is a fragmentation pattern not yet
|
||
seen.
|