Skip to content

arm optimizer: dynamic i32.load/store address constant-folded to a fixed 0x100 — pointer params ignored (--no-optimize correct) #178

Description

@avrabe

Summary

On synth 8f213351 (post-v0.11.2), the optimized path miscompiles i32.load/i32.store of a dynamic address (an address from local.get / a function parameter): it lowers to a load/store from a fixed constant address (linmem_base + 0x100), ignoring the dynamic operand entirely. --no-optimize is correct (ldr [fp, r0] — base register + param), so this is an optimizer bug. Any wasm function that dereferences a pointer parameter, compiled with optimization on (the default), reads/writes the wrong memory.

This is the on-target blocker for the cross-language-LTO-via-wasm route: the C shim's z_impl_k_sem_give(struct k_sem *sem) reads sem->count from 0x20000100 instead of from the sem pointer the kernel passes in r0, so it cannot work as a drop-in native kernel primitive.

(The relocation/encoding issues #167/#173/#174 are all fixed and verified — calls now resolve correctly. This is a separate, memory-addressing bug. gale's verified functions like gale_k_sem_give_decide(count, limit, has_waiter) are unaffected because they take values, not pointers — which is why GALE_USE_SYNTH worked. The bug surfaces the moment a wasm function dereferences a pointer arg.)

Minimal reproduction

(module
  (memory 1)
  (func $load_field (export "load_field") (param i32) (result i32)
    local.get 0
    i32.load offset=0)          ;; return *(i32*)param0
  (func $store_field (export "store_field") (param i32) (param i32)
    local.get 0
    local.get 1
    i32.store offset=4))        ;; param0->field4 = param1
synth compile ptr.wat --target cortex-m4f --all-exports --relocatable -o ptr.o
arm-zephyr-eabi-objcopy -O binary --only-section=.text ptr.o ptr.bin
arm-zephyr-eabi-objdump -D -b binary -marm -Mforce-thumb ptr.bin
00000000 <load_field>:
   0:  movw  ip, #0x100        ; \
   4:  movt  ip, #0x2000       ;  > ip = 0x20000100  (constant — should be linmem_base + r0)
   8:  adds  r4, r5, r1        ; dead/garbage (r5,r1 not initialized)
   a:  ldr.w r4, [ip]          ; load from 0x20000100 — r0 (the address operand) is never used
   e:  mov   r0, r4
  10:  bx    lr

00000014 <store_field>:
  14:  movw  ip, #0x100
  18:  movt  ip, #0x2000        ; ip = 0x20000100 again
  1e:  str.w r1, [ip, #4]       ; store to 0x20000104 — r0 ignored

--no-optimize is correct — it uses the param via the linear-memory base register fp:

00000000 <load_field>:
   0:  stmdb sp!, {r4,r5,r6,r7,r8,lr}
   4:  ldr.w r0, [fp, r0]      ; linmem_base(fp) + param0  — CORRECT, uses the operand
   8:  ldmia.w sp!, {...,pc}
0000000c <store_field>:
  10:  add.w ip, r0, #4        ; param0 + offset 4
  14:  str.w r1, [fp, ip]      ; store at linmem_base(fp) + (param0+4)  — CORRECT

So the optimizer is dropping the dynamic operand and substituting the constant 0x100. (The dead adds r4, r5, r1 in the optimized output, with r5/r1 uninitialized, looks like the remains of the intended base+offset computation after the operand was constant-folded away.)

Real-module manifestation

merged.both.wasm's z_impl_k_sem_give (the wasm shim hosting the C↔Rust seam):

800009e: movw ip,#0x100 ; movt ip,#0x2000   → ip = 0x20000100
80000a8: ldr.w r8,[ip]                       → sem->count   (kernel passed sem in r0 — ignored)
80000b6: ldr.w ip,[ip,#4]                    → sem->limit
80000fa: str.w r5,[sp,#4]                     → (the new_count store likewise never reaches sem)

The kernel calls z_impl_k_sem_give(real_sem_ptr), but the code reads/writes 0x20000100 — wrong RAM — so the semaphore is never actually updated.

Impact

Blocker for any optimized synth arm output that dereferences pointer parameters / does dynamic memory access — i.e., any wasm function operating on host-provided memory. Functions that take and return only scalar values are unaffected (which is why gale_k_sem_give_decide(count, limit, has_waiter) and the GALE_USE_SYNTH value-only path work).

Secondary / architectural note (separate from the optimizer bug)

Even the correct --no-optimize form uses fp as the linear-memory base ([fp, r0]). For a --relocatable object dropped into a native host (Zephyr kernel) where the caller passes a real pointer in r0 via a plain AAPCS call, the effective address becomes linmem_base + real_ptr, which is wrong unless linmem_base == 0. So making a pointer-dereferencing wasm function callable as a native drop-in also needs a way to set the linmem base to 0 (or otherwise treat pointer params as native addresses). Possibly already covered by the Meld/kiln host model (#34); flagging in case a --relocatable/native-ABI mode should zero the linmem base.

Environment

  • synth 8f213351 (post-v0.11.2), arm backend, target cortex-m4f
  • optimized (default) path miscompiles; --no-optimize is correct
  • Zephyr SDK 1.0.1 binutils 2.43.1

Activity

  1. avrabe commented on May 30, 2026

    @avrabe
    ContributorAuthor

    Kernel-side / host-ABI context (we own the host — let's design the contract together)

    Flagging the broader picture, since PulseEngine owns both sides here: gale is the kernel that hosts and calls this code, so we're not a sandboxed guest stuck with a fixed wasm memory model — we control __linear_memory_base, the value of fp/r11 at the call site, the linker script, and the call site itself. We can meet synth halfway. Here's what we see and what we can offer, so the fix lands in a shape that actually works on-target.

    What synth does today (from the emitted code)

    • Memory is addressed as [fp + offset], fp (r11) = linear-memory base, initialized from the linker symbol __linear_memory_base (synth's own linker script: PROVIDE(__linear_memory_base = _bss_end)).
    • --no-optimize is correct: a pointer-param deref (local.get 0)(i32.load) → ldr [fp, r0] (base + param).
    • Optimized path is wrong (this issue): the dynamic operand is constant-folded away → ldr [0x20000100], param ignored.

    The on-target problem for a host-pointer-dereferencing primitive

    Our shim's z_impl_k_sem_give(struct k_sem *sem) dereferences a native k_sem pointer the kernel passes in r0. With fp = __linear_memory_base = <some RAM region>, [fp + sem] ≠ the real sem. For the deref to be correct, either the host pointer must be a linmem offset, or the base must be 0 for that access.

    What the kernel side can provide (pick whichever fits synth's model)

    1. Native-pointer ABI / __linear_memory_base = 0. If synth supports (or documents) fp/__linear_memory_base = 0 in --relocatable mode, then [fp + r0] = [r0] = a correct native deref. We will set __linear_memory_base = 0 in the Zephyr link and, if needed, emit a 3-instruction trampoline that forces r11 = 0 around the call (r11 is callee-saved, so it survives the imported kernel calls). The catch we hit: this only works if the wasm module has no internal mutable statics at low linmem offsets (they'd land in flash at base 0). Our shim's only static is its spinlock — we made it extern (kernel-provided gale_wasm_shim_lock) to remove it. But then --no-optimize z_impl fails with the arm regalloc: register exhaustion (no consecutive i64 pair) hard-fails on compiler_builtins::float::div::div #168 i64 regalloc exhaustion — so this path also needs the regalloc work, and ideally a way for &extern_data to lower to an R_ARM_ABS32/MOVW-MOVT data relocation (we didn't get to confirm synth emits one).

    2. Marshalling trampoline (works today, but costs). The kernel copies sem->count/limit into linmem before the call and new_count back after. Correct on-target immediately, but the marshalling cost is in the measurement — so it won't show LTO-parity (it'd land ~GALE_USE_SYNTH class). Fine as a first real number, not the end state.

    3. A dedicated "native pointer" param attribute. Cleanest long-term: synth marks selected pointer params as native (dereferenced at base 0 / directly), while module-internal data keeps the linmem base. That lets the optimized path emit ldr [r0] for host pointers and still place statics in linmem. This is the one that gives a true native drop-in and optimized codegen.

    Concrete asks to sync on

    • Fix this issue (optimized dynamic load must use the operand) — needed regardless.
    • Document/parameterize the fp / __linear_memory_base contract for --relocatable output so a host can set the base (e.g., 0 for native pointers).
    • Confirm whether &extern_data lowers to a data relocation (R_ARM_ABS32 / R_ARM_MOVW_ABS_NC+MOVT) — we need it for option 1.
    • The arm regalloc: register exhaustion (no consecutive i64 pair) hard-fails on compiler_builtins::float::div::div #168 i64 regalloc exhaustion resurfaces on z_impl once the lock is extern (--no-optimize); relevant if option 1 is the chosen path.

    Happy to provide the linker symbol, the trampoline, a reserved register, or a relocated gale_wasm_shim_lock — whatever shape works best for synth's codegen. Tell us the contract and we'll meet it on the kernel side.

  2. avrabe commented on May 30, 2026

    @avrabe
    ContributorAuthor

    Thanks for the detailed host-ABI context — really useful that you own both sides.

    The bug itself is fixed in PR #179 (merging): the optimized path was dropping the load/store address operand for both dynamic and constant addresses (not just pointer params). Until the optimized memory codegen is properly repaired (tracked in #180), optimize_full declines memory-using modules and falls back to select_with_stack, which is correct — so the optimized (default) output now byte-matches --no-optimize (ldr [fp, r0]). That's the "optimized dynamic load must use the operand — needed regardless" item.

    On the broader contract (separate from the bug) — your three options map cleanly onto a real design axis, and since it sets the native-drop-in ABI it deserves its own design issue rather than being decided inside a bugfix. I've captured it as a discussion thread and will pull in #34 (Meld host model), #170 ($t mapping + standalone resolution), and #171 (i64 regalloc, which you correctly note resurfaces on z_impl once the lock is extern). Quick reactions:

    • Option 1 (__linear_memory_base = 0 / native-pointer mode) is the cleanest path to true LTO-parity and the one I'd want to land. The catch you hit (module-internal statics at low linmem offsets landing in flash at base 0) is real; the right shape is probably Option 3 — a per-param "native pointer" attribute so host pointers deref at base 0 while internal data keeps the linmem base. That gives native drop-in and keeps the optimized path.
    • Option 2 (marshalling trampoline) is a fine first real number; agreed it won't show LTO-parity.
    • &extern_data → data relocation: needs verifying on the synth side (whether it emits R_ARM_MOVW_ABS_NC+MOVT / R_ARM_ABS32 for an address-of-extern). I'll confirm and report in the design issue rather than guess.

    Will open the design issue and tag it here. For now, PR #179 unblocks the correctness side (optimized pointer derefs are correct), shipping as v0.11.3.

  3. avrabe commented on May 30, 2026

    @avrabe
    ContributorAuthor

    Design issue opened: #181 (host-ABI / linmem-base contract). Optimizer repair: #180.

  4. added 2 commits that reference this issue on May 30, 2026
    814dcce
    7ccc413
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions