A whole-image unit's body no longer returns 0 whenever a callee did: it goes to one resume block, which -- when ctx::PC is one of its own blocks and the stack is at or below this activation's -- reloads every register and carries on there. A longjmp into it, an exception landing in it, a jump table recovery left unresolved (now a leave rather than a tail call through the table), a return used as a jump, the depth guard of a recursive call: the frame that owns the code catches it and nothing further out unwinds. Correct for any activation (the guest stack holds the real returns); the stack test keeps a deeper activation from taking an outer one's. The bridge samples one nest exit in 4096 (GEX_NT_STATS nest exits), the fallback one call in 1024 (fallback targets), and the stats line counts resume_at trap-outs (resumes=), which discard every wasm frame. docs/function-tier.md §11 describes units on the function table. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01DM9rkwwVPPuSorM4pbUSp2
33 KiB
The function tier: GIR → C → clang → wasm, inside gex modules
Stream S3 of the pewasm merge (docs/merge-plan.md §4, §6 M2, §8). This note
fixes the mechanics before the bulk of the code: how clang-built code shares
gex's memory and tables, how it reads the context, how it keeps tier-1's
memory semantics, and how it leaves and re-enters code it does not own.
Two phases:
- Phase A (blocks). Every tier-1 block becomes one C function with the
uniform
(ctx)signature, chained withmusttail. Same GIR, same write-backs, same safepoints as tier-1; only the code generator differs. It proves the plumbing and prices clang against gex's own emitter on identical input. - Phase B (functions). One C function per recovered guest function (S2's function IR): registers and flags as C locals across blocks, written back only where §4.1 of the merge plan requires.
1. Where the code lives: spliced into the tier-1 module
The function tier does not produce a second kind of module. For a translation unit (an image, or later a closure object) it:
- builds the ordinary tier-1 module from the blocks, exactly as today
(
ModuleBuilder, anyLinkage); - emits C for the same blocks (Phase B: for the functions that own them),
compiles it with
clang --target=wasm32and links it withwasm-ldinto a standalone module whose imports are gex's host functions (gex.syscall, …), the memory (env.memory), and one synthetic function,gexc.dispatch : (i32) -> (); - splices that module into the tier-1 module (
gex-cgen::splice, onwasmparser+wasm-encoder's reencoder, both already in the lockfile):- clang's function types and bodies are appended, with function indices
remapped; its host imports are resolved by
(module, name)against the tier-1 module's own imports, and its memory against memory 0; gexc.dispatchbecomes a local function that does exactly what tier-1'semit_dispatch_tail_calldoes (recordsLAST_TARGET/LAST_SOURCE, two-level directory lookup,br_tableover the eight dispatch tables,return_call_indirect), reading the target fromctx::PC;- each block the C side owns is retargeted: its tier-1 body is replaced by
local.get 0; return_call $c_fn, and the element-segment entries naming it are pointed at$c_fndirectly, so dispatch pays nothing extra. Trampolines (x86-64 shared slots) andenterreach it through the stub.
- clang's function types and bodies are appended, with function indices
remapped; its host imports are resolved by
The result has the same imports, exports, tables, element layout, data
segments and gex.meta as the tier-1 module it came from. So both hosts
install it with no change at all: wasmtime (shims/native, via
gex_xlate::translate_image when Options::cgen is set) and the browser (a
gexc build --cgen module is loaded like any gexc build module). A Shared
module keeps importing the tables and the memory; an Own one keeps defining
them. Blocks the C side does not own (Phase B: interior entries, anything
S2's function IR does not cover) keep their tier-1 bodies in the same module,
which is what "both tiers coexist per address" (merge plan §2) means
concretely.
Alternatives rejected:
- A second module the host instantiates beside the tier-1 one. Every host (wasmtime, the browser shim, the Worker-per-thread instantiation, the demand-fill machinery) would need to learn about it, and every C→tier-1 transfer would be a cross-instance tail call.
- wasm-ld producing the whole gex module. It cannot express gex's element
layout (per-page segments at slots fixed by the
PageTable, passive segments withgexfill*helpers) or its eight tables. - Dispatch written in C (
__builtin_wasm_table_geton__funcreftables). Clang lowers a funcref call through a private table, not areturn_call_indirectinto the dispatch tables. The synthetic import keeps the C side free of table plumbing.
The splice fails loudly (a build error naming the function) if the linked
C module has anything the splice cannot place: a data segment, a global
access (clang's __stack_pointer included, §4), a table of its own, a
call (not return_call) to a C block function or to gexc.dispatch in
Phase A, or an import it cannot resolve (e.g. a memmove libcall).
2. The context, through a header generated from gex-abi
gex-cgen writes gex_abi.h from gex_abi itself (a Rust function, so the
header cannot drift): every ctx::* offset, ctx::SIZE, GUEST_LIMIT,
POISON_ADDR, the dispatch constants, the import signatures with
import_module("gex")/import_name(...) attributes, and the memory
helpers below. A unit test compiles the header and checks a few offsets
through _Static_assert against values printed from Rust.
C functions take int ctx (wasm i32, the same ABI tier-1 and the shims
use) and address fields as *(u64u *)(GEX_CTXP(ctx) + GEX_CTX_PC), where
GEX_CTXP casts to char *. Offsets are constants on a pointer, so they
fold into the access's memarg offset. The flags follow tier-1's layout: one
byte each, four packed into one i32 store when all four are cached.
3. Guest memory: tier-1's semantics exactly
Guest VA is the linear-memory offset, and a C pointer is a linear-memory
offset in wasm32, so a guest access is a plain C access through a pointer
typed __attribute__((aligned(1), may_alias)). Compiled with
-fno-strict-aliasing -fno-delete-null-pointer-checks (guest address 0 is an
address, not UB) and -ffp-contract=off.
The effective address follows tier1::emit_address case by case:
| GIR access | tier-1 | C |
|---|---|---|
| constant address | folded; ≥ GUEST_LIMIT → POISON_ADDR |
the same constant |
base + off, 0 ≤ off ≤ 0xffff |
guard(base), off in the memarg (33-bit sum, cannot wrap) |
(char *)G(base) + off, where G steers to GUEST_LIMIT |
0xffff < off ≤ max_static (AArch64 only) |
as above | e = G(base) + off in 64 bits; e ≥ 2^32 → POISON_ADDR |
| anything else (negative, x86-64 displacements > 64 KiB) | guard(base + off) | G(base + off) |
The one deliberate difference is in the middle row: C steers a bad base to
GUEST_LIMIT rather than POISON_ADDR. With POISON_ADDR, correctness would
depend on clang folding off into the memarg (it does, for inbounds nuw
GEPs, but that is an optimisation and not a promise); an unfolded i32.add
would wrap 0xfffffff0 + 16 to address 0 and read low memory instead of
trapping. From GUEST_LIMIT, off ≤ 0xffff plus any access size stays below
2^32 and above the memory's end, so the access traps whether or not the add is
folded. Both trap; the host sees the same fault. Bulk operations (MemCopy,
MemFill, StrOp, MemCmp) have no static offset and use POISON_ADDR as
tier-1 does, keeping its length clamp bit for bit (the clamp's behaviour on a
poisoned base depends on the constant).
Ordering: plain GIR accesses are plain C accesses — clang may reorder and
merge them exactly as far as V8/Cranelift may reorder tier-1's plain wasm
accesses (guest data races stay races, PLAN §5.8). Order::SeqCst and the
RMW/CAS expressions use __atomic_*/__sync_val_compare_and_swap on
naturally typed pointers, which lower to the same wasm atomics tier-1 emits,
misalignment trap included. Fence is __atomic_thread_fence(SEQ_CST).
MemCopy/MemFill are __builtin_memmove/__builtin_memset, lowered to
memory.copy/memory.fill under -mbulk-memory (a residual libcall is an
unresolved import, so the splice fails loudly rather than miscompiling).
Floating point goes through __builtin_bit_cast; min/max, saturating
truncation, nearest and the SIMD ops use the __builtin_wasm_* builtins and
wasm_simd128.h, which map one-to-one onto the instructions tier-1 emits.
FMA reuses tier-1's exact algorithms (fma.rs): round-to-odd for f32,
guarded Dekker for f64 with the gex.fma import as its fallback. Nothing
is left to libm.
4. clang's shadow stack
Guest memory is the linear memory, and wasm-ld places its stack (and
__stack_pointer's initial value) at low addresses — inside gex's reserved
region, where the thread contexts live. A spill there would corrupt a
context silently. So:
- the emitted C never takes the address of a local, never uses arrays,
structs by value, varargs or
alloca; every value is a scalar or av128_t, and wasm locals are unlimited, so LLVM has no reason to touch the shadow stack; - this is checked on every build: the splice rejects any C function that
reads or writes any global (the only globals
wasm-lddefines are__stack_pointerand TLS bookkeeping) and any module with a data segment, naming the offender. No build can ship a function that uses the region.
Fallback if a future pass needs a real C stack (a large promoted frame, say):
wasm globals are per instance, and gex instantiates the module once per thread
(Worker per thread), so __stack_pointer is already per thread; it only needs
an initial value naming a reserved per-thread region. That region is a
gex-abi change (carved beside each context or below each guest stack) and
goes through the coordinator. Not built; the check above keeps it from being
needed silently.
5. Syscalls and safepoints from C
The C side calls the same imports with the same arguments at the same points as tier-1:
- Syscall (
Term::Syscall,HostFunc::Syscall): write back every dirty cached register, storectx::PC = next, callgex.syscall(ctx, svc_pc), forget every cached register (reload lazily), continue atnext. - Safepoint: the
ctx::SAFEPOINT_COUNTERcountdown on backward direct branches and on every indirect branch, callinggex.safepoint(ctx, pc)when it expires, with the registers written back first. Not read back afterwards, as in tier-1: the host only changes guest state on a path that does not return (it traps out withStopExecution/resume_atand re-enters throughenter). - Other clobbering host calls (
illegal,breakpoint,sysreg_write) the same way;cache_op,sysreg_readandfmado not flush.
In Phase A no C frame ever nests (every transfer is a tail call, which the splice verifies), so fork, execve, thread migration and signal delivery see exactly tier-1's flat stack.
In Phase B C frames nest (a guest call is a wasm call). The host's existing
escape — trap out of the whole wasm stack and resume from ctx — is then
correct provided every frame's state is architectural at every point the
host can act on. Phase B guarantees that without landing pads. Registers
and flags are C locals across the whole function (loaded at entry, reloaded
after every call and clobbering host call), and write-backs follow one
invariant.
5.1 The write-back invariant
At every host-visible point — a syscall, a safepoint, a clobbering host call, a trap, a return to dispatch — all live architectural state is exact in the context. Flags that no continuation reads are unspecified, exactly as tier-1's dead-flag elimination already allows.
Concretely (func.rs):
- Only registers that may differ from the context are stored (clean-spill
elision:
dirty_in, a forward may-analysis; a call, syscall or clobbering host call makes everything clean again, since everything is reloaded). - Every exit that leaves the function (return to dispatch, tail call,
syscall, unknown or indirect call, unresolved jump, switch default) and
every safepoint write back every dirty register, flags excepted: the
flags are written only where they may be read (
live_in, a backward liveness over the function; at a safepoint, the loop head's live set). - A call to a function-tier callee writes back every dirty non-flag
register, plus the flags the callee's summary reads or the continuation
reads — unless the callee is trap-free (
trap_free): S2's summary says it cannot unwind, leave pending or reach unknown code, and it has no syscall, no safepoint (no backward edge, no tail call), no clobbering host call, no exit from the tier, no indirect call and no recursion, and calls only trap-free functions. Nothing the host observes or resumes from can happen before a trap-free callee returns, so its callers store only what it reads plus what their continuation reads. 15% of the known call sites of the Tier 0 kernels (AArch64) qualify, 19% of the functions. - A summarised callee's
preservedregisters are not reloaded after it returns as expected. - A guest
retthat goes where the caller expects writes back every dirty non-flag register, and the flags only if the caller's continuation reads one (§7, predicted returns).
The one theoretical exposure: a signal handler reading its ucontext, or an
NT CONTEXT capture, can see stale dead flags. Tier-1 shows the same today.
Validation (crates/gex-cgen/tools/lockstep-gate, part of Gate B and of
every later pass's gate) compares against tier-1 syscall by syscall twice:
(a) the default build with the flags left out (lockstep --ignore-flags),
and (b) GEX_CGEN_LIVE=0, which writes every flag back at every exit,
compared exactly. A divergence in (a), or in a non-flag register in (b), is a
bug. GEX_CGEN_MUTATE=IGNOREUNWIND (every summarised callee treated as
trap-free) and =NORETCHECK (every return treated as expected) are the
mutations that show the rules are needed.
Landing pads (S7) become necessary exactly when a pass keeps live state in
locals across a call that is not trap-free (callee-saved registers, promoted
stack slots); the may_unwind summaries then decide where.
6. Tail calls
Every function the C side registers or chains to has the C type
void f(int ctx), i.e. wasm (i32) -> (), identical to tier-1's block type,
so __attribute__((musttail)) is always legal (clang requires identical
signatures) and lowers to return_call under -mtail-call. Unknown or
computed targets store ctx::PC and musttail into gexc_dispatch(ctx),
which the splice turns into the dispatch function of §1. (Phase B's bodies
share one internal signature of their own, int f(ctx, depth, need, ret),
so musttail between them is equally legal; only F_entry has the uniform
one.) Observed with clang
22: a cycle of musttail among functions of one translation unit becomes a
loop after inlining, so Phase A already gets some loop formation for free (the
context traffic stays, since the stores are to memory).
7. Calls into code the function tier does not own
The proposed rule: "a call or jump to non-function-tier code writes back all state and tail-calls into dispatch, so the caller's continuation runs in tier-1". The continuation-in-tier-1 half is right; the tail-call half breaks once frames nest.
Take H (C) calling F (C) with a wasm call, and F calling unknown G. If F tail-calls dispatch, F's frame becomes G's tier-1 chain, but H's frame (and the root below it) stays on the wasm stack. G returns into F's interior (tier-1), F returns into H's interior (tier-1), and the program carries on in tier-1 with H's frame stranded underneath; tier-1 chains never return, so it is never popped. If the loop that calls F now runs in tier-1, every iteration enters F through dispatch, nests, calls G and strands another frame: unbounded wasm stack growth. So the rule is:
Leaving the function tier unwinds the C nest by returning. The leaving function writes back all state, sets
ctx::PCto the target, and returns. Every C call site checks after the call (the §4.2 return check:ctx::PCis the return address the caller set up,SPis back where it was); on a mismatch it returns too, with nothing to write back (under §5's baseline scheme it wrote everything back before the call and has executed nothing since). The nest root then tail-calls dispatch onctx::PC.
Each function-tier function therefore has two forms:
int F_body(ctx, depth, need, ret): the code. C callers call it with a wasm call.retis the return address the caller set up (in x30, or the slot the x86-64callpushed);needsays whether the caller's continuation reads a flag;depthis §7.1's.F_entry(ctx): registered in the dispatch table with the uniform signature (§4.1.2).F_body(ctx, 0, all, ~0); musttail gexc_dispatch(ctx);— it is the nest root: whatever madeF_bodyreturn (a real return,longjmp, an exit to tier-1),ctx::PCsays where to go next, and the root polls and goes there flat.
Predicted returns. The return check lives in the callee, which knows
both halves of it: a guest ret whose target is ret with SP back at its
entry value (plus the popped slot on x86-64) is the return the caller
expects. The callee writes back every dirty non-flag register (and the flags
if need asks) and returns 1: the caller carries on without reading the
context. Any other exit — an unexpected return target, a tail call or jump to
code the tier does not own, a switch default, an unresolved jump, the depth
guard — writes back everything, sets ctx::PC, and returns 0; every C
caller then returns 0 too, with nothing to write back, and the root
dispatches on ctx::PC. A return stores no PC and does not poll: every
dynamic cycle still passes a loop back-edge, a known tail call (which polls)
or the root (which polls).
This is pewasm's pending-jump trampoline (recomp_jmp_pending +
recomp_run_pending) with the pending flag replaced by the callee's
prediction, and it is §4.2's return check, moved to where it is cheapest:
one compare in the callee, one test of the result in the caller. It handles
longjmp (the ret into setjmp's frame is not the one expected, so every
frame on the way down returns 0 and the root resumes there in tier-1), ret
used as a computed jump, and coroutine stack switches the same way.
Guest tail calls (b G to another function's entry) are C musttail
G_body(ctx, depth, need, ret): G returns to F's caller with F's
prediction. Indirect calls (blr) compare the target against the module's
function entries (a switch, lowered to compares or a br_table) and call
the matching _body; anything else leaves as above. That is the slow path
pewasm's inline dispatch-cache probe and profile-guided devirtualization
later speed up; it is priced before being optimised.
Alternatives considered: tail-calling dispatch in place (strands frames, as
above); a nested wasm call into tier-1 that waits for it to return (tier-1
never returns); a Wasm exception to unwind the nest (zero cost on the normal
path, but the return check is needed anyway for longjmp, and C cannot catch
Wasm exceptions without C++ EH — S7 measures exceptions against the explicit
flag for its landing pads, and may switch this path over if it wins).
7.1 The recursion-depth guard
Nesting follows guest call depth inside the function tier, so without a
bound deep guest recursion would be limited by the engine's stack rather than
the guest's 8 MiB: 100,000 frames of the soundness corpus's deep_recursion
exhaust wasmtime (max_wasm_stack, 512 KiB: ~5,000 frames of that function)
and V8 (~984 KiB: "Maximum call stack size exceeded" at ~5,500). So the
nest's stack use is bounded, with the same leave-by-returning mechanism:
F_body'sdepthparameter is an estimate, in bytes, of the engine stack the C frames below it in this nest use. The root passes 0; a call passesdepth + W(F), whereW(F)is the caller's frame estimate (64 + 3 ×the bytes of the registers it keeps in locals; clang's wasm locals measured up to ~2.5× those bytes on coremark, and the rest covers the engine's spill slots and frame header). A guest tail call (musttail) passesdepthunchanged: the frame is replaced. An indirect call through the entryswitchadds that helper's frame too.F_body's first statement, before any register is loaded: ifdepth > BUDGET − RESERVE − W(F), setctx::PC = Fand return 0. Every caller of a function with a guard wrote everything back before the call (§5.1: a function with a guard is not trap-free), so this is exactly "the caller left for F" (§7): the caller returns 0, and so does every frame of the nest; the root dispatches onctx::PC = F, which entersF_entryand starts a new nest at depth 0. The frames that were unwound finish in tier-1 when their callees return into them (their return addresses are real guest state).- It needs no context field and no
gex-abichange: the depth lives in a wasm parameter, so it is per nest by construction. The StopExecution unwind discards it with the rest of the wasm stack, and a nest the host starts afresh (the root afterresume_at, a nested guest loop) starts at zero, with its own budget on top of whatever stack its caller used. - Trap-free functions (§5.1) have no guard: their callers need not have
written everything back, so they must not leave. They are not recursive,
and a trap-free function's whole subtree must fit in
RESERVE(64 KiB of estimate), which every guarded function keeps free below the budget. BUDGETis 384 KiB of estimate (GEX_CGEN_STACK_BUDGEToverrides it). The estimate is conservative against both engines:deep_recursion's function (AArch64) is estimated at 304 bytes and uses ~100 (wasmtime) and ~180 (V8), so a nest holds ~1,290 of its frames where either engine holds ~5,000. Cost: one compare on entry, one add per call; no memory traffic.GEX_CGEN_STACK_BUDGET=<huge>is the mutation:deep_recursionthen exhausts the engine's stack again.
Recursion deeper than the budget therefore runs as a sequence of nests of bounded depth, each unwound by returning, with the unwound frames' remaining work in tier-1.
7.2 Loops are outlined: there is no OSR for wasm
V8 (and SpiderMonkey) never move a running wasm frame onto optimised code:
dynamic tiering compiles a hot function with TurboFan in the background and
installs it for the next call. A loop that runs for the whole of one call
of a function entered once — a main with the kernel inlined into it, a
game's main loop — stays in Liftoff for the whole run. Measured on gx64
array (kernel-us): tier-1 244 ms, function tier 316 ms with the default
flags; 135 ms and 100 ms with --no-liftoff. ga64 matmul: 389 ms against
191 ms. (Tier-1 escapes it partly: its loops are separate functions that are
re-entered, though each still runs a whole loop in one call.)
So every outermost natural loop also gets a function of its own,
static __attribute__((noinline)) int l_<F>_<k>(ctx, depth, need, ret, sp0),
over the same registers (loaded on entry). The body keeps its inline copy of
the loop, and the first 1024 trips run there; only when the poll countdown
expires at the loop's back-edge does the body hand over:
<write back every dirty register>; safepoint;
do r = l_F_k(ctx, depth + W(F), need, ret, sp0); while (r == LOOP_CHUNK);
if (r <= 1) return r; /* returned (1) or left (0), as F would */
<reload>; switch (r) { case LOOP_EXIT + b: goto L<b>; ... }
Short loops in frequently called functions therefore pay nothing (an eager version that always called the loop function cost fib 1.32x and gx64 ack 1.89x on CPU time), and long ones get re-entry.
- The loop's own back-edge ends a chunk when the poll countdown expires:
everything written back, the safepoint as usual,
return LOOP_CHUNK. Every 1024 back-edges is therefore a fresh call, which picks up optimised code once the engine has it; the cost is one write-back and one reload per chunk. - Leaving the loop writes back every dirty register and returns
LOOP_EXIT + b, wherebis the block the body resumes at. - A guest
retinside the loop is the function's predicted return (§7): the loop function hasret,needand the entry SP, and returns 1 or 0 exactly as the body would. Every other exit (tier leave, depth guard in a callee) returns 0 through it. - A guest tail call inside a loop is an ordinary call there (a loop
function's signature is not the bodies', so no
musttail). - Inner loops stay inside their outer loop's function, polling as before.
- Functions with irreducible control flow (a backward edge whose target does not dominate its source) are not outlined: outlining needs the header to be the only way into the loop.
GEX_CGEN_OUTLINE=0turns it off.
8. Build and flags
gexc build --cgen …andgexc run --cgen …setgex_xlate::Options::cgen(part of the cache key, sinceOptionsis hashed throughDebug).translate_imagethen routes throughgex-cgen. Runtime translation stays tier-1.- Toolchain:
$GEX_CLANG/$GEX_WASM_LDorclang/wasm-ldonPATH, clang ≥ 20 for wasm32. Missing tools are a build error, never a silent tier-1 fallback. clang --target=wasm32 -O2 -mtail-call -msimd128 -matomics -mbulk-memory -mnontrapping-fptoint -msign-ext -fno-strict-aliasing -fno-delete-null-pointer-checks -ffp-contract=off -ffreestanding -nostdlib, one translation unit per slice of the block list so clang runs in parallel;wasm-ld --no-entry --import-memory --shared-memory --allow-undefined --export=<every C entry>. Content-addressed object caching (pewasm'sbuild.py) comes later.GEX_CGEN_KEEP=<dir>keeps the generated C, objects and the linked module for inspection.
9. Not handled yet (Phase B, before S7)
- Landing pads and
gex_unwind(S7). The write-back invariant (§5.1) does not need them, only passes that keep live state in locals across calls that are not trap-free. - Interior entries, undiscovered code, runtime-modified code: tier-1 (the splice leaves those blocks' tier-1 bodies in place).
- Invalidation when a function's pages change (the x86-64
guardbytes): a guarded block is not given to the function tier.
10. Where Phase B's functions come from
translate_image runs S2's recovery (gex_xlate::func::recover) and its
call-graph summaries (summary::analyze) on the image and hands gex-cgen the
memory-form functions and the summaries. Jump tables are in-function
switches (their default leaves). Functions whose entry tier-1 serves with a
host builtin (intrinsic: memcpy and friends) are re-formed from that
tier-1 block, so C callers get the builtin. A function sharing a page with a
block that has a self-modification guard stays tier-1. Only entries tier-1
also has a block for are registered in dispatch; the others are reached by C
calls only. No ELF bytes reach translate_image, so .eh_frame does not
seed recovery there yet (symbols, relocations and call targets do).
GEX_CGEN_RECOVER=standin keeps the earlier stand-in (recover.rs: symbols
and call targets, no jump tables), for comparison. GEX_CGEN_STATS=1 prints
what the emitted functions do at their exits.
Profiling (GEX_PROFILE=1, gexc run --record) and the function tier are
refused together, loudly: the C code carries no profiling hooks.
11. Whole images under the NT layer (PE, M7)
Wine's loader maps every image of a Windows program itself, so its code
arrives through the runtime translator. With gexc run --cgen on a PE
program, every image the NT layer maps also gets a function-tier unit:
Wine's DLLs (compiled when the prefix is staged) and the program's own
images alike. Dispatch and tier-1 are then the fallback for code generated at
run time (LuaJIT) and code recovery missed, not the boundary between images.
- Unit. The whole image's recovered functions (S2's
recover_pe), as C, compiled and linked once into a module of its own (gex_cgen::image, formatgex-cgen-image 3), position-independent (-fPIC -fvisibility=hidden,wasm-ld --experimental-pic -shared; docs/merge-plan.md §9.8.1). Every function is oneF_body,f_<entry>; there are no roots and no exports per function (wasm caps a module at 100,000 exports; noita.exe has 100,147 functions). Each translation unit takes its bodies' addresses (an exportedgex_refs_<first>, never called), so wasm-ld puts every body in the one element segment that fills the process's function table from the imported__table_base; the build reads which body each slot holds from the element segment and the name section and appends that as thegex.funcssection (and beside the module asc.entries). Functions clang gives a shadow-stack frame are stubs that re-enter the table at their own address, which the host serves with something else. - Calls. Inside the image a call is a wasm call to the callee's body.
Anything else --
call [IAT]into another image, a vtable, a callback, a direct call to code the image does not hold -- stores its target inctx::LAST_TARGET, probes the process's probe cache (gex_ftab_idx: one load at a constant address for a constant target) andcall_indirects the function table: a function pointer in a position-independent module is a table index. A miss is index 0, the resolve stub, which asks the host (gex.lookup). Jumps out of a function (tail calls, switch defaults, jumps recovery left unresolved) are tail calls through the table under the function's ownneedandret, so whatever runs there returns where the function would have. - Installed at map time. The NT layer calls a hook after mapping an image
(
gex_kernel::nt::section::set_image_mapped_hook); the host compares the image's executable bytes in guest memory with the unit's and refuses loudly on a mismatch, reserves a range of the function table for the unit's element segment, maps each entry to its slot, and publishes it (shims/native/src/ftab.rspublish); each store instantiates it with__table_baseat that range as it replays the table's log. Tier-1 still runs what no unit holds; its dispatch slot for every entry holds the runtime module'sbridge(gex_cgen::runtime), which calls the function with no predicted return and dispatches on what it leaves inctx::PC(an x86 slot covers four bytes: a neighbour that no function serves is a slot conflict, handed to tier-1's miss path). Code no unit holds, called from a unit, is the table's index 1: tier-1, run nested until the guest returns to the caller (the call-out). - Code rewritten after install (Detours patching noita.exe's prologues):
the host sets the page's bit in
gex_abi::PATCHED_PAGESwhen a retired page no longer hashes to what its unit was built from (nt_cgen::on_retire), before it clears the probe cache entries there. Every function that is not trap-free tests its own entry page on entry and, when set, does the call sequence above to its own address: the lookup sees the bit, retires the unit's slot and serves the new bytes (tier-1 now; the function JIT or the region tier later), and the probe finds that from then on. A trap-free function's callers test it instead, where they can write everything back first. tests/nt/detour.c is the case (GEX_CGEN_MUTATE=NOPATCHCHECKis the mutation). Tier-1's own direct jumps check their page's directory entry against the one they were translated for (gex-emitemit_page_check). - Keyed on the image as placed.
nt_cgen::image_keyhashes the format, the sources that decide a unit (gex_cgen::image::INPUTS) and the image's relocated segments at the base it was mapped at. A unit is built for the base the image is actually mapped at. - Wine's DLLs at fixed bases. 616 of Wine 11.15's 834 i386 DLLs prefer
0x1000_0000 (the x64 ones 0x1_8000_0000, above the guest), so the first-free
placement made a DLL's base follow each program's load order.
gexc cgen-prefix(run by tools/nt-stage-wine.sh) writesPREFIX/gex-layout-- a fixed base for each core DLL (tools/nt-cgen-core.txt: what tests/nt and Noita map), Wine's own unique bases kept, the rest packed below them -- and builds each DLL's unit at its base. The process reads it once at boot, refuses a stale one, reserves its architecture's ranges (find_freeavoids them while anything else fits), and places a core DLL at its base when the range is free; one that is not is said under GEX_TRACE and counted (layout_miss=on GEX_NT_STATS). - Two stages, bounded memory. Stage 1 recovers the image and writes its
functions in GIR's text form plus a line of summary per function
(
write_recovery). Stage 2 reads them back one at a time, emits C in units of ~512 KiB, compiles each with a precompiledgex_abi.hand at mostjobsclangs, caching objects by the hash of the unit, the header, the flags and clang's version, and links (build_c_module). Functions clang gives a shadow-stack frame become stubs that leave for tier-1. Stage 1's memory is recovery's (10 GB on noita.exe). - Built ahead of time, or for the next run.
gexc cgen-image <image> --pe-base Bandgexc cgen-prefixbuild ahead of time. An image mapped with no unit runs on tier-1 while a capped child (systemd-run -p MemoryMax=$GEX_CGEN_MEMORY_MAX, default 12G) builds one.
Next step, not built: base-independent units. A unit's C holds absolute
guest addresses (entries, branch targets, relocated data addresses), so it is
built per base, and an app image whose base follows its load order needs a
build per order. With the image's .reloc information the lifter knows which
immediates are relocated; emitting those as base + offset, with base a
parameter the host supplies at install (a wasm global, or a constant folded
at link time per base from one object set), would let one build serve any
base, and make the staged layout a performance choice rather than a
correctness one.