Skip to content

thumb-2: i32.shl/shr_s/shr_u don't mask the shift amount mod 32 — amounts >=32 silently compile to 0 (VCR-SEL regression; Rocq ARM model masks like WASM so proof is vacuous) #682

Description

@avrabe

Summary

The thumb-2 (ARMv7-M) lowering for i32.shl, i32.shr_s, and i32.shr_u emits a bare register shift (LSL.W/ASR.W/LSR.W Rd, Rn, Rm) with no masking of the shift amount. On ARMv7-M, register-controlled shifts take the amount from Rm[7:0] and produce 0 (LSL/LSR) or the sign (ASR) when the amount is ≥ 32. WebAssembly requires the shift amount to be reduced modulo 32 (k = amount mod 32). Therefore any i32 shift whose amount is ≥ 32 — whether a constant or a runtime value — silently miscompiles.

This is a silent wrong-code bug (no trap, no diagnostic). The i64 shift path is unaffected (it correctly emits AND Rx, #0x3f); only the i32 rules regressed.

The regression came in with VCR-SEL-001 increment 2 (commit c778197, PR #639, epic #242): the old hand-written selector masked the amount with AND Rm, #31; the new verified-selector DSL rules dropped it. Worse, the generated rules carry a Rocq "Qed" correctness obligation that is vacuous against real hardware (details below).

Found by qemu thumb EXECUTION cross-checked against wasmtime 42.

Repro

(module
  (func (export "shl32")   (param i32) (result i32) (local.get 0) (i32.const 32)  (i32.shl))
  (func (export "shl33")   (param i32) (result i32) (local.get 0) (i32.const 33)  (i32.shl))
  (func (export "shl300")  (param i32) (result i32) (local.get 0) (i32.const 300) (i32.shl))
  (func (export "shr300")  (param i32) (result i32) (local.get 0) (i32.const 300) (i32.shr_u))
  (func (export "sar300")  (param i32) (result i32) (local.get 0) (i32.const 300) (i32.shr_s))
  (func (export "shl_var") (param i32 i32) (result i32) (local.get 0) (local.get 1) (i32.shl))
)

Compile: synth compile shift.wat -t cortex-m3 --all-exports --relocatable -o shift.o (synth 0.37.0). Result is identical with/without --optimize/--no-optimize.

Disassembly (llvm-objdump --triple=thumbv7m) — note the missing mask:

<shl_var>:  fa00 f201   lsl.w  r2, r0, r1          ; no AND r1,#31
<shl300>:   f240 112c   movw   r1, #0x12c          ; 300
            fa00 f201   lsl.w  r2, r0, r1          ; ARM uses r1[7:0]=44 -> 0

Compare the i64 path, which is correct:

<i64shl80>: ... f002 023f   and  r2, r2, #0x3f     ; i64 masks mod 64

Execution (qemu-system-arm lm3s6965evb) vs wasmtime 42

call WASM spec (amount mod 32) wasmtime 42 synth on qemu
shl32(1) 32 mod 32 = 0 → 1 1 0
shl33(1) 33 mod 32 = 1 → 2 2 0
shl300(1) 300 mod 32 = 12 → 4096 4096 0
shr300(0x80000000) u >> 12 = 0x80000 524288 0
sar300(0x40000000) 0x40000000 >> 12 262144 0
shl_var(1, 33) 1 << 1 2 0

Spec citation

WebAssembly Core Specification, numeric instructions, ishl_N / ishr_u_N / ishr_s_N: "Let k be i₂ modulo N." For i32, N = 32. The amount is always reduced mod 32; there is no valid amount that yields 0 for a nonzero operand under <<.

Root cause

crates/synth-synthesis/src/sel_dsl/generated.rs:

pub fn rule_i32_shl(rd: Reg, rn: Reg, rm: Reg)   -> Vec<ArmOp> { vec![ArmOp::LslReg { rd, rn, rm }] }
pub fn rule_i32_shr_s(rd: Reg, rn: Reg, rm: Reg) -> Vec<ArmOp> { vec![ArmOp::AsrReg { rd, rn, rm }] }
pub fn rule_i32_shr_u(rd: Reg, rn: Reg, rm: Reg) -> Vec<ArmOp> { vec![ArmOp::LsrReg { rd, rn, rm }] }

No mask is applied. A correct lowering must reduce the amount mod 32 first (e.g. AND Rt, Rm, #31; LSL.W Rd, Rn, Rt), which the pre-VCR-SEL selector did.

(i32.rotr/i32.rotl are fine: register ROR is naturally mod-32, and the rotate-by-32-k idiom preserves that.)

Proof-soundness gap (verified-codegen model is unfaithful)

The generated rules claim a Rocq theorem rule_i32_shl_correct (Qed). It is vacuous because the ARM model masks like WASM does. In coq/Synth/ARM/ArmSemantics.v:

(* Shift operations — register (shift amount in Rm, masked to 0-31 by I32.shl etc.) *)
| LSL_reg rd rn rm =>
    let v := get_reg s rn in
    let shift_amt := get_reg s rm in
    Some (set_reg s rd (I32.shl v shift_amt))    (* <-- I32.shl masks mod 32 *)

and coq/Synth/Common/Integers.v:

Definition shl (x y : int) : int := repr (Z.shiftl x (unsigned y mod 32)).

So the Rocq LSL_reg semantics model an ARM register shift as x << (amount mod 32), which is not how ARMv7-M behaves (LSL(register) uses Rm[7:0] and yields 0 for amounts ≥ 32). VcrSelRules.v even comments: "the shift amount is masked mod 32 by the I32 semantics of LSL_reg/LSR_reg/ASR_reg/ROR_reg, matching WASM" — that assumption is false for the non-rotate shifts. The proof should be discharged against a faithful ARM shift semantics (Rm[7:0], saturate-to-0 at ≥ 32), which would fail for the bare-shift rule and force the mask back in.

Fix

Re-insert the AND Rm, #31 (mod-32 mask) into rule_i32_shl/rule_i32_shr_s/rule_i32_shr_u, and correct the LSL_reg/LSR_reg/ASR_reg ARM semantics in ArmSemantics.v to model Rm[7:0] (so the proof re-obligates the mask).

Environment: synth 0.37.0, cortex-m3, qemu-system-arm lm3s6965evb, wasmtime 42.0.1.

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