diff --git a/crates/synth-synthesis/src/instruction_selector.rs b/crates/synth-synthesis/src/instruction_selector.rs index 35491d98..3d735cfd 100644 --- a/crates/synth-synthesis/src/instruction_selector.rs +++ b/crates/synth-synthesis/src/instruction_selector.rs @@ -183,6 +183,14 @@ struct SpillState { /// exhaustion `Err` — so every function that compiles without it keeps /// byte-identical output by construction. spill_on_exhaustion: bool, + /// Whether `compute_local_layout` actually reserved the spill area this + /// state hands slots out of (`has_i64 || force_spill_area`). When it did + /// NOT (an i32-only first pass), `base` is just the end of the frame and + /// any slot handed out would alias the #204 param-backing slots — callers + /// that can need a slot outside the i64 paths (the #326 arg-cycle + /// resolver) must check this and fail with the ladder-recoverable + /// exhaustion `Err` instead. + area_reserved: bool, } impl SpillState { @@ -191,6 +199,7 @@ impl SpillState { base, used: [false; I64_SPILL_SLOTS], spill_on_exhaustion: false, + area_reserved: true, } } /// Reserve a free slot, returning its byte offset from SP. @@ -679,66 +688,6 @@ fn is_caller_saved(reg: Reg) -> bool { /// the scope note on `marshal_call_args`). const ARG_REGS: [Reg; 4] = [Reg::R0, Reg::R1, Reg::R2, Reg::R3]; -/// Emit a cycle-safe parallel register move: for each `(src, dst)` pair, move -/// `src` into `dst`, even when the moves form chains or cycles (e.g. moving -/// `R1 -> R0` and `R0 -> R1` simultaneously). This is needed for AAPCS argument -/// marshalling because an argument's source register may itself be another -/// argument's destination. -/// -/// Algorithm: repeatedly emit any move whose destination is not (still) needed -/// as a source by a pending move. When only cycles remain, break one by routing -/// a single source through a scratch register, then continue. Self-moves -/// (`src == dst`) are skipped. -/// -/// `scratch` must be a register not used as any source or destination here. -fn emit_parallel_move( - instructions: &mut Vec, - moves: &[(Reg, Reg)], - scratch: Reg, - idx: usize, -) { - // Pending moves still to be performed (skip trivial self-moves). - let mut pending: Vec<(Reg, Reg)> = moves - .iter() - .copied() - .filter(|(src, dst)| src != dst) - .collect(); - - let emit_mov = |instructions: &mut Vec, dst: Reg, src: Reg| { - instructions.push(ArmInstruction { - op: ArmOp::Mov { - rd: dst, - op2: Operand2::Reg(src), - }, - source_line: Some(idx), - }); - }; - - while !pending.is_empty() { - // Find a move whose destination is not the source of any other pending - // move — safe to emit now without clobbering a value still needed. - if let Some(pos) = pending - .iter() - .position(|&(_, dst)| !pending.iter().any(|&(src2, _)| src2 == dst)) - { - let (src, dst) = pending.remove(pos); - emit_mov(instructions, dst, src); - } else { - // Only cycles remain: break one by parking its source in scratch. - // Rewrite every move that reads that source to read scratch instead. - let (cycle_src, cycle_dst) = pending[0]; - emit_mov(instructions, scratch, cycle_src); - emit_mov(instructions, cycle_dst, scratch); - pending.remove(0); - for m in pending.iter_mut() { - if m.0 == cycle_src { - m.0 = scratch; - } - } - } - } -} - /// Given the low register of an i64 register pair, return the high register. /// /// Convention: i64 values on 32-bit ARM use two consecutive registers. @@ -793,6 +742,10 @@ struct LocalLayout { /// never clobbered in its home reg between reads. Appended last so existing /// local/spill offsets are unchanged. param_slots: std::collections::HashMap, + /// Whether the `i64_spill_base` area was actually reserved + /// (`has_i64 || force_spill_area`). Mirrored into + /// [`SpillState::area_reserved`]; see that field for why (#326). + spill_area_reserved: bool, } /// Compute the stack-frame layout for non-parameter locals in a function. @@ -937,6 +890,7 @@ fn compute_local_layout( spill_base, i64_spill_base, param_slots, + spill_area_reserved: has_i64, } } @@ -4761,36 +4715,101 @@ impl InstructionSelector { stack: &[Reg], local_to_reg: &std::collections::HashMap, layout: &LocalLayout, + spill: &mut SpillState, idx: usize, ) -> Result { + use crate::parallel_move::{MoveStep, sequentialize}; if arg_srcs.is_empty() { return Ok(0); } + // The resolver takes (dst, src) pairs; arg `i` goes to ARG_REGS[i]. let moves: Vec<(Reg, Reg)> = arg_srcs .iter() .enumerate() - .map(|(i, &src)| (src, ARG_REGS[i])) + .map(|(i, &src)| (ARG_REGS[i], src)) .collect(); - // Scratch for cycle-breaking: a callee-saved register holding no live - // value. `emit_parallel_move` uses it ONLY to break a true cycle, so it - // is needed only when some move source is also a destination. Acquiring - // it lazily avoids spurious exhaustion under i64 pressure (#171): a - // single acyclic arg move needs no scratch even when R4–R8 are full. - let dests: Vec = moves.iter().map(|&(_, d)| d).collect(); - // A scratch is needed only for a genuine cycle: a NON-self move whose - // source is also some move's destination. Self-moves (src == dst) and - // acyclic chains never use it. - let needs_scratch = moves + // Scratch candidate for cycle-breaking: a callee-saved register holding + // no live value, when one exists — the resolver uses it ONLY to break a + // true cycle, and filters it against the move set itself (an arg source + // parked in a callee-saved register is invisible to `free_callee_saved` + // because the args were already popped). When NONE is free (#326: the + // #311 i64 result pairs legitimately pin R4–R8), the resolver breaks + // the cycle through ONE stack slot instead of hard-failing — this is + // exactly the exhaustion gale's dissolved `z_impl_k_mutex_unlock` hit. + let scratch: Vec = self + .free_callee_saved(stack, local_to_reg, layout) + .ok() + .into_iter() + .collect(); + let steps = sequentialize(&moves, &scratch).map_err(|e| { + synth_core::Error::synthesis(format!( + "call-arg marshalling produced an invalid parallel move set: {e} \ + (compiler bug: ARG_REGS destinations are distinct by construction)" + )) + })?; + + // A stack-slot scratch is needed only when a cycle exists AND no + // callee-saved register was free. Reserve one resolver slot from the + // spill area for the duration of the sequence. + let needs_slot = steps .iter() - .any(|&(src, dst)| src != dst && dests.contains(&src)); - let scratch = if needs_scratch { - self.free_callee_saved(stack, local_to_reg, layout)? + .any(|s| matches!(s, MoveStep::SpillScratch { .. })); + let slot = if needs_slot { + if !spill.area_reserved { + // i32-only first pass: the spill area does not exist, so a slot + // would alias the param-backing frame slots. Fail with the + // ladder-recoverable exhaustion Err — the backend retry + // (VCR-RA-001 3b-lite) re-runs with the area reserved and the + // resolver then breaks the cycle through it. + return Err(synth_core::Error::synthesis( + "register exhaustion: all allocatable registers are live on the stack — \ + arg-move cycle needs a resolver spill slot but no spill area is reserved" + .to_string(), + )); + } + Some(spill.alloc().ok_or_else(|| { + synth_core::Error::synthesis( + "register exhaustion: i64 spill-slot pool exhausted while \ + breaking a call-arg move cycle — function too complex" + .to_string(), + ) + })?) } else { - // Unused for acyclic moves; pass a harmless caller-saved register. - Reg::R12 + None }; + let before = instructions.len(); - emit_parallel_move(instructions, &moves, scratch, idx); + for step in &steps { + let op = match *step { + MoveStep::Move { dst, src } => ArmOp::Mov { + rd: dst, + op2: Operand2::Reg(src), + }, + MoveStep::SpillScratch { slot_store } => ArmOp::Str { + rd: slot_store, + addr: MemAddr::imm( + Reg::SP, + slot.expect("SpillScratch implies a reserved resolver slot"), + ), + }, + MoveStep::ReloadScratch { slot_load_into } => ArmOp::Ldr { + rd: slot_load_into, + addr: MemAddr::imm( + Reg::SP, + slot.expect("ReloadScratch implies a reserved resolver slot"), + ), + }, + }; + instructions.push(ArmInstruction { + op, + source_line: Some(idx), + }); + } + // The marshal sequence is self-contained: the slot is dead once the + // last reload ran, so release it for reuse by later spills. + if let Some(off) = slot { + spill.free(off); + } Ok(instructions.len() - before) } @@ -5048,6 +5067,7 @@ impl InstructionSelector { // i64 register-pair spill slots (#171), reused across the function. let mut spill = SpillState::new(layout.i64_spill_base); spill.spill_on_exhaustion = self.spill_on_exhaustion; + spill.area_reserved = layout.spill_area_reserved; // Next available register for temporaries (start after params) let mut next_temp = num_params.min(4) as u8; @@ -7173,6 +7193,7 @@ impl InstructionSelector { &stack_live_regs(&stack), &local_to_reg, &layout, + &mut spill, idx, )?; for _ in 0..n_arg_moves { @@ -7308,6 +7329,7 @@ impl InstructionSelector { &stack_live_regs(&stack), &local_to_reg, &layout, + &mut spill, idx, )?; for _ in 0..n_arg_moves { @@ -15329,6 +15351,167 @@ mod tests { assert_eq!(spill.alloc(), Some(0), "slot 0 reusable after reload"); } + // ── #326: arg-move cycles break via the parallel-move resolver ── + + /// Minimal layout for driving `emit_arg_moves` directly. + fn arg_move_layout(spill_area_reserved: bool) -> LocalLayout { + LocalLayout { + locals: std::collections::HashMap::new(), + frame_size: 0, + spill_base: None, + i64_spill_base: 0, + param_slots: std::collections::HashMap::new(), + spill_area_reserved, + } + } + + /// gale's #326 shape: a genuine arg cycle (swap into R0/R1) while every + /// callee-saved register R4–R8 is pinned by live operand-stack values + /// (`free_callee_saved` sees [R4, R6, R8] as i64-conservative, pinning + /// R5/R7 as implicit pair-his too). The old path `Err`ed here ("no free + /// callee-saved register..."); the resolver must break the cycle through + /// ONE stack slot: `STR R0; MOV R0, R1; LDR R1`. + #[test] + fn arg_move_cycle_with_saturated_callee_saved_spills_not_errs_326() { + let selector = fresh_selector(); + let mut instructions: Vec = Vec::new(); + let mut spill = SpillState::new(40); // resolver slot at [SP, #40] + let live = [Reg::R4, Reg::R6, Reg::R8]; // pins R4..R8 (pair-his) + let n = selector + .emit_arg_moves( + &mut instructions, + &[Reg::R1, Reg::R0], // arg0 in R1, arg1 in R0 — a swap + &live, + &std::collections::HashMap::new(), + &arg_move_layout(true), + &mut spill, + 3, + ) + .expect("#326: saturated callee-saved must not be an Err anymore"); + assert_eq!(n, 3, "swap via stack slot is exactly STR + MOV + LDR"); + match ( + &instructions[0].op, + &instructions[1].op, + &instructions[2].op, + ) { + ( + ArmOp::Str { rd: s, addr: sa }, + ArmOp::Mov { + rd: m, + op2: Operand2::Reg(msrc), + }, + ArmOp::Ldr { rd: l, addr: la }, + ) => { + assert_eq!((*s, sa.clone()), (Reg::R0, MemAddr::imm(Reg::SP, 40))); + assert_eq!((*m, *msrc), (Reg::R0, Reg::R1)); + assert_eq!((*l, la.clone()), (Reg::R1, MemAddr::imm(Reg::SP, 40))); + } + other => panic!("expected STR/MOV/LDR swap sequence, got {other:?}"), + } + // The resolver slot was released after the sequence. + assert_eq!(spill.alloc(), Some(40), "resolver slot freed for reuse"); + } + + /// With a free callee-saved register the cycle still goes through the + /// register (no stack traffic): `MOV R4, R0; MOV R0, R1; MOV R1, R4`. + #[test] + fn arg_move_cycle_with_free_callee_saved_uses_register_326() { + let selector = fresh_selector(); + let mut instructions: Vec = Vec::new(); + let mut spill = SpillState::new(0); + let n = selector + .emit_arg_moves( + &mut instructions, + &[Reg::R1, Reg::R0], + &[], + &std::collections::HashMap::new(), + &arg_move_layout(true), + &mut spill, + 3, + ) + .unwrap(); + assert_eq!(n, 3); + let movs: Vec<(Reg, Reg)> = instructions + .iter() + .map(|i| match &i.op { + ArmOp::Mov { + rd, + op2: Operand2::Reg(src), + } => (*rd, *src), + other => panic!("register cycle must be pure MOVs, got {other:?}"), + }) + .collect(); + assert_eq!( + movs, + vec![(Reg::R4, Reg::R0), (Reg::R0, Reg::R1), (Reg::R1, Reg::R4)] + ); + // No stack slot was touched. + assert_eq!(spill.alloc(), Some(0), "no resolver slot consumed"); + } + + /// Acyclic marshals never need scratch — even fully saturated, they are + /// plain MOVs in ascending destination order (byte-identity with the + /// legacy emitter). + #[test] + fn arg_move_acyclic_saturated_is_plain_movs_326() { + let selector = fresh_selector(); + let mut instructions: Vec = Vec::new(); + let mut spill = SpillState::new(0); + let live = [Reg::R4, Reg::R6, Reg::R8]; + let n = selector + .emit_arg_moves( + &mut instructions, + &[Reg::R2, Reg::R3], + &live, + &std::collections::HashMap::new(), + &arg_move_layout(true), + &mut spill, + 0, + ) + .unwrap(); + assert_eq!(n, 2); + let movs: Vec<(Reg, Reg)> = instructions + .iter() + .map(|i| match &i.op { + ArmOp::Mov { + rd, + op2: Operand2::Reg(src), + } => (*rd, *src), + other => panic!("expected MOV, got {other:?}"), + }) + .collect(); + assert_eq!(movs, vec![(Reg::R0, Reg::R2), (Reg::R1, Reg::R3)]); + } + + /// An i32-only first pass has NO spill area: a cycle that needs the slot + /// must fail with the LADDER-RECOVERABLE exhaustion `Err` (the backend + /// retry re-runs with the area reserved), not silently alias the frame. + #[test] + fn arg_move_cycle_without_spill_area_errs_ladder_recoverable_326() { + let selector = fresh_selector(); + let mut instructions: Vec = Vec::new(); + let mut spill = SpillState::new(0); + spill.area_reserved = false; + let live = [Reg::R4, Reg::R6, Reg::R8]; + let err = selector + .emit_arg_moves( + &mut instructions, + &[Reg::R1, Reg::R0], + &live, + &std::collections::HashMap::new(), + &arg_move_layout(false), + &mut spill, + 0, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("all allocatable registers are live on the stack"), + "must be the retry-ladder exhaustion class, got: {err}" + ); + assert!(instructions.is_empty(), "no partial sequence on Err"); + } + /// End-to-end fail-then-retry contract (the backend's wrapper): the /// high-pressure body (10 simultaneously-live i32 consts + 2 reserved /// params = > 9-reg pool) hard-fails by default with the exhaustion `Err`, diff --git a/crates/synth-synthesis/src/parallel_move.rs b/crates/synth-synthesis/src/parallel_move.rs index 1543e3e7..b5681157 100644 --- a/crates/synth-synthesis/src/parallel_move.rs +++ b/crates/synth-synthesis/src/parallel_move.rs @@ -32,8 +32,13 @@ //! shrank after every cycle. The emitted sequence is bounded by //! `moves.len() + 2 * cycle_count` steps, asserted before returning. //! -//! Nothing here is wired into codegen yet: it is a pure function over -//! [`Reg`], so it cannot change emitted bytes. +//! **Consumers.** `InstructionSelector::emit_arg_moves` (#326) lowers the +//! returned steps when marshalling call arguments into R0–R3: `Move` becomes +//! `MOV`, the spill/reload pair becomes `STR`/`LDR` against one reserved +//! resolver stack slot. Phase 1 emits the lowest-destination ready move first +//! — exactly the order the legacy `emit_parallel_move` produced for the +//! ascending-destination arg-move lists — so wiring the resolver in keeps +//! every acyclic marshal byte-identical. use crate::rules::Reg; use std::collections::{BTreeMap, BTreeSet}; @@ -121,12 +126,17 @@ pub fn sequentialize( // exactly one pending move; each destination enters the worklist at most // once (initially, or on the single transition of its read-count to 0), // so this loop runs at most `pending.len()` times by construction. - let mut ready: Vec = pending + // + // The worklist is an ordered set popped lowest-destination-first: for an + // ascending-destination move list (the arg-marshal shape) this selects + // exactly the move the legacy `emit_parallel_move` scan picked, keeping + // the emitted `MOV` order — and therefore bytes — identical (#326). + let mut ready: BTreeSet = pending .keys() .filter(|dst| src_reads.get(dst).copied().unwrap_or(0) == 0) .copied() .collect(); - while let Some(dst) = ready.pop() { + while let Some(dst) = ready.pop_first() { let src = pending .remove(&dst) .expect("worklist invariant: every ready destination has a pending move"); @@ -138,7 +148,7 @@ pub fn sequentialize( if *reads == 0 && pending.contains_key(&src) { // Emitting `dst <- src` freed `src`: the move writing `src` is // now safe. - ready.push(src); + ready.insert(src); } } @@ -409,6 +419,61 @@ mod tests { ); } + /// #326 wiring contract: acyclic move sets come out lowest-destination + /// first — the exact order the legacy `emit_parallel_move` produced for + /// the ascending-destination arg-marshal lists. The byte-identity of + /// every already-compiling function's call marshalling depends on this. + #[test] + fn phase1_emits_lowest_destination_first_326() { + // Independent moves: ascending destination order. + let steps = sequentialize( + &[(Reg::R0, Reg::R4), (Reg::R1, Reg::R5), (Reg::R2, Reg::R6)], + &[], + ) + .unwrap(); + assert_eq!( + steps, + vec![ + MoveStep::Move { + dst: Reg::R0, + src: Reg::R4 + }, + MoveStep::Move { + dst: Reg::R1, + src: Reg::R5 + }, + MoveStep::Move { + dst: Reg::R2, + src: Reg::R6 + }, + ] + ); + // A freed LOW destination is emitted before a higher initially-ready + // one (the legacy scan restarted from the front after every emission). + let steps = sequentialize( + &[(Reg::R0, Reg::R1), (Reg::R1, Reg::R5), (Reg::R2, Reg::R6)], + &[], + ) + .unwrap(); + assert_eq!( + steps, + vec![ + MoveStep::Move { + dst: Reg::R0, + src: Reg::R1 + }, + MoveStep::Move { + dst: Reg::R1, + src: Reg::R5 + }, + MoveStep::Move { + dst: Reg::R2, + src: Reg::R6 + }, + ] + ); + } + #[test] fn duplicate_dst_is_an_error() { assert_eq!( diff --git a/scripts/repro/mutex_pressure.wat b/scripts/repro/mutex_pressure.wat new file mode 100644 index 00000000..9e403791 --- /dev/null +++ b/scripts/repro/mutex_pressure.wat @@ -0,0 +1,71 @@ +;; #326 — register exhaustion on an arg-move CYCLE under callee-saved pressure. +;; +;; gale's dissolved `z_impl_k_mutex_unlock` stopped compiling on v0.11.36: +;; the #311 call-result pair tagging legitimately keeps i64 register PAIRS +;; live across the surrounding code, so at a later call whose argument +;; marshalling contains a genuine register cycle (two sources that must SWAP +;; into r0/r1), `emit_arg_moves` demanded a free callee-saved scratch from +;; R4-R8 — all pinned — and the `Err` propagated: +;; +;; register exhaustion: no free callee-saved register to hold a call +;; result while reloading a preserved param +;; +;; This module reproduces that exact shape without gale's firmware: +;; * three i64 values stay live ON the operand stack across the calls, +;; occupying the pairs (r3,r4), (r5,r6), (r7,r8) — every callee-saved +;; register R4-R8 is pinned, exactly the #311 pressure class; +;; * `$main`'s param is reloaded into r1, then `$get32`'s result lands in +;; r0 ON TOP of it — so `$swap2(param, result)` needs arg0: r1->r0 and +;; arg1: r0->r1, a genuine 2-cycle with no free callee-saved scratch. +;; +;; v0.11.36..v0.11.39 fail compile-time with the message above (the 3b-lite +;; retry covers a different exhaustion site). Fixed by routing the marshal +;; through the parallel-move resolver (synth_synthesis::parallel_move), which +;; breaks the cycle via ONE stack slot when no scratch register exists: +;; str r0, [sp, #slot] ; mov r0, r1 ; ldr r1, [sp, #slot] +;; +;; Differential: scripts/repro/mutex_pressure_differential.py +;; (wasmtime ground truth vs unicorn on the synth ELF, internal BLs +;; resolved like u64_unpack_differential.py). +;; +;; Compile (fails on v0.11.36..39, compiles after the #326 fix): +;; synth compile scripts/repro/mutex_pressure.wat -o /tmp/mp.elf \ +;; --target cortex-m4 --all-exports --relocatable +(module + ;; i32-returning kernel-primitive stand-in: its result arrives in r0 on + ;; top of the already-live marshal source — one half of the cycle. + (func $get32 (result i32) + i32.const 37) + + ;; Argument-order-sensitive callee: swap2(a, b) = 2*a - b, so a marshal + ;; that swaps (or clobbers) its arguments is numerically visible. + (func $swap2 (param i32 i32) (result i32) + local.get 0 + i32.const 2 + i32.mul + local.get 1 + i32.sub) + + (func $main (export "main") (param i32) (result i64) + ;; Walk the temp allocator past r1/r2 so the i64 pairs land on + ;; (r3,r4), (r5,r6), (r7,r8) — pinning all of R4-R8. + i32.const 1 + drop + i32.const 2 + drop + ;; Three i64 values that stay live across both calls (#311 pressure). + i64.const 0x1111111100000007 ;; pair (r3,r4) + i64.const 0x2222222200000005 ;; pair (r5,r6) + i64.const 0x4444444400000003 ;; pair (r7,r8) + ;; Advance the allocator off r0 so the preserved param reloads into r1. + i32.const 3 + drop + local.get 0 ;; a -> r1 (arg0 of $swap2) + call $get32 ;; result -> r0 (arg1 of $swap2) + ;; Marshal cycle: arg0 r1->r0, arg1 r0->r1 — with R4-R8 all pinned. + call $swap2 + ;; Fold everything so the i64 values were genuinely live across the calls. + i64.extend_i32_u + i64.add + i64.add + i64.add)) diff --git a/scripts/repro/mutex_pressure_differential.py b/scripts/repro/mutex_pressure_differential.py new file mode 100644 index 00000000..907794df --- /dev/null +++ b/scripts/repro/mutex_pressure_differential.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""#326 — arg-move-cycle-under-pressure differential oracle. + +`mutex_pressure.wat` reproduces the shape that stopped gale's dissolved +`z_impl_k_mutex_unlock` from compiling on v0.11.36+: three i64 values pin the +callee-saved pairs (r3,r4)/(r5,r6)/(r7,r8) across two calls, and the second +call's argument marshalling is a genuine r0/r1 SWAP — the old emit_arg_moves +demanded a free callee-saved cycle scratch and `Err`ed ("register exhaustion: +no free callee-saved register to hold a call result while reloading a +preserved param"). The #326 fix routes the marshal through the parallel-move +resolver, breaking the cycle via one stack slot (str/mov/ldr) instead. + +This harness proves the fixed output is numerically correct, not just +compilable: wasmtime is ground truth; unicorn runs synth's ARM (`--relocatable` +path) with internal `BL func_N` relocations resolved in-image (same approach +as u64_unpack_differential.py). `main` returns i64 — checked as the full +r1:r0 pair, so a swapped/clobbered marshal (swap2(a,b) = 2a-b is +argument-order-sensitive) or a corrupted live i64 shows up immediately. + +Run: + synth compile scripts/repro/mutex_pressure.wat -o /tmp/mp.elf \ + --target cortex-m4 --all-exports --relocatable + /tmp/armv/bin/python scripts/repro/mutex_pressure_differential.py /tmp/mp.elf + +Exits nonzero on any mismatch. +""" + +import struct +import sys + +import wasmtime +from elftools.elf.elffile import ELFFile +from unicorn import UC_ARCH_ARM, UC_MODE_THUMB, Uc +from unicorn.arm_const import ( + UC_ARM_REG_LR, + UC_ARM_REG_R0, + UC_ARM_REG_R1, + UC_ARM_REG_R11, + UC_ARM_REG_SP, +) + +ELF = sys.argv[1] if len(sys.argv) > 1 else "/tmp/mp.elf" +WAT = sys.argv[2] if len(sys.argv) > 2 else "scripts/repro/mutex_pressure.wat" + +# ── ground truth ── +eng = wasmtime.Engine() +mod = wasmtime.Module(eng, open(WAT, "rb").read()) +st = wasmtime.Store(eng) +inst = wasmtime.Instance(st, mod, []) +gt_main = inst.exports(st)["main"] + +# ── synth ELF: text + internal BL reloc resolution (R_ARM_THM_CALL = 10) ── +e = ELFFile(open(ELF, "rb")) +text = bytearray(e.get_section_by_name(".text").data()) +symtab = [s for s in e.iter_sections() if s["sh_type"] == "SHT_SYMTAB"][0] +syms = {s.name: s["st_value"] for s in symtab.iter_symbols()} +rel = e.get_section_by_name(".rel.text") +if rel: + for r in rel.iter_relocations(): + if r["r_info_type"] == 10: + s = symtab.get_symbol(r["r_info_sym"]) + if s.name in syms: + off, target = r["r_offset"], syms[s.name] + dd = target - (off + 4) + S_ = (dd >> 24) & 1 + i1 = (dd >> 23) & 1 + i2 = (dd >> 22) & 1 + j1 = (~(i1 ^ S_)) & 1 + j2 = (~(i2 ^ S_)) & 1 + imm10 = (dd >> 12) & 0x3FF + imm11 = (dd >> 1) & 0x7FF + hw1 = 0xF000 | (S_ << 10) | imm10 + hw2 = 0xD000 | (j1 << 13) | (j2 << 11) | imm11 + struct.pack_into("