Conversation
|
Code size report: |
|
Hi @o-murphy thank you for this MR. The documentation looks comprehensive and well-structured. Thumbs up from me :) One thing I am not sure about is the formatting of the steps - does that render well in Github? I would maybe expect using a Markdown header for each line, over bold. |
It looks like padded numeric sections for each step with the same paddings for code blocks, good I mean, but If you wish I can change it So do you want me to change #### Ensure Static Library Development Packages:
...
#### Build MicroPython with `LDFLAGS_EXTRA="-static"`:
...
#### Verification
... |
|
Should I change something? |
7b0a2f1 to
f1f46b3
Compare
|
Should I change something or it can be rebased and merged? |
|
@jonnor Do i need to make changes? |
|
@o-murphy there is an empty file added, that must be removed. Otherwise I think it is fine. |
Done |
Signed-off-by: Jos Verlinde <Jos_Verlinde@hotmail.com>
|
@o-murphy , Pick your closing keyword |
Done |
738471f to
0d0d0f7
Compare
|
Is the PR would be merged? Since the opening of PR, no improvements have been added to the documentation. |
The two targets both reference projects cover and this one did not. Like aarch64, they are natmod-unreachable rather than duplicates of natmod coverage: dynruntime.mk's arm ARCHes are bare-metal EABI, not the Linux ABI, and there is no mips ARCH at all. Own job rather than two more rows in the existing matrix, because the recipe differs -- libffi built from source as a separate `deplibs` pass, a static link, and a qemu-user hop to run the result. a7p splits its armhf/mipsel out the same way. Toolchains follow upstream MicroPython's tools/ci.sh (ci_unix_qemu_arm_*/ci_unix_qemu_mips_*) rather than being reconstructed: gnueabi not gnueabihf, mipsel via gcc-mipsel-linux-gnu. qemu is invoked explicitly instead of relying on the runner having binfmt_misc registered. -static is a deployability requirement (these are minimal embedded Linux boards with no guaranteed ld.so), same reasoning as micropython/micropython#17456. Verified locally with the workflow's exact command lines, both green first try -- no wasm3 portability problems on either: armhf ELF 32-bit ARM EABI5, static 20/20 + 31/31, CoreMark 85.978 mipsel ELF 32-bit MIPS32 rel2, static 20/20 + 31/31, CoreMark 140.400 README also records the three targets still absent (ports/qemu usermod, webassembly, windows) with the reason for each, so the gap reads as a decision rather than an oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KCHLdPAAFif683TJB1hhGC
* Scaffold Wasm3 as a MicroPython module
Add the wasm3 submodule and lay the repository out the same way as
micropython-bclibc: shared src/, a natmod/ build, a usermod/ build, plus
tests/, tools/ and benchmarks/.
Unlike an AOT approach, the .wasm blob is not baked into the build — wasm3
interprets, so a blob can be read off the filesystem or fetched at runtime
and executed without rebuilding.
What is here:
- wasm3/ submodule pinned to o-murphy/wasm3.
- src/wasm3_mp.c — the glue: load/unload/find/call/link/memory/mem_size over
one shared M3Environment and a fixed table of runtime slots. Host imports
are routed to Python callables through a raw-call trampoline. One file
serves both build modes, switched on WASM3_BUILD_NATMOD.
- src/wasm3.py — Module/Function wrapper, context-manager lifetime, attribute
sugar for exports.
- src/wasm3_mp_config.h — the wasm3 knobs both modes share, each override
annotated with why the stock value is wrong here. Notably
M3_HAS_TAIL_CALL=0: wasm3 dispatches every opcode through
__attribute__((musttail)), which under natmod PIC is a hard compile error
on ABIs that forbid indirect sibling calls.
- src/libc_shim.c, src/math_shim.c — the libc/libm surface a natmod must
bring itself. Measured, not guessed: with DEBUG off and
d_m3VerboseErrorMessages=0 the core needs only calloc/free/realloc,
memcpy/memset/memmove/memcmp, strlen/strcmp, abort and seven libm pairs;
every printf-family site in wasm3 is behind DEBUG or a log macro.
- tools/make_test_wasm.py — hand-assembles the three .wasm fixtures so the
suite needs no WABT install.
Verified on Linux x86-64 against MicroPython v1.29:
- natmod links for x64 — text 85736 B, bss 428 B, 63 GOT entries. This
settles whether wasm3 can be a natmod at all: the opcode tables in
m3_compile.c are arrays of function pointers and relocate fine, and wasm3
needs no executable memory because it interprets rather than JITs.
- usermod builds into ports/unix, ~108 KB of added text.
- 20/20 tests pass under both, covering calls and i32 wrapping, linear memory
shared by reference in both directions, OOB traps, host imports calling
back into Python, and slot lifetime.
- math_shim.c checked bit-exact against glibc over 60M values, including
signed zeros, subnormals, infinities and NaN.
No cross-toolchain was available, so armv6m/armv7m*/xtensa/xtensawin/rv32imc/
rv64imc have Makefile branches but are untried; CI wires up x64 only, for the
same reason. README and CHANGELOG record this and the other open limitations
(i64 via mp_int_t, no globals/WASI, m3_CallArgv stubbed out).
* Pin CI to MicroPython v1.28.0
The workflow named v1.29.0, which does not exist — upstream has only
v1.29.0-preview, so `git clone --branch v1.29.0` would have failed on the
first run. v1.28.0 is the latest release tag.
Rebuilt and retested against v1.28.0 before changing the pin, since mpy_ld,
dynruntime.mk and the .mpy ABI all move between versions:
- natmod x64: text 85736 B, bss 428 B, 63 GOT entries — byte-identical to the
earlier master build; wasm3.mpy is 87685 B rather than 87667 B, the merged
wasm3.py bytecode being the only difference.
- usermod into ports/unix: ~109 KB of added text.
- 20/20 tests pass under both.
README and CHANGELOG now quote the v1.28.0 figures and note that master
(v1.29.0-preview) gives identical results; the prerequisites snippet clones
the pinned tag so a local build matches CI's .mpy ABI. Also corrects the
aarch64 note in natmod/Makefile to name v1.28.
* Build every natmod arch in CI; fix x86
CI now mirrors micropython-bclibc's natmod workflow: a fail-fast:false matrix
over all ten targets (x64, x86, armv6m, armv7m, armv7emsp, armv7emdp,
rv32imc, rv64imc, xtensa, xtensawin), each uploading its .mpy as an artifact,
plus a .github/actions/fetch-micropython composite action so the pinned
release tarball is fetched the same way everywhere.
The suite runs where the runner can execute the result — x64 and x86 — with a
separate usermod-firmware leg, since that links against the port's own
libc/libm with no mpy_ld or PIC and can break independently. An on-target leg
(QEMU armv7m, rp2040py armv6m) needs a raw-REPL bridge this repo does not
have, so it is described in a comment rather than committed untested.
x86 needed two fixes, both found by actually building it:
- -msse2 -mfpmath=sse. With x87 math GCC reports __FLT_EVAL_METHOD__ == 2, so
glibc's <math.h> typedefs float_t/double_t as long double while
lib/libm_dbl/libm.h typedefs them float/double — a conflicting-types error.
The deeper reason is conformance, not just the build: x87 evaluates f32/f64
arithmetic in 80-bit extended precision, which wasm's IEEE-754 semantics
forbid.
- Dropped the dead CROSS override block. dynruntime.mk assigns CROSS with a
plain `=` (py/dynruntime.mk:94,101), so anything set before the include is
overwritten; `make -n` confirmed ARCH=xtensa was already resolving to
xtensa-lx106-elf- and xtensawin to xtensa-esp32-elf-, not the prefixes that
block named. The CI toolchain packages are picked to match dynruntime.mk's
actual choices.
Verified locally against MicroPython v1.28.0:
- x86 natmod: EM_386, text 103348 B, bss 216 B, 69 GOT entries, 105575 B.
- x86 usermod firmware: ~140 KB of added text.
- 20/20 tests pass on a real 32-bit interpreter (MICROPY_FORCE_32BIT=1,
sys.maxsize 2147483647) for both, and the x86 .mpy is correctly refused by
the 64-bit interpreter with "incompatible .mpy arch".
- x64 rebuilt clean after the Makefile change: unchanged at text 85736 B,
bss 428 B, 63 GOT entries, 20/20.
* Fix CI: drop `make submodules`, build mpy-cross, silence xtensa warning
Run 3 failed four jobs for two unrelated reasons.
`make submodules` against a release tarball (test x64, test x86, usermod):
Updating submodules: lib/mbedtls lib/berkeley-db-1.xx lib/micropython-lib
error: pathspec 'lib/mbedtls' did not match any file(s) known to git
fetch-micropython downloads micropython-1.28.0.tar.xz, which has no .git —
and does not need one, since the tarball already ships those directories
vendored in (1023 / 86 / 549 files). ports/unix/README.md says so outright:
the step "can be skipped if you didn't clone the MicroPython source from
git". micropython-bclibc's workflows call `make submodules` nowhere for the
same reason. Removed; verified by building the unix port, and the usermod
firmware, straight from the extracted tarball.
The usermod job also lacked the README's first setup step, `make -C
mpy-cross`, which FROZEN_MANIFEST needs to freeze wasm3.py. Added as its own
step ahead of the build, following bclibc's note: py/mkenv.mk's auto-build
rule for mpy-cross resets USER_C_MODULES= for that recursive make but not
FROZEN_MANIFEST=, so the latter leaks through MAKEFLAGS if mpy-cross is left
to build implicitly. BUILD/PROG stay command-line assignments, not job env,
so they cannot leak into that step either. The two unix build steps are now
`if:`-gated per matrix leg rather than an if/else in shell, matching bclibc.
`build (xtensa)` failed separately, on the ESP8266 toolchain's older GCC:
m3_compile.c:2241: error: 'type' may be used uninitialized [-Werror=maybe-uninitialized]
A false positive. Compile_Ref_Null reaches PushConst(o, 0, type) only when
ReadRefType returned m3Err_none — the `_` macro jumps to _catch otherwise —
and both of ReadRefType's branches assign *o_type before that. Newer GCCs
prove it; this one does not, and dynruntime.mk compiles with -Werror. Added
-Wno-maybe-uninitialized with that reasoning recorded next to it.
The other nine build legs were already green, which the README and CHANGELOG
now reflect: armv6m, armv7m, armv7emsp, armv7emdp, rv32imc, rv64imc and
xtensawin all link, contradicting the earlier note that no cross target had
been built. They are still marked "not run" — only x64 and x86 have been
executed anywhere, and nothing has been on real hardware.
x64 and x86 rebuilt against the tarball MPY_DIR after the new flag: 20/20.
* Add an example that runs the embedded-wasm-apps blobs
natmod/examples/run_wiring_app.py supplies the `wiring` host interface —
print, millis, delay, pinMode, digitalWrite, stopWdt — as plain Python
callables via wasm3.link(), so the demo blobs from wasm3/embedded-wasm-apps
(the same ones in wasm2mpy's test/) run unmodified.
All seven blink apps work, in a single process with no rebuild between them:
WAT, Zig, C++, Virgil, AssemblyScript, Rust, TinyGo. CoreMark runs as well
(Result: 1908.640 on an x86-64 dev box, wasm3 natmod, --stack 16384).
Two behaviours the example had to handle, both now documented in the README:
- rust.wasm calls memory.grow during setup() to seed its allocator, which
invalidates the bytearray m.memory hands out. Binding it once at startup
made wiring.print read past the end and raise mid-run; the example
re-fetches whenever m.mem_size changes. This is the caveat the module docs
already warned about, now with a concrete case behind it.
- tinygo.wasm exports a WASI-style _initialize() that has to run before
setup().
Also exercises slot reuse: seven modules through four WASM3_MP_MAX_SLOTS in
one process, each closed via the context manager.
* Provide newlib's classification helpers for the xtensa link
Run 5 was 12/13 green — the tarball and mpy-cross fixes worked, and test x64,
test x86 and usermod all pass. build (xtensa) still failed, now at link
rather than compile:
LinkError: m3_compile.o: undefined symbol: __fpclassifyd
undefined symbol: __fpclassifyf
undefined symbol: __signbitd
undefined symbol: __signbitf
newlib resolves the C99 classification macros to these four helpers, which
live in libm.a — and a natmod cannot link the Xtensa toolchain's libm.a at
all: it drags in lib_a-s_lib_ver.o, whose non-empty .data section mpy_ld
rejects outright (the same wall micropython-bclibc hit there). wasm3 needs
both: isnan() throughout m3_exec.h's float ops, and m3_math_utils.h:83
explicitly routing signbit() to __signbitf/__signbitd on ESP8266/ESP32.
Added to math_shim.c under #if defined(__XTENSA__), alongside the libm
routines already there. The FP_* return values come from the toolchain's own
<math.h> rather than being hardcoded, so they match whatever that newlib's
macros compare against; fpclassify() itself cannot be used in the
implementation, since it is the macro that expands to these functions.
Validated the classification logic on the host against glibc's fpclassify and
signbit — 8M values including signed zeros, subnormals, infinities and NaN,
zero mismatches. The real xtensa link is still unproven until CI runs it: no
Xtensa toolchain is available locally.
x64 and x86 rebuilt: unaffected (the block is guarded), 20/20 each.
* Test against the real wasm2mpy demo blobs
Adds wasm2mpy as a submodule (208 KB, wanted only for its test/*.wasm) and
tests/test_wiring_apps.py, which runs those blobs through this module: the
same blink program compiled from seven languages — WAT, Virgil, C++, Zig,
TinyGo, AssemblyScript, Rust — plus CoreMark. Real toolchain output, real
memory layouts, real host imports; considerably better coverage than the
three fixtures tools/make_test_wasm.py assembles by hand.
29 checks. Per app it asserts the exact stdout, that the elapsed-time
accounting comes to 2000 ms over two loops, that pin 2 is configured as an
output, and that it is toggled four times alternating. It also cross-checks
that make_test_wasm.py reproduces wasm2mpy's own simple.wasm byte-for-byte.
The `wiring` interface is Python here, so nothing is rebuilt per blob, and
delay()/millis() share a virtual clock: delay advances a counter rather than
sleeping, millis reads it back. That is what makes the assertions exact — the
apps printing elapsed milliseconds emit a fixed string — and it drops the
suite from ~14s of real sleeping to instant.
CoreMark is the exception and cost a debugging round: it reads millis() to
decide how long to keep iterating, so under a clock that never advances on
its own its timing loop never reaches the minimum duration and it spins
forever. It gets a real clock, and sits behind --slow (~20s, Result: 1936.984
on an x86-64 dev box).
CI runs the new suite with --slow on the x64, x86 and usermod legs. Verified
locally on both the 64- and 32-bit interpreters: 29/29 plain, 31/31 with
--slow, alongside the existing 20/20.
* Run the suites on armv7m under QEMU; point submodules at upstream
First leg that executes the module as something other than x86. Both suites
pass on an emulated Cortex-M3 — 20/20 and 29/29, so all seven real-world
blobs run as ARM machine code.
natmod/ci/run_qemu.py drives it. The qemu port has no filesystem, so
wasm3.mpy is served from a RAM-backed VFS mounted on sys.path and every .wasm
blob is injected into the target's globals; both suites gained an _INJECTED
hook in their read() helper, which falls back to open() on a host run. Two
things this cost:
- The firmware needs MICROPY_HEAP_SIZE=1048576 rather than the port's 143360
default. A natmod's text is copied into the GC heap at import and the
armv7m wasm3.mpy is ~82 KB, before a 64 KB wasm page is allocated on top;
the stock heap cannot hold both. The port's linker gives RAM 2 MB.
- Each suite ends with `raise SystemExit`, which soft-resets the target and
takes the mounted VFS and _INJECTED with it — the second suite then failed
on `import wasm3`. The runner re-establishes both between suites.
CoreMark is deliberately not run there: interpreted wasm inside an emulated
Cortex-M3 would dominate the job.
Submodules now point at wasm3/wasm3 and vshymanskyy/wasm2mpy rather than the
o-murphy forks. Verified before switching that this changes nothing: both
forks sit on the identical commit as their upstream (0da0ef6, 0bf025c) with
byte-identical trees, and every config knob src/wasm3_mp_config.h depends on
— M3_HAS_TAIL_CALL, d_m3CanTailCall, d_m3MaxNativeStack and the rest — is
present upstream. The recorded commits are unchanged.
* Run the core suite on armv6m via rp2040py
Fourth executing target, and the one that finally hits a wall worth
documenting. natmod/ci/run_rp2040py.py flashes wasm3.mpy and every .wasm blob
into a littlefs image and runs against a RPI_PICO firmware built from the
pinned MicroPython — not rp2040py's own default download, which is a
different release and would reject the .mpy on ABI. rp2040py 0.3.1 (0.2.5,
which bclibc pins, has a different CLI: mklittlefs is gone, so the image is
built here with littlefs-python).
test_wasm3.py: 20/20 on the emulator, including a 64 KB linear memory on a
~192 KB heap.
test_wiring_apps.py does not pass there, and both reasons are properties of
the part rather than the harness — CI therefore runs the core suite only,
with the reasons recorded in the workflow and in README:
- The armv6m .mpy is ~94 KB of text, all copied into the GC heap at import.
One 64 KB linear memory fits on top; rust.wasm, which grows its memory
during setup(), raises MemoryError. Without a gc.collect() between modules
zig.wasm fails the same way, so the suite now collects between apps — worth
having on every target.
- assemblyscript.wasm traps with "[trap] stack overflow" and cpp.wasm with
"maximum recursion depth exceeded". With M3_HAS_TAIL_CALL=0 the native
stack grows once per wasm call, and Cortex-M0+ frames on RP2040's stack
leave no room for the deeper call graphs. d_m3MaxNativeStack turning that
into a trap rather than a crash is the guard working.
This is the concrete evidence behind the "prefer usermod on RAM-constrained
parts" note, which until now was reasoning rather than measurement.
Both suites gained a small hook for this: test_wiring_apps.py probes for the
blobs at ../wasm2mpy/test/ or /wasm2mpy/test/, since a device has no cwd to
be relative to. Re-verified after the changes — x64, x86 and QEMU armv7m all
still 20/20 and 29/29.
* Correct the armv6m diagnosis: native stack, not RAM
The previous commit attributed rust.wasm's armv6m failure to memory. Running
each failing blob on its own, on a freshly collected heap, shows that is
wrong — every blob loads, allocates its 64 KB linear memory, and still has
40-65 KB of heap left:
zig free 140080 -> 64560 after load runs
cpp free 140416 -> 65552 after load maximum recursion depth exceeded
assemblyscript free 137248 -> 56624 after load [trap] stack overflow
rust free 129024 -> 40944 after load [trap] stack overflow
What stops three of the four is call depth. M3_HAS_TAIL_CALL=0 costs a native
frame per wasm call, and Cortex-M0+ frames on RP2040's stack cannot absorb
the deeper call graphs. cpp.wasm trips MicroPython's own C-stack guard rather
than d_m3MaxNativeStack, which is independent confirmation that the stack is
genuinely being consumed rather than our conservative bound being too low.
RAM is still tight and does matter second-order: run the blobs back-to-back
and fragmentation converts rust's and zig's failures into MemoryError, which
is what the earlier suite run showed and what the gc.collect() between
modules addresses. But on a fresh heap the same blobs fail on stack, so more
RAM alone would not buy much — a usermod build (text in flash) or a toolchain
with working musttail would.
README, CHANGELOG and the workflow comment now say this.
* Record why the RP2040 stack cannot just be enlarged
Follow-up to the armv6m diagnosis: freeing heap — by trimming modules or any
other means — cannot help, because on RP2040 the stack is not in the region
the heap comes from. It lives in SCRATCH_Y, a dedicated 8 KB SRAM bank.
Rebuilding RPI_PICO with PICO_STACK_SIZE=0x8000 fails at link:
ld: section `.stack_dummy' will not fit in region `SCRATCH_Y'
ld: region `SCRATCH_Y' overflowed by 24576 bytes
32 KB requested, 24 KB over — an 8 KB bank exactly. MicroPython derives its
own limit from the same linker symbols (__StackTop/__StackBottom, main.c:131),
so one constant governs both. Getting past it would need a custom pico-sdk
linker script that relocates the stack into main SRAM.
Also notes that M3_HAS_TAIL_CALL=1 is not a workaround on that toolchain
today: musttail landed in GCC 15 and arm-none-eabi 13 has no such attribute,
so the flag would expand to nothing.
Firmware restored to the stock 0x2000 and the armv6m leg re-verified: 20/20.
* Measure the RP2040 stack-vs-heap tradeoff
Answering whether the stack can be taken from the heap instead: it can, and
it fixes most of the armv6m blob failures.
MicroPython's RP2350 linker script already carves the stack out of the GC
heap, and the two lines port cleanly to memmap_mp_rp2040.ld — RAM, SCRATCH_X
and SCRATCH_Y are contiguous on this part, so the stack simply grows down out
of SCRATCH_Y and into the top of RAM:
__GcHeapEnd = ORIGIN(RAM) + LENGTH(RAM) - __micropy_extra_stack__;
__StackBottom = __GcHeapEnd;
Measured on a patched firmware, with d_m3MaxNativeStack raised to match:
extra stack guard blob suite
0 (stock) 8 KB 17/20 cpp, assemblyscript, rust fail
16 KB 16 KB 25/26 only rust fails
32 KB 24 KB 25/26 only rust fails
Raising the guard alongside is required: with the real stack enlarged,
d_m3MaxNativeStack itself becomes the limit and assemblyscript keeps trapping
at 8 KB.
rust.wasm is the one genuine wall. With 24 KB moved to the stack it now gets
through setup() and prints its banner before dying in loop():
free_before 105296 -> loaded 17248 -> after setup 16400 -> MemoryError 976
It wants ~15 KB more from a heap that just gave 24 KB away. Both come out of
the same 264 KB, and the ~94 KB .mpy resident in it is what makes the two
demands irreconcilable.
Nothing in this repo changes: d_m3MaxNativeStack stays 8 KB (the right
default for an unpatched port) and the firmware patch is a recipe in the
README, not a committed file. Config, linker script and firmware restored to
stock and everything re-verified — armv6m 20/20, x64 and x86 20/20 + 29/29.
* Accept the RP2040 ceiling; record what was tried
A natmod exists to drop onto the firmware people already have, so a patched
MicroPython is not an answer here even though it works. Recording the failed
attempts so this is not re-litigated:
-O2 instead of -Os 17/20, +17 KB of text
explicit -foptimize-sibling-calls 17/20, byte-identical output
M3_HAS_TAIL_CALL=1 no effect, musttail needs GCC 15
heap-allocated interpreter stack not reachable from a .mpy
PICO_STACK_SIZE past SCRATCH_Y fails to link, 4 KB bank
The byte-identical output on the second row is the proof: the flag was
already in effect at -Os, and GCC still does not sibling-call wasm3's
indirect dispatch under the natmod PIC model on Thumb.
The heap-stack idea is the one that would work in principle. It is out of
reach because dynruntime.h and mp_fun_table expose nothing for it:
gc_collect() derives its scan length from MP_STATE_THREAD(stack_top)
(gchelper_generic.c:219) and mp_cstack_check() its depth from the same
extents, neither of which a natmod can retarget — moving the stack would
corrupt root scanning and disable the recursion guard for Python callbacks.
The patched-firmware result (25/26 with the RP2350 linker lines ported to
memmap_mp_rp2040.ld and d_m3MaxNativeStack raised) stays in the README as a
measurement, explicitly not as a recommendation.
Framed accurately rather than as a blanket failure: the core suite is 20/20
on RP2040 and four of seven demo blobs run there. What the part cannot take
is the call depth of AssemblyScript's or C++'s output, or Rust's footprint.
armv7m clears all of it at 49/49.
No code changes — Makefile and config restored after the experiments.
* Install the ARM C++ headers for the rp2040py leg
The armv6m job failed at "Build RPI_PICO firmware":
pico_cxx_options/new_delete.cpp:11:10: fatal error: cstdlib: No such file or directory
pico-sdk compiles one C++ file of its own, so the firmware build needs
libstdc++-arm-none-eabi-newlib even though nothing in this repo is C++. It
was present locally, which is why the leg passed here and not on the runner.
* Fix three things a real-world module exposed
Loading a C++ build of bclibc — Emscripten -O3, 249 KB, 51 imports, an
initial 258-page linear memory — as a probe of the limits. It is not being
integrated; these are the defects it found on the way.
- src/libc_shim.c's calloc/realloc went through dynruntime's m_malloc and
m_realloc, which call m_malloc_fail() on exhaustion and raise MemoryError
through nlr (py/dynruntime.h:83-93). That longjmps out of whichever wasm3
frame was allocating, past its own cleanup, when wasm3 is written to check
every allocation for NULL and report m3Err_mallocFailed instead. Both now
use m_realloc_maybe. calloc also gained the num*size overflow check it
never had.
- WASM3_MP_MAX_ARGS was 8; Emscripten output goes well past it (one of
bclibc's exception trampolines takes 23 parameters). Now 16, chosen against
a Cortex-M0+'s 8 KB stack — those arrays are C-stack locals — rather than
raised arbitrarily. Past 16 it still fails loudly.
- d_m3MaxLinearMemoryPages stays 16 for devices, but natmod/Makefile now
raises it to 1024 for x64/x86. A module built for the web declares whatever
its toolchain chose; 258 pages is unremarkable on a host and unreachable on
an MCU, and the cap is what makes that a refusal at load rather than a
failure later.
Two limits are documented rather than fixed, because both fixes would be
wrong:
- No m3_GetTableFunction binding, so a host import cannot dispatch through
the indirect function table. That is exactly what Emscripten's invoke_*
imports do, so a -fexceptions C++ module traps with "uninitialized element"
once __wasm_call_ctors runs. Entry points that avoid the table work:
bclibc loads, all 51 imports link, and _BCLIBCFFI_get_version() returns
"1.1.7" read out of linear memory.
- A usermod needs the port to have a C heap, since wasm3 allocates through
the port's calloc. rp2 defaults MICROPY_C_HEAP_SIZE to 0, so wasm3.Module()
faults the CPU there; -DMICROPY_C_HEAP_SIZE=131072 fixes it with no code
change (verified, 20/20 on rp2040py). Routing usermod allocations at the GC
heap instead was tried and reverted: a usermod's globals live in firmware
.bss, which gc_collect() does not scan, so wasm3's memory was collected out
from under it — a segfault on the unix port two blobs into the suite. Doing
it properly needs MP_REGISTER_ROOT_POINTER, which is a larger change than
the config knob that already works.
Re-verified on every executing target: x64 and x86 20/20 + 29/29, armv7m
20/20 + 29/29 under QEMU, armv6m 20/20 on rp2040py.
* CI: usermod on four targets, matrix-shaped like a7p's
Was one job building unix-x64. Now a single matrix job with
`runs-on: ${{ matrix.runs_on }}` covering unix-x64, unix-x86, unix-aarch64
and rp2-RPI_PICO — the shape a7p's mp-usermod.yml uses, rather than
bclibc's job-per-target with cross-toolchains and qemu-user.
aarch64 is the row that earns the job existing. dynruntime.mk has no aarch64
ARCH, so a natmod cannot reach that architecture by any means, and usermod is
the only way this module runs on ARM64. Following a7p: GitHub's
ubuntu-24.04-arm runners are free on public repos and GA since August 2025,
so it builds natively there — no cross-compiler, no qemu-user, no Docker.
The other three rows duplicate architectures natmod already covers, which
a7p's own scope policy would exclude. They stay because usermod links against
the port's libc rather than src/libc_shim.c, and that difference has already
produced one real defect.
That defect is now caught at configure time. rp2 defaults MICROPY_C_HEAP_SIZE
to 0 and wasm3 allocates through the port's calloc(), so every allocation
failed and the firmware faulted inside wasm3.Module() with no diagnostic.
usermod/micropython.cmake now fails the configure with an error naming the
fix, guarded on DEFINED so ports with a real malloc (esp32's IDF heap) never
trip it. Verified both ways: the error fires without the flag, and
-DMICROPY_C_HEAP_SIZE=131072 builds and passes.
The rp2 row configures in two steps, because ports/rp2/Makefile composes its
cmake arguments with `CMAKE_ARGS +=` and a command-line CMAKE_ARGS= replaces
all of them, MICROPY_BOARD and USER_C_MODULES included.
run_rp2040py.py gains --no-mpy: with the module in the firmware, the littlefs
image carries only the .wasm blobs, and a wasm3.mpy on the filesystem would
shadow the frozen wasm3.py.
Verified locally with the workflow's exact command lines: unix-x64 20/20 +
29/29, unix-x86 20/20 + 29/29 (ELF 32-bit, Intel 80386), rp2-RPI_PICO 20/20
on rp2040py via --no-mpy. unix-aarch64 is the one row I cannot run here —
this machine is x86-64, so that leg is unproven until CI runs it.
* CI: split usermod into its own workflow
It was sitting in natmod.yml as carry-over from the original single-job
scaffold, not as a decision -- and it diverges from both reference repos
(micropython-bclibc's usermod.yml, a7p's mp-usermod.yml).
The two halves have nothing in common at the CI level: no shared
artifacts, no `needs:`, different trigger paths, and different failure
modes -- a usermod links against the port's own libc, a natmod against
src/libc_shim.c. In one file, one red X stood for both.
The job itself is unchanged (same four-row matrix). New file gets its own
`usermod-` concurrency group, its own path filter, and no workflow_call
trigger: nothing it builds is a distributable artifact. The shared
.github/actions/fetch-micropython and natmod/ci/run_rp2040py.py stay
where they are and are used from both.
Also refreshes the README status table, which still showed the usermod
column as covering only x64/x86.
* Make it visible that usermod is tested, and fix the counts
Both were misleading rather than wrong-in-CI: the usermod rows have been
running the full suite all along (aarch64 log for a898f20: "20 passed,
0 failed" then "31 passed, 0 failed").
- Job name was `usermod (unix-x64)`, so the checks list showed four jobs
with no "test" anywhere in the name, next to natmod's explicit
`test (x64 / micropython)`. Now `build + test (unix-x64)`, matching
micropython-bclibc's `build + test (aarch64, static)`.
- The status table had one shared "tests" column across a natmod and a
usermod cell, which read as natmod-only. Split into a result per mode,
with a note that each is an independently executed suite.
Counts in that column were also wrong, introduced in the previous commit:
x64/x86 ran 49/49, but both legs pass --slow, so it is 20 + 31 = 51.
Checked against the run-17 and usermod-run-1 logs rather than restated.
* CI: usermod on armhf and mipsel, under qemu-user
The two targets both reference projects cover and this one did not. Like
aarch64, they are natmod-unreachable rather than duplicates of natmod
coverage: dynruntime.mk's arm ARCHes are bare-metal EABI, not the Linux
ABI, and there is no mips ARCH at all.
Own job rather than two more rows in the existing matrix, because the
recipe differs -- libffi built from source as a separate `deplibs` pass,
a static link, and a qemu-user hop to run the result. a7p splits its
armhf/mipsel out the same way.
Toolchains follow upstream MicroPython's tools/ci.sh
(ci_unix_qemu_arm_*/ci_unix_qemu_mips_*) rather than being reconstructed:
gnueabi not gnueabihf, mipsel via gcc-mipsel-linux-gnu. qemu is invoked
explicitly instead of relying on the runner having binfmt_misc
registered. -static is a deployability requirement (these are minimal
embedded Linux boards with no guaranteed ld.so), same reasoning as
micropython/micropython#17456.
Verified locally with the workflow's exact command lines, both green
first try -- no wasm3 portability problems on either:
armhf ELF 32-bit ARM EABI5, static 20/20 + 31/31, CoreMark 85.978
mipsel ELF 32-bit MIPS32 rel2, static 20/20 + 31/31, CoreMark 140.400
README also records the three targets still absent (ports/qemu usermod,
webassembly, windows) with the reason for each, so the gap reads as a
decision rather than an oversight.
* CI: usermod on Windows, cross-built with mingw-w64
Settles the question the previous commit left open. natmod is not merely
absent on Windows, it is impossible: ports/windows/mpconfigport.h:45 sets
MICROPY_EMIT_X64 (0), and py/persistentcode.c:586 gates native .mpy
loading on MICROPY_EMIT_MACHINE_CODE. No wasm3.mpy loads there whatever
its arch, so usermod is the only way this module runs on Windows -- the
same natmod-can't-reach case as aarch64/armhf/mipsel.
Cross-compiled from Linux, which ports/windows/README.md documents as its
first build method, rather than windows-latest under MSYS2. Both are
legitimate; this one is what could actually be verified before pushing.
The MSYS2 route also needs its own workarounds (STRIP=""/SIZE="true",
$(pwd) instead of $GITHUB_WORKSPACE because MSYS2 bash consumes the
backslashes in a Windows path) and would mean replacing
.github/actions/fetch-micropython, which uses wget and POSIX paths.
Noted in the job comment as the upgrade path when Windows-native
execution of both arches is wanted.
Verified locally with the workflow's exact command lines:
x64 PE32+ x86-64, 659488 B text 20/20 + 31/31 under wine,
CoreMark 1923.447
x86 PE32 Intel 80386, 705120 B text builds clean
x86 is build-check only: running a 32-bit PE needs wine's WOW64 half
(wine32:i386), whose dependencies do not resolve on a plain 24.04 image.
That code path is not untested -- unix-x86 and armhf both execute the
full suite.
README's "still uncovered" list is now grounded in the v1.28.0 tree
rather than in what sibling repos happen to build. Two findings worth
recording:
- esp8266 is worse than uncovered. posix_helpers.c:35 implements malloc
as gc_alloc, and a usermod's globals live in firmware .bss, which
gc_collect() does not scan -- it would build and then be silently
wrong, exactly the defect that made the GC-heap experiment segfault on
unix. Needs MP_REGISTER_ROOT_POINTER first, as do stm32/samd/nrf/alif/
zephyr/cc3200, which have no C heap at all.
- esp32 was attempted and abandoned, not skipped: the ESP-IDF component
registry is unreachable from this environment and espressif/mdns and
espressif/lan867x are real dependencies of the port for that target,
not vendored in the release tarball. Recorded rather than pushed
unverified.
* Fix the wine install step in the Windows job
Run 4: "build + test (windows-x64, mingw)" failed at Install wine with
"wine: command not found" -- apt had succeeded a moment earlier.
On Ubuntu 24.04 the wine64 package ships the loader and libraries but no
wine64 command; the runnable /usr/bin/wine comes from the `wine` package,
which wine64 only Recommends. --no-install-recommends therefore installed
wine64 and nothing that can actually launch a PE.
My own slip rather than a surprise: locally I ran `apt-get install -y
wine64`, whose recommends pulled `wine` in, then wrote
--no-install-recommends into the workflow -- so the step that ran in CI
was not the step I verified.
Both packages are now named explicitly. `wine` is Architecture: all and
depends on "wine64 | wine32", so this pulls in no i386 packages -- which
is the point, since wine32:i386's dependency tree is what does not
resolve on a plain 24.04 image.
Re-verified with the corrected command: wine-9.0, 20/20 then 31/31.
The x86 job was unaffected and already passed, as did every other
usermod job -- the build recipe itself was never in question.
* CI: build and run Windows natively, dropping wine
wine was the wrong call. GitHub hosts Windows runners for both
architectures, so there is no reason to emulate: windows-latest is a real
x64 box (WOW64 covers the 32-bit build) and windows-11-arm gives arm64
Windows, which nothing else in this project can reach at all.
I reached for the cross-compile-plus-wine route because it was what I
could verify locally, and that traded a worse job for my own convenience
-- it emulated x64, could not run x86 at all (wine32:i386 does not
resolve on 24.04), and left arm64 out entirely.
x64/x86 use MSYS2's MINGW64/MINGW32 and their unprefixed gcc, the same
recipe as upstream's own build-mingw job. arm64 uses CLANGARM64.
Upstream does not test Windows ARM64, but a7p's mp-usermod.yml attempted
it and recorded every failure it hit in comments before dropping the row,
so the overrides here are taken from that rather than rediscovered:
LDFLAGS_ARCH drop --cref; CLANGARM64 links via clang+lld, which
rejects it
COMPILER_TARGET mpy-cross/Makefile greps `$(CC) -dumpmachine` for
"mingw" to decide whether to compile fmode.c; the
CLANGARM64 wrapper is clang and does not match, so the
link fails on set_fmode_binary
STRIP="" py/mkenv.mk defines $(CROSS_COMPILE)strip
unconditionally; gcc-compat ships no aarch64 strip.
Empty works because py/mkrules.mk guards it with ifdef
SIZE="true" same missing binary, but no ifdef guard, so it needs a
real no-op rather than ""
a7p stopped at WINDRES. That one looks like a consequence of prefixing:
py/mkenv.mk has WINDRES = $(CROSS_COMPILE)windres, and with CROSS_COMPILE
empty inside an MSYS2 environment it resolves to plain `windres`, which
gcc-compat does provide -- only the aarch64-w64-mingw32-prefixed name is
missing. Unproven until this runs.
This job cannot use .github/actions/fetch-micropython: it shells out to
wget and writes a backslash $GITHUB_WORKSPACE path into MPY_DIR, and
MSYS2 bash eats backslashes as escapes. It fetches with curl and keeps
POSIX paths throughout, same reason a7p passes $(pwd) for USER_C_MODULES.
Unlike every other job here this one was not run locally first -- there is
no Windows machine to run it on. What is verified is the module's Windows
behaviour, from the previous commit's mingw cross-build: x64 passed 20/20
and 31/31 under wine and x86 compiled clean. The MSYS2 plumbing is what
CI is proving. README's Windows rows say "in CI" until it does.
* Cast the f32 return explicitly; clang rejects the promotion
Windows x64 and x86 pass natively, 51/51 each. arm64 got as far as
building mpy-cross -- so a7p's CLANGARM64 overrides did their job -- and
then failed in this module's own code:
src/wasm3_mp.c:145:55: error: implicit conversion increases
floating-point precision: 'const float' to 'mp_float_t' (aka
'double') [-Werror,-Wdouble-promotion]
Not a CI quirk. gcc does not enable -Wdouble-promotion here, clang does,
and ports/windows compiles with -Werror -- so this would fail any
clang-based build of the usermod, not only ARM64.
Only one site has an implicit promotion: line 146 is double to double,
and line 135 already carries an explicit (float) for the narrowing
direction. Cast to mp_float_t rather than double, so it stays a no-op
under MICROPY_FLOAT_IMPL_FLOAT.
The natmod path never saw this because src/wasm3_mp.c:32 redefines
mp_obj_new_float with a (double) cast already baked in; the usermod path
uses MicroPython's own macro, which does not.
Verified rather than assumed: reproduced the exact diagnostic in a
five-line file under clang 18, confirmed the cast silences it, then ran
clang -Wdouble-promotion -Werror -fsyntax-only over the real translation
unit -- zero diagnostics. Re-ran both build modes on x64 afterwards:
natmod 20/20 + 31/31, usermod 20/20 + 31/31.
* Scope the f32 cast to the usermod build
The previous commit fixed clang and broke three natmod arches: rv32imc,
rv64imc and xtensa all failed with
src/wasm3_mp.c:149: error: 'mp_float_t' undeclared
py/dynruntime.mk selects MICROPY_FLOAT_IMPL=none for exactly those three
(lines 96, 121, 140), and with no float implementation the type does not
exist at all. The natmod path never needed the cast anyway -- line 32
already redefines mp_obj_new_float with a (double) baked in -- so naming
mp_float_t there was pointless as well as fatal.
Now a WASM3_FLOAT macro defined once per branch, next to the other
natmod/usermod differences: identity under natmod, (mp_float_t) under
usermod, where the type always exists if mp_obj_new_float does.
I had only rebuilt x64 before pushing, which cannot see this: x64 is
MICROPY_FLOAT_IMPL=double. Verified this time on the arches that actually
failed, with their real toolchains installed locally -- rv32imc, rv64imc
and xtensa all build, alongside x86, armv6m, armv7m and x64.
Correcting the previous message on one point: it claimed clang
-Wdouble-promotion -Werror -fsyntax-only over the real translation unit
gave zero diagnostics. It did not -- that command aborted early on a
missing mpconfigvariant.h and never reached the line in question, and I
read the empty diagnostic count as a pass. Done properly here by building
ports/unix through its own makefile with CC=clang and
CFLAGS_EXTRA="-Wdouble-promotion -Werror": clean, with wasm3_mp.c
genuinely in the compile, and both suites pass on the resulting binary
(20/20, 31/31). The five-line repro of the diagnostic, and the cast
silencing it, stands as reported.
* Windows arm64: disable an upstream warning on upstream's own code
Eight of nine usermod jobs green; windows-arm64 got past this module's
code and failed inside MicroPython's:
../../py/binary.c:165:16: error: implicit conversion increases
floating-point precision: '_Float16' to 'float'
[-Werror,-Wdouble-promotion]
mp_decode_half_float returns a _Float16 from a float-returning function.
ports/windows/Makefile:42 compiles with -Wdouble-promotion -Werror, and
gcc does not diagnose that particular promotion while clang does -- so
the MINGW32/MINGW64 rows pass and only CLANGARM64 trips it. Upstream has
evidently never built this port with clang.
Not something this repo can fix: it is upstream's warning firing on
upstream's code inside MPY_DIR. Disabled for the arm64 row alone via
CFLAGS_EXTRA, which ports/windows/Makefile appends after the flag it
overrides.
This module's own f32 promotion stays fixed properly rather than
silenced -- x64 and x86 still build with -Wdouble-promotion active, so a
regression there would still fail.
Worth reporting upstream; out of scope here.
* Windows arm64: -Wno-error instead of chasing warnings one at a time
Third distinct clang-vs-upstream collision on this row, all inside
MPY_DIR:
py/binary.c:165 _Float16 -> float
[-Wdouble-promotion]
shared/runtime/gchelper_generic.c:136-146 [-Wdefault-const-init-var-unsafe]
shared/runtime/gchelper_generic.c:147-154 [-Wuninitialized]
The gchelper ones are not bugs at all: that file declares uninitialized
`register const long x19 asm("x19")` and friends precisely to capture the
callee-saved registers for GC root scanning. clang objects, gcc does not.
MSYS2 ships no gcc for ARM64 Windows, so this row cannot avoid clang,
and ports/windows/Makefile:42 sets -Werror with a gcc-tuned warning set.
Adding -Wno-<name> per round-trip is exactly what made a7p give this
target up, and -Wno-uninitialized is too blunt to want. One -Wno-error
instead: warnings still print, they just do not fail the build.
No coverage lost for this module: the other eight usermod jobs build it
with -Werror. This row is a build-and-run smoke test for a platform
nothing else in the matrix reaches. Its one real finding, this module's
own f32 promotion, was fixed properly rather than silenced, and the
x64/x86 rows keep -Wdouble-promotion on to hold it that way.
* README: record the Windows arm64 result
usermod run 10: all nine jobs green, windows-arm64 included, with the
suites genuinely executed rather than skipped -- 20/20 then 31/31,
CoreMark 1670.286.
Also records why that row carries -Wno-error, and why "impossible" for
natmod on Windows is a fact read out of the port's config rather than an
assumption. Drops a stale "cross-built with" left over from the wine
version of this job; these builds are native now.
* CI: usermod on ports/webassembly
Answering whether it was possible rather than assuming: it is, and it
needed no changes to the module at all. wasm3 compiled to wasm by
Emscripten, running under node, passes both suites — 20/20 and 31/31,
CoreMark 221.697.
I had written this one off in README as "wasm3 interpreting wasm inside
wasm, which is not a deployment target anyone here has". That was a
judgement about usefulness standing in for a claim about feasibility, and
the usefulness judgement was wrong too: it is a genuinely distinct
portability datapoint — 32-bit target, a libc that is neither glibc nor
newlib, and no filesystem whatsoever. Like aarch64, armhf, mipsel and
Windows, natmod cannot reach it (no WASM ARCH in dynruntime.mk), which is
the case this half of CI exists for.
Two pieces of harness, both because the port gives the interpreter no
filesystem — open() raises OSError 44 for every blob, even though node
itself read the script off the host:
- natmod/ci/run_wasm.py prepends every .wasm through the same _INJECTED
hook the QEMU runner already uses, then hands node one combined script.
- test_wiring_apps.py reads --slow through a new _ARGV hook. It cannot
just read sys.argv: node forwards no arguments, and this port's sys has
no argv attribute at all. Falls back to getattr(sys, "argv", []) so
every other target is unaffected.
That second one touches a file every target shares, so it was re-checked
rather than assumed: x64 usermod 31 with --slow and 29 without, x64
natmod 31, and armv7m under QEMU still 29 with CoreMark correctly
skipped.
* Make the f64 return conversion explicit; correct the ports/qemu note
Trying the ports/qemu usermod build rather than continuing to assert it
was blocked. It is blocked, but not where this repo said, and it failed
first on something real in this module:
src/wasm3_mp.c:155: conversion from 'double' to 'mp_float_t'
{aka 'float'} may change value [-Werror=float-conversion]
That is the f64 case, not the f32 one fixed earlier, and it is a
narrowing rather than a promotion: mp_float_t is float wherever
MICROPY_FLOAT_IMPL_FLOAT is selected. Made explicit through the same
WASM3_FLOAT macro, and documented as a limitation -- a wasm f64 handed to
Python really does lose precision on those ports, which is worth stating
plainly rather than casting away silently.
Past that, the link fails on both libm (sqrt, rint, trunc, floor, ceil,
rintf) and libc (calloc, free, realloc): ports/qemu/Makefile:74 is
-nostdlib with only libgcc. The earlier note here claimed only a missing
C heap and framed it as a config gap; both halves are missing, and it is
a link failure rather than a zero-sized heap.
src/libc_shim.c and src/math_shim.c already provide precisely those
symbols. Compiling them into a usermod would link -- and then corrupt,
because they allocate on the GC heap while a usermod's globals sit in
firmware .bss that gc_collect() does not scan. MP_REGISTER_ROOT_POINTER
is the prerequisite, and the hard part of it is not the bookkeeping:
MicroPython's GC only traces block-aligned pointers, and wasm3 holds
interior ones. README now says that.
Re-verified after the cast: usermod x64 20/20 + 31/31, natmod x64 20/20 +
31/31, natmod armv7m builds.
* README: the ports/qemu and f64 notes the last commit described
The previous commit's message said README carried these; it did not --
the edit ran from the wrong directory and only the code change landed.
Here they are.
Two things recorded: that a wasm f64 returned to Python narrows on any
MICROPY_FLOAT_IMPL_FLOAT port, and what actually blocks a ports/qemu
usermod -- a -nostdlib link missing both libm and libc, where supplying
the existing shims would link and then corrupt without
MP_REGISTER_ROOT_POINTER, whose hard part is that MicroPython's GC does
not trace interior pointers.
* README: split the coverage table by build mode
The status table had grown a single "Target" column mixing two different
keys -- ARCH values (x64, armv6m, rv32imc) and ports (Windows x64,
webassembly) -- because usermod targets kept getting appended to a table
originally keyed on natmod's ARCH. It read as one axis when it is two.
Now a table each. natmod is keyed on ARCH, and the ten rows are every
value py/dynruntime.mk defines, with a built/executed split that makes
the four-of-ten honest rather than buried in a footnote. usermod is keyed
on port, lists all ten targets, and -- more useful -- lists the ports it
does not cover with the reason: qemu blocked, esp32 untried, esp8266
unsafe, six with no C heap at all, two plausible but untried.
The overview table near the top gains the same distinction, since
"Architectures" was wrong for the usermod row: a usermod is selected by
port, not by arch, which is exactly why aarch64, armhf, mipsel, Windows
and wasm are reachable there and nowhere else.
Also drops the paragraph that restated the usermod table in prose, and
fixes two counts that went stale as jobs were added: six unexecuted
natmod arches (said five) and nine other usermod jobs keeping -Werror
(said eight).
* Probe whether a GitHub ARM64 runner can execute AArch32
Temporary job, to be removed once it has answered.
aarch64 already builds and runs natively in this repo, in
micropython-bclibc and in a7p -- ubuntu-24.04-arm, no cross-compiler, no
emulation. The only rows still cross-compiled and run under qemu-user are
armhf and mipsel. mipsel has no possible native runner. armhf would need
the ARM64 host to support the AArch32 execution state at EL0, which
ARMv8-A makes optional and server-class cores generally omit.
That is a claim about specific hardware, so it gets measured rather than
asserted: build a static armhf binary on the runner and try to exec it
with no qemu in the way. The job reports either way and does not fail the
run.
* Fix the AArch32 probe: freestanding, and genuinely non-fatal
The first version answered nothing. gcc-arm-linux-gnueabihf on an arm64
host ships no armhf libc headers, so it died at
/usr/include/stdio.h:28: fatal error: bits/libc-header-start.h:
No such file or directory
before ever attempting to execute anything. And contrary to what its
commit message claimed, that did fail the run -- the compile error came
before the `if`, so the step went red.
Now -nostdlib with a raw exit syscall, which needs no cross sysroot at
all and tests the CPU rather than the toolchain's headers. `set +e` plus
an explicit `exit 0` so neither outcome can fail the run, as intended the
first time.
* Fix the AArch32 probe again, and stop it printing a false verdict
Second attempt failed too, and this time it lied about why:
t.c:5:1: error: r7 cannot be used in 'asm' here
compile rc=1
./t32: No such file or directory
RESULT: AArch32 is NOT supported (exit 127)
r7 is the frame pointer, so gcc rejects it as an asm register variable.
127 then means "file not found" -- the compile had failed -- not "the CPU
refused". The verdict line was printed regardless, which is worse than
answering nothing.
Now: a bare asm block naming the registers inline rather than register
variables, -fomit-frame-pointer to free r7, an explicit compile check
that reports INCONCLUSIVE instead of guessing, and exit codes read
separately so 126 (exec format error) is distinguished from anything
else.
Validated locally this time before pushing, which is what the first two
attempts skipped: arm-linux-gnueabi-gcc builds this exact source and the
result exits 0 under qemu-arm. The binary is known good, so the only
variable left in CI is whether the runner's CPU will execute it.
* ci: run armhf on a real AArch32 runner, and retire the probe that proved it
The probe job added two commits ago answered its question on usermod run #18:
a statically linked, freestanding AArch32 binary executes on ubuntu-24.04-arm
with no emulator and no binfmt handler -- "RESULT: AArch32 IS supported on
this runner". So armhf stops going through qemu-user. The arm64 runner
cross-builds the 32-bit binary and then runs it on its own CPU, which turns
the armhf leg from an interpreter's opinion into a real execution test on
real hardware. mipsel keeps qemu-user; GitHub has no mips runner.
That move is also why armhf switched from gnueabi to gnueabihf, deliberately
departing from upstream's own tools/ci.sh (ci_unix_qemu_arm_* uses gnueabi).
Under qemu the ABI choice is nearly free -- the emulator implements whatever
ARMv5 asks for. On real ARMv8 hardware it is not: gnueabi is soft-float and
baselines at ARMv5TE, whose SWP/SWPB atomics were removed outright in ARMv8
and survive only through the kernel's opt-in ARMV8_DEPRECATED emulation. The
probe binary was built with gnueabihf, so that is the toolchain actually
proven on that runner, and "armhf" is what this row has always been called.
Verified before pushing, on this machine, rather than left to CI: the
gnueabihf cross toolchain builds MICROPY_STANDALONE=1 deplibs and the static
usermod binary with no new diagnostics (only the two familiar glibc
static-link notes for dlopen/getaddrinfo), the result is "ELF 32-bit LSB
executable, ARM, EABI5 ... statically linked", and it passes the suite under
qemu-arm -cpu max -- qemu's ARMv8-AArch32 model, the closest local stand-in
for the runner's own CPU. Disassembly carries no SWP/SWPB at all; the eight
apparent SETENDs are Thumb-2 regions decoded as ARM (their neighbours are
nonsense like `lfmne` and `<UNDEFINED>`), not real instructions.
The README's armhf row is marked pending rather than carrying over its old
green: 51/51 was measured under qemu-user, and the first run on real
hardware has not reported yet.
The probe job is deleted in the same commit. It was labelled temporary when
it was added, it has produced its answer, and its findings now live in the
usermod-cross header comment where they are actually load-bearing.
* ci: execute the armv7emsp/armv7emdp natmods on real ARM silicon
Every ARM leg in natmod.yml was an emulator until now -- armv7m under
qemu-system-arm, armv6m under rp2040py. A new test-arm-linux matrix job on
ubuntu-24.04-arm builds a 32-bit armhf ports/unix interpreter, loads the
armv7emsp and armv7emdp .mpy artifacts into it, and runs both suites against
each. The runner executes AArch32 on its own CPU, so there is no emulation
anywhere in this leg.
Why it works: py/persistentcode.h gives a Thumb-2 host with a
double-precision FPU MPY_FEATURE_ARCH = MP_NATIVE_ARCH_ARMV7EMDP, and
MPY_FEATURE_ARCH_TEST is a *range* -- ARMV6M <= x <= that -- not an equality.
Ubuntu's arm-linux-gnueabihf-gcc defaults to Thumb-2 with __ARM_FP=12, so
every ARM natmod ARCH clears the header check on an armhf host. Read back off
the built binary rather than assumed: sys.implementation._mpy >> 10 is 8.
Why only two of four: that check says nothing about the float ABI.
py/dynruntime.mk gives armv6m and armv7m no -mfloat-abi=hard, so their floats
cross into the runtime in core registers while an armhf host reads them from
VFP registers per AAPCS-VFP. Those .mpy files load and then return wrong
numbers rather than failing -- measured on micropython-bclibc's module, where
find_zero_angle came back 984.252 rad (its range in feet) instead of
0.002502. Silently wrong, never a crash, so both keep their emulator legs.
armv7emsp and armv7emdp are hard-float and line up, each against a host built
with its own MICROPY_FLOAT_IMPL, which decides sizeof(mp_float_t) on both
sides of the dynruntime call boundary. The single-precision host takes
-DMICROPY_FLOAT_IMPL=MICROPY_FLOAT_IMPL_FLOAT, which works because
mpconfigvariant_common.h guards its double default with #ifndef; confirmed on
the resulting binary (print(1/3) gives 0.33333334) rather than inferred.
Both legs were run end to end locally before this job existed, against real
dist artifacts built with arm-none-eabi-gcc: 20/20 then 31/31 on each, with
CoreMark 94.041 (dp) and 94.738 (sp).
Scope, stated in the job comment too: this runs Cortex-M code inside a Linux
process. It proves the native module and its relocations are correct on real
ARM silicon. It does not replace the QEMU leg, which exercises a -nostdlib
firmware environment this module carries its own libc/math shims for.
README's natmod table goes from four executed ARCHes to six, and its usermod
armhf row drops the "pending" marker -- run #19 came back green on the real
AArch32 runner.
* fix: link the 32-bit ARM natmod host statically
The test-arm-linux job failed on its first run, both legs, exit 127:
micropython: ELF 32-bit LSB pie executable, ARM, EABI5 ...
dynamically linked, interpreter /lib/ld-linux-armhf.so.3
...: cannot execute: required file not found
The runner is arm64. Its CPU executes AArch32 -- that part was measured and
is right -- but its filesystem carries no armhf glibc and no
/lib/ld-linux-armhf.so.3, so the loader named in the ELF header does not
exist and execve fails before any of this reaches MicroPython.
My own gap in verification, not a surprise from CI. Locally I ran the host
under qemu-arm-static -L /usr/arm-linux-gnueabihf, and that -L is exactly
the armhf sysroot the runner does not have -- so the one property this job
depends on was the one property my local run supplied for free. The usermod
armhf rows never hit it because they have always linked -static, for
deployability reasons rather than this one.
LDFLAGS_EXTRA=-static, then: a static binary names no interpreter. Re-checked
locally the way CI actually runs it, with no -L and no sysroot: file now says
"statically linked", and every leg passes.
wasm3: armv7emdp and armv7emsp both 20/20 then 31/31.
* fix: build mpy-cross before the 32-bit ARM host, so CFLAGS_EXTRA cannot leak
Second failure of test-arm-linux, and unlike the first one this was specific
to this repo's version of the job:
./mpconfigport.h:77: error: "MICROPY_FLOAT_IMPL" redefined [-Werror]
77 | #define MICROPY_FLOAT_IMPL (MICROPY_FLOAT_IMPL_DOUBLE)
<command-line>: note: this is the location of the previous definition
make: *** [../../mpy-cross/build/mpy-cross] Error 2
py/mkrules.mk auto-builds mpy-cross as a prerequisite of the port build, and
CFLAGS_EXTRA reaches that sub-make through MAKEFLAGS. The unix port takes
-DMICROPY_FLOAT_IMPL=... happily, because mpconfigvariant_common.h guards its
own default with #ifndef -- that part was checked. mpy-cross does not: its
mpconfigport.h defines the macro unconditionally and it compiles -Werror, so
the armv7emsp row's -D collided head-on. The armv7emdp row passes an empty
CFLAGS_EXTRA and died for the same reason a step later, on the same
auto-build.
The sibling repos never hit it because their versions of this job already
build mpy-cross explicitly first, for the older FROZEN_MANIFEST-leak reason.
This one did not -- my omission when transplanting it, not a difference
between the repos. Same step, same placement, now here too: built natively
for arm64 with no CFLAGS_EXTRA anywhere near it, so the port build finds the
binary already present and the auto-build path never runs.
* README: record the musl measurement, and fix a stale executed-arch count
Two documentation-only changes.
A "Not done: musl for the static unix builds" section, written down rather
than acted on. The armhf and mipsel rows link -static against glibc, and
glibc warns on every such link that dlopen and getaddrinfo still need the
shared libraries from the glibc they were linked against -- so a "static"
glibc binary is not actually self-contained on the minimal target it exists
for. musl has no NSS and a stub dlopen, so the same build has neither caveat.
That was measured, not assumed: a musl static build came back with zero link
warnings against glibc's two, ldd reporting "not a dynamic executable", and
getaddrinfo working, at the cost of MICROPY_PY_BTREE=0 and MICROPY_PY_FFI=0.
Deliberately not implemented for now; recorded so the measurement is not lost
and nobody has to re-derive it.
The natmod prose still said four executed ARCHes. The table above it already
says six -- armv7emsp and armv7emdp now run on a 32-bit armhf ports/unix host
on ubuntu-24.04-arm, with no emulator involved -- so the paragraph now says
so too.
* ci: build the esp32 usermod, and stop calling it impossible
README wrote this target off as "cannot be built in the environment this was
developed in", which was true and also the wrong scope: that is a claim about
one machine, not about the target. dl.espressif.com and
components-file.espressif.com are both refused by this project's development
environment, and espressif/mdns and espressif/lan867x are real dependencies of
the port for target esp32, vendored in neither the release tarball nor esp-idf
itself -- so it could not be checked locally before pushing, which is this
repo's usual bar. On a GitHub runner none of that applies: esp-idf's own
install.sh runs there green in ballistics-lab/micropython-bclibc's natmod
xtensawin leg, and has for a while.
So a usermod-esp32 job, BOARD=ESP32_GENERIC under ESP-IDF v5.5.1 (what
ports/esp32/README.md names as recommended for this MicroPython release),
written CI-first rather than verified locally. It clones esp-idf --recursive,
unlike a natmod build of the same ISA, which only borrows the xtensa compiler
install.sh downloads and skips IDF's submodules on purpose.
Build-only, and not as a shortcut: there is no esp32 emulator to hand a
firmware image to the way rp2040py takes an RP2040 .uf2 or qemu-system-arm
takes a Cortex-M3 .elf. It proves wasm3 compiles and links into a real esp32
firmware; it proves nothing runs, and the job comment says so.
The interesting question here is size rather than portability. wasm3 is an
interpreter, not a leaf module, and a usermod goes into the firmware image --
so this is the first target where the port's flash and IRAM budget is a real
constraint on it rather than a footnote. A red result is informative either
way, which is the point of running it instead of continuing to assert it.
The status table's esp32 row is marked pending rather than green: the first
run has not reported yet.
* ci: surface the IDF build logs when the esp32 job fails
First run of usermod-esp32 got a long way -- esp-idf cloned, install.sh ran,
the managed components resolved, the bootloader linked and 1439 of 1460
objects compiled -- and then stopped here:
[1440/1460] Building C object esp-idf/main/CMakeFiles/__idf_main.dir/
.../wasm3/source/m3_compile.c.obj
ninja: build stopped: subcommand failed.
ninja failed with exit code 1, output of the command is in
build-ESP32_GENERIC/log/idf_py_stderr_output_5252
So the interesting parts are settled: the component registry is reachable
from a runner, the IDF toolchain installs, and this is a compile error in
wasm3's own source under xtensa rather than a flash/IRAM overflow, which is
what I expected to hit first. What is not settled is *why*, because idf.py
redirects the compiler's stderr into that log file and prints only its own
one-line summary to the console -- so the actual diagnostic never reached the
Actions log.
This step dumps those logs on failure. No fix attempted yet: guessing at a
diagnostic I have not read is how the earlier rounds in this session went
wrong, and the environment this repo is developed in cannot reach
dl.espressif.com to reproduce it locally.
* ci: print the esp32 stderr log last, not first
The dump step worked and still told me nothing useful. `for f in
.../idf_py_std*_output_*` expands alphabetically -- stderr, then stdout -- so
the compiler diagnostic went out first and then 200 lines of ninja progress
went out on top of it, pushing the one thing worth reading far enough up the
log that it never came back in a tail.
Order reversed and sizes matched to what each file is actually for: 20 lines
of stdout (ninja's progress, useful only to confirm where it stopped), then
the stderr tail last and uncollapsed, since that is the diagnostic. Still no
fix for the underlying m3_compile.c failure -- I have not read it yet.
* ci: re-run ninja verbosely to get the esp32 diagnostic out of hiding
Third attempt at reading why m3_compile.c fails, and the reason the first two
found nothing is now clear: the diagnostic is not in the log files.
idf_py_stderr_output_* holds only idf.py's own bookkeeping --
Command: cmake -G Ninja -DPYTHON_DEPS_CHECKED=1 ...
Including User C Module(s) from .../usermod/micropython.cmake
Found User C Module(s): usermod_wasm3
Command: ninja all
-- and the stdout copy of ninja's stream shows the failing target followed
directly by "ninja: build stopped: subcommand failed", with nothing between
where the compiler's output should be.
So stop reading files and reproduce instead. Everything in that build
directory is built except the one object that failed, so re-running `ninja -v
-k 0` there recompiles exactly that translation unit and prints both the full
command line and whatever the compiler says, straight to the console where no
redirection can swallow it. `|| true` so the step reports rather than
re-failing a job that has already failed.
Still no fix: I have not read the diagnostic yet and am not going to guess at
one. Worth noting what the logs did settle, though -- USER_C_MODULES resolved
("Found User C Module(s): usermod_wasm3"), the manifest was accepted, and
CMake configured cleanly. Whatever this is, it is a plain compile failure of
one file, not a wiring problem with the usermod integration.
* esp32: stop -Werror=maybe-uninitialized failing on upstream wasm3 code
Three rounds of instrumentation later, the diagnostic finally reached the
log, and it is not where ninja's progress line pointed. It fails in
m3_validate.c, not m3_compile.c -- that line named the last target ninja had
started, not the one that failed:
In function 'v_validate_body',
inlined from 'ValidateFunction' at wasm3/source/m3_validate.c:1165:9:
m3_validate.c:628:17: error: 't2' may be used uninitialized
[-Werror=maybe-uninitialized]
628 | r = v_pop_expect(v, t2, &t1);
m3_validate.c:624:16: note: 't2' was declared here
cc1: some warnings being treated as errors
Reading the site, t2 is written by `v_pop(v, &t2)` two lines above and the
function returns immediately if that call fails, so every path that reaches
line 628 has written it. gcc only reaches the opposite conclusion after
inlining the entire validator body into ValidateFunction, which is the
classic shape of a -Wmaybe-uninitialized false positive.
It is upstream wasm3's code, in a submodule, and ESP-IDF is the only port
here that compiles it with -Werror and this warning enabled -- the natmod
xtensawin build of the same sources is green, which is why nothing caught it
before. Not something to patch downstream, so: -Wno-error=maybe-uninitialized
in the usermod cmake, next to the -Wno-sign-compare and -Wno-float-conversion
that are already there for exactly this class of collision.
-Wno-error rather than -Wno-, so the warning still prints.
What th…
Add a new section to `ports/unix/README.md` that describes how to perform fully static builds of the MicroPython unix port. This provides clearer guidance for advanced users and CI configurations, improving documentation clarity. Fixes micropython#17399, micropython#17353 Signed-off-by: o-murphy <thehelixpg@gmai.com>


Summary
This Pull Request adds new documentation to
ports/unix/README.mdto guide users on how to build a fully statically linked MicroPython executable for the Unix port. The existing documentation forMICROPY_STANDALONE=1only covers static linking oflibffi. However, for deployments to minimal Linux environments (like Buildroot) or for creating truly self-contained binaries, a full static link of all dependencies (includinglibc,libpthread, etc.) is often required. This change addresses the common issue of binaries failing to run due to missingld.soorlibcmismatches in target environments.The new section, titled "Fully Static Builds (for minimal environments)", includes:
LDFLAGS_EXTRA="-static"during the build process, including an example for cross-compilation withmipsel-linux-gnu-.fileandldd.Testing
Testing was performed by:
ports/unixdefault configuration withLDFLAGS_EXTRA="-static"on a Debian-based Linux system to create a fully static native executable. Verification was done usingfileandldd.ports/unixwithCROSS_COMPILE=mipsel-linux-gnu-andLDFLAGS_EXTRA="-static"on a Debian-based Linux system (withlibc6-dev-mipsel-crossinstalled) to simulate a static build for an embedded Linux target. Verification was performed usingfileand attempting to run the binary viaqemu-mipsel.All builds successfully completed, and the resulting executables were confirmed to be statically linked.
For a practical demonstration of a successful static build, please refer to the following workflow run
Trade-offs and Alternatives
The primary trade-off for fully static builds is the increased binary size. Statically linked executables are significantly larger compared to dynamically linked ones because they embed all necessary library code directly. However, this is an acceptable trade-off for users targeting minimal environments where a dynamic linker is unavailable or incompatible, or for situations requiring a single, self-contained executable.
No direct alternatives within the current MicroPython build system were identified for achieving a full static link without passing custom
LDFLAGS. The existingMICROPY_STANDALONE=1flag specifically addresseslibffi's static linking, not the entire binary's dependencies on system libraries. This documentation clarifies the existing mechanism (LDFLAGS_EXTRA) for this specific use case.Relative issues: #17399, #17353
closes #17399