ARM64: SP_EL1 is never set, the boot thread's interrupt frames land on the bootloader's stack

Aperta
#3,348 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@valentinbreiz ci sta già lavorando.

Dal 27/10/2026.

  • #3368 di @valentinbreiz — aperta

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
18/100
Tipo di issue
Bug
Chiarezza
Abbastanza chiara
Stato di attività
Ferma
Stack tecnologico
csharp, linux, shell

Direzione di ricerca

The stack setup belongs in the native ARM64 assembly under src/Cosmos.Kernel.Native.ARM64/CPU/Interrupts.s, with the vector install in _native_arm64_init_exception_vectors and the boot-thread bounds in BootStack and RhGetCurrentThreadStackBounds. Start by reading the EL1t special case at Interrupts.s:161-168 and the GC stack scan in GarbageCollector.Mark.cs. Done means SP_EL1 points at a kernel-owned interrupt stack before vbar_el1 is written, and the boot thread's frames land in a range the kernel reports. A linked pull request (#3368) is already open, so check it before starting.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

Area: Kernel arm64 Bug Priority: Low

Found on #3323 while reviewing the ARM64 fix in c1bb68ec1 (Interrupts.s: the IRQ stub saves SP_EL0 as the interrupted stack pointer when the saved SPSR says EL1t). That fix is right as far as it goes, and it exposes a boot-state gap of its own: nothing in the kernel ever writes SP_EL1, so every exception the boot thread takes pushes its 832-byte frame on whatever stack the bootloader left in SP_EL1, inside bootloader-reclaimable memory the kernel does not own.

Problem

Limine enters the kernel in EL1t: enter_in_el1 in common/lib/spinup.asm_aarch64 of the v10.x tree does msr spsel, #0; mov sp, x1 and enters with spsr_el1 = 0x3c4 (M[3:0] = 0100, EL1t), and the protocol's machine state at entry gives SP as the top of a stack in bootloader-reclaimable memory with every other PSTATE field 0. On that path the kernel's stack pointer is SP_EL0, and SP_EL1 keeps the value it had while Limine ran: AArch64 resets with PSTATE.SP = 1, so a firmware and a loader that never select SP_EL0 run on SP_EL1, and what is left there is the UEFI stack Limine executed on, memory its pmm.s2.c converts from EfiBootServicesData/EfiLoaderData into the bootloader-reclaimable type the kernel sees. The vector table installed by _native_arm64_init_exception_vectors (Interrupts.s:359) covers both the "Current EL with SP0" and the "Current EL with SPx" vectors with the same stub, and the architecture selects SP_EL1 on every exception taken to EL1 (PSTATE.SP becomes 1 on entry regardless of its value before). So:

  • Every scheduler thread runs EL1h: ThreadContext.Initialize sets Spsr = 0x3C5 (ThreadContext.ARM64.cs:82), the first run does mov sp, x12 with SPSel = 1, so SP_EL1 is the thread's own stack while it runs and the frame lands on it. Correct.
  • The boot thread (thread 0, CPU 0's idle thread, the one Main runs on) runs EL1t, on SP_EL0. On its exceptions the frame lands on SP_EL1, which holds whatever Limine last had there. The kernel sets it nowhere: grep -rn 'sp_el1\|spsel' src/ finds only the comment at Interrupts.s:162.

What that stack is: [GenericTimer] Tick 1 SP=0x476863A0 is the frame's address on every ARM64 boot of the test kernels (the probe at GenericTimer.cs:214 prints SavedContextStackPointer, the frame's base). The memory map of the same boot puts it in Entry 5: 0x47666000 - 0x48000000 (9 MB) Type: 5 (Bootloader Reclaimable): an identity-mapped low address in Limine's own allocations, next to the kernel's Entry 6: 0x48000000 - ... (Usable). The kernel's stack at that moment is BootStack.Top in the higher half (0xFFFF000059DBDFD0 in the commit message of c1bb68ec1), the 1 MiB Limine stack the Stack Size request asks for, and the boot thread's frames never land on it.

Why it has not bitten:

  • PageAllocator hands out only Usable entries (PageAllocator.cs:196) and nothing reclaims bootloader memory yet, so the region stays mapped and unused by anyone else: the frames land on memory nobody owns and nobody overwrites.
  • The stub runs the whole managed handler (__managed__irq → InterruptManager.Dispatch, the timer tick, the scheduler) with IRQs masked, so no interrupt nests on that stack and its depth is bounded by what Dispatch calls (a synchronous exception raised inside the handler would nest, on the same unknown stack). Limine's stack has had room for that so far.

What it costs:

  • Unowned memory. The first consumer of bootloader-reclaimable memory (a later PageAllocator that reclaims it, which the memory-map type exists for) turns every boot-thread interrupt into a write into someone's pages, with nothing pointing back at SP_EL1.
  • No bounds for the boot thread's interrupt frames. BootStack reports [Top - Size, Top) for the boot thread (RhGetCurrentThreadStackBounds, Thread.cs:47), and that range never contains the frames; a stack walk, a panic dump, or the GC's ScanThreadStack has no bounds for the stack the frames sit on. c1bb68ec1 works around the GC half of that by scanning from SP_EL0 when SPSR.M[0] is 0 (GarbageCollector.Mark.cs:243-250); the saved context itself, which that scan reads through GetContext(), still lives on the unowned stack.
  • An asymmetry per thread kind. The boot thread is the only one whose context is not on its own stack; every special case in the stub (Interrupts.s:161-168) and in the GC exists for it.
  • SMP (#3339). Each application processor Limine starts enters the kernel the same way: msr spsel, #0, its own reclaimable stack in SP_EL0, spsr_el1 = 0x3c4 (common/sys/smp_trampoline.asm_aarch64), and SP_EL1 as PSCI CPU_ON handed the core over, which that contract leaves undefined: the trampoline never writes it. Without a kernel-owned SP_EL1 per CPU the same gap exists on every CPU, with a different unknown stack each, and on an AP not even a stack that was once valid.

Evidence

From an ARM64 run of the Network suite (same lines on every test kernel):

[KMAIN]   - HHDM offset: 0x0xFFFF000000000000
[MemMap] Entry 4: 0x44020000 - 0x47666000 (54 MB) Type: 0 (Usable)
[MemMap] Entry 5: 0x47666000 - 0x48000000 (9 MB) Type: 5 (Bootloader Reclaimable)
[MemMap] Entry 6: 0x48000000 - 0x596A2000 (278 MB) Type: 0 (Usable)
...
[Kernel] Enabling interrupts...
[GenericTimer] Tick 1 SP=0x476863A0
[GenericTimer] Tick 2 SP=0x476863A0

0x476863A0 is inside entry 5; the kernel's boot stack is in the higher half. Before c1bb68ec1 the GC scanned from that address up to BootStack.Top when a worker collected while the boot thread was switched out, which is how the gap surfaced (the GC_CollectFromWorkerThread cell, 35/90 cells and QEMU exit 134 without the fix on #3323).

Suggested fix

Give SP_EL1 a kernel-owned interrupt stack before the first exception can be taken, that is before _native_arm64_init_exception_vectors installs the vectors (ARM64InterruptController.Initialize): a static per-CPU area in the native ARM64 assembly or a page allocation, then

msr spsel, #1
mov sp, x0          // top of the kernel's interrupt stack for this CPU
msr spsel, #0

run early in kmain (or from the controller's Initialize, before msr vbar_el1). With that, BootStack can report two ranges for the boot thread (its call stack and its interrupt stack), the frames have an owner, the GC and the panic dump have bounds, and the AP bring-up of #3339 does the same per CPU with the stack it allocates anyway.

The alternative is to run the boot thread in EL1h from kmain (msr spsel, #1 after copying SP_EL0 into SP_EL1), so its frames land on the boot stack like every other thread's: it removes the EL1t special case from Interrupts.s:161-168 and from GarbageCollector.Mark.cs, at the cost of the boot stack carrying the frames too (1 MiB, the same arrangement as the x64 side, where no TSS is loaded and every frame goes on the interrupted stack). Either way the fix is a few lines of assembly plus the bounds; the choice is whether the kernel wants a separate interrupt stack per CPU, which SMP and a future user mode both argue for.

Not for #3323: the fix there makes the GC right with the stacks as they are; this is the follow-up that gives those frames a home.

Lingua principale
C#
Stelle
3.2k
Fork
591
Merge medio
21h 34m
PR unite (30g)
26

Preparare l'ambiente

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di CosmosOS/Cosmos

Tutte le issue di CosmosOS/Cosmos

Issue simili

Altre issue su C#

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.