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:
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));
}
}