Stage 2: cooperative VM context switch primitive, proven on all 3 arches (FABRIC-3.md §XXVIII)
Build / build-amd64-iso (push) Canceled after 0s
Build / build-aarch64-iso (push) Canceled after 0s
Build / build-riscv64-img (push) Canceled after 0s

Third stage of the preemptive context-switching plan. The real
save/restore switch mechanism now exists -- the first time anything has
ever executed on a VM's own native stack (Stage 1 allocated them,
unused).

New sk_vm_switch_to() (switch.S, one per arch) is an ordinary function
call, not an interrupt -- so unlike Stage 0's trap frame, the ABI already
covers every caller-saved register; only the callee-saved set needs
explicit save/restore (amd64: rbx/rbp/r12-r15, no FP at all since SysV
has no callee-saved XMM; aarch64: x19-x28/x29/x30 + d8-d15; riscv64:
s0-s11/ra + fs0-fs11, FS-gated like Stage 0 but read once and reused for
both halves within one call, since FS is genuine global CPU state, not
part of what's switched). A sibling sk_vm_switch_prime() in the same file
builds the synthetic first-entry frame, kept in assembly so the layout
can never drift out of sync with sk_vm_switch_to() itself.

New switch.c/switch.h: sk_vm_context_switch(from, to) handles first-entry
priming vs. resuming a parked context, and updates registry state (new
VM_STATE_SWITCHED_OUT, distinct from VM_STATE_STOPPED -- STOPPED means no
live frame, this means the opposite). sk_vm_switch_entry() is the minimal
permanent trampoline every freshly-entered VM lands in: no production
behavior defined yet, so it just yields straight back to whoever switched
to it, forever.

Closes the confirmed unguarded-KILL UAF found during planning:
capsule_vm_kill(), mama_word_kill(), and capsule_vm_kill_all_nonmama()
all now refuse (or silently leak rather than free, on the cold-restart
path where arch_cold_reset() wipes everything immediately after anyway)
tearing down a switched-out VM. Side effect found, not built on purpose:
the existing MSG-TICK idle-pump already filters on VM_STATE_LIVE, so it
automatically stopped dispatching into a switched-out VM with zero
changes needed there.

Verified via a temporary SWITCH-TEST probe (boot-triggered, since nothing
can type interactively into a foreground-only QEMU session) that
round-tripped a sentinel through 5 real Hera<->Hermes switches on all 3
architectures: 5/5 rounds, 0 failures, clean continuation to ok>. Probe
fully reverted after capture; kernel_main.c shows zero diff.

Also: Makefile.starkernel's LOADER_EXTRA_SRCS/LOADER_ASM needed the new
files added explicitly (this project's "loader" PE binary is the full
running kernel, not a thin bootstrap stage), and aarch64's switch.S
needed the same #ifndef _WIN32 guard around .hidden that isr.S already
carries (aarch64's loader assembles via clang targeting a PE/COFF
target with no .hidden equivalent) -- caught by a build failure, fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UNhH1mhi52i6Qihh7ZV5S
This commit is contained in:
Robert Allan James
2026-09-13 15:25:39 -04:00
co-authored by Claude Sonnet 5
parent 57ac3fc304
commit f790d0995e
25 changed files with 54894 additions and 7 deletions
+105
View File
@@ -0,0 +1,105 @@
/*
* switch.S (aarch64) - Cooperative VM context switch primitive.
*
* FABRIC-3.md §XXVIII (Stage 2, preemptive-context-switch save/restore
* primitive, 2026-09-13). Deliberately NOT isr.S's interrupt trap frame
* (Stage 0) -- that saves everything because an interrupt has no call
* site. sk_vm_switch_to() is an ordinary `bl`, so AAPCS64 already makes
* every caller-saved register (x0-x18, v0-v7, v16-v31) the caller's own
* problem across the call. Only the callee-saved set needs explicit
* handling: x19-x28, x29 (fp), x30 (lr), and d8-d15 (only the low 64
* bits of v8-v15 are callee-saved per AAPCS64).
*
* void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
* x0 = save_sp_out, x1 = new_sp
*
* For a VM that has never run before, new_sp must point at a synthetic
* 160-byte frame built by the C glue (switch.c) to this exact layout,
* with x30 (lr) set to the trampoline entry address and everything else
* zeroed, so the final `ret` below (which branches to lr) jumps there.
*/
.section .text
.balign 4
.equ SW_X19, 0
.equ SW_X21, 16
.equ SW_X23, 32
.equ SW_X25, 48
.equ SW_X27, 64
.equ SW_X29, 80 /* x29 (fp), x30 (lr) */
.equ SW_D8, 96
.equ SW_D10, 112
.equ SW_D12, 128
.equ SW_D14, 144
.equ SW_SIZE, 160
/* .hidden is an ELF-only visibility directive with no COFF equivalent;
* this file is also assembled for the aarch64 loader, which clang targets
* at aarch64-pc-windows-msvc (PE/COFF) -- guard on _WIN32 (defined only by
* that target), matching isr.S's own precedent exactly. */
#ifndef _WIN32
.hidden sk_vm_switch_to
#endif
.global sk_vm_switch_to
sk_vm_switch_to:
sub sp, sp, #SW_SIZE
stp x19, x20, [sp, #SW_X19]
stp x21, x22, [sp, #SW_X21]
stp x23, x24, [sp, #SW_X23]
stp x25, x26, [sp, #SW_X25]
stp x27, x28, [sp, #SW_X27]
stp x29, x30, [sp, #SW_X29]
stp d8, d9, [sp, #SW_D8]
stp d10, d11, [sp, #SW_D10]
stp d12, d13, [sp, #SW_D12]
stp d14, d15, [sp, #SW_D14]
mov x2, sp
str x2, [x0] /* *save_sp_out = sp (this context's parked SP) */
mov sp, x1 /* sp = new_sp (switch stacks) */
ldp x19, x20, [sp, #SW_X19]
ldp x21, x22, [sp, #SW_X21]
ldp x23, x24, [sp, #SW_X23]
ldp x25, x26, [sp, #SW_X25]
ldp x27, x28, [sp, #SW_X27]
ldp x29, x30, [sp, #SW_X29]
ldp d8, d9, [sp, #SW_D8]
ldp d10, d11, [sp, #SW_D10]
ldp d12, d13, [sp, #SW_D12]
ldp d14, d15, [sp, #SW_D14]
add sp, sp, #SW_SIZE
ret
/*
* uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
*
* Builds the synthetic frame above at the top of a fresh native stack, so
* the first ever sk_vm_switch_to() into it lands at `entry` (x30/lr) via
* its final `ret`. Kept in this file (not C) so the layout can never
* drift from sk_vm_switch_to() above -- one set of offsets, not two.
*/
#ifndef _WIN32
.hidden sk_vm_switch_prime
#endif
.global sk_vm_switch_prime
sk_vm_switch_prime:
sub x2, x0, #SW_SIZE
stp xzr, xzr, [x2, #SW_X19]
stp xzr, xzr, [x2, #SW_X21]
stp xzr, xzr, [x2, #SW_X23]
stp xzr, xzr, [x2, #SW_X25]
stp xzr, xzr, [x2, #SW_X27]
stp xzr, x1, [x2, #SW_X29] /* x29=0, x30(lr)=entry */
stp xzr, xzr, [x2, #SW_D8]
stp xzr, xzr, [x2, #SW_D10]
stp xzr, xzr, [x2, #SW_D12]
stp xzr, xzr, [x2, #SW_D14]
mov x0, x2
ret
+79
View File
@@ -0,0 +1,79 @@
/*
* switch.S (amd64) - Cooperative VM context switch primitive.
*
* FABRIC-3.md §XXVIII (Stage 2, preemptive-context-switch save/restore
* primitive, 2026-09-13). This is deliberately NOT the same mechanism as
* isr.S's interrupt trap frame (Stage 0) -- that saves every register
* because an interrupt is not a call site the interrupted code knows
* about. sk_vm_switch_to() IS an ordinary function call (SWITCH-TO's C
* handler calls it directly), so the standard System V AMD64 ABI already
* guarantees every caller-saved register (rax, rcx, rdx, rsi, rdi, r8-r11,
* all XMM) is the caller's own problem to preserve across the call, exactly
* like any other call. Only the callee-saved set -- rbx, rbp, r12-r15 --
* needs explicit handling here, because this function does not really
* "return" in the normal sense: it swaps to a completely different stack
* and (from the newly-resumed context's point of view) appears to return
* from whichever earlier call to this same function originally parked it.
* SysV has no callee-saved XMM registers at all, so there is no FP state
* to carry here.
*
* void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
* rdi = save_sp_out, rsi = new_sp
*
* For a VM that has never run before, new_sp must point at a synthetic
* frame built to this exact layout by the C glue (switch.c): six zeroed
* quadwords (r15, r14, r13, r12, rbx, rbp -- values irrelevant, never a
* real prior context) followed by the trampoline entry address at
* new_sp+48, so the final `ret` below jumps there instead of into some
* prior caller.
*/
.intel_syntax noprefix
.section .text
.hidden sk_vm_switch_to
.global sk_vm_switch_to
sk_vm_switch_to:
push rbp
push rbx
push r12
push r13
push r14
push r15
mov [rdi], rsp /* *save_sp_out = rsp (this context's parked SP) */
mov rsp, rsi /* rsp = new_sp (switch stacks) */
pop r15
pop r14
pop r13
pop r12
pop rbx
pop rbp
ret
/*
* uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
*
* Builds the synthetic frame described above at the top of a fresh native
* stack, so the first ever sk_vm_switch_to() into it lands at `entry`
* instead of some real prior caller. Returns the resulting sp value to
* pass as sk_vm_switch_to()'s new_sp. Kept in this file (not C) so the
* layout can never drift from what sk_vm_switch_to() above actually
* expects -- one set of offsets, not two copies to keep in sync.
*/
.hidden sk_vm_switch_prime
.global sk_vm_switch_prime
sk_vm_switch_prime:
mov rax, rdi
sub rax, 56
mov qword ptr [rax+0], 0 /* r15 */
mov qword ptr [rax+8], 0 /* r14 */
mov qword ptr [rax+16], 0 /* r13 */
mov qword ptr [rax+24], 0 /* r12 */
mov qword ptr [rax+32], 0 /* rbx */
mov qword ptr [rax+40], 0 /* rbp */
mov [rax+48], rsi /* return address = entry */
ret
+162
View File
@@ -0,0 +1,162 @@
/*
* switch.S (riscv64) - Cooperative VM context switch primitive.
*
* FABRIC-3.md §XXVIII (Stage 2, preemptive-context-switch save/restore
* primitive, 2026-09-13). Deliberately NOT isr.S's interrupt trap frame
* (Stage 0) -- that saves everything because an async trap has no call
* site. sk_vm_switch_to() is an ordinary `call`, so the LP64D psABI
* already makes every caller-saved register (ra excepted -- see below --
* t0-t6, a0-a7, ft0-ft11, fa0-fa7) the caller's own problem across the
* call. Only the callee-saved set needs explicit handling: s0-s11,
* fs0-fs11, and ra itself (ra is not "callee-saved" in the usual sense,
* but this function does not really return to its own caller -- it swaps
* stacks and later resumes as if returning from whichever earlier call
* parked this context, so ra must be captured explicitly here rather than
* left to a normal prologue/epilogue).
*
* The FP half is saved only when sstatus.FS != Off -- same reasoning and
* bit test as isr.S's trap frame, checked once per call and reused for
* both the save and restore decision (nothing changes FS in between; it
* is genuine single global CPU state, not part of what's "switched," so
* this function reads it but never writes it).
*
* void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
* a0 = save_sp_out, a1 = new_sp
*
* For a VM that has never run before, new_sp must point at a synthetic
* 208-byte frame built by the C glue (switch.c) with ra set to the
* trampoline entry address and everything else zeroed, so the final
* `ret` below (jalr via ra) jumps there.
*/
.section .text
.balign 4
.equ SW_S0, 0
.equ SW_RA, 96
.equ SW_FS0, 104
.equ SW_SIZE, 208
.equ SSTATUS_FS_SHIFT, 13
.equ SSTATUS_FS_MASK, 3
.hidden sk_vm_switch_to
.global sk_vm_switch_to
sk_vm_switch_to:
addi sp, sp, -SW_SIZE
sd s0, SW_S0+0(sp)
sd s1, SW_S0+8(sp)
sd s2, SW_S0+16(sp)
sd s3, SW_S0+24(sp)
sd s4, SW_S0+32(sp)
sd s5, SW_S0+40(sp)
sd s6, SW_S0+48(sp)
sd s7, SW_S0+56(sp)
sd s8, SW_S0+64(sp)
sd s9, SW_S0+72(sp)
sd s10, SW_S0+80(sp)
sd s11, SW_S0+88(sp)
sd ra, SW_RA(sp)
csrr t0, sstatus
srli t1, t0, SSTATUS_FS_SHIFT
andi t1, t1, SSTATUS_FS_MASK
beqz t1, 1f
fsd fs0, SW_FS0+0(sp)
fsd fs1, SW_FS0+8(sp)
fsd fs2, SW_FS0+16(sp)
fsd fs3, SW_FS0+24(sp)
fsd fs4, SW_FS0+32(sp)
fsd fs5, SW_FS0+40(sp)
fsd fs6, SW_FS0+48(sp)
fsd fs7, SW_FS0+56(sp)
fsd fs8, SW_FS0+64(sp)
fsd fs9, SW_FS0+72(sp)
fsd fs10, SW_FS0+80(sp)
fsd fs11, SW_FS0+88(sp)
1:
sd sp, 0(a0) /* *save_sp_out = sp (this context's parked SP) */
mv sp, a1 /* sp = new_sp (switch stacks) */
ld s0, SW_S0+0(sp)
ld s1, SW_S0+8(sp)
ld s2, SW_S0+16(sp)
ld s3, SW_S0+24(sp)
ld s4, SW_S0+32(sp)
ld s5, SW_S0+40(sp)
ld s6, SW_S0+48(sp)
ld s7, SW_S0+56(sp)
ld s8, SW_S0+64(sp)
ld s9, SW_S0+72(sp)
ld s10, SW_S0+80(sp)
ld s11, SW_S0+88(sp)
ld ra, SW_RA(sp)
beqz t1, 2f
fld fs0, SW_FS0+0(sp)
fld fs1, SW_FS0+8(sp)
fld fs2, SW_FS0+16(sp)
fld fs3, SW_FS0+24(sp)
fld fs4, SW_FS0+32(sp)
fld fs5, SW_FS0+40(sp)
fld fs6, SW_FS0+48(sp)
fld fs7, SW_FS0+56(sp)
fld fs8, SW_FS0+64(sp)
fld fs9, SW_FS0+72(sp)
fld fs10, SW_FS0+80(sp)
fld fs11, SW_FS0+88(sp)
2:
addi sp, sp, SW_SIZE
ret
/*
* uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
*
* Builds the synthetic frame above at the top of a fresh native stack, so
* the first ever sk_vm_switch_to() into it lands at `entry` (ra) via its
* final `ret`. Kept in this file (not C) so the layout can never drift
* from sk_vm_switch_to() above -- one set of offsets, not two. FP slots
* are zeroed via plain integer stores (sd, not fsd) -- this is just
* writing zero bytes to memory, not an FP computation, so it needs no
* FS-enabled check at all, unlike every other f-register touch in this
* file.
*/
.hidden sk_vm_switch_prime
.global sk_vm_switch_prime
sk_vm_switch_prime:
addi t0, a0, -SW_SIZE
sd zero, SW_S0+0(t0)
sd zero, SW_S0+8(t0)
sd zero, SW_S0+16(t0)
sd zero, SW_S0+24(t0)
sd zero, SW_S0+32(t0)
sd zero, SW_S0+40(t0)
sd zero, SW_S0+48(t0)
sd zero, SW_S0+56(t0)
sd zero, SW_S0+64(t0)
sd zero, SW_S0+72(t0)
sd zero, SW_S0+80(t0)
sd zero, SW_S0+88(t0)
sd a1, SW_RA(t0)
sd zero, SW_FS0+0(t0)
sd zero, SW_FS0+8(t0)
sd zero, SW_FS0+16(t0)
sd zero, SW_FS0+24(t0)
sd zero, SW_FS0+32(t0)
sd zero, SW_FS0+40(t0)
sd zero, SW_FS0+48(t0)
sd zero, SW_FS0+56(t0)
sd zero, SW_FS0+64(t0)
sd zero, SW_FS0+72(t0)
sd zero, SW_FS0+80(t0)
sd zero, SW_FS0+88(t0)
mv a0, t0
ret