From 879db62379e644ce2780ce32ad443a27c0c0f5cc Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Mon, 10 Aug 2026 22:09:09 +0200 Subject: [PATCH] boot: stage2 climbs to long mode, and both loaders use one kernel entry The BIOS loader handed the kernel a 32-bit entry that climbed into long mode itself; the UEFI loader arrived already there and used a separate 64-bit entry. Two entries meant two copies of the same preconditions, and that is not a hypothetical cost -- it is exactly where EFER.SCE went missing for months, set on one path and not the other, invisible until a program tried to return from a syscall. stage2 does the climb now. It builds the page tables where the UEFI loader builds them and meaning the same thing -- 4 GB identity-mapped with 1 GB pages, and the same 4 GB again at 0xffff800000000000, because entry64low's first instruction is a movabs to a high address and identity alone triple faults on it -- sets PAE, LME and SCE, enables paging, and far jumps through a GDT with an L-bit code descriptor, which a 32-bit descriptor cannot express. stage2main.c is compiled for x86_64 and runs in long mode. It still reads the multiboot header to find where the kernel's segments go, but enters through the SCH64 header rather than the entry named there: that entry is 32-bit code whose only job is the climb, and dropping back to 32 bits to redo work already done is a mode switch for no reason. If the SCH64 header is absent it halts, because there is no longer anything to fall back to. So both loaders now hand over identically -- long mode, interrupts off, CR3 mapping the low 4 GB twice, framebuffer in RBX -- and there is one set of preconditions instead of two that can drift. Verified on every path that exists: BIOS to schinit; BIOS with -smp 2, which brings up cpu1 through the AP trampoline's own separate climb; the resolution menu still selecting 1024x768 over serial; the ISO, which loads through ATAPI and a ramdisk root; UEFI unchanged at 1018 MB in 7 regions; bootcheck 108 [ok]. stage2 is 21988 of 24576 bytes. The 32-bit mboot_entry stays in entry.S. Nothing of ours uses it now, but it is what makes the kernel loadable by a multiboot loader that is not ours, and removing it is a separate decision from this one. Co-Authored-By: Claude Opus 5 --- Makefile | 6 +-- boot/bios/stage2.S | 91 +++++++++++++++++++++++++++++++++++++++++- boot/bios/stage2main.c | 52 +++++++++++++++++------- 3 files changed, 131 insertions(+), 18 deletions(-) diff --git a/Makefile b/Makefile index dd2c3ca..db47930 100644 --- a/Makefile +++ b/Makefile @@ -569,9 +569,9 @@ $(BUILD)/boot/cdboot: boot/bios/cdboot.S $(BUILD)/boot/stage2: boot/bios/stage2.S boot/bios/stage2main.c include/logo.h @mkdir -p $(BUILD)/boot - $(CC32) $(STAGE2_DEF) -fno-builtin -fno-pic -nostdinc -I./include -c boot/bios/stage2.S -o $(BUILD)/boot/stage2asm.o - $(CC32) $(STAGE2_DEF) -fno-builtin -fno-pic -O -nostdinc -I./include -c boot/bios/stage2main.c -o $(BUILD)/boot/stage2main.o - $(LD) -m elf_i386 -nostdlib $(LD_LOWBASE) -n -N -e stage2start -Ttext 0x7E00 \ + $(CC) $(STAGE2_DEF) -fno-builtin -fno-pic -nostdinc -I./include -c boot/bios/stage2.S -o $(BUILD)/boot/stage2asm.o + $(CC) $(STAGE2_DEF) -fno-builtin -fno-pic -O -nostdinc -mno-red-zone -mno-sse -mcmodel=small -I./include -c boot/bios/stage2main.c -o $(BUILD)/boot/stage2main.o + $(LD) -m elf_x86_64 -nostdlib $(LD_LOWBASE) -n -N -e stage2start -Ttext 0x7E00 \ -o $(BUILD)/boot/stage2tmp.o $(BUILD)/boot/stage2asm.o $(BUILD)/boot/stage2main.o $(OBJCOPY) -S -O binary -j .text -j .rodata -j .data $(BUILD)/boot/stage2tmp.o $(BUILD)/boot/stage2 ./tools/padstage2.pl $(BUILD)/boot/stage2 $(STAGE2_BYTES) diff --git a/boot/bios/stage2.S b/boot/bios/stage2.S index 44de461..e486b07 100644 --- a/boot/bios/stage2.S +++ b/boot/bios/stage2.S @@ -499,11 +499,100 @@ start32: movw %ax, %gs movl $0x7c00, %esp - call stage2main + + /* --------------------------------------------------------------------- + * Climb into long mode here, rather than leaving it to the kernel. + * + * The kernel has always had a 32-bit multiboot entry that does this climb + * itself, and the UEFI loader arrives already in long mode and uses a + * separate 64-bit entry. Two entries meant two versions of the setup, and + * that is exactly where EFER.SCE went missing on one of them for months: + * the 32-bit path set it, the 64-bit path did not, and nothing noticed + * until a program tried to return from a syscall. + * + * Climbing here means both loaders hand over in the same state, through the + * same kernel entry, and there is one copy of these preconditions instead of + * two that can drift. + * + * The tables are built exactly where the UEFI loader builds them, and mean + * the same thing: 4 GB identity-mapped with 1 GB pages, and the same 4 GB + * again at 0xffff800000000000, because entry64low's first instruction is a + * movabs to a high address and identity alone triple faults on it. + * ------------------------------------------------------------------- */ + + /* Zero the two table pages at 0x1000-0x3000. */ + movl $0x1000, %edi + movl $0x2000, %ecx + xorl %eax, %eax + rep stosb + + /* pml4[0] and pml4[256] both -> the one PDPT at 0x2000. Entry 256 is + * 0xffff800000000000 >> 39, and its slot is at 0x1000 + 256*8 = 0x1800. */ + movl $(0x2000 | PTE_P | PTE_W), 0x1000 + movl $(0x2000 | PTE_P | PTE_W), 0x1800 + + /* pdpt[0..3]: four 1 GB pages covering the low 4 GB. */ + movl $(0x00000000 | PTE_P | PTE_W | PTE_PS), 0x2000 + movl $(0x40000000 | PTE_P | PTE_W | PTE_PS), 0x2008 + movl $(0x80000000 | PTE_P | PTE_W | PTE_PS), 0x2010 + movl $(0xC0000000 | PTE_P | PTE_W | PTE_PS), 0x2018 + + /* PAE. Long mode has no other paging format. */ + movl %cr4, %eax + bts $5, %eax + movl %eax, %cr4 + + movl $0x1000, %eax + movl %eax, %cr3 + + /* EFER: LME to enter long mode, SCE so SYSCALL works. SCE belongs here and + * not in the kernel, so that whichever loader ran, the kernel starts able + * to return from a system call. */ + movl $MSR_EFER, %ecx + rdmsr + bts $0, %eax /* SCE */ + bts $8, %eax /* LME */ + wrmsr + + /* Paging on: this is the instruction that actually enters long mode. */ + movl %cr0, %eax + orl $CR0_PG, %eax + movl %eax, %cr0 + + lgdt gdt64desc + ljmp $0x08, $start64 spin: jmp spin +.code64 +start64: + movw $0x10, %ax + movw %ax, %ds + movw %ax, %es + movw %ax, %ss + xorw %ax, %ax + movw %ax, %fs + movw %ax, %gs + + movq $0x7c00, %rsp + call stage2main + +spin64: + jmp spin64 + +/* A 64-bit GDT. The 32-bit one above got us into protected mode; long mode + * needs a code descriptor with the L bit, which a 32-bit descriptor cannot + * express. Selector 0x08 is code, 0x10 is data. */ +.p2align 3 +gdt64: + .quad 0 + .quad 0x00209A0000000000 /* code: present, DPL0, executable, L=1 */ + .quad 0x0000920000000000 /* data: present, DPL0, writable */ +gdt64desc: + .word (gdt64desc - gdt64 - 1) + .quad gdt64 + .p2align 2 gdt: SEG_NULLASM diff --git a/boot/bios/stage2main.c b/boot/bios/stage2main.c index c189f59..5d68827 100644 --- a/boot/bios/stage2main.c +++ b/boot/bios/stage2main.c @@ -569,6 +569,20 @@ splash(void) draw_centered(g_height - 40, "Press F8 for boot options", SCH_DIM); } +// Find the kernel's 64-bit entry: the SCH64 header, 8-byte aligned in the first +// 16 KB, exactly as include/schboot.h describes it. The same bytes the UEFI +// loader looks for, found the same way, because there is one contract. +static uint64 +find_sch64_entry(const uchar *buf, unsigned len) +{ + for (unsigned off = 0; off + sizeof(struct sch64_header) <= len; off += 8) { + const struct sch64_header *h = (const struct sch64_header *)(buf + off); + if (h->magic0 == SCH64_MAGIC0 && h->magic1 == SCH64_MAGIC1) + return h->entry; + } + return 0; +} + void stage2main(void) { @@ -643,21 +657,31 @@ stage2main(void) if (g_have_fb) progress_label("Starting SchOS..."); - // Jump to kernel entry. NOT a normal C call: kernel/arch/x86_64/ - // entry.S's mboot_entry reads its "argument" (the framebuffer - // physical address) out of EAX directly, not off the stack — a - // multiboot-style raw-register handoff, not cdecl. A plain - // `((void(*)(uint32))entry)(fbaddr)` pushes fbaddr on the stack - // per cdecl and leaves EAX as whatever happened to be lying around; - // it only ever "worked" because simpler bootmain.c code coincidentally - // left the right value in EAX as a side effect of its own codegen. - // Adding the boot-menu/progress-bar code changed that codegen and - // broke it silently (kernel received garbage instead of the real - // LFB address). Pin it down explicitly instead of hoping. + // Enter through the kernel's 64-bit entry, not the multiboot one. + // + // The multiboot header still says where the kernel's segments go -- + // that part is unchanged -- but stage2 is in long mode now, and the + // entry named in that header is 32-bit code whose whole job is to + // climb into it. Using it would mean dropping back to 32 bits to redo + // a climb already done, and a mode switch is not a thing to do twice + // for no reason. + // + // So the same SCH64 header the UEFI loader looks for is found here, + // and both loaders hand over identically: long mode, interrupts off, + // CR3 mapping the low 4 GB twice, framebuffer in RBX. One entry, one + // set of preconditions, nothing left to drift between two copies. { - uint32 fbaddr = *(uint32*)(SCHBOOT_VBE_INFO + 40); - uint32 entry = hdr->entry_addr; - asm volatile("jmp *%0" : : "r"(entry), "a"(fbaddr)); + uint32 fbaddr = *(uint32*)(uint64)(SCHBOOT_VBE_LFB); + uint64 entry64 = find_sch64_entry((const uchar*)x, 16384); + + // Nothing to fall back to: the 32-bit entry cannot be reached from + // long mode. Halt rather than jump somewhere arbitrary. + if (!entry64) + for (;;) asm volatile("hlt"); + + // RBX, because that is what entry64uefi reads. Not a C call -- the + // kernel entry is not a function and has no prologue to match. + asm volatile("jmp *%0" : : "r"(entry64), "b"((uint64)fbaddr)); } } }