Summary
On the Thumb-2 backend (-t cortex-m3), memory.copy and memory.fill silently clobber any operand (dest / src / len) that is a local reused after the op. The lowering loads the operand into a working register and mutates it in place as the copy/fill loop pointer/counter, without spilling the still-live local — so a later local.get of that operand reads a corrupted value (for a dest/src, a wild mem_base + <mutated> pointer). Const operands are unaffected. This is a straight-line-code sibling of the loop back-edge clobber #663, but in the bulk-memory lowering, so a loop-specific fix will not cover it.
Common idiom that breaks: memcpy(buf, src, n); /* use buf, src, or n */. Also a wild-pointer safety/security hazard (the subsequent memory access uses a corrupted pointer).
- synth
0.37.0
- oracle: wasmtime 42.0.1 (and the RISC-V rv32 backend, which spills and is correct)
Reproducers (executed under QEMU Cortex-M, r11 = valid RAM base, vs wasmtime)
dest is a local, read back after the copy:
(module (memory 1)
(func (export "f")(param i32 i32)(result i32)
(i32.store (i32.const 16)(i32.const 67305985)) ;; 0x04030201 at [16]
(memory.copy (local.get 0)(i32.const 16)(local.get 1)) ;; copy len=local1 bytes 16 -> local0
(i32.load (local.get 0)))) ;; read [local0]
f(dest=32, len=4): wasmtime 67305985, synth 0 (wrong — local0 was clobbered).
Control with a const dest ((memory.copy (i32.const 32) ...) then (i32.load (i32.const 32))): synth 67305985 ✓ — isolating the corruption to the local operand.
memory.fill, dest is a local:
(memory.fill (local.get 0)(i32.const 66)(local.get 1)) (i32.load8_u (local.get 0))
f(32,4): wasmtime 66, synth 0 (wrong).
len operand reused after the copy:
(i32.store (i32.const 16)(i32.const 99))
(memory.copy (i32.const 32)(i32.const 16)(local.get 1))
(local.get 1)
f(_, len=4): wasmtime 4, synth 99 (wrong — local1 clobbered).
Root cause (Thumb-2 disassembly of the copy)
add.w r0, r11, r0 ; r0 = mem_base + local0 (dest pointer; r0 holds the dest local)
add.w r12, r0, r1 ; end = r0 + len
... copy loop ...
adds r0, r0, #1 ; r0 += 1 <-- mutates r0 (== dest local0) in place, no spill of the live local
...
ldr.w r0, [r11, r0] ; later i32.load(local.get 0): r0 is the mutated loop pointer, not local0 -> wild
The dest/src/len operands are advanced in place by the copy/fill loop, but the underlying locals are live past the op (local.get afterward). The allocator does not preserve the live local across the bulk-memory lowering — same defect class as #663, here reachable in straight-line code. rv32 is correct (it spills operands to the stack).
Suggested fix
The bulk-memory lowering must operate on a scratch copy of each operand (or reload the local afterward), so the source locals survive the copy/fill loop. Do not consume a still-live local's register as the loop induction pointer/counter without spilling.
Distinct from #374 (which implemented memory.copy/fill lowering — this is a correctness bug in that new lowering) and from #663 (loop back-edge clobber). Found via the QEMU thumb execution-differential harness (r11 = memory base) vs wasmtime; const-vs-local isolation + disasm confirm.
Summary
On the Thumb-2 backend (
-t cortex-m3),memory.copyandmemory.fillsilently clobber any operand (dest / src / len) that is a local reused after the op. The lowering loads the operand into a working register and mutates it in place as the copy/fill loop pointer/counter, without spilling the still-live local — so a laterlocal.getof that operand reads a corrupted value (for a dest/src, a wildmem_base + <mutated>pointer). Const operands are unaffected. This is a straight-line-code sibling of the loop back-edge clobber #663, but in the bulk-memory lowering, so a loop-specific fix will not cover it.Common idiom that breaks:
memcpy(buf, src, n); /* use buf, src, or n */. Also a wild-pointer safety/security hazard (the subsequent memory access uses a corrupted pointer).0.37.0Reproducers (executed under QEMU Cortex-M, r11 = valid RAM base, vs wasmtime)
dest is a local, read back after the copy:
f(dest=32, len=4): wasmtime 67305985, synth 0 (wrong —local0was clobbered).Control with a const dest (
(memory.copy (i32.const 32) ...)then(i32.load (i32.const 32))): synth 67305985 ✓ — isolating the corruption to the local operand.memory.fill, dest is a local:
f(32,4): wasmtime 66, synth 0 (wrong).len operand reused after the copy:
f(_, len=4): wasmtime 4, synth 99 (wrong —local1clobbered).Root cause (Thumb-2 disassembly of the copy)
The dest/src/len operands are advanced in place by the copy/fill loop, but the underlying locals are live past the op (
local.getafterward). The allocator does not preserve the live local across the bulk-memory lowering — same defect class as #663, here reachable in straight-line code. rv32 is correct (it spills operands to the stack).Suggested fix
The bulk-memory lowering must operate on a scratch copy of each operand (or reload the local afterward), so the source locals survive the copy/fill loop. Do not consume a still-live local's register as the loop induction pointer/counter without spilling.
Distinct from #374 (which implemented memory.copy/fill lowering — this is a correctness bug in that new lowering) and from #663 (loop back-edge clobber). Found via the QEMU thumb execution-differential harness (r11 = memory base) vs wasmtime; const-vs-local isolation + disasm confirm.