A native driver written from the NVMe 1.4 base specification. It resets the controller the firmware left running, then sets up an admin queue and one I/O queue pair. Completion is polled, with interrupts disabled at the queue, for the same reason AHCI polls: without AML, interrupt routing on real hardware cannot be trusted. Each active namespace with 512-byte to 4 KB sectors and no metadata is registered as nvme<C>n<N>, the name Linux uses, and its GPT is read like any other disk's. Tested under QEMU q35: disk.img on NVMe as the only disk boots through OVMF, roots on nvme0n1p2, and writes read back after sync. A 4 KB-sector namespace registers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
623 lines
31 KiB
Markdown
623 lines
31 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.
|
||
|
||
### Choosing a display, on a machine that has more than one
|
||
|
||
`LocateProtocol` answers with *a* GOP, and the specification says nothing about
|
||
which. On QEMU with a stdvga and a virtio-gpu there are **three** handles, and
|
||
two of them report `PixelBltOnly` with a framebuffer address of zero:
|
||
|
||
```
|
||
schos: 3 display adapter(s)
|
||
gop: 1280x800 BGRX scanline 1280 fb 0x0000000080000000 size 0x3e8000
|
||
gop: 1280x800 Blt-only scanline 1280 fb 0x0000000000000000 size 0x0
|
||
gop: 1280x800 Blt-only scanline 1280 fb 0x0000000000000000 size 0x0
|
||
schos: display 1280x800 BGRX scanline 1280 fb 0x0000000080000000 size 0x3e8000
|
||
```
|
||
|
||
A laptop with switchable graphics is the same shape: an integrated adapter, a
|
||
discrete one, and often a console splitter over both. Taking the first handle
|
||
means a one-in-three chance of drawing into an adapter with no aperture — a
|
||
black screen with the firmware's own cursor still blinking on it, which looks
|
||
exactly like a loader that crashed.
|
||
|
||
So every handle is examined and scored: an adapter with no framebuffer address
|
||
is not a candidate at all, one whose current mode is readable beats one that
|
||
would need changing, and size breaks the tie. The report above is printed to
|
||
the firmware's text console, which is cleared a few lines later before the
|
||
splash — so on a boot that works nobody sees it, and on a boot that dies before
|
||
the splash it is the only thing on the screen.
|
||
|
||
### A framebuffer address is sixty-four bits
|
||
|
||
The loader used to hand over `(UINT32)gop->Mode->FrameBufferBase`, and the
|
||
kernel used to read a 32-bit field out of a *VBE mode-info block* — a BIOS
|
||
structure the UEFI path had been filling in because the kernel already knew how
|
||
to read one.
|
||
|
||
Any machine that puts its aperture above 4 GB — which is ordinary on a laptop
|
||
with a discrete GPU — got a truncated address that pointed at nothing. The
|
||
splash was drawn into it, the kernel was handed it, and the screen kept the
|
||
firmware's cursor. There is no error anywhere in that sequence; every step
|
||
succeeded on a number that was wrong.
|
||
|
||
`SCHBOOT_FB_BASE` is the fix: the loader's own display block, with a 64-bit
|
||
address, a 64-bit size, 32-bit width, height and stride, and the pixel format.
|
||
The VBE block is still written for the BIOS loader and for anything that has
|
||
not been taught to look at the new one, but anything that does not fit is left
|
||
at **zero** rather than wrapped — a truncated address is the bug above, and a
|
||
zero is at least honest about it.
|
||
|
||
The kernel then has a second problem the loader does not: it runs on its own
|
||
page tables, and its direct map covers the low 4 GB. A framebuffer above that
|
||
needs `ioremap()`, which needs `kpdpt`, which `kvmalloc()` has not allocated
|
||
when the early console is set up. So a low framebuffer is attached exactly as
|
||
before, and a high one is attached a few dozen instructions later, immediately
|
||
after `kvmalloc()` — still thousands of lines before anything else happens.
|
||
|
||
### The loader jumped out from under itself
|
||
|
||
A machine with a lot of RAM froze at "Starting the kernel" — the splash still
|
||
up, no panic, no reset, nothing. It reproduces exactly on QEMU with `-m 8G` and
|
||
never on `-m 1G`, which is why every test here passed for months.
|
||
|
||
The firmware's own exception handler had the answer all along, once a machine
|
||
was found that printed it:
|
||
|
||
```
|
||
X64 Exception Type - 0E(#PF - Page-Fault)
|
||
ExceptionData - 0x10 I:1 P:0
|
||
RIP - 0000000140004DEE <- the loader's own code, at 5.4 GB
|
||
RAX - 0000000000001000 <- the page tables it had just installed
|
||
RCX - 00000000001000F0 <- the kernel it was about to jump to
|
||
```
|
||
|
||
`I:1 P:0` is an instruction fetch from a page that is not present. The loader
|
||
built page tables covering the low four gigabytes, loaded `cr3` with them — and
|
||
**the firmware had loaded the loader image above four gigabytes**, so the very
|
||
next instruction fetch was unmapped. It jumped out from under itself, between
|
||
one instruction and the next.
|
||
|
||
The fix costs nothing. The PDPT is one page whether four of its entries are
|
||
filled or all 512, so all 512 are: a 512 GB identity map, which covers any
|
||
machine whose firmware puts us somewhere surprising. The kernel replaces the
|
||
whole thing in `kvmalloc()` a few hundred instructions later.
|
||
|
||
### The memory map does not fit in a guess
|
||
|
||
`GetMemoryMap` answered **`EFI_BUFFER_TOO_SMALL` (0x5)** on real hardware, and
|
||
the loader stopped there. The buffer was 32 KB — about 680 descriptors — which
|
||
is generous next to QEMU's map and not generous at all next to a firmware that
|
||
has carved out every reserved range it has ever needed. The count is a property
|
||
of the vendor, not of the RAM.
|
||
|
||
The fix is not a bigger guess. A call with a **zero size** fails on purpose and
|
||
fills in how much is actually needed; it allocates nothing, so the map key from
|
||
the real call immediately after is still valid — which is the constraint this
|
||
whole sequence is written around, and the reason the buffer is static in the
|
||
first place. The buffer is now 256 KB, and if a machine ever exceeds even that,
|
||
it says so with both numbers rather than dying on a status code:
|
||
|
||
```
|
||
schos: the firmware's memory map needs 5664 bytes and this loader has 512
|
||
schos: memory map larger than the loader's buffer (status 0x8000000000000005)
|
||
```
|
||
|
||
(That is the failure path deliberately provoked by shrinking the buffer to 512.
|
||
QEMU's own map needs 5664 bytes — 118 descriptors — so the real number on
|
||
hardware is larger still, and 256 KB is 5461 descriptors.)
|
||
|
||
**A second limit is nearby and is only a warning.** `SCHBOOT_E820_MAX` is 40
|
||
entries, fixed by the hand-off block's layout. `build_e820()` merges adjacent
|
||
regions of the same type, so 40 survives a long map — but if it ever truncates,
|
||
what gets dropped is the *high* end (the map arrives sorted, and `phystop` is
|
||
capped below 4 GB regardless). It now says so on screen as well as on the
|
||
serial line, because by that point `print()` goes to a port a laptop does not
|
||
have.
|
||
|
||
### Two signals for a machine that cannot speak
|
||
|
||
A laptop has no serial port. When it stops with the loader's splash still on
|
||
screen, three very different things look identical: the jump never landed, the
|
||
kernel started and hung, or the kernel started and *panicked* with a full
|
||
report drawn into a framebuffer nobody can see. Each calls for a different next
|
||
step, so the kernel now produces two signals that need no console at all.
|
||
|
||
**A bar, as the kernel's first act.** Eight lines of accent colour across the
|
||
top of the framebuffer, written straight through the handoff block before a
|
||
single subsystem exists — no gfx, no scanout, no allocator. If it appears over
|
||
the loader's splash, the jump landed and the kernel is executing; if it does
|
||
not, nothing below that point is worth looking at. It is skipped when the
|
||
framebuffer is above the direct map, because a fault there would leave the
|
||
machine quieter still, and it costs nothing on a boot that works: the console
|
||
clears the screen a few lines later.
|
||
|
||
**Blinking keyboard lights, on panic.** `panic_blink()` toggles Scroll, Num and
|
||
Caps together, about three times a second, for ever. It is the one output every
|
||
PC has had since before any of this existed: no driver, no interrupt, no
|
||
memory, and it works when the graphics do not. Linux does the same thing for
|
||
the same reason.
|
||
|
||
Every wait in it is bounded. It runs after `panicked` is set, so there is
|
||
nothing left to protect — but a machine that hangs inside its own death rattle
|
||
tells you less than one that stops quietly, so no loop in there can be entered
|
||
that cannot be left. The delay is `microdelay`, which spins on the TSC and
|
||
needs no timer, no interrupt and no calibration — all three of which a kernel
|
||
that died during boot may not have.
|
||
|
||
So the three cases separate:
|
||
|
||
| What you see | What happened |
|
||
| --- | --- |
|
||
| No bar, no blink | The jump did not land. The loader's last label says where. |
|
||
| Bar, no blink | The kernel is executing and hung. Not a panic. |
|
||
| Blinking lights | The kernel panicked. The report is in `/proc/kmsg` after a restart. |
|
||
|
||
### The loader says which stage it is in
|
||
|
||
After the splash, `print()` goes to the serial port and nowhere else — writing
|
||
text into the framebuffer would scribble it across the splash. On a laptop with
|
||
no serial port that means the last thing on screen is a progress bar, and a
|
||
loader that stops has no way to say where.
|
||
|
||
So every stage after the kernel load names itself under the bar:
|
||
*Reading the console font*, *Reading the memory map*, *Leaving the firmware*.
|
||
On a machine that works these are invisible — they take microseconds — and on
|
||
one that stops, the label is the last thing on the screen and names the call
|
||
that did not come back. It costs three strings.
|
||
|
||
Past `ExitBootServices` there is nothing left to report with, so the final
|
||
label stands for the whole of the jump into the kernel.
|
||
|
||
### Mirroring is a nicety, and booting is not
|
||
|
||
`mirrors_show()` copies the primary's framebuffer onto every other display
|
||
adapter with `Blt`. Three rules keep a cosmetic feature from being able to stop
|
||
a machine:
|
||
|
||
**Modes are matched once, at discovery, before anything is drawn.** It used to
|
||
be done inside `mirrors_show()` — which is to say on every menu redraw and
|
||
every segment of the kernel load. Setting a mode is not a cheap call on real
|
||
hardware: it can take the best part of a second, it can blank a panel while it
|
||
happens, and on a laptop the "other" adapter is quite often the *same* panel
|
||
reached through a second GOP. Doing that repeatedly, in the middle of drawing a
|
||
progress bar, produces a machine showing a background colour and nothing else
|
||
while the loader waits on a firmware call.
|
||
|
||
**One refusal turns it off for the boot.** A `Blt` that fails once will fail
|
||
every time, and retrying it on every screen is how a nicety becomes a hang.
|
||
|
||
**`\schos
|
||
omirror` disables it entirely.** An empty file whose only job is to
|
||
exist. There is no console to ask a question on and no way to reach a menu if
|
||
the menu is what is broken, so the answer has to already be on the volume.
|
||
|
||
### Modes a real machine actually has
|
||
|
||
`mode_usable()` rejected three things it should not have:
|
||
|
||
| Was rejected | Why it was wrong |
|
||
| --- | --- |
|
||
| `PixelsPerScanLine != HorizontalResolution` | A GPU is entitled to pad each row, and 1920×1080 arriving with a 1984- or 2048-pixel scanline is ordinary. The stride is carried through to the kernel, so this excluded nothing but every machine that pads. |
|
||
| `HorizontalResolution % 8` | Excludes 1366×768, the panel on a very large number of laptops. |
|
||
| resolutions below 800×600 | 640×480 is a legitimate fallback and the only mode some firmware offers over a KVM. |
|
||
|
||
All three passed on QEMU, which is why they survived. None of them protected
|
||
anything: the drawing code steps by the stride and has done for as long as
|
||
there has been a stride to step by.
|
||
|
||
### 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.
|
||
|
||
### The menu is on the serial line too
|
||
|
||
The three F8 screens paint into the framebuffer, which is the right place for
|
||
them on a machine with a screen and nowhere at all on one driven over a serial
|
||
cable — which is how this loader is usually driven. `f8_window()` already went
|
||
out of its way to support that, accepting `o` as well as F8 because *"a serial
|
||
console sends no scan codes at all"*. So the prompt invited a serial user in,
|
||
drew them a menu they could not see, and blocked for ever on a key they had no
|
||
way to choose.
|
||
|
||
Each screen now echoes itself to COM1: the whole menu once, then one line per
|
||
*move*. Not on every keypress — a serial terminal has no cursor to paint over,
|
||
so an unrecognised key would scroll the selection past again for nothing.
|
||
|
||
Straight to COM1 rather than through `print()`, because `print()` goes to
|
||
`ConOut` until the splash is up, and `ConOut` on a machine with graphics draws
|
||
its text across the menu that was just painted there.
|
||
|
||
Two details that are not cosmetic:
|
||
|
||
- **The item number is shown only where it is a key you can press.** The boot
|
||
menu takes 1–5. The resolution list takes only W/S and Enter and is longer
|
||
than nine entries, so numbering it printed `:`, `;`, `<` past the ninth — a
|
||
character nobody can type, offering to select something.
|
||
- **Q goes back as well as Esc.** On a serial line a lone `ESC` byte is
|
||
indistinguishable from the start of an escape sequence, so the terminal
|
||
driver holds it back waiting for the rest and it never arrives as a key. Esc
|
||
is the obvious thing to press on a screen and unreachable on a cable — the
|
||
same shape of problem, and the same answer, as `o` for F8.
|
||
|
||
## 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
|
||
|
||
### A processor announces itself after it can run, not before
|
||
|
||
`mpmain()` used to open with `kinfo("cpu%d: starting")` and *then* call
|
||
`enable_sse()` and `idtinit()`. On a verbose boot with more than one CPU that
|
||
reset the machine — silently, with no panic and no last line, because the
|
||
failure is a triple fault and a triple fault prints nothing.
|
||
|
||
Two of those three lines have to come first, for different reasons:
|
||
|
||
- **`enable_sse()` clears `CR0.EM`.** Until it runs, *any* SSE instruction on
|
||
this CPU faults, and the kernel is not built with `-mno-sse`, so the compiler
|
||
emits them freely for struct copies and inline `memset`.
|
||
- **`idtinit()` loads this CPU's IDT.** Until it runs there is nowhere for a
|
||
fault to go — so the `#UD` above is not a panic, it is a double fault with no
|
||
handler, then a triple fault, then the firmware comes back.
|
||
|
||
**Why only on a verbose boot**, which is what made it look like an F8 bug
|
||
rather than an SMP one: `kinfo()` is log-only when the boot is quiet, so the
|
||
work is a few bytes into a ring and no XMM is involved. A verbose boot renders
|
||
the same line through the TrueType rasteriser, and that is where the SSE lands.
|
||
|
||
The symptom was that choosing "Boot SchOS (verbose)" appeared to do nothing:
|
||
the machine really did reboot, and the second boot was quiet, so what you ended
|
||
up looking at was an ordinary boot.
|
||
|
||
|
||
`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`.
|
||
|
||
### Which device the root is on is decided there, not at build time
|
||
|
||
`ROOTDEV` used to be `#define ROOTDEV 1` — IDE disk 1 — so a kernel could only
|
||
ever root on the disk it was *built* expecting. Booting the same image from a
|
||
USB stick was impossible by construction, and that was the one thing standing
|
||
between this kernel and its own acceptance test, which begins "boot on real
|
||
hardware from a USB stick".
|
||
|
||
`rootdev_mount()` probes instead. `schfs_kmount()` was already written to be
|
||
used this way — *"the uberblock scan doubles as the probe: no valid uberblock
|
||
means this is simply not a SchFS volume, which is not an error worth reporting
|
||
loudly"* — and a failed probe leaves the slot unmounted and reusable, which is
|
||
what makes a loop over candidates legal at all.
|
||
|
||
| order | device | |
|
||
|---|---|---|
|
||
| 1 | `RAMDISK_DEV` | only in a `RAMDISK_ROOT` build, where it is an explicit choice |
|
||
| 2 | `USBDISK_DEV` | a stick that is plugged in was plugged in on purpose |
|
||
| 3 | registered partitions typed SchFS | labelled that way on purpose |
|
||
| 4 | every other registered device | whole disks, untyped and MBR partitions |
|
||
| 5 | IDE 1 | what the old constant named |
|
||
| 6 | IDE 0 | usually the ESP, so usually not SchFS |
|
||
|
||
The **registered** devices are the ones found at probe time rather than known
|
||
at build time, numbered from 4 (`include/blkdev.h`): a SATA disk behind AHCI
|
||
(`sata0`), an NVMe namespace (`nvme0n1`), each USB stick (`usb0`..`usb3`, the
|
||
first of them also seen as device 3), and every partition on any of them
|
||
(`sata0p2`, `nvme0n1p2`, `usb0p2`). When a disk registers, its GPT is read, or its MBR
|
||
if it has no GPT. Each partition then becomes a device whose block 0 is the
|
||
partition's first sector, so SchFS in a partition and SchFS on a whole disk are
|
||
the same bytes. The ESP is skipped because it is FAT.
|
||
|
||
SchFS's GPT partition type is `47138FA9-B79C-47A6-9F7D-AC1F7F572D5E`.
|
||
`make disk` builds `build/disk.img` (`tools/posix/mkdisk.sh`), a GPT disk with a
|
||
64 MB ESP and a SchFS partition of that type. `dd` it to a stick or a disk, or
|
||
give it to QEMU's q35 as the only disk. Both boot, the root comes up as
|
||
`sata0p2` or `usb0p2`, and the q35 run is the same path a PC takes.
|
||
|
||
`iderw()` *panics* on an absent drive rather than failing, so each candidate is
|
||
checked with `ide_disk_present()` / `usb_storage_present()` /
|
||
`blkdev_present()` first. That is the whole reason those predicates exist.
|
||
|
||
When nothing matches, the panic names what it looked at, because "no root
|
||
filesystem" with no list is the least useful thing a kernel can say at the one
|
||
moment somebody needs to know which disk it expected:
|
||
|
||
```
|
||
root: no SchFS volume on any candidate device. Looked at:
|
||
device 3, USB disk: not present
|
||
device 1, IDE disk 1: not present
|
||
device 0, IDE disk 0: present, but no SchFS on it
|
||
```
|
||
|
||
The chosen device is reported in `dmesg` as `root: USB disk (device 3)`.
|
||
|
||
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: an absolute `exec` 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/schinit`**, and [init.md](init.md) takes over.
|
||
There is no fallback path and deliberately no `/bin/init` any more: the older
|
||
hardcoded fork-a-shell PID 1 was unreachable from the day that string changed,
|
||
and an unreachable fallback rots rather than saves.
|
||
|
||
## 6. What the screen shows
|
||
|
||
**The kernel log is on screen from the first line that can draw.** `kmain`
|
||
attaches the loader's framebuffer and clears `cons_serial_only` before it does
|
||
anything else, so `pci: 7 devices` and `schfs: mounted` are visible on the
|
||
machine itself.
|
||
|
||
This is the reverse of what the flag was added for, and the reversal was earned.
|
||
The original argument was that a boot which prints a developer's log to the
|
||
screen looks like a hobby project, and it is a good argument — right up until a
|
||
Chromebox with **no serial port** froze at boot showing nothing at all. The whole
|
||
diagnosis had to be made from a photograph of a screen that had been told to stay
|
||
quiet. A tidy boot that cannot be debugged on the hardware it fails on is not a
|
||
trade worth making.
|
||
|
||
`cons_serial_only` still exists for the case it is now the right answer to: no
|
||
framebuffer from the loader. Then everything goes to the UART and
|
||
`console_takeover()` clears the flag when userspace starts.
|
||
|
||
---
|
||
|
||
## 7. What the boot says
|
||
|
||
A working boot prints one line:
|
||
|
||
```
|
||
SchOS UEFI loader
|
||
Press F8 for boot options... booting
|
||
|
||
Welcome to SchOS! (F8 at boot for the full log, or dmesg once you are in)
|
||
|
||
root@schos / »
|
||
```
|
||
|
||
It used to print about thirty — the memory map, the local APIC's calibration,
|
||
every PCI device, each driver announcing itself, each job starting. All of that
|
||
is genuinely useful, and all of it is useful *once*. Printed on every boot it
|
||
trains the person in front of the machine to stop reading their own console,
|
||
which is expensive on the day one of those lines changes.
|
||
|
||
**Nothing was deleted; it was routed.** `consputc()` writes to the klog ring
|
||
before it writes anywhere else, so a line can be kept in full while being held
|
||
back from the two places a person is looking. `kinfo()` is `cprintf()` with
|
||
that routing, and the whole boot is still there afterwards:
|
||
|
||
```
|
||
# dmesg | head -4
|
||
[ 0.000000] e820: 1017 MB usable in 8 regions, top 1002 MB (21 entries)
|
||
[ 0.000000] klog: 128 KB at ffff800000737000, nothing recoverable was there
|
||
[ 0.000000] acpi: 4 cpus, lapic at ffff8000fee00000, ioapic id 0
|
||
[ 0.000000] console: JetBrains Mono, 10x21 cells, 128 by 38
|
||
```
|
||
|
||
**`dmesg` printed nothing at all until 2026-09-22**, which made that promise in
|
||
the banner a lie for as long as it had been there. Two separate things were
|
||
wrong, and each alone was enough:
|
||
|
||
- `sys_syslog()` was `return 0`. `dmesg` does not read `/proc/kmsg`; it calls
|
||
`klogctl(10)` for the buffer size and `klogctl(3)` to read, and 0 to the
|
||
first one means "the log is zero bytes". A stub that answers 0 is
|
||
indistinguishable from a machine that logged nothing.
|
||
- With that implemented, `dmesg` was *still* empty. It parses each line with
|
||
`sscanf(msg, "<%u>[%llu.%llu] %n", ...)` and **silently drops** anything that
|
||
does not match. The ring's `[ 0.00] text` already matches everything
|
||
after the priority — scanf skips the leading spaces — so the only missing
|
||
thing was the `<6>`.
|
||
|
||
`klog_render_syslog()` adds it on the way out rather than storing it, because
|
||
the ring is also what a person reads with `cat /proc/kmsg` and what PSOGM
|
||
points at after a crash. An ABI detail belongs at the boundary that has the
|
||
ABI.
|
||
|
||
The rule for `kinfo` against `cprintf` is not how interesting a line is —
|
||
everything was interesting to somebody once. It is: **would you want to see
|
||
this on a machine that is working?** `pci: 7 devices` is yes the first time and
|
||
no every time after. `e1000: no DMA memory for the rings` is yes for ever, and
|
||
stays a `cprintf`.
|
||
|
||
Three levels, then, and the middle one is new:
|
||
|
||
| | |
|
||
|---|---|
|
||
| quiet (default) | the greeting, plus anything that went wrong |
|
||
| verbose (F8) | every line above, on the console and the serial line |
|
||
| the klog | all of it, always, whichever of the two you booted |
|
||
|
||
Userspace follows the same rule through `/proc/bootmode`, which holds one word.
|
||
`schinit`, `schinitd` and `vgpud` read it and hold back their progress lines —
|
||
but never their failures, because a supervisor that goes quiet when a job dies
|
||
is worse than one that chatters.
|
||
|
||
**`schinit` reads it after `bootstrap()` and not before**, which is the sort of
|
||
detail that only shows up when tested: `/proc` is what `bootstrap()` mounts, so
|
||
reading it earlier returned "quiet" every time and made the verbose boot quiet
|
||
in the one process that runs first.
|
||
|
||
## What it does not do
|
||
|
||
- **No BIOS path.** A machine without UEFI does not boot SchOS.
|
||
- **The quiet boot hides a slow one.** With nothing printing between the
|
||
loader and the greeting, a boot that stalls for ten seconds looks the same as
|
||
one that does not. F8 is the answer and it has to be pressed *before* the
|
||
stall, which is the wrong order for finding out.
|
||
- **`/proc/bootmode` is read once, at start.** A program that wants to be
|
||
quieter or louder later has nowhere to say so, and nothing re-reads it.
|
||
- **The root is chosen once, at boot.** A stick plugged in later is registered
|
||
with its partitions, but nothing mounts it. See [usb.md](usb.md).
|
||
- **NVMe and AHCI poll for completion**, one command at a time per disk. That
|
||
is enough to boot and run from them, not to be fast.
|
||
- **`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.
|