Commit graph SchOS/boot/uefi/main.c
Author SHA1 Message Date
Nexoniarz
1e52cb591b boot: the loader jumped into the framebuffer, because rbx was not clobbered
Boot died straight after "exiting boot services" with a KVM internal error,
RIP=0x801a5dc5 and RBX=0x80000000 -- the framebuffer base. The bytes at RIP were
0x0C0E13 and 0xD1D5DC: SCH_BG and a grey from the splash's metal gradient. The
machine was executing the picture of itself starting up.

The hand-off to the kernel writes rbx and did not say so:

    "mov %1, %%rbx\n\t"      <- writes rbx
    "jmp *%2\n\t"
    : : "r"(PML4_ADDR), "r"(fb), "r"(entry)
    : "memory");            <- rbx not declared clobbered

With rbx absent from the clobber list the compiler may allocate an input there,
and clang 21 allocated `entry`:

    movq %rax, %cr3
    movq %rcx, %rbx         <- the framebuffer, into rbx
    jmpq *%rbx              <- %2 was rbx, and now holds the framebuffer

Declaring "rbx" forbids the allocation. Verified in the generated assembly
rather than by reasoning: jmpq *%rbx became jmpq *%r14, and the machine now
boots through to schinit: ready.

This had been latent since the loader was written. It worked only because every
compiler that had built it happened to choose a different register, and it
surfaced the first time the binary was relinked with a different clang -- which
only happened because the previous commit fixed the Makefile dependency that had
been leaving the loader stale. A stale artifact hides the bug the fresh one
reveals.

It is also the third time this project has been broken by a register the code
depends on but never asks for: docs/history/kernel.md section 4 (the BIOS
loader passing the framebuffer in EAX by luck) and section 8 (%fs:0 used as
scratch when the ABI reserves it). Written up as section 7b, with the one-line
recipe that settles all three -- read the generated assembly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 18:44:24 +02:00
Nexoniarz
f4107177ea boot: the disc that could not be chainloaded, and the error nobody could see
Reported from a real laptop booting through Ventoy: the F8 menu appears,
selecting Boot SchOS gives a black screen and a white dash in the corner.
Both halves of that are bugs.

The disc could not be chainloaded. Booting it directly, firmware runs the
El Torito FAT image and the loader opens \schos\kernel on that volume,
which is where mkiso.sh put it. Ventoy does not use El Torito at all: it
mounts the ISO 9660 tree and runs \EFI\BOOT\BOOTX64.EFI from there. The
loader then looks for the kernel on the volume it was itself loaded from
-- the ISO tree -- and the kernel was only ever inside the FAT image. It
is now in both. The comment in mkiso.sh had even described this failure,
as the reason the kernel is in the FAT image, without noticing the ISO
tree had the same requirement.

The error was invisible. print() redirects to the serial port once the
splash is up, which is right for a development machine and useless on a
laptop: the loader died with a perfectly good explanation and sent it to
a wire nobody was holding. What is left on screen is the firmware's own
cursor -- the white dash. die() now draws the message on the framebuffer
too, over a cleared band so it does not land on the logo.

Verified by reproducing the exact failure: the ISO tree extracted and
booted as a plain volume, as a chainloader presents it. With the kernel
removed it dies visibly with "no kernel at \schos\kernel or \kernel, EFI
status 0x000E" -- EFI_NOT_FOUND, which is what the laptop was hitting
silently. With it present, that same volume boots through to a shell.

110 [ok] on the ESP path, the ISO check passes, no failures.
2026-08-11 11:54:47 +02:00
Nexoniarz
c4dd76cc66 boot/uefi: the memory map the kernel gets is the one the firmware agreed to
Two bugs on the ExitBootServices path, both invisible under OVMF and both
aimed squarely at real firmware.

The retry did not re-translate. If ExitBootServices returns an error the
loader re-reads the memory map and tries again -- correct, and the
specification expects it -- but the e820 table handed to the kernel was
still the one built from the *previous* map. The retry exists precisely
because the map changed, so the kernel was being given a description of
memory the firmware had already rearranged. QEMU never takes this path,
which is why it survived. Translation is now a function called on both.

Truncation was silent. phystop comes from the largest usable region, so
dropping the tail of the map can leave the kernel believing a large
machine has a fraction of its RAM -- and nothing said so, because the
loop simply stopped at the cap. It now warns while there is still a
console to warn on; after ExitBootServices there is nowhere to report
anything.

SCHBOOT_E820_MAX goes from 32 to 40. The map sits at BASE+0x208 and the
next field is at BASE+0x600, so 1016 bytes are reserved and 42 entries of
24 bytes fit. Real firmware emits far more descriptors than QEMU's; the
headroom was already allocated and unused.

Coalescing adjacent same-type runs is what keeps any of this in budget,
and it works because boot services memory becomes usable at this point --
that is most of the fragmentation in a UEFI map.

110 [ok], no failures, and the map still reads 7 regions under OVMF.
2026-08-11 11:42:22 +02:00
Nexoniarz
ed77244df0 boot/uefi: the system information panel, and the offset that faked a version
ui_panel was written for this screen during the loader restyle and then
never called -- dead code sitting in boot/common/ui.c waiting for the row
that would use it. This is that row: F8 -> System Information, showing
firmware vendor and revision, UEFI revision, CPU brand, usable memory,
current mode and whether ACPI was found.

Everything is read when the screen is drawn rather than captured at
startup, because the resolution row above it can have changed the mode,
and a panel still reporting what the machine booted with is worse than no
panel at all.

Two facts are deliberately read through the same code that feeds the
kernel -- efi_type_to_e820() for what counts as usable memory, and
find_rsdp() for ACPI. A panel with its own idea of either would be a
second implementation to keep in step, and the ACPI line exists precisely
because a missing RSDP means no MADT, no IOAPIC and no SMP, which has
already been debugged once from the far end.

The screenshot caught a bug in the screen it was taken to verify: UEFI
revision read "21587.2286". EFI_TABLE_HEADER begins with a UINT64
Signature, so Revision is at offset 8 and I had read offset 4 -- the
upper half of "IBI SYST", formatted as a version number. It now reads 2.7
under OVMF, which is correct.

Verified by driving the menu over the QEMU monitor and reading the
pixels: five rows with the hint updated to 1-5, the panel centred, and
every value cross-checkable -- EDK II, firmware 1.0, UEFI 2.7, "QEMU
Virtual CPU version 2.5+" against -cpu qemu64, 1018 MB of the 1 GB given,
1280x800 matching what fbshot captures, ACPI present. The highlight sits
9 px inside both panel borders, symmetric.

F8 has a 1.5 s window, so the capture presses it repeatedly through boot
rather than guessing when OVMF finishes -- and only F8, since any other
key breaks the wait and boots.

Also: every ESP drive now says format=raw. QEMU warned about probing the
format on each of the fifteen or so places that attach it, which is new
noise on what is now the only boot path. 110 [ok], no failures, no
warnings.
2026-08-11 10:52:43 +02:00
Nexoniarz
751f5718cd boot/uefi: a real resolution list, and the mode that sheared
The screenshot was mode 16. OVMF advertises it as 1366 wide; asking for
it produces a framebuffer QEMU scans out at 1360, so every row written
lands six pixels further along than the one above and the whole screen
shears. The firmware never says it rounded, and nothing in the mode
information hints at it -- PixelsPerScanLine agrees with the width it
claims.

So a mode is only usable if its width is a multiple of 8, which is the
VGA scanout granularity. Checked rather than assumed: 1400x1050 and
1280x720 render perfectly centred, 1366x768 does not, and it is the only
advertised width that is not a multiple of 8.

The single cycling row is now a list. Every usable mode the firmware
offers, deduplicated -- it advertises the same geometry several times with
different internals, and a menu with 1280x1024 in it three times is one
nobody trusts -- plus a row for typing a size. A typed size is matched
against the same filtered list, so a mode that would shear cannot be
reached by typing it either, and a size that does not exist says so
rather than silently keeping the old one.

Also fixes the panel layout you spotted: the title band was exactly as
tall as the heading, so the rule was drawn through the lettering and the
first row sat on its baseline; and the selected row's highlight had a
fixed 3px offset against a 24px row, so it sat on the text rather than
around it.

Verified by applying four resolutions from the list -- 1280x800,
1024x768, 1280x960, 1600x1200 -- and checking the panel is centred in
each. bootcheck 108 [ok].

BIOS still asks in a text prompt before the framebuffer exists, and that
is the remaining half of "the same for both". See the next commit message
or the note in stage2.S: VBE is INT 10h, so it needs real mode, and a
graphical chooser needs either unreal mode before the switch or a return
to real mode after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:29:11 +02:00
Nexoniarz
55a585792e boot/uefi: an F8 menu, with the resolution chooser inside it
UEFI had no boot menu at all, and asked about resolution in a prompt of
its own before anything else. Two questions at two moments through two
mechanisms is what made the firmwares feel like different programs.

There is one window now: press F8 (or 'o' -- a serial console sends no
scan codes, and that is how this is usually driven) and the same panel
the BIOS loader shows appears, with resolution as a row in it. Selecting
it steps to the next 32bpp mode at or above 800x600 and redraws; the
framebuffer is re-read every pass, because changing mode moves it and
drawing where it used to be is a black screen with no explanation.

Two layout bugs, both visible and both arithmetic:

  - TITLE_H was 34 while the heading alone occupies PANEL_PAD + GLYPH_H
    = 34, so the rule at TITLE_H - 6 was drawn *through* the lettering
    and the first row started on the heading's baseline. TITLE_H is
    derived from its parts now and cannot drift from them again.
  - the selected row's highlight was offset by a fixed 3px against a
    24px row, so it sat on the text rather than around it. It is centred
    on the glyph box: (ROW_H - GLYPH_H) / 2 above and below.

Verified from the host: the panel is centred at 640 on a 1280 screen, the
highlight is exactly ROW_H tall and clear of the rule, and Enter boots
through to cpu0. bootcheck 108 [ok], iso builds.

The BIOS resolution chooser is still a separate text prompt, and that is
not an oversight to fix later in the same way. VBE mode setting is an
INT 10h call, so it can only happen in real mode -- before there is any
framebuffer to draw a menu in. Offering it from the graphical menu means
either returning to real mode from long mode, or entering unreal mode to
draw the menu before the switch. Both are real mode-switching work in the
riskiest code in the tree, and neither is a tidy-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:04:57 +02:00
Nexoniarz
799abbc19a boot: one UI for both firmwares, and a metal look
The BIOS loader drew its own splash and the UEFI loader drew a different
one, which is how two implementations of a single screen end up looking
nothing alike: only one of them ever got a change. stage2main.c's
fourteen drawing functions are deleted and it calls boot/common/ui.c,
which is what the UEFI loader already used. There is one splash now.

What changed in the look, in order of how much it mattered:

  - No product name under the mark. The name is *in* the mark, and
    setting it again underneath read as a placeholder nobody removed.
  - The mark is scaled to the bar's width and sits directly above it, so
    the two are one object rather than a large logo with an unrelated
    line beneath. Box-filtered down from the source mask, never up:
    point-sampling a coverage mask drops half the edge pixels and puts
    holes in the thin strokes.
  - Metal rather than flat grey. A vertical gradient with a knee near the
    top where a highlight falls, applied to the mark, the bar fill and
    panel headings. A single colour on a dark ground looks printed; the
    knee is what makes it look lit. No texture -- at this size noise
    reads as dirt.
  - The boot manager is a panel with a lit selected row, replacing a
    screen laid out in literals with a magic y of 340 that only ever
    looked right at 800x600.

Three defects found by reading pixels rather than code:

  - the UEFI loader kept calling print() after the splash, and ConOut on
    a graphical firmware renders into the same framebuffer -- a
    screenshot showed firmware text across our mark from the left edge.
    Diagnostics go to COM1 directly now, which is where they were being
    read from anyway, and the console is cleared before drawing so the
    firmware has nothing to put back.
  - SPLASH_UP was declared and read but never assigned, so that redirect
    was dead code and the "serial works" reading was OVMF's own console.
  - stage2 overflowed its allocation -- 25440 bytes against 24576 -- and
    the build did not stop. The kernel is written at sector
    1 + STAGE2_SECTORS, so a stage2 past its allocation is overwritten by
    the kernel it is loading. STAGE2_SECTORS is 64 now, and the padding
    tool's check does fire; it had simply not been re-run.

Verified from the host on both: BIOS 800x600 and UEFI 1280x800 both draw
a centred group with zero stray pixels, UEFI's spanning x 576-703 which
is exactly the mark width centred. bootcheck 108 [ok], iso builds.

Still text-mode: the resolution menu. On BIOS it necessarily is -- it
runs in real mode before any framebuffer exists, which is the whole
reason it can set one. On UEFI it could be graphical and is not yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:49:44 +02:00
Nexoniarz
64e4835524 boot: a shared UI, and a graphical splash on UEFI
The first piece of the two loaders that is genuinely common code rather
than a contract they both honour. It can be, because the difference
between the firmwares is entirely in how a framebuffer is *obtained* --
VBE in real mode, or GOP -- and not in what you do with one afterwards.
boot/common/ui.c takes a base, a size and a stride and draws; it knows
nothing about disks, memory maps or mode setting, which is what lets it
compile into a 16/32/64-bit BIOS loader and a PE/COFF UEFI application
without either leaking into it.

The look is deliberately quiet: dark ground, the mark centred, one thin
bar beneath it, nothing on screen that is not load-bearing. The whole
group is laid out from the centre outwards rather than from the top down,
so it sits identically at 800x600 and at 1600x1200 instead of drifting up
the screen as the mode grows.

UEFI had no graphical splash at all -- it printed text to a console the
QEMU window is usually not even showing. It draws the same screen as the
BIOS path now, with the bar tracking bytes of kernel actually written
rather than a segment count, which says nothing about how long each takes.

Two ordering bugs found by looking at the pixels rather than the code:
setup_framebuffer ran *after* the kernel was read, so every progress tick
went to a framebuffer that did not exist yet and the splash then painted
over the result; and that also put the resolution prompt after the
slowest step instead of at the start. The display is set up first now.

Verified from the host with screendump: background is exactly SCH_BG, the
bar is 4px tall and width/4 wide at y=495 on a 1280x800 screen, which is
where centring the group puts it. UEFI still boots to cpu0; BIOS
bootcheck 27/27.

Not yet shared: the BIOS loader still draws with its own copy of this
code. Deleting that copy in favour of this one is the next step and the
point of the exercise -- until then this is one loader using shared code
and one duplicating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 22:26:54 +02:00
Nexoniarz
0500e4382d boot: one hand-off contract, defined once
Groundwork for the unified loader: before the two paths can share code,
they have to agree on what they are producing, and until now they only
agreed by coincidence.

Every field of the loader/kernel hand-off was #defined in whichever file
happened to need it -- kernel/core/main.c, kernel/dev/vga.c,
kernel/arch/x86_64/e820.c, boot/bios/cdboot.S, boot/bios/stage2main.c and
boot/uefi/main.c, six copies of the same addresses. schboot.h existed for
exactly this and held only the entry magic and the RSDP slot, with a
comment describing the rest of the block it did not define.

The whole block lives there now: the VBE info fields, the verbose and
media flags (with the real-mode segment:offset spelling cdboot.S needs),
the e820 count, map and cap, and the RSDP. Every one of those files
includes it instead of restating it.

This is not a tidy-up. It is the same hazard as defining the entry magic
twice, only quieter -- nothing breaks until one copy moves, and the two
loaders diverging is precisely what produced the EFER.SCE and RSDP bugs,
where a fix on one path silently did not exist on the other.

Verified on both firmwares: BIOS boots to schinit with the same e820,
ACPI and VGA lines as before; UEFI reaches cpu0 with 1018 MB in 7 regions
and its own 1280x800 mode. `make iso` still builds. bootcheck 108 [ok].

Next: the loaders become starters that gather firmware-specific facts and
hand to one common stage. That needs the BIOS side to climb to long mode
first, since common code can only be compiled for one CPU mode -- which
is the risky part, and is why the contract went first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:37:36 +02:00
Nexoniarz
bf9ac843f7 schcom: repaint what changed, and stop rendering frames nobody sees
Moving the mouse repainted the whole screen. At 1280x800 that is a
million pixels of desktop fill, every surface re-blitted, and a 4 MB copy
to the framebuffer -- per event. Under emulation the pointer lagged the
mouse by whole seconds, and that was not the emulation's fault: moving a
10x10 cursor does not require touching four megabytes.

Two changes, and the second matters as much as the first.

Damage tracking: one rectangle, unioned from whatever actually changed,
and only those rows are redrawn and copied out. A pointer move damages
where it was and where it is; a commit damages that surface; a client
going away damages what it had. One rectangle rather than a list, because
in practice the changes are a cursor and the window under it, and a list
costs more bookkeeping than it saves.

Draining input: a single mouse movement produces a burst of EV_REL ending
in EV_SYN, and the loop handled one event per poll and repainted. So the
compositor rendered every intermediate position of a cursor that was
never seen at those positions. Both queues are now drained before
anything is painted. handle_input grew a distinct "queue empty" return
for it -- EV_SYN changes nothing but is not the end of the burst, and a
drain that stopped there would leave the rest queued.

Also cuts the loader's resolution prompt from four seconds to one and a
half, on both the BIOS and UEFI paths. Four was mine and it was too long:
it is paid on every unattended boot, and adding four seconds to every
start to offer a menu nobody usually wants makes the common case worse to
serve the rare one.

schcom-test passes, including two overlapping clients and stacking order.
bootcheck 108 [ok].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:54:06 +02:00
Nexoniarz
0362427e80 boot/uefi: choose a resolution with R, the same as the BIOS path
GOP instead of VBE, and the same reasoning. There are no mode numbers
worth hardcoding here: the firmware publishes however many modes the
hardware can do and which index means what varies by machine, so the menu
is built by walking them. Press R within four seconds for a list, then
either a mode number or Enter to type a size. 800x600 stays the floor.

Fixes a header bug this is the first code to notice. The spec's order in
EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE is MaxMode then Mode; efi.h had them
swapped. Info, SizeOfInfo and FrameBufferBase sit at the same offsets
either way because both fields are UINT32, so the framebuffer worked
perfectly and nothing was wrong until something asked how many modes
existed -- and was told the current mode number instead. Zero, on a
machine reporting thirty.

QueryMode and SetMode were void* padding and now have real prototypes;
ConIn likewise, so the loader can read a key. Under OVMF the console
includes the serial line, which is where `make qemu-uefi` puts it and
where the person actually is -- the same mistake the BIOS menu made first
time round.

Verified over serial against OVMF: 30 modes enumerated; picking listed
mode 14 gives 1280x1024; typing 1024x768 gives 1024x768; no key keeps the
firmware's mode and boots. All reach schinit. bootcheck 108 [ok].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:30:03 +02:00
Nexoniarz
9735279970 acpi: take the RSDP from the loader when firmware gives it to us
find_rsdp() scanned the EBDA and 0xE0000-0x100000. That is a BIOS
convention, and UEFI firmware is under no obligation to leave a copy
there -- OVMF does not. So the kernel found no RSDP, no MADT and no
IOAPIC, and nothing routed IRQ 4. Serial output is polled, so the machine
booted all the way to a shell prompt and then ignored every key, which
reads as a broken console rather than a missing table.

The UEFI loader reads the RSDP out of the EFI configuration table,
preferring ACPI 2.0 for its XSDT, and leaves it at SCHBOOT_RSDP_ADDR --
the same low-memory hand-off block the e820 map already uses, now
described in include/schboot.h so the two sides cannot disagree.

The kernel validates it rather than trusting it: the BIOS path never
writes that slot, so there it holds whatever was in memory, and following
a wild pointer faults before there is anything to report the fault with.
Signature and checksum do that. It deliberately does not require 16-byte
alignment -- that rule is about searching the BIOS area, and OVMF's RSDP
sits at 0x3f77e014, so demanding it threw away the one address this code
exists to use.

UEFI now reports "acpi: 1 cpu, lapic at ..., ioapic id 0" and the ISO
boots to an interactive shell: uname, ls /bin (241), writing and reading
a file, ps, /proc/schfs -- all driven over serial.

BIOS unchanged: bootcheck 108 [ok], same ACPI line as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:02:57 +02:00
Nexoniarz
56139bd0e6 boot/uefi: a working UEFI loader, with one known limitation
Loads the kernel from the ESP, places its ELF segments, records the
framebuffer, translates the UEFI memory map into E820 entries, exits boot
services, builds the page tables the kernel expects and jumps. Verified
under OVMF: the kernel starts, brings up PCI, VGA, e1000 and the RTC, and
reaches "cpu0: starting".

UEFI structures are written from the specification rather than importing
gnu-efi or EDK II -- a large dependency for two hundred lines of use, and
a licensing question the project does not need to answer. clang emits
PE/COFF directly, so this needs no new toolchain.

The kernel gained a second entry point. The multiboot entry is 32-bit
code that climbs into long mode itself, and firmware that already put us
there would have to drop back down only to climb out again -- a mode
switch whose failure mode is a triple fault with no console. So entry.S
publishes its 64-bit entry the same way it publishes the multiboot
header: a magic to scan for, SCH64BOO, with include/schboot.h owning the
constants so the two cannot disagree. That header goes *after* the
multiboot header, never before: header_addr says the multiboot header
lives at mboot_load_addr, and the BIOS loader computes the kernel's file
offset from it.

The kernel loads its own GDT at that entry rather than the loader doing
it. Arriving in long mode means arriving on the firmware's descriptors,
and the first thing depending on the kernel's own selectors faulted --
"unexpected trap 13 ... cs=38", a long way from the cause.

KNOWN LIMITATION, diagnosed and not yet fixed: e820_init takes the usable
run *containing the kernel*, which under OVMF ends at 8 MB -- the
firmware places an ACPI NVS region at 0x800000, so the run is genuinely
fragmented. The map is truthful and untruncated (21 entries); the
assumption of one contiguous run is BIOS-shaped. The fix is for the
kernel to free each usable region rather than one range, which touches
kalloc and deserves its own change rather than the tail of this one.

BIOS boot is unaffected: 27 checks, no failures.
2026-08-09 17:13:09 +02:00