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:
- 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.
- 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.
- 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.
A
--relocatableobject 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 indocs/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 0.69.0, binary mtime 2026-09-19 11:16:31 +0200px4_fmu-v6xrt(i.MX RT1170)Object built with:
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:
In each case a float-looking bit pattern had been written over an unrelated RTOS pointer.
0x3f8001a0is 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_MAINchanged between these; everything else identical.lo=0 hi=0, result bit-exactlo=0 hi=0, result bit-exactSo 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:
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:
call_indirectcount is 0), so the depth is computable. Something likecontroller#step: 2312 bytes max stack depthwould be enough.docs/embedder-abi-relocatable-arm.mdunder "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-wcetalready walks the call graph, the same traversal would yield the depth.Severity
Not a blocker — we set
STACK_MAIN 16384and 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.