uefi: copy the kernel home and jump from a page it cannot reach

The staged segments were copied home after ExitBootServices by the
loader itself -- on code, a stack and a copy list the firmware had
placed wherever it liked. On an HP all-in-one some of that was in the
kernel's range: "Placing the kernel", then a triple fault and a reset.

The copy and the jump now 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. It switches
to the loader's page tables and its own stack before copying anything.
Called sysv_abi, because UEFI code is the Microsoft convention and the
first try loaded the framebuffer address into rsp.

The framebuffer address handed over in rbx is 64-bit now too; the
kernel already read the full one from the hand-off block.

Also write.sh: writes schos.iso to a USB stick, after showing the
device and asking for its name to be typed back; refuses partitions,
the system disk and non-removable disks; reads the result back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
This commit is contained in:
Nexoniarz 2026-10-24 11:00:02 +02:00
commit 726091000e
2 changed files with 239 additions and 45 deletions

View file

@ -582,15 +582,97 @@ claim_covers(UINT64 lo, UINT64 hi)
return 1;
}
/* After ExitBootServices: the staged segments, home. The buffers came from
* outside the kernel's range -- claim_span() took every free page inside it
* -- so no copy can land on a buffer still to be copied. */
/* ---------------------------------------------------------------------------
* The last jump, from somewhere the kernel cannot land
*
* The staged segments are copied home after ExitBootServices -- and this
* loader is still running then, on code, a stack and a copy list the firmware
* placed wherever it liked. On an HP all-in-one some of that was inside the
* kernel's range, so the copy overwrote the loader while it ran: the screen
* said "Placing the kernel" and the machine reset, a triple fault, with
* "Starting the kernel" never drawn.
*
* So the copy and the jump run from one page of their own, below the kernel's
* lowest address and so outside anything it occupies: this routine at the start of it,
* the copy list after it, a stack at the end. It is position independent --
* every address it uses arrives in a register -- so it runs wherever it is
* copied. It switches to the loader's page tables (which map all memory 1:1)
* and its own stack first, so nothing it needs afterwards is anywhere the
* copies can reach. The buffers being copied *from* are outside the kernel's
* range too: claim_span() took every free page inside it.
*
* rdi copy list: { dst, src, len } x n rsi n
* rdx kernel entry rcx framebuffer, for rbx
* r8 page tables r9 stack top
*
* RBX carries the framebuffer address into kmain and must never be 1 -- that
* value marks an application processor in entry64high. A physical address or
* zero cannot collide. (The jump this replaces was inline asm, where leaving
* rbx out of the clobbers once let clang put the jump target in it; written
* out in full here, every register is spelled.)
* ------------------------------------------------------------------------- */
__asm__(
".text\n"
".globl schos_tramp\n"
".globl schos_tramp_end\n"
"schos_tramp:\n"
" mov %r9, %rsp\n"
" mov %r8, %cr3\n"
" mov %rdx, %r10\n"
" mov %rcx, %rbx\n"
" mov %rdi, %r11\n"
" mov %rsi, %r9\n"
" cld\n"
"1: test %r9, %r9\n"
" jz 2f\n"
" mov 0(%r11), %rdi\n"
" mov 8(%r11), %rsi\n"
" mov 16(%r11), %rcx\n"
" rep movsb\n"
" add $24, %r11\n"
" dec %r9\n"
" jmp 1b\n"
"2: jmp *%r10\n"
"schos_tramp_end:\n");
extern const UINT8 schos_tramp[], schos_tramp_end[];
#define TRAMP_LIST 256 /* the copy list's offset in the page */
static EFI_PHYSICAL_ADDRESS tramp_page;
static UINT64 kern_lo; /* the kernel's lowest address; set by load_elf() */
/* Before ExitBootServices: the page, below the kernel and above the first
* megabyte -- the hand-off block and the page tables are written down there
* after the firmware exits. Loader *code*, not data: a firmware that maps
* data pages no-execute (OVMF does) would fault on the first instruction. */
static void
place_staged(void)
tramp_reserve(void)
{
for (UINTN i = 0; i < nlater; i++)
copy((void *)(UINTN)later[i].dst, (void *)(UINTN)later[i].src,
(UINTN)later[i].len);
EFI_PHYSICAL_ADDRESS a = kern_lo - 1;
if (EFI_ERROR(BS->AllocatePages(AllocateMaxAddress, EfiLoaderCode, 1, &a)) ||
a < 0x100000)
die(L"no page below 16 MB for the final jump", 0);
tramp_page = a;
}
/* After ExitBootServices, and the last thing this loader does. */
static void __attribute__((noreturn))
tramp_go(UINT64 entry, UINT64 fb)
{
UINT8 *page = (UINT8 *)(UINTN)tramp_page;
copy(page, schos_tramp, (UINTN)(schos_tramp_end - schos_tramp));
copy(page + TRAMP_LIST, later, nlater * sizeof later[0]);
// sysv_abi: the registers above are System V's argument order. This
// loader is compiled for UEFI, which is the *Microsoft* convention (rcx,
// rdx, r8, r9, then the stack), and without the attribute the first try
// loaded the framebuffer address into rsp and a kernel address into cr3.
typedef void __attribute__((sysv_abi)) tramp_fn(void *, UINT64, UINT64,
UINT64, UINT64, UINT64);
tramp_fn *go = (tramp_fn *)page;
go(page + TRAMP_LIST, nlater, entry, fb, PML4_ADDR, tramp_page + 4096);
for (;;)
__asm__ volatile("hlt");
}
// Load PT_LOAD segments to p_paddr. Physical addresses, not virtual: the
@ -639,6 +721,7 @@ load_elf(EFI_FILE_PROTOCOL *file)
if (span_hi)
claim_span(lowest_paddr & ~0xfffULL, (span_hi + 0xfff) & ~0xfffULL);
kern_lo = lowest_paddr & ~0xfffULL;
for (UINT16 i = 0; i < eh.e_phnum; i++) {
struct elf64_phdr ph;
@ -1758,7 +1841,7 @@ f8_window(EFI_GRAPHICS_OUTPUT_PROTOCOL *gop)
return 0;
}
static UINT32
static UINT64
setup_framebuffer(void)
{
EFI_GUID gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID;
@ -2033,10 +2116,11 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
// and the display is the only thing that can report a failure afterwards.
open_boot_volume(image);
UINT32 fb = setup_framebuffer();
UINT64 fb = setup_framebuffer();
EFI_FILE_PROTOCOL *file = open_kernel(image);
UINT64 entry = load_elf(file);
tramp_reserve();
ui_progress_end();
file->Close(file);
@ -2166,11 +2250,6 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
__asm__ volatile("cli");
// The kernel segments that could not be read straight home: the firmware
// has let go of that memory now. See claim_span().
if (nlater)
ui_progress_label("Placing the kernel");
place_staged();
copy((void *)SCHBOOT_E820_MAP, e820, nent * sizeof e820[0]);
*(UINT32 *)SCHBOOT_E820_COUNT = nent;
@ -2220,35 +2299,10 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
for (int i = 0; i < 512; i++)
pdpt[i] = ((UINT64)i << 30) | PTE_P | PTE_PS | PTE_W;
ui_progress_label("Starting the kernel");
// RBX carries the framebuffer address into kmain, and must never be 1 --
// that value marks an application processor in entry64high. A physical
// MMIO address or zero, so it cannot collide.
//
// "rbx" is in the clobber list because this writes it, and leaving it out
// is not a tidiness problem -- it is a jump to the wrong address. Without
// it the compiler may allocate an *input* into rbx, and it did: clang 21
// put `entry` there, so the second instruction overwrote the jump target
// with the framebuffer address and the third jumped into video memory. The
// machine then executed pixels until KVM gave up.
//
// movq %rax, %cr3 <- %0, the page tables
// movq %rcx, %rbx <- %1, the framebuffer, into rbx
// jmpq *%rbx <- %2 was rbx, and now holds the framebuffer
//
// It had worked for as long as the compiler happened to choose differently.
// That is the same failure as the BIOS loader's EAX hand-off in
// docs/history/kernel.md section 4: a register the code depends on, that
// nothing in the source actually asks for.
__asm__ volatile(
"mov %0, %%cr3\n\t"
"mov %1, %%rbx\n\t"
"jmp *%2\n\t"
:
: "r"((UINT64)PML4_ADDR), "r"((UINT64)fb), "r"(entry)
: "rbx", "memory");
for (;;)
__asm__ volatile("hlt");
// The kernel segments that could not be read straight home are copied
// there now, by tramp_go(), from a page they cannot overwrite -- and the
// jump into the kernel is made from the same page.
ui_progress_label(nlater ? "Placing and starting the kernel"
: "Starting the kernel");
tramp_go(entry, fb);
}