SchOS/docs/architecture/boot.md
Nexoniarz dee3affa3b Boot manager: Niveus window, keys and wells, Instrument Sans, pointer, other systems, working power entries
The F8 menu is redrawn in the Niveus language: a window with three lights,
bevelled keys with a lit edge, wells for panels, anti-aliased corners and a
soft shadow, set in Instrument Sans rasterised into include/gfx/nvfont.h
(tools/python/mknvfont.py, make nvfont). Scale doubles on screens 1400 px tall
or more. Lists taller than the screen scroll.

The keyboard still drives everything, and a mouse, touchpad or tablet does
too: both UEFI pointer protocols are polled, hover moves the highlight, a
click activates. The cursor stays hidden until the pointer has really done
something, since the firmware installs a pointer protocol with or without a
mouse. The 'keep this resolution?' countdown is one ten-second budget however
many times the pointer moves.

Loaders found at well-known paths on any volume (Windows, Ubuntu, Debian,
Fedora, Arch, openSUSE, systemd-boot, rEFInd, other BOOTX64.EFI) are listed
and chain-loaded with LoadImage/StartImage. 'Reboot to firmware' used to halt;
it now sets OsIndications and restarts, and Restart and Shut Down call
ResetSystem.

Checked under QEMU+OVMF: menu, resolution list (incl. 2560x1600 at double
scale), system information, shut down, firmware setup, chain-loading a copy of
the loader from a second disk, and a full boot of the ISO to the login prompt.
Pointer hover and click were checked only with synthetic events: in this setup
a USB mouse or tablet delivered no event to the loader (one USB I/O handle
appeared but no pointer protocol came from it), and why was not established.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVRhxi9zpdW2uDUN4FVcao
2026-10-26 15:31:39 +01:00

38 KiB
Raw Blame History

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 — 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/arch/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
+0x600 ACPI RSDP physical address, or 0
+0x638 SMBIOS entry point physical address, or 0
+0x640 "EFI!" when the UEFI loader booted the kernel
+0x1000 the E820 entries, up to 256 × 24 bytes

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 entries have an area of their own, room for 256. They used to sit between the count and the RSDP, which held 40, and a laptop's UEFI map is 60 to 150 descriptors. The firmware lists RAM above 4 GB last, so that was what fell off: a 16 GB machine came up with 2 or 3 GB of it.

The SMBIOS entry point is passed the same way as the RSDP, from the configuration table, preferring the 64-bit _SM3_ one. It names the machine in the boot log's DMI: line and in /sys/class/dmi/id.

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.

Where the kernel goes, and how it gets there

The kernel is linked at physical 16 MB (KERNPHYS in memlayout.h, repeated in kernel.ld, which must agree) and is about 34 MB with the ramdisk inside it. From the old 1 MB it ran across 8 MB, where OVMF keeps ACPI NVS memory that outlives the firmware, so it was overwritten on every boot, after ExitBootServices. Firmware leaves its leftovers low; above 16 MB is where a machine is most reliably empty. (The 15 MB below the kernel is not given to the page allocator yet.)

Every free page of that range is claimed first. A segment lying wholly on claimed pages is read straight home; one that is not is read into a buffer elsewhere and copied home after ExitBootServices, when boot-services memory is ours. An HP all-in-one from 2012 used to freeze at the start of the loading bar because the loader said "could not reserve" and read the kernel there anyway, over the firmware's own memory with the firmware still running on it. Memory under the kernel that outlives the firmware (ACPI NVS, runtime services) is named, and so is the loader or its stack sitting in the range. Segments are read a megabyte at a time, so the bar moves through the ramdisk and shows where a machine stopped.

The copy and the jump themselves run from one page below the kernel's lowest address, allocated as loader code before the firmware exits: a short position-independent routine, the copy list and a stack, switching to the loader's page tables and its own stack before it copies anything. Doing the copy from code the firmware had placed wherever it liked put some of it inside the kernel's range on that machine: a triple fault and a reset. The routine is sysv_abi, because UEFI code is the Microsoft convention and the first try loaded the framebuffer address into rsp.

No 1 GB pages, no problem. Intel's desktop CPUs before Haswell have none, and there the PS bit in a PDPT entry is reserved: the first access after the switch is a page fault that cannot be handled either. Both the loader and the kernel check CPUID 0x80000001 EDX bit 26; without it the low 4 GB is four page directories of 2 MB pages (placed below the kernel, with the staged segments kept below 4 GB), and the kernel gives each gigabyte of its direct map and of ioremap() a page directory of its own. Neither KVM nor QEMU enforces the reserved bit, so the fault could not be reproduced here; with the fallback forced on, the kernel boots at 2 and 8 GB and both CPUs start.

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 256 entries. build_e820() sorts the descriptors by address first (the specification does not promise an ordered map) and then merges neighbours of the same type, so a long map fits easily. If one ever truncates, it 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. The kernel keeps at most 32 usable regions; past that, the smallest give way to larger ones, so what is lost is crumbs rather than whatever was listed last.

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 boot manager

F8 (or o on a serial line) opens the boot manager. It used to be a text list on a dark plate; it is now drawn in the Niveus language the site is drawn in (docs/Website/niveus): a window with three lights, keys with a lit upper edge and a shadow beneath, panels cut into the surface as wells, a graphite ground that darkens towards the bottom, and the mark above the list.

What makes that possible in a loader is that nothing in it is large:

  • Shapes are rounded rectangles with a vertical gradient. Corners are anti-aliased by supersampling only the corner pixels, against a colour the caller names. Nothing reads the framebuffer back, because that memory is uncached on real hardware and a read per pixel is the difference between a menu that appears and one that crawls. (The one exception is the pointer, which saves sixteen by nineteen pixels.)
  • Type is Instrument Sans, the face Niveus ships. A TrueType rasteriser does not belong in a loader, so tools/python/mknvfont.py renders it once into include/gfx/nvfont.h as 8-bit coverage bitmaps with fractional advances, at the size for ordinary screens and at twice that for screens 1400 pixels tall or more. Rasterising at the real size is what keeps it crisp; doubling pixels does not.
  • Layout is computed from the item list, not drawn from fixed positions: a list taller than the screen drops the mark, then scrolls with a bar.

An entry is a label, a newline and a line of detail. The entries are:

Entry Does
Boot SchOS / (verbose) the two ways of starting this system
other systems one entry per loader found at a well-known path on any volume: Windows Boot Manager, Ubuntu, Debian, Fedora, Arch, openSUSE, systemd-boot, rEFInd, and any other \EFI\BOOT\BOOTX64.EFI that is not this one. Chosen, they are started with the firmware's own LoadImage/StartImage, and if the other loader returns, the menu is still here
Resolution the list of modes the firmware offers, or a typed size, with the "keep this?" confirmation
System Information firmware, UEFI revision, processor, memory, display, ACPI
Firmware Setup sets OsIndications and restarts into the firmware's own settings; on firmware that does not support it, says so instead of restarting for nothing
Restart, Shut Down ResetSystem
Remote debugging make DEBUG=1 builds only

Keyboard, mouse, touchpad and touchscreen all drive it: both UEFI pointer protocols are polled (a mouse reports movement, a tablet reports position), hover moves the highlight, a click activates, and the wheel scrolls. All input goes through one function, wait_event(), so every screen behaves the same way. The cursor stays hidden until the pointer has actually done something, because the firmware's console layer installs a pointer protocol whether or not a mouse is attached. USB devices appear some time after their controller is connected and the menu is already up by then, so the loader connects every controller when the menu opens and looks for pointers again twice a second for ten seconds.

The "keep this resolution?" confirmation counts ten seconds down once, however many times the pointer moves over the button: a person with a garbled screen waving a mouse around must not be able to keep postponing the revert.

Not verified: hover and click were exercised with synthetic events. Under QEMU and OVMF a USB mouse or tablet never delivered an event to the loader, so the pointer path has not been seen working on a device -- real hardware first.

The menu is on the serial line too

The 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–9. 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.

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, and the one worth carrying forward is in kernel.md: __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/fs/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 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 ISO carries a real EFI system partition (MBR type 0xEF, FAT16 by cluster count, the type old firmware reads best), so a stick written with dd or write.sh boots on strict firmware as well as on lenient.
  • 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.
  • 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.
  • The live ISO is a ramdisk root. make iso builds the root as a SchFS image, compresses it (scff.md) and links it into the kernel; nothing is written to the machine's disks, and everything written to the root is gone at reboot. There is no installer, and no kernel that finds a root on a disk by SchFS's GPT type as its default. make ramroot is the same without compression.
  • 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.