Skip to content

--relocatable: no way to size the embedder's stack for a lowered export — undersized stacks corrupt memory silently instead of failing #1341

Description

@cpetig

A --relocatable object tells the embedder how much linear memory to reserve (--embedder-data-init / --embedder-global-init, the R10 size in the register contract), but nothing about how much native stack the lowered export needs. There is no field in the object, no line in the compile output, and nothing in docs/embedder-abi-relocatable-arm.md.

An embedder therefore has to guess. Guess low and the failure is memory corruption with a plausible-looking result, not an error.

Setup

synth synth 0.69.0, binary mtime 2026-09-19 11:16:31 +0200
built from c18c185, tree clean
target cortex-m7
host RTOS NuttX 12.12.0 under PX4, px4_fmu-v6xrt (i.MX RT1170)

Object built with:

synth compile cascade-fused.wasm --target cortex-m7 --relocatable --all-exports \
  --embedder-data-init --embedder-global-init -o falcon-cascade.o

One export under test, controller#step, (param i32) (result i32). It lowers to a single 50722-byte function (0000c622). Total object: 21 functions, 68374 bytes of code.

What happened

The export was called with the register contract established (R11 = linmem base, R10 = 65536, R9 = globals base) from a NuttX task whose stack is PX4's default 1984 usable bytes.

The call returned the correct answer every time — bit-identical to the same component run under wasmtime on the host — and then the system crashed somewhere unrelated, differently on each run:

arm_usagefault.c:116   PC=nxmutex_is_hold+4     R0=3bdabcb2
arm_usagefault.c:116   PC=atexit_call_exitfuncs R0=3bdabb12
arm_busfault.c:108     PC=nxmutex_is_hold+4     R0=3f8001a0
arm_hardfault: Hard Fault escalation

In each case a float-looking bit pattern had been written over an unrelated RTOS pointer. 0x3f8001a0 is not mapped, hence the bus fault. The register contract itself was intact at fault time (SB=2025d7dc, SL=00010000, R11=2025dbe8), and guard bands of 32768 bytes below and 4096 bytes above linear memory were untouched — so the export did not write outside the memory it was given. It overran its own task stack.

Bracketing

Only STACK_MAIN changed between these; everything else identical.

usable stack result
1984 B faults, as above
4032 B clean, guards lo=0 hi=0, result bit-exact
16320 B clean, guards lo=0 hi=0, result bit-exact

So the requirement sits between 1984 and 4032 bytes for this one export.

Why the embedder cannot work this out

Static inspection of the object is misleading. Summing every frame allocation in the whole object:

$ arm-none-eabi-objdump -d falcon-cascade-renamed.o \
  | grep -oE 'sub(\.w)?\s+sp, (sp, )?#[0-9]+' | grep -oE '[0-9]+' \
  | awk '{s+=$1} END {print "sum:", s, "across", NR, "frames"}'
sum: 1456 across 18 frames

Largest single frame is 232 bytes; the sum over all 18 frames is 1456. That number suggests 1984 bytes should be ample, and it is not. Without the call graph an embedder reading the object cannot derive the real depth, and summing every frame is both wrong and still an underestimate here.

What would fix it

Some way for the embedder to size the region. In rough order of usefulness to us:

  1. Report max stack depth per export at compile time, alongside the existing code-size line. synth has the call graph and the frame sizes, and there are no indirect calls in this module (call_indirect count is 0), so the depth is computable. Something like controller#step: 2312 bytes max stack depth would be enough.
  2. Record it in the object — a note section, or an extra field wherever the linear-memory size is already communicated — so a build step can assert the embedder's region against it rather than a human copying a number.
  3. Document it in docs/embedder-abi-relocatable-arm.md under "Region requirements", which currently covers linear memory and globals but not stack. Even "the embedder must size this itself, synth does not bound it" would have saved the investigation.

If --emit-wcet already walks the call graph, the same traversal would yield the depth.

Severity

Not a blocker — we set STACK_MAIN 16384 and moved on. Raising it because of the failure mode rather than the difficulty: the export produced correct output while corrupting the system, so the natural conclusion is "the cascade works, something else is broken". We spent four flash/crash cycles chasing the logging path, the print format and out-of-bounds writes before looking at the task stack size, which was in the first fault dump all along (dump_stackinfo: size: 00001984).

Happy to supply the 41055-byte fused module if a repro is wanted.

Measurement we could not make

We tried to report peak stack usage from NuttX's stack colouring and the scan returned "fully used" at both 4032 and 16320 bytes, so the method was unsound and we dropped it. The bracketing table above is behavioural only — pass/fail — not a measured high-water mark.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions