kit

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

commit c6e10c46a421a8e9127fdb5b284aad2537aaf592
parent 18d3db836cc06be73a3fa41b7c0594780b2429d6
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 16 Jun 2026 18:55:12 -0700

arm32: fix i64 compound-compare miscompile (lr scratch clobber in large-offset mem access)

arm_emit_mem's large frame/memory-offset branch staged the destination
address into ARM_TMP (lr) unconditionally, then accessed [lr]. The branch's
comment asserted lr can never alias the transfer register rt -- but
arm_copy_bytes and arm_bitfield_store route their value transfer THROUGH lr,
so for a deep frame slot a wide8/aggregate copy emitted

    ldr  lr, [src]        ; lr = value
    mov  lr, #off         ; lr clobbered with the dest offset
    sub  lr, r7, lr       ; lr = dest address
    str  lr, [lr]         ; stores the ADDRESS, not the value

This silently corrupted i64 lane copies once frame offsets exceeded the
direct sub.w r7,#imm range. It surfaced as the wrong result of compound
i64 compares (e.g. `!(x>y)||!(y<x)||(x==y)`): the second/third compare
copy x,y into deep temps, so a lane copied its own destination address.
The first compare alone (shallow frame) was correct, which is why only the
compound under accumulated frame pressure failed. Arch-specific (the lane
lowering in src/cg/arith.c is shared; rv32/rv64 were unaffected).

Fix: in the large-offset branch, pick a staging register that aliases
neither rt nor base, choosing ip (ARM_SCRATCH) when rt is lr (and vice
versa); both are reserved/dead-in-body scratch. Panic only if genuinely no
free scratch (unreachable: base is never lr here).

Diffstat:
Msrc/arch/arm32/native.c | 28+++++++++++++++++-----------
1 file changed, 17 insertions(+), 11 deletions(-)

diff --git a/src/arch/arm32/native.c b/src/arch/arm32/native.c @@ -412,18 +412,24 @@ static void arm_emit_mem(Arm32NativeTarget* a, int is_load, NativeLoc reg, } 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): stage base+off into ARM_TMP - * (lr) and access [lr]. lr is reserved (never an NDT operand) and dead in - * the body, so it can never alias the transfer reg rt — including the - * rt==IP stack-arg path. base is a frame/general register here, never - * ARM_TMP: a GLOBAL base (the only ARM_TMP producer) folds its full byte - * offset into the MOVW/MOVT reloc addend, so it always reaches arm_emit_mem - * with off in range and never takes this branch. */ + /* 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"); - arm_emit_load_u32(a, ARM_TMP, (u32)(off < 0 ? -off : off)); - arm_emit_t32(mc, off < 0 ? arm_sub_reg(ARM_TMP, base, ARM_TMP) - : arm_add_reg(ARM_TMP, base, ARM_TMP)); - arm_emit_t32(mc, arm_t32(t3hw1 | ARM_TMP, (rt << 12) | 0u)); + 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)); } }