Skip to content

[CRITICAL] thumb-2 silently miscompiles any call with an i64 parameter that is not last — args land in the wrong registers, exit 0, no warning #929

Description

@avrabe

On the ARM/thumb-2 backend, a call whose parameter list has an i64 before any further
parameter
marshals the arguments into the wrong registers. synth compile exits 0 with
no warning
, and the callee receives garbage — the i64's high half is never placed, and
every later argument is shifted one register down.

(i32, i64) — the shape #837 fixed — is correct. The defect is specifically an i64 that is
not last.

Verified on v0.55.0 (clone HEAD 97bd6db), executed under qemu-system-arm, oracle wasmtime.

Repro

(module
  (func $g (param i64 i32) (result i32) (local.get 1))
  (func (export "f") (param i32 i32) (result i32)
    (call $g (i64.const 1234605616436508552) (local.get 0))))
$ synth compile i64arg.wat -b arm -t cortex-m3 --all-exports --relocatable -o i64arg.o
exit=0        stderr warnings: 0
f(7) f(42)
wasmtime 7 42
thumb-2 (cortex-m3) 287454020 287454020

287454020 = 0x11223344 = the high half of the i64 constant, returned regardless of
the actual argument.

Not a constant-folding artifact — with a runtime-computed i64 the callee still gets the
wrong operand:

(func (export "f") (param i32 i32) (result i32)
  (call $g (i64.extend_i32_s (local.get 1)) (local.get 0)))

f(9,5) → wasmtime 9, thumb-2 5.

Which signatures are affected

Callee returns one of its parameters; caller invoked as f(9,5); rc is synth's exit code.

callee params rc wasmtime thumb-2
(i64, i32) 0 9 5 ✗ silent miscompile
(i64, i64) 0 5 9 ✗ silent miscompile
(i64, i32, i32) 0 5 9 ✗ silent miscompile
(i32, i64) 0 9 9 ✓ correct (#837's shape)
(i32, i32) 0 5 5 ✓ correct

aarch64 and rv32 are unaffected — both produce the wasmtime result for all five.

Root cause

crates/synth-synthesis/src/instruction_selector.rs:9730 pop_call_args pops one register
per WASM parameter
:

let mut srcs: Vec<Reg> = Vec::with_capacity(n);
for _ in 0..n {
    let r = pop_operand(stack, next_temp, instructions, spill, &[], idx)?;
    srcs.push(r);
}
srcs.reverse();

and the caller then marshals srcs[k] into ARG_REGS[k]. Under AAPCS an i64 parameter
occupies an even-aligned pair of core registers, so as soon as one appears, every
subsequent parameter is off by one register and the i64's high half is never written. The
emitted caller shows it plainly — r2 (the high half) is never assigned, and the following
i32 is placed into r1 where that half belongs.

What makes this a gap rather than an oversight is that the same hazard is already guarded
on both adjacent paths
:

  • the >4-arg stack path refuses it loudly (#503: call arg {k} is passed on the stack and is i64/f64; only i32 stack args are supported, :9748);
  • pop_call_args_mixed (:9780) declines i64-mixed-with-float for exactly this reason —
    its own comment says the "even-aligned core PAIR is not marshalled on this path".

Only the plain integer register path stays silent.

Suggested fix

Assign ARG_REGS by core-register position, not WASM parameter index: advance the
allocator by 2 (with even alignment) for an i64 param and emit both halves. If that is more
than this path should carry right now, the minimum safe change is to make it match its
neighbours and decline loudly when a non-final i64 parameter is present — an honest
refusal is enormously better than a wrong value here, because the shape
fn(u64, u32)/fn(i64, i32) is completely ordinary in C and Rust code compiled to wasm,
and nothing about the failure is observable on target.

Found while executing spec assertions across all three backends (context: #928).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions