SchOS/kernel/core/main.c
Nexoniarz ff0acfc913 gfx: a framebuffer at physical zero is not a framebuffer
Booting with a virtio-gpu as the *only* display reboot-looped. Not a hang, not
a panic: the firmware came back round to the boot menu, started the loader
again, and did it again, for ever, with nothing to say why.

virtio-gpu has no linear aperture, so OVMF's virtio GOP implements Blt and
nothing else. On a machine with one and nothing else, the firmware reports a
width and a height and a FrameBufferBase of **zero**:

    schos: framebuffer 0x0000000000000000 0x500x0x320

Two places took that at face value.

**kmain() tested `if (bi.ptr && ...)` with bi.ptr already run through P2V()**,
and P2V(0) is KERNBASE, which is not zero. So the guard could not fail -- and
its own comment, three lines above, said "drawing into address zero is a worse
failure than staying quiet". The intent was exactly right and the test could
never enforce it. It reads the physical value first now and tests that.

**vga_fb_init() attached the scanout regardless**, one layer down, so fixing
kmain alone just moved the same write.

Either one had gfx_clear() write four megabytes over physical 0: the real-mode
IVT, the BIOS data area, the AP trampoline at 0x7000, the early page tables.
Hence a reset with no output -- there was nothing left to run.

Both refuse now, and the refusal says what it is:

    vga: firmware reported a 1280x800 display with no framebuffer address;
         no graphical console

A machine with no usable linear framebuffer gets no graphical console. That is
the truth about it, and the serial line still has everything.

**It still does not reach a shell on such a machine**, and that is recorded
rather than implied: the console path assumes a framebuffer -- vt_init()
reports a terminal of `0 by 0` cells -- and that path has never been exercised.
What this fixes is a machine that destroyed low memory and reset; what it does
not do is make a headless boot work. Supported remains stdvga, with virtio-gpu
optional beside it, which is what docs/architecture/graphics.md already said.

Both supported configurations verified: stdvga alone and stdvga with
virtio-gpu, one boot each, no traps, socktest and memfdtest green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:58:21 +02:00

398 lines
18 KiB
C

