kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

commit 09c0cfd167b6640c0da40184f4a2576da51b6ee5
parent 6a21d4a06c4b95ff4f586305994be0628b49d280
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 16 Jun 2026 21:36:58 -0700

arm32 §1: unified memory-access address layer (correct by construction)

Replace the ad-hoc lr/ip rescue paths in the load/store/copy layer with a
single address resolver (arm_resolve_mem) carrying ONE scratch-reservation
invariant, asserted in arm_pick_scratch:

- Every access maps a NativeAddr × offset × width to a legal (base, in-range
  displacement), or materializes the full effective address once into a reserved
  scratch (LR preferred; IP is the NDT operand scratch) chosen to alias neither
  the transfer reg rt nor a live base. Fixes the i64/wide store-to-global
  miscompile (str lr,[lr]) — the resolver never lands a global address in the
  value register.
- Wide (i64/double) accesses resolve ONE shared base pinning both lane regs, so
  the high lane's address is correct for frame/reg/global uniformly.
- copy_bytes resolves each side once (direct in-range, else materialized) with an
  explicit register budget (IP/LR + one borrowed callee-save for the rare
  both-materialized case) instead of the old fast/slow fork; arm_emit_base_off
  stages into rd (no ARM_TMP dependency).
- arm_emit_global_addr applies a non-zero byte offset as a SEPARATE add (reloc
  addend stays 0): ARM REL MOVW/MOVT addends are carry-lossy across the 16-bit
  split, so the separate-add form is always-correct and matches the contract
  elf_arm_reloc_field_addend documents. Fixes initialized global array/record/
  tuple data reads at a non-zero element offset (table[1] read table[0]).

Also fixes the switch jump-table crashes (the .Lkit_jt table read went through
the global path) and static-local data. Validates clean: full toy corpus
281 pass (was 270), no regressions; arm32_decode_test 314/0.

scripts/arm32probe.sh + arm32batch.sh: fast single-case / full-corpus arm32
bring-up harnesses over the qemu bare lane.

Diffstat:
Ascripts/arm32batch.sh | 31+++++++++++++++++++++++++++++++
Ascripts/arm32probe.sh | 28++++++++++++++++++++++++++++
Msrc/arch/arm32/native.c | 503++++++++++++++++++++++++++++++++++++++++++-------------------------------------
3 files changed, 328 insertions(+), 234 deletions(-)

diff --git a/scripts/arm32batch.sh b/scripts/arm32batch.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# arm32batch.sh — run the whole toy corpus through the arm32 bare lane at O0+O1, +# print a compact PASS/FAIL summary + the failing list. Reuses one stub setup. +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export KIT="${KIT:-$ROOT/build/release/kit}" +source "$ROOT/test/lib/exec_bare.sh" +WORK="${ARM32PROBE_WORK:-/tmp/arm32batch}"; mkdir -p "$WORK" +exec_bare_setup arm32 "$WORK" main >"$WORK/setup.log" 2>&1 || { echo "setup failed"; cat "$WORK/setup.log"; exit 3; } +pass=0; fail=0; fails="" +for f in "$ROOT"/test/toy/cases/*.toy; do + name=$(basename "${f%.toy}") + [ -e "${f%.toy}.arm32.skip" ] && continue + grep -q 'asmnop' "$f" 2>/dev/null && continue + exp=$(( $(cat "${f%.toy}.expected" 2>/dev/null || echo 0) & 255 )) + for opt in 0 1; do + obj="$WORK/$name.O$opt.o" + if ! "$KIT" cc -O"$opt" -target arm-none-eabi -mcpu=cortex-m3 -mfloat-abi=soft \ + -ffreestanding -c "$f" -o "$obj" >"$WORK/cc.out" 2>"$WORK/cc.err"; then + fail=$((fail+1)); fails="$fails $name/O$opt(cc)"; continue + fi + [ -s "$WORK/cc.err" ] && { fail=$((fail+1)); fails="$fails $name/O$opt(stderr)"; continue; } + if ! exec_bare_run arm32 "$obj" "$WORK" "$WORK/rc" >/dev/null 2>&1; then + fail=$((fail+1)); fails="$fails $name/O$opt(link)"; continue + fi + rc=$(cat "$WORK/rc" 2>/dev/null || echo 99) + if [ "$rc" -eq "$exp" ]; then pass=$((pass+1)); else fail=$((fail+1)); fails="$fails $name/O$opt(exp=$exp,got=$rc)"; fi + done +done +echo "PASS=$pass FAIL=$fail" +echo "FAILS:"; for x in $fails; do echo " $x"; done diff --git a/scripts/arm32probe.sh b/scripts/arm32probe.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# arm32probe.sh — fast single-case arm32 bring-up probe. +# arm32probe.sh <case.toy|case.c> [opt] compile+link+run under qemu, vs .expected +# arm32probe.sh --dis <case.toy> [opt] compile + objdump the .text +# KIT defaults to build/release/kit. Sets up the bare stub once under /tmp/arm32probe. +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export KIT="${KIT:-$ROOT/build/release/kit}" +source "$ROOT/test/lib/exec_bare.sh" +WORK="${ARM32PROBE_WORK:-/tmp/arm32probe}"; mkdir -p "$WORK" +exec_bare_setup arm32 "$WORK" main >/"$WORK/setup.log" 2>&1 || { echo "setup failed"; cat "$WORK/setup.log"; exit 3; } + +dis=0 +if [ "${1:-}" = "--dis" ]; then dis=1; shift; fi +f="$1"; opt="${2:-0}" +name=$(basename "${f%.*}") +obj="$WORK/$name.O$opt.o" +if ! "$KIT" cc -O"$opt" -target arm-none-eabi -mcpu=cortex-m3 -mfloat-abi=soft \ + -ffreestanding -c "$f" -o "$obj" >"$WORK/cc.out" 2>"$WORK/cc.err"; then + echo "$name/O$opt: CC-FAIL"; sed 's/^/ | /' "$WORK/cc.err"; exit 1 +fi +if [ -s "$WORK/cc.err" ]; then echo "$name/O$opt: CC-STDERR"; sed 's/^/ | /' "$WORK/cc.err"; fi +if [ "$dis" -eq 1 ]; then "$KIT" objdump -d "$obj"; exit 0; fi +reason="$(exec_bare_run arm32 "$obj" "$WORK" "$WORK/rc" 2>&1)" +if [ $? -eq 2 ]; then echo "$name/O$opt: LINK/RUN-FAIL: $reason"; exit 2; fi +rc=$(cat "$WORK/rc" 2>/dev/null || echo 99) +exp=$(( $(cat "${f%.*}.expected" 2>/dev/null || echo 0) & 255 )) +if [ "$rc" -eq "$exp" ]; then echo "$name/O$opt: PASS (rc=$rc)"; else echo "$name/O$opt: FAIL exp=$exp got=$rc"; fi diff --git a/src/arch/arm32/native.c b/src/arch/arm32/native.c @@ -312,58 +312,93 @@ static int arm_addr_legal(NativeTarget* t, const NativeAddr* addr, /* ============================ memory ============================ */ +static void arm_emit_load_u32(Arm32NativeTarget* a, u32 rd, u32 v); +static u32 arm_pick_scratch(Arm32NativeTarget* a, u32 rt, u32 pinned); + +/* Add a signed compile-time constant to rd in place (rd += k), using a reserved + * scratch only for a magnitude exceeding the inline immediate forms. */ +static void arm_emit_add_const(Arm32NativeTarget* a, u32 rd, i64 k) { + MCEmitter* mc = a->base.mc; + u32 enc, mag; + if (k == 0) return; + mag = (u32)(k < 0 ? -k : k); + if (mag <= 0xfffu) { + arm_emit_t32(mc, k < 0 ? arm_sub_imm12(rd, rd, mag) + : arm_add_imm12(rd, rd, mag)); + } else if (thumb_expand_imm_encode(mag, &enc)) { + arm_emit_t32(mc, arm_dp_imm(k < 0 ? 13u : 8u, 0u, rd, rd, enc)); + } else { + u32 s = arm_pick_scratch(a, rd, 0u); + arm_emit_load_u32(a, s, mag); + arm_emit_t32(mc, k < 0 ? arm_sub_reg(rd, rd, s) : arm_add_reg(rd, rd, s)); + } +} + /* Materialize the runtime address of `sym` (+ addend) into register `rd`. * * arm32's house strategy is MOVW/MOVT absolute (Cortex-M has no literal pools * and the bare-metal lane is non-PIC): - * MOVW rd, #:lower16:sym ; R_ARM_THM_MOVW_ABS_NC -> (S+addend)[15:0] - * MOVT rd, #:upper16:sym ; R_ARM_THM_MOVT_ABS -> (S+addend)[31:16] - * The 16-bit imm placeholders the encoders write are overwritten by the - * patcher (src/arch/arm32/reloc.c); the addend rides in the reloc record (an - * explicit addend), so the symbol's full address+addend lands in `rd` and no - * post-add is emitted. (A GOT-indirect variant would belong here if a PIC - * arm32 lane were ever added; bare-metal ELF resolves these absolutely.) */ + * MOVW rd, #:lower16:sym ; R_ARM_THM_MOVW_ABS_NC -> S[15:0] + * MOVT rd, #:upper16:sym ; R_ARM_THM_MOVT_ABS -> S[31:16] + * A constant byte `addend` is applied as a SEPARATE add, NOT folded into the + * reloc: ARM uses REL relocations (the addend rides in the relocated field), and + * the MOVW/MOVT pair splits a 32-bit value across two 16-bit fields whose + * per-half (S+A) computation is carry-lossy across the 16-bit boundary. Keeping + * the reloc addend 0 and adding the offset here is both always-correct and the + * contract elf_arm_reloc_field_addend documents (and ld.lld-compatible). */ static void arm_emit_global_addr(Arm32NativeTarget* a, u32 rd, ObjSymId sym, i64 addend) { MCEmitter* mc = a->base.mc; u32 pos = mc_pos(mc); arm_emit_t32(mc, arm_movw(rd, 0u)); - mc_emit_reloc_at(mc, mc->section_id, pos, R_ARM_THM_MOVW_ABS_NC, sym, addend, - 1, 0); + mc_emit_reloc_at(mc, mc->section_id, pos, R_ARM_THM_MOVW_ABS_NC, sym, 0, 1, 0); pos = mc_pos(mc); arm_emit_t32(mc, arm_movt(rd, 0u)); - mc_emit_reloc_at(mc, mc->section_id, pos, R_ARM_THM_MOVT_ABS, sym, addend, 1, - 0); -} - -/* Resolve a NativeAddr to a (base reg, signed byte offset) pair. A GLOBAL base - * is materialized via MOVW/MOVT into ARM_TMP (lr) — NOT the IP scratch (r12), - * which the NDT uses to materialize operands: in a store `str rt, [base]` the - * transfer reg rt may itself be r12 (an NDT-materialized value or scratch), - * which the IP global-address would clobber. lr is reserved (never an NDT - * operand) and dead in the body, so it can never alias rt. */ -static u32 arm_addr_base(Arm32NativeTarget* a, const NativeAddr* addr, - i32* off_out) { - switch (addr->base_kind) { - case NATIVE_ADDR_BASE_FRAME: { - NativeFrameSlotEntry* s = - native_frame_slot_at(&a->frame, addr->base.frame); - *off_out = -(i32)s->off + addr->offset; /* [r7, #-(off) + extra] */ - return ARM_FP; - } - case NATIVE_ADDR_BASE_REG: - *off_out = addr->offset; - return addr->base.reg & 0xfu; - case NATIVE_ADDR_BASE_GLOBAL: - arm_emit_global_addr(a, ARM_TMP, addr->base.global.sym, - addr->base.global.addend); - *off_out = addr->offset; - return ARM_TMP; - default: - arm_panic(a, "unsupported addressing mode"); - } + mc_emit_reloc_at(mc, mc->section_id, pos, R_ARM_THM_MOVT_ABS, sym, 0, 1, 0); + arm_emit_add_const(a, rd, addend); } +/* The unified memory-access address layer (ARM32.md §1) lives here. Every + * load/store/copy resolves a NativeAddr to a legal (base register, in-range + * displacement) through ONE function, arm_resolve_mem, with ONE scratch- + * reservation invariant asserted in one place. + * + * Scratch budget: two registers are reserved as emit scratch in the function + * body — IP (r12, ARM_SCRATCH) and LR (r14, ARM_TMP); r7 (FP) holds the frame + * anchor and is never a scratch. A single access needs at most one + * address-staging scratch, distinct from the transfer register `rt`. The picker + * draws it from {IP,LR} minus {rt} minus a caller `pinned` mask (held bases the + * access must preserve) and panics if none is free, so the invariant is explicit + * rather than relying on incidental liveness. */ +static void arm_emit_base_off(Arm32NativeTarget* a, u32 rd, u32 base, i32 off); + +/* True if every byte of a `span`-byte access at signed displacement `off` fits + * the Thumb-2 single-granule immediate forms (T4 negative -255..0, T3 positive + * 0..4095): off >= -255 and off+span <= 4096 implies every granule offset in + * [off, off+span-1] lands in [-255, 4095]. */ +static int arm_span_inline(i32 off, u32 span) { + return off >= -255 && off + (i32)span <= 4096; +} + +/* Pick a reserved staging register from {LR, IP} that is neither the transfer + * register `rt` (0xff = none) nor in `pinned` (held base registers the access + * must preserve). LR (ARM_TMP) is preferred because IP (ARM_SCRATCH) is the NDT's + * operand-materialization scratch and can hold a live value across the access + * (e.g. a store whose value the NDT homed in IP); LR is only ever a transient + * backend temp within a single emit, so staging an address there is free. The + * single point that enforces the scratch invariant. */ +static u32 arm_pick_scratch(Arm32NativeTarget* a, u32 rt, u32 pinned) { + u32 avoid = (rt < 16u ? (1u << rt) : 0u) | pinned; + if (!(avoid & (1u << ARM_TMP))) return ARM_TMP; + if (!(avoid & (1u << ARM_SCRATCH))) return ARM_SCRATCH; + arm_panic(a, "memory access: no free address-staging scratch"); +} + +typedef struct ArmMemAddr { + u32 base; /* base register to address through */ + i32 off; /* signed displacement; access [base, #off (+lane/granule)] */ +} ArmMemAddr; + /* Materialize the unsigned 32-bit constant `v` into `rd` (MOVW + optional MOVT). * Used to stage large frame/memory offsets and SUB-sp amounts that exceed the * inline immediate forms. */ @@ -382,39 +417,63 @@ static void arm_emit_load_u32(Arm32NativeTarget* a, u32 rd, u32 v) { if ((v >> 16) != 0u) arm_emit_t32(mc, arm_movt(rd, (v >> 16) & 0xffffu)); } -/* Emit a load (is_load=1) or store of `reg` to [base, #off], dispatching the - * width from mem.size. Loads zero-extend (LDR/LDRB/LDRH); signed narrowing is a +/* Resolve `addr` for a `span`-byte access whose transfer register is `rt` + * (0xff = none; the wide path pins both lane registers via `pinned` instead), + * returning a legal (base, off). A FRAME/REG base already in range is used + * directly; otherwise the full effective address is materialized once into a + * picked scratch (off := 0). A GLOBAL base is always materialized via MOVW/MOVT, + * folding addr.offset into the reloc addend so off := 0 — this is the single + * point that guarantees a store's global address never lands in the value + * register (the i64-store-to-global miscompile). */ +static ArmMemAddr arm_resolve_mem(Arm32NativeTarget* a, const NativeAddr* addr, + u32 rt, u32 span, u32 pinned) { + ArmMemAddr r; + switch (addr->base_kind) { + case NATIVE_ADDR_BASE_FRAME: { + NativeFrameSlotEntry* s = + native_frame_slot_at(&a->frame, addr->base.frame); + i32 off = -(i32)s->off + addr->offset; + if (arm_span_inline(off, span)) { + r.base = ARM_FP; + r.off = off; + return r; + } + r.base = arm_pick_scratch(a, rt, pinned | (1u << ARM_FP)); + arm_emit_base_off(a, r.base, ARM_FP, off); + r.off = 0; + return r; + } + case NATIVE_ADDR_BASE_REG: { + u32 base = addr->base.reg & 0xfu; + if (arm_span_inline(addr->offset, span)) { + r.base = base; + r.off = addr->offset; + return r; + } + r.base = arm_pick_scratch(a, rt, pinned | (1u << base)); + arm_emit_base_off(a, r.base, base, addr->offset); + r.off = 0; + return r; + } + case NATIVE_ADDR_BASE_GLOBAL: + r.base = arm_pick_scratch(a, rt, pinned); + arm_emit_global_addr(a, r.base, addr->base.global.sym, + addr->base.global.addend + addr->offset); + r.off = 0; + return r; + default: + arm_panic(a, "unsupported addressing mode"); + } +} + +/* Emit one in-range granule access: LDR/STR (+B/H) of `rt` at [base, #off], + * dispatching width. The caller (via arm_resolve_mem) guarantees `off` is in the + * T3/T4 immediate range for this width. Loads zero-extend; signed narrowing is a * separate convert. */ -static void arm_emit_mem(Arm32NativeTarget* a, int is_load, NativeLoc reg, - NativeAddr addr, MemAccess mem) { +static void arm_emit_mem_one(Arm32NativeTarget* a, int is_load, u32 rt, u32 base, + i32 off, u32 size) { MCEmitter* mc = a->base.mc; - i32 off; - u32 base = arm_addr_base(a, &addr, &off); - u32 rt = loc_reg(reg); - u32 size = mem.size ? mem.size : native_type_size(&a->base, reg.type); u32 t3hw1, t4hw1; - if (size >= 8u) { - /* Split a wide (8-byte) access into two 4-byte halves over the consecutive - * register pair: low lane = rt at [base, off], high lane = rt+1 at - * [base, off+4] (little-endian). The cg hands a few paths — notably an i64 - * switch scrutinee — a single 8-byte access into an even/odd pair; rv32 - * splits identically. (Wider-than-8 is not produced by the C frontend.) */ - KitCgTypeId i32t = builtin_id(KIT_CG_BUILTIN_I32); - NativeLoc lo = reg, hi = reg; - NativeAddr addr_hi = addr; - MemAccess m = mem; - if (size != 8u) arm_panic(a, "memory access wider than 8 bytes"); - m.size = 4u; - m.type = i32t; - lo.type = i32t; - hi.type = i32t; - hi.v.reg = (rt + 1u) & 0xfu; - addr_hi.offset += 4; - arm_emit_mem(a, is_load, lo, addr, m); - arm_emit_mem(a, is_load, hi, addr_hi, m); - return; - } - /* pick the op (T3 positive, T4 +/-) for this width. */ if (size == 1u) { t3hw1 = is_load ? 0xf890u : 0xf880u; t4hw1 = is_load ? 0xf810u : 0xf800u; @@ -429,27 +488,31 @@ static void arm_emit_mem(Arm32NativeTarget* a, int is_load, NativeLoc reg, arm_emit_t32(mc, arm_t32(t3hw1 | base, (rt << 12) | (u32)off)); } else if (off < 0 && off >= -255) { arm_emit_t32(mc, arm_ldst_t4(t4hw1, rt, base, (u32)(-off), 0u)); - } else if (off >= 0 && off <= 255) { - arm_emit_t32(mc, arm_ldst_t4(t4hw1, rt, base, (u32)off, 1u)); } else { - /* Large frame/memory offset (-O1 big frames, or a deep -O0 wide8/aggregate - * copy): stage base+off into a scratch register and access [scratch]. The - * staging register must alias neither the transfer reg `rt` nor `base`, - * because it is overwritten before the access. Both reserved scratch regs - * (lr=ARM_TMP, ip=ARM_SCRATCH) are dead in the body. Normally lr is used, - * but `rt` can itself be lr (arm_copy_bytes / arm_bitfield_store route their - * transfer through ARM_TMP) — staging the address into lr would then clobber - * the value mid-copy (str lr,[lr]); fall back to ip in that case. `base` is - * never ARM_TMP here (a GLOBAL base — the only ARM_TMP producer — folds its - * byte offset into the MOVW/MOVT reloc addend, so it reaches arm_emit_mem in - * range), but it can be ip; the picked register avoids it either way. */ - u32 stg = (rt == ARM_TMP) ? ARM_SCRATCH : ARM_TMP; - if (base == ARM_TMP) arm_panic(a, "large offset on lr-staged base"); - if (stg == base || stg == rt) arm_panic(a, "no free scratch for large offset"); - arm_emit_load_u32(a, stg, (u32)(off < 0 ? -off : off)); - arm_emit_t32(mc, off < 0 ? arm_sub_reg(stg, base, stg) - : arm_add_reg(stg, base, stg)); - arm_emit_t32(mc, arm_t32(t3hw1 | stg, (rt << 12) | 0u)); + arm_panic(a, "arm_emit_mem_one: offset out of range (resolver bug)"); + } +} + +/* Emit a load (is_load=1) or store of `reg` at `addr`, dispatching width from + * mem.size. A wide (8-byte i64/double) access splits into two 4-byte lanes over + * the register pair rt:rt+1 sharing ONE resolved base — the high lane's address + * is thus computed correctly for frame/reg/global uniformly. */ +static void arm_emit_mem(Arm32NativeTarget* a, int is_load, NativeLoc reg, + NativeAddr addr, MemAccess mem) { + u32 rt = loc_reg(reg); + u32 size = mem.size ? mem.size : native_type_size(&a->base, reg.type); + if (size > 8u) arm_panic(a, "memory access wider than 8 bytes"); + if (size == 8u) { + u32 hi = (rt + 1u) & 0xfu; + ArmMemAddr r = + arm_resolve_mem(a, &addr, 0xffu, 8u, (1u << rt) | (1u << hi)); + arm_emit_mem_one(a, is_load, rt, r.base, r.off, 4u); + arm_emit_mem_one(a, is_load, hi, r.base, r.off + 4, 4u); + return; + } + { + ArmMemAddr r = arm_resolve_mem(a, &addr, rt, size, 0u); + arm_emit_mem_one(a, is_load, rt, r.base, r.off, size); } } @@ -516,12 +579,15 @@ static void arm_emit_base_off(Arm32NativeTarget* a, u32 rd, u32 base, i32 off) { } else if (off < 0 && thumb_expand_imm_encode((u32)(-off), &enc)) { arm_emit_t32(mc, arm_dp_imm(13u, 0u, rd, base, enc)); /* SUB rd, base, #-off */ } else { - /* Large frame/array offset (-O1 big frames): stage |off| in ARM_TMP (lr) so - * the base register survives even when rd aliases it, then add/sub into rd. - * lr is reserved and dead in the body. */ - arm_emit_load_u32(a, ARM_TMP, (u32)(off < 0 ? -off : off)); - arm_emit_t32(mc, off < 0 ? arm_sub_reg(rd, base, ARM_TMP) - : arm_add_reg(rd, base, ARM_TMP)); + /* Large frame/array offset: stage |off| in a temp then add/sub into rd. The + * temp must not alias `base` (read after write). Use rd itself when rd != + * base (the resolver guarantees a staged base reg differs from its source); + * only when rd aliases base (load_addr's address-of-an-indexed-REG case) + * fall back to the reserved ARM_TMP, which is dead in that context. */ + u32 tmp = (rd != base) ? rd : ARM_TMP; + arm_emit_load_u32(a, tmp, (u32)(off < 0 ? -off : off)); + arm_emit_t32(mc, off < 0 ? arm_sub_reg(rd, base, tmp) + : arm_add_reg(rd, base, tmp)); } } @@ -536,14 +602,17 @@ static void arm_emit_base_off(Arm32NativeTarget* a, u32 rd, u32 base, i32 off) { static void arm_load_addr(NativeTarget* t, NativeLoc dst, NativeAddr addr) { Arm32NativeTarget* a = arm_of(t); MCEmitter* mc = t->mc; - i32 off; - u32 base, rd = loc_reg(dst); + u32 rd = loc_reg(dst); if (addr.base_kind == NATIVE_ADDR_BASE_GLOBAL) { arm_emit_global_addr(a, rd, addr.base.global.sym, addr.base.global.addend + addr.offset); + } else if (addr.base_kind == NATIVE_ADDR_BASE_FRAME) { + NativeFrameSlotEntry* s = native_frame_slot_at(&a->frame, addr.base.frame); + arm_emit_base_off(a, rd, ARM_FP, -(i32)s->off + addr.offset); + } else if (addr.base_kind == NATIVE_ADDR_BASE_REG) { + arm_emit_base_off(a, rd, addr.base.reg & 0xfu, addr.offset); } else { - base = arm_addr_base(a, &addr, &off); - arm_emit_base_off(a, rd, base, off); + arm_panic(a, "unsupported addressing mode in load_addr"); } if (addr.index_kind == NATIVE_ADDR_INDEX_REG) { u32 idx = addr.index.reg & 0xfu; @@ -1789,151 +1858,117 @@ static void arm_tls_addr_of(NativeTarget* t, NativeLoc dst, ObjSymId sym, * with a software thread pointer is a follow-on for a multi-threaded lane.) */ arm_emit_global_addr(arm_of(t), loc_reg(dst), sym, addend); } -/* copy_bytes / set_bytes: aggregate (struct/array, and the wide8 i64/double) - * memory ops, transferring a word/halfword/byte ladder through ARM_TMP (lr), - * mirroring the aa64 sibling backend. +/* Aggregate / wide byte copy (struct/array, INDIRECT param/return copies, the + * wide va_arg copy, and memmove's loops). Each side is resolved ONCE to a stable + * (base register, displacement): a FRAME/REG side in range for the whole span is + * addressed directly; a GLOBAL or out-of-range side has its effective address + * materialized into a held scratch. A 4|2|1 granule ladder then transfers each + * chunk through a register that aliases neither base. * - * copy_bytes has two regimes. The common FAST path keeps the src/dst NativeAddr - * bases as-is and bakes the running byte offset into each load/store immediate - * (arm_emit_mem dispatches the width + T3/T4 offset encoding); lr is the only - * scratch and it is RESERVED, so a single copy needs no extra register. When a - * side's effective offset would leave the inline immediate range (a deep -O0 - * frame slot / wide8 store through a pointer the NDT homed in ip), arm_emit_mem - * would have to stage the address through a *second* scratch (ip) — which can - * be the sibling base register, silently corrupting it. The SLOW path below - * therefore materializes each side's effective address into a stable register - * once and copies at small offsets, so no per-granule staging happens. */ -/* True if a load/store at signed byte offset `off` from a base fits - * arm_emit_mem's inline T3/T4 immediate forms (no large-offset staging). */ -static int arm_mem_off_inline(i32 off) { - return (off >= 0 && off <= 4095) || (off >= -255 && off <= 255); -} - -/* Side-effect-free preview of the per-base byte offset an access would carry, - * for the copy_bytes range check below. Mirrors arm_addr_base's FRAME/REG - * offset math WITHOUT emitting the GLOBAL MOVW/MOVT (which arm_addr_base does as - * a side effect). A GLOBAL base re-materializes its address into lr per access - * and folds the addend into the reloc, so it never reaches the large-offset - * lane — report it as in-range. */ -static i32 arm_addr_peek_off(Arm32NativeTarget* a, const NativeAddr* addr) { - switch (addr->base_kind) { - case NATIVE_ADDR_BASE_FRAME: { - NativeFrameSlotEntry* s = - native_frame_slot_at(&a->frame, addr->base.frame); - return -(i32)s->off + addr->offset; - } - case NATIVE_ADDR_BASE_REG: - return addr->offset; - default: - return 0; /* GLOBAL (in-range by construction) or unsupported */ + * Register budget, allocated explicitly up front: two reserved scratch (IP, LR); + * one callee-save borrowed (pushed/popped) only for the rare case where BOTH + * sides must be materialized AND the two bases plus the transfer exceed {IP,LR}. + * The borrow is taken before any frame-relative materialization, so r7-relative + * offsets (and already-held REG bases) are unaffected by the SP change. */ + +/* The natural (base register, displacement) of a FRAME/REG addr, no range check. */ +static void arm_addr_natural(Arm32NativeTarget* a, const NativeAddr* addr, + u32* base, i32* off) { + if (addr->base_kind == NATIVE_ADDR_BASE_FRAME) { + NativeFrameSlotEntry* s = native_frame_slot_at(&a->frame, addr->base.frame); + *base = ARM_FP; + *off = -(i32)s->off + addr->offset; + } else { + *base = addr->base.reg & 0xfu; + *off = addr->offset; + } +} + +/* Whether `addr` can be addressed directly for a `span`-byte access (a real base + * register + in-range displacement for the whole span). GLOBAL is never direct. + * On success sets base+off to the natural (base register, displacement). */ +static int arm_addr_direct(Arm32NativeTarget* a, const NativeAddr* addr, + u32 span, u32* base, i32* off) { + if (addr->base_kind != NATIVE_ADDR_BASE_FRAME && + addr->base_kind != NATIVE_ADDR_BASE_REG) + return 0; + arm_addr_natural(a, addr, base, off); + return arm_span_inline(*off, span); +} + +/* Take a transfer/held-base register for copy_bytes: a free reserved scratch + * (IP then LR) not already in `*used`, else a borrowed callee-save (r4..r6 not in + * `*used`, pushed; recorded in `*borrowed`, which starts 0xff and is set once). */ +static u32 arm_copy_take_reg(Arm32NativeTarget* a, u32* used, u32* borrowed) { + u32 r; + if (!(*used & (1u << ARM_SCRATCH))) r = ARM_SCRATCH; + else if (!(*used & (1u << ARM_TMP))) r = ARM_TMP; + else { + r = (*used & (1u << 4)) ? ((*used & (1u << 5)) ? 6u : 5u) : 4u; + *borrowed = r; + arm_emit_t32(a->base.mc, arm_push_w(1u << r)); } + *used |= 1u << r; + return r; } static void arm_copy_bytes(NativeTarget* t, NativeAddr dst, NativeAddr src, AggregateAccess acc) { Arm32NativeTarget* a = arm_of(t); - KitCgTypeId i32t = builtin_id(KIT_CG_BUILTIN_I32); - NativeLoc tmp = native_loc_reg(i32t, NATIVE_REG_INT, ARM_TMP); - i32 soff = arm_addr_peek_off(a, &src); - i32 doff = arm_addr_peek_off(a, &dst); - i32 last = acc.size ? (i32)(acc.size - 1u) : 0; - u32 off = 0; - /* Fast path: every per-granule access on BOTH sides stays in the inline - * immediate range, so arm_emit_mem never needs to stage an offset through a - * scratch register and cannot collide with the sibling base. */ - if (arm_mem_off_inline(soff) && arm_mem_off_inline(soff + last) && - arm_mem_off_inline(doff) && arm_mem_off_inline(doff + last)) { - /* Widest convenient granule is a word (4); the trailing 1/2/3 bytes fall to - * halfword/byte ops. The width ladder also keeps each chunk naturally - * aligned for an aligned aggregate (4|4|...|2|1) and never over-runs - * acc.size. */ - while (off < acc.size) { - u32 rem = acc.size - off; - u32 sz = rem >= 4u ? 4u : rem >= 2u ? 2u : 1u; - MemAccess mem = acc.mem; - NativeAddr s = src, d = dst; - mem.size = sz; - mem.align = sz; - s.offset += (i32)off; - d.offset += (i32)off; - arm_emit_mem(a, 1, tmp, s, mem); /* LDR/LDRH/LDRB tmp, [src+off] */ - arm_emit_mem(a, 0, tmp, d, mem); /* STR/STRH/STRB tmp, [dst+off] */ - off += sz; + u32 size = acc.size, off; + u32 sbase, dbase, xfer, used = 0u, borrowed = 0xffu; + i32 soff = 0, doff = 0; + int sdir, ddir; + if (!size) return; + if (size > 4096u) arm_panic(a, "aggregate copy > 4096 bytes (needs memcpy)"); + + sdir = arm_addr_direct(a, &src, size, &sbase, &soff); + ddir = arm_addr_direct(a, &dst, size, &dbase, &doff); + if (sdir) used |= 1u << sbase; + if (ddir) used |= 1u << dbase; + + /* Allocate held scratch for the non-direct sides, then the transfer reg. */ + if (!sdir) sbase = arm_copy_take_reg(a, &used, &borrowed); + if (!ddir) dbase = arm_copy_take_reg(a, &used, &borrowed); + xfer = arm_copy_take_reg(a, &used, &borrowed); + + /* Materialize the non-direct sides (after any borrow-push so a frame-relative + * address still reads the unchanged r7). */ + if (!sdir) { + if (src.base_kind == NATIVE_ADDR_BASE_GLOBAL) + arm_emit_global_addr(a, sbase, src.base.global.sym, + src.base.global.addend + src.offset); + else { + u32 nb; + i32 no; + arm_addr_natural(a, &src, &nb, &no); + arm_emit_base_off(a, sbase, nb, no); } - return; - } - /* Slow path: at least one side reaches a deep frame offset (large -O0 frame / - * wide8 i64 copy). arm_emit_mem's large-offset path stages the address into a - * scratch register (ip if the transfer reg is lr), which would clobber a - * sibling base that the NDT legitimately materialized into ip — the wide8 - * `*p = i64` miscompile. Instead, compute each side's full effective address - * into a stable register ONCE, then access it at small (>=0) offsets so - * arm_emit_mem stays on its inline T3 path and never stages again. - * - * Register budget: the per-granule walk needs three distinct regs — src addr, - * dst addr, transfer. r7 is the frame pointer; lr (ARM_TMP) is consumed by - * arm_emit_base_off as its own MOVW/MOVT staging scratch, so it can't also - * hold a live address during setup. We therefore hold the dst address in a - * borrowed callee-saved reg (pushed/popped here) and the src address in ip - * (ARM_SCRATCH), and transfer through lr. The borrowed reg is chosen to alias - * neither base. r7-relative bases are unaffected by the SP change, and - * copy_bytes never addresses through SP. */ - { - MCEmitter* mc = a->base.mc; - i32 sb_off, db_off; - u32 sbase, dbase, xfer; - NativeLoc lrloc = native_loc_reg(i32t, NATIVE_REG_INT, ARM_TMP); - /* GLOBAL bases fold their offset into the reloc addend and always stay in - * range, so they never reach here; the only out-of-range bases are - * FRAME/REG, for which arm_addr_base is side-effect-free. */ - if (src.base_kind == NATIVE_ADDR_BASE_GLOBAL || - dst.base_kind == NATIVE_ADDR_BASE_GLOBAL) - arm_panic(a, "large-offset aggregate copy with global base"); - sbase = arm_addr_base(a, &src, &sb_off); - dbase = arm_addr_base(a, &dst, &db_off); - /* Per-granule offsets are relative to the freshly-materialized base (off >= - * 0), so they stay in arm_emit_mem's inline T3 range (0..4095) as long as - * the aggregate fits; a copy that large would re-stage through ip/lr and - * corrupt the held addresses — panic cleanly instead of miscompiling. */ - if (acc.size > 4096u) arm_panic(a, "aggregate copy too large for arm32 lane"); - /* Borrow the first callee-saved reg in {r4,r5,r6} that aliases neither base - * (r7 is the frame pointer; r8 is the spare). Two bases, three candidates, - * so one is always free. */ - xfer = (dbase != 4u && sbase != 4u) ? 4u - : (dbase != 5u && sbase != 5u) ? 5u - : 6u; - { - arm_emit_t32(mc, arm_push_w(1u << xfer)); - /* Materialize dst into the borrowed reg FIRST, while its base is still - * live; then src into ip. arm_emit_base_off reads `base` then may stage - * |off| through lr, so neither dst-into-xfer nor src-into-ip disturbs the - * other side's not-yet-read base (xfer aliases no base; ip is only read by - * its own src materialization). */ - arm_emit_base_off(a, xfer, dbase, db_off); - arm_emit_base_off(a, ARM_SCRATCH, sbase, sb_off); - off = 0; - while (off < acc.size) { - u32 rem = acc.size - off; - u32 sz = rem >= 4u ? 4u : rem >= 2u ? 2u : 1u; - MemAccess mem = acc.mem; - NativeAddr s, d; - mem.size = sz; - mem.align = sz; - memset(&s, 0, sizeof s); - memset(&d, 0, sizeof d); - s.base_kind = NATIVE_ADDR_BASE_REG; - s.base.reg = ARM_SCRATCH; - s.offset = (i32)off; - d.base_kind = NATIVE_ADDR_BASE_REG; - d.base.reg = xfer; - d.offset = (i32)off; - arm_emit_mem(a, 1, lrloc, s, mem); /* LDR/LDRH/LDRB lr, [ip+off] */ - arm_emit_mem(a, 0, lrloc, d, mem); /* STR/STRH/STRB lr, [xfer+off] */ - off += sz; - } - arm_emit_t32(mc, arm_pop_w(1u << xfer)); + soff = 0; + } + if (!ddir) { + if (dst.base_kind == NATIVE_ADDR_BASE_GLOBAL) + arm_emit_global_addr(a, dbase, dst.base.global.sym, + dst.base.global.addend + dst.offset); + else { + u32 nb; + i32 no; + arm_addr_natural(a, &dst, &nb, &no); + arm_emit_base_off(a, dbase, nb, no); } + doff = 0; + } + + /* Granule ladder: word, then trailing halfword/byte. Each chunk stays in range + * (the resolver guaranteed the span fits, and a materialized base has off 0). */ + for (off = 0; off < size;) { + u32 rem = size - off; + u32 sz = rem >= 4u ? 4u : rem >= 2u ? 2u : 1u; + arm_emit_mem_one(a, 1, xfer, sbase, soff + (i32)off, sz); + arm_emit_mem_one(a, 0, xfer, dbase, doff + (i32)off, sz); + off += sz; } + if (borrowed != 0xffu) arm_emit_t32(a->base.mc, arm_pop_w(1u << borrowed)); } /* set_bytes: store the materialized fill byte `v` across acc.size bytes. The * common case is struct/array zero-init (v == 0); any fill byte is correct. One