#include "types.h"
#include "defs.h"
#include "vt.h"
#include "gfx.h"
#include "param.h"
#include "memlayout.h"
#include "mmu.h"
#include "proc.h"
#include "x86.h"
#include "irq.h"
#include "dma.h"
#include "klog.h"
static void *klog_base; // the persistent log, just above the kernel image
static void startothers(void);
static void mpmain(void) __attribute__((noreturn));
extern char end[]; // first address after kernel loaded from ELF file
// Set by stage2main.c (boot/bios/stage2main.c, SCHBOOT_VERBOSE_FLAG) when the
// user picks "Boot SchOS (verbose)" from the F8 boot menu; read once here,
// before anything else, so subsystem init can log its own steps.
int boot_verbose;
// Must match SCHBOOT_VERBOSE_FLAG in boot/bios/stage2main.c exactly: just past
// the VBE info block, not 0x7000 (startothers() below uses that address for
// the AP boot trampoline).
#include "schboot.h"
// Bootstrap processor starts running C code here.
// Allocate a real stack and switch to it, first
// doing some setup required for memory allocator to work.
int
kmain(uint32 fb_addr)
{
boot_verbose = *(uchar*)P2V(SCHBOOT_VERBOSE_FLAG);
// On a verbose boot, put the kernel log on the screen from the first line.
//
// Everything below talks to the serial port and nothing else until the first
// exec calls console_takeover(). That is fine on a machine with a UART and
// useless on one without -- and a laptop, a Chromebox, anything built this
// century has no 16550 at 0x3f8. The screen showed the loader's progress bar
// and then nothing at all, for the whole of boot, on exactly the machines
// where something was going wrong.
//
// The loader has already put a framebuffer on the screen and described it in
// the boot block, so there is a perfectly good console sitting there unused.
// Attaching it here costs one struct and makes the difference between "it
// froze" and knowing which line it froze after.
//
// Unconditional, not just on a verbose boot. F8 is no use to someone whose
// machine resets before they can read anything, and "it froze after the
// loading bar" is a bug report nobody can act on. This is what Linux does
// too: the log is the default and `quiet splash` is the thing you ask for.
//
// The splash stays on screen until the kernel's first message, and a boot
// that succeeds clears the whole lot when the first program starts -- so
// the only time anyone sees this is the only time anyone needs it.
{
struct fb_info bi;
// The *physical* address, tested before it is translated.
//
// This used to be `if (bi.ptr && ...)` with bi.ptr already run through
// P2V -- and P2V(0) is KERNBASE, which is not zero, so the guard could
// not fail. The comment below was right and the test could never enforce
// it.
//
// It matters on any machine whose only display is a virtio-gpu. OVMF's
// virtio GOP implements Blt and nothing else: there is no linear
// aperture to report, so it publishes width and height and a
// FrameBufferBase of **zero**. The kernel took that at face value, and
// gfx_clear() then wrote four megabytes over physical address 0 -- the
// real-mode IVT, the BIOS data area, the AP trampoline at 0x7000 and the
// early page tables. The machine reset before printing anything and came
// back round to the firmware, for ever.
uint32 fb_phys = *(uint32*)P2V(SCHBOOT_VBE_LFB);
bi.ptr = (volatile uchar *)P2V((uint64)fb_phys);
bi.width = *(uint16*)P2V(SCHBOOT_VBE_WIDTH);
bi.height = *(uint16*)P2V(SCHBOOT_VBE_HEIGHT);
bi.stride = *(uint16*)P2V(SCHBOOT_VBE_STRIDE);
bi.bpp = *(uchar *)P2V(SCHBOOT_VBE_BPP);
// Only if the loader actually described something. A zero here means the
// BIOS path or a firmware that gave no GOP, and drawing into address zero
// is a worse failure than staying quiet.
if (fb_phys && bi.width && bi.height && bi.stride && bi.bpp >= 16) {
gfx_attach_scanout(&bi);
// Before vt_init, which reads the cell size to work out how many rows
// and columns there are. The font is in the kernel image, so this needs
// nothing that does not exist yet -- no allocator, no filesystem.
vt_init();
gfx_clear(0x0C0E13); /* SCH_BG; theme.h is not included here */
cons_serial_only = 0;
}
}
uartearlyinit();
if (boot_verbose) cprintf("verbose boot: uartearlyinit done\n");
// The DMA arena is carved off the top of physical memory *before* kalloc is
// given the rest, because 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; asking for it later would mean asking kalloc for something it
// fundamentally cannot provide. See include/dma.h.
// Before anything touches PHYSTOP, which is now a variable read from the
// firmware's memory map rather than a constant asserting 224 MB exists.
e820_init();
// The log region: the page after the kernel image, and the page allocator is
// started above it rather than at `end`.
//
// Low memory, deliberately, and it took a measurement to learn why. The
// obvious place is the top of RAM beside the DMA arena -- and on a reboot
// the magic came back as ASCII, because that is exactly where the firmware
// puts its own allocations before it hands the machine over. The bytes below
// the kernel image are the ones the loader has already committed to leaving
// alone, and `end` is the same address on every boot of the same build,
// which is the property a region has to have to be found again.
//
// No hole has to be punched in the allocator for this: kinit1's lower bound
// is a parameter, so moving it up by KLOG_BYTES is the whole of it.
klog_base = (void *)PGROUNDUP((addr_t)end);
klog_init(klog_base, KLOG_BYTES);
kinit1((char *)klog_base + KLOG_BYTES, P2V(PHYSTOP - DMA_ARENA_BYTES));
if (boot_verbose) cprintf("verbose boot: kinit1 done\n");
dma_init(P2V(PHYSTOP - DMA_ARENA_BYTES), P2V(PHYSTOP));
if (boot_verbose) cprintf("verbose boot: dma_init done\n");
kvmalloc();
if (boot_verbose) cprintf("verbose boot: kvmalloc done\n");
// ACPI first, the legacy MP table only if it finds nothing. Modern firmware
// publishes a complete MADT and an MP table with one processor in it, so
// asking the older interface first means never seeing more than one CPU.
// See kernel/arch/x86_64/acpi.c.
if (!acpi_init())
mpinit();
if (boot_verbose) cprintf("verbose boot: cpu enumeration done\n");
lapicinit();
if (boot_verbose) cprintf("verbose boot: lapicinit done\n");
tvinit();
// Once, on the boot processor. mpmain() runs on every CPU, and registering
// from there published the command as many times as there are processors.
watch_init();
if (boot_verbose) cprintf("verbose boot: tvinit done\n");
seginit();
if (boot_verbose) cprintf("verbose boot: seginit done\n");
ioapicinit();
if (boot_verbose) cprintf("verbose boot: ioapicinit done\n");
irq_init(); // must precede any driver init that registers a handler
if (boot_verbose) cprintf("verbose boot: irq_init done\n");
// Before the keyboard and mouse, which start producing events the moment
// their handlers are registered.
inputinit();
if (boot_verbose) cprintf("verbose boot: inputinit done\n");
// Before consoleinit, because the console writes to a terminal and there
// has to be one. vt_init reads the framebuffer geometry, so it also has to
// come after whatever attached a scanout.
gfx_font_init(); // no-op if the early path already did it
vt_init();
if (boot_verbose) cprintf("verbose boot: vt_init done\n");
consoleinit();
if (boot_verbose) cprintf("verbose boot: consoleinit done\n");
// Now that there is a console to say it on. gfx_font_init() ran before
// vt_init() and deliberately printed nothing; see the note there.
{
int cw, chh;
gfx_cell_size(&cw, &chh);
if (gfx_font_ready())
kinfo("console: JetBrains Mono, %dx%d cells, %d by %d\n",
cw, chh, gfx_cols(), gfx_rows());
else
kinfo("console: built-in bitmap font (the typeface would not open)\n");
}
uartinit();
if (boot_verbose) cprintf("verbose boot: uartinit done\n");
mouseinit();
if (boot_verbose) cprintf("verbose boot: mouseinit done\n");
// The devices that are simply known to be on a PC rather than enumerated by
// any bus, and the framebuffer the loader left running. Registered before
// pci_init() so that /proc/devices lists them first, which puts the machine's
// fixed furniture above whatever happened to be plugged into it -- and
// because the display has to work long before any bus is walked.
//
// main() names no display driver. Whichever one owns the boot framebuffer
// publishes it there as this kernel's scanout, and the console and crash
// screen draw on that; see include/gfx.h.
//
// No splash drawing here on purpose. The loader owns the splash, logo and
// all, and bringing the framebuffer up deliberately does not clear it -- so
// it stays on the display right through to console_takeover(). Painting it
// again from here would land in the last few hundred milliseconds of boot
// and change nothing.
platform_register_devices(fb_addr);
pciuser_init();
pci_init();
if (boot_verbose) cprintf("verbose boot: pci_init done\n");
pinit();
if (boot_verbose) cprintf("verbose boot: pinit done\n");
binit();
if (boot_verbose) cprintf("verbose boot: binit done\n");
fileinit();
if (boot_verbose) cprintf("verbose boot: fileinit done\n");
sockinit();
if (boot_verbose) cprintf("verbose boot: sockinit done\n");
// After the timer is running, so boot_epoch can subtract the uptime so far,
// and before userspace, so the very first timestamp any program takes is
// already real. Reading the RTC is slow, which is exactly why it happens once
// here rather than on every gettimeofday().
time_init();
ideinit();
if (boot_verbose) cprintf("verbose boot: ideinit done\n");
ramdiskinit();
if (boot_verbose) cprintf("verbose boot: ramdiskinit done\n");
// Start the application processors.
//
// This was broken from the beginning of the project and is fixed as of
// 2026-08-08. Five distinct bugs, each hidden behind the one before it, and
// all of them invisible under QEMU because it publishes only one processor in
// the legacy MP table -- see kernel/arch/x86_64/acpi.c. VMware published the
// real count, which is why the triple fault only ever appeared there.
//
// 1. entry64high had no AP branch at all; the check was a comment saying it
// had never been written. Every AP fell into the boot processor's path,
// took the BSP's stack, and ran kmain() a second time.
// 2. seginit() called kalloc() for its per-CPU page. kalloc locks, locking
// reads `cpu`, and `cpu` is what seginit() was about to set up.
// 3. kernel_fs_base was one global for all CPUs, and trapasm.S read it on
// every entry from user mode, so both CPUs shared one per-CPU block.
// 4. `cpu` and `proc` were __thread. That means per *thread*, so the compiler
// cached the thread pointer across calls -- but %fs here is per *CPU*, and
// a kernel thread changes CPU at every sched(). See include/proc.h.
// 5. The new per-CPU slots collided with syscall_entry's scratch word at
// %fs:(8), which is the same memory as %gs:8.
//
int smp_start_aps = 1;
if (smp_start_aps) {
startothers();
if (boot_verbose) cprintf("verbose boot: startothers done\n");
} else if (ncpu > 1) {
cprintf("smp: %d cpus found, running on 1 (AP startup is broken)\n", ncpu);
}
kinit2();
if (boot_verbose) cprintf("verbose boot: kinit2 done\n");
sinit();
if (boot_verbose) cprintf("verbose boot: sinit done\n");
// The TCP/IP stack, after sinit because it starts a kernel thread and
// kthread_create needs the process table to be usable. It returns
// immediately: everything it does happens in that thread, which cannot run
// until mpmain() below enters the scheduler.
netinit();
if (boot_verbose) cprintf("verbose boot: netinit done\n");
// The debugger's own thread, for the same reason netinit's comes after
// sinit: kthread_create needs a usable process table. From here on Ctrl-G
// on the console answers, with no filesystem mounted and nothing in
// userspace started -- which is the whole point of putting it this early.
// After timeinit(), which is what boot_epoch comes from, and before any
// program can ask for a random byte.
random_init();
netuser_init();
dbg_thread_start();
if (boot_verbose) cprintf("verbose boot: dbg thread started\n");
// The greeting is not printed here.
//
// It was, and it landed before userspace had said anything -- so on a quiet
// boot it was followed by the last few lines of `schinit` and whatever the
// jobs had to say, which is the wrong way round for a line whose whole job is
// to be the last thing you read. It belongs at the end of the thing it is
// announcing, and the end of the thing it is announcing is in userspace.
//
// schinitd prints it, once its RunAtLoad jobs are away. See cmd/schinitd.
mpmain();
}
// Other CPUs jump here from entryother.S.
void
mpenter(void)
{
switchkvm();
seginit();
lapicinit();
mpmain();
}
// Enable SSE/FXSAVE so user binaries can use XMM registers.
static void
enable_sse(void)
{
uint64 cr0, cr4;
asm volatile("mov %%cr0, %0" : "=r"(cr0));
cr0 &= ~(1UL << 2); // clear CR0.EM (no FP emulation)
cr0 |= (1UL << 1); // set CR0.MP (monitor coprocessor)
asm volatile("mov %0, %%cr0" :: "r"(cr0));
asm volatile("mov %%cr4, %0" : "=r"(cr4));
cr4 |= (1UL << 9); // CR4.OSFXSR — OS handles FXSAVE/FXRSTOR
cr4 |= (1UL << 10); // CR4.OSXMMEXCPT — OS handles SSE exceptions
asm volatile("mov %0, %%cr4" :: "r"(cr4));
}
// Common CPU setup code.
static void
mpmain(void)
{
// **Set the processor up before saying anything.** This used to announce
// itself first, and that reset the machine on any verbose boot with more
// than one CPU -- silently, with no panic and no last line, because the
// failure is a triple fault and a triple fault prints nothing.
//
// Two of the three lines below have to come first, and 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. A
// quiet boot got away with it because kinfo() is log-only then and the
// work is a few bytes into a ring; a verbose boot renders the line
// through the TrueType rasteriser, which is where the XMM lands.
//
// 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 up.
// That is why the only symptom was "F8 verbose reboots into a normal
// boot": the machine really did reboot, and the second one was quiet.
//
// The general rule this is an instance of: a processor announces that it
// is up after it is able to run, not before.
enable_sse(); // allow user XMM/SSE instructions
idtinit(); // load idt register
syscallinit(); // syscall set up
kinfo("cpu%d: starting\n", cpunum());
xchg(&mycpu()->started, 1); // tell startothers() we're up
scheduler(); // start running processes
}
void entry32mp(void);
// One scheduler stack per possible CPU. See the note at its use below.
__attribute__((aligned(PGSIZE)))
static char apstacks[NCPU][KSTACKSIZE];
// Start the non-boot (AP) processors.
static void
startothers(void)
{
extern uchar _binary_entryother_start[], _binary_entryother_size[];
uchar *code;
struct cpu *c;
char *stack;
// Write entry code to unused memory at 0x7000.
// The linker has placed the image of entryother.S in
// _binary_entryother_start.
code = P2V(0x7000);
memmove(code, _binary_entryother_start,
(addr_t)_binary_entryother_size);
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;
// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
// The AP's per-CPU page, allocated here rather than by the AP itself.
// seginit() explains why: an AP cannot call kalloc() before %fs is set up,
// because kalloc locks and locking reads the per-CPU pointer.
if (c->local == 0) {
c->local = kalloc();
memset(c->local, 0, PGSIZE);
}
// The AP's scheduler stack. Static rather than kalloc(), for the same
// reason and by the same argument as kstacks[] in proc.c -- and here there
// was a second, sharper problem: this said `kalloc()` for one page and
// then handed the AP `stack + KSTACKSIZE` as its stack top. That was
// exactly right for as long as KSTACKSIZE happened to equal PGSIZE, and
// silently points a whole page past the allocation the moment it does not.
//
// This is not a throwaway boot stack. The AP runs mpmain() and then
// scheduler() on it for the rest of the machine's life, and takes
// interrupts on it whenever no process is current.
stack = apstacks[c - cpus];
*(uint32*)(code-4) = 0x8000; // enough stack to get us to entry64mp
*(uint32*)(code-8) = v2p(entry32mp);
*(uint64*)(code-16) = (uint64) (stack + KSTACKSIZE);
lapicstartap(c->apicid, V2P(code));
// wait for cpu to finish mpmain()
while(c->started == 0)
;
// Counted here, on the boot processor, after the AP has actually reported
// in -- not by the AP itself. This is serialised by the loop, so it needs
// no atomic, and it cannot overcount a processor that started and died.
ncpu_online++;
}
}