Skip to content

turbo: espressif boards loader enable native .mpy - #11392

Open
mikeysklar wants to merge 9 commits into
adafruit:mainfrom
mikeysklar:espressif-load-native
Open

mikeysklar wants to merge 9 commits into
adafruit:mainfrom
mikeysklar:espressif-load-native

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

What

Enables the turbo loader (CIRCUITPY_LOAD_NATIVE) on all espressif boards. Follows #11332, #11355, #11356 and #11360.

  • Port hook frees code after teardown
  • Commit loaded code to executable RAM
  • Memory protection off with loader enabled
  • Separate native prelude on windowed Xtensa
  • Loader on by default, every board
  • update_sdkconfig.py follows the loader default

How I tested it

Hardware ran 10.4.0-alpha.1-22-gcd6e5ec116 on an Ubuntu host.

Test Boards Result
mandelbrot viper .mpy vs float bytecode Metro ESP32-S3 172 ms vs 4,867 ms
Metro ESP32-S2 206 ms vs 7,520 ms
ESP32-C5-DevKitC-1 159 ms vs 7,535 ms
@micropython.viper in source all three SyntaxError
armv7emsp .mpy all three ValueError: incompatible .mpy arch
exec RAM freed each soft reload all three same module count, 10 of 10 rounds
build, loader on 67 boards: ESP32, C2, C5, C6, C61, H2 all fit, least free 39,264 B (C5 DevKitC)
build, loader on and off Metro S2, Metro S3, QT Py C3 loader images smaller, IDF memory protection driver dropped

AI assistance

Claude Code was used.

mikeysklar and others added 9 commits September 14, 2026 17:16
stop_mp() runs the heap finalisers in gc_deinit(). A port that keeps
memory outside the VM heap which heap objects point into, such as native
code copied into executable RAM, can only free it after that point.
reset_port() runs before stop_mp(), which is too early.

The weak default does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default MP_PLAT_ALLOC_EXEC places native code on the GC heap, which on
these chips is DRAM or PSRAM and cannot be fetched as instructions. Define
MP_PLAT_COMMIT_EXEC, used by py/persistentcode.c for native .mpy files, to
copy the finished code into a heap_caps_malloc(MALLOC_CAP_EXEC) block,
applying the relocations against the final address first. Blocks are kept
on a list and freed together from port_gc_deinit(), once the heap
finalisers have run.

Modelled on esp_native_code_commit() in MicroPython's ports/esp32/main.c,
including the ESP32-S2 esp_ptr_executable() workaround for
espressif/esp-idf#14835. The copy is word-wise because Xtensa
executable RAM is not byte-addressable.

With CONFIG_ESP_SYSTEM_MEMPROT=y, the current default, the heap component
disables CONFIG_HEAP_HAS_EXEC_HEAP and MALLOC_CAP_EXEC allocations return
NULL, so loading native code fails with MemoryError rather than a fault.
The sdkconfig side is the next commit.

Only compiled when MICROPY_PERSISTENT_CODE_LOAD_NATIVE is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add esp-idf-config/sdkconfig-native.defaults with CONFIG_ESP_SYSTEM_MEMPROT=n
and include it in SDKCONFIG_DEFAULTS only when CIRCUITPY_LOAD_NATIVE=1 or
CIRCUITPY_ENABLE_MPY_NATIVE=1. Nothing changes for builds without either.

On ESP-IDF 6 the PMS (S2, S3, C3) and PMP (C6, C5) memory protection
marks RAM non-executable and sets CONFIG_HEAP_HAS_EXEC_HEAP=n, so
MALLOC_CAP_EXEC allocations always fail and native code cannot run.
Turning it off is a security trade-off: a write-anywhere bug becomes an
execute-anywhere bug. It is opt-in per build for that reason. MicroPython
ships every esp32 board with CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n
(ports/esp32/boards/sdkconfig.base).

check-sdkconfig.py refuses a build that has either flag on while
CONFIG_ESP_SYSTEM_MEMPROT is still set, so a board sdkconfig cannot
silently re-enable it and produce firmware that faults on import.

tools/update_sdkconfig.py loads the same defaults file for boards with
either flag, so regenerating a board sdkconfig does not copy the setting
into it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… loading

MICROPY_EMIT_NATIVE_PRELUDE_SEPARATE_FROM_MACHINE_CODE is keyed on
MICROPY_EMIT_XTENSAWIN alone. A build that loads native .mpy without the
emitter on the ESP32-S2/S3 left it at 0, so persistentcode.c read the
prelude of every loaded @micropython.native function byte-wise out of
IRAM. Windowed Xtensa cannot do that: the first call of a native
(non-viper) function took a LoadStoreError and the board reset. Viper
functions carry no prelude, which is why viper alone passed.

Key the define on the compiler target as well. Upstream has the same
expression and does not hit this because its esp32 port always builds
the emitter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Turn on CIRCUITPY_LOAD_NATIVE for the two Xtensa boards. Host-compiled
xtensawin viper mandelbrot (160x120, 64 iterations): Metro ESP32-S3
172 ms, Metro ESP32-S2 206 ms, against 4,867 ms and 7,520 ms float
bytecode. @micropython.viper from source raises SyntaxError and an
armv7emsp .mpy raises ValueError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Turn on CIRCUITPY_LOAD_NATIVE for the RISC-V devkit. Host-compiled
rv32imc viper mandelbrot (160x120, 64 iterations): 159 ms against
7,535 ms float bytecode and 3,569 ms fixed-point bytecode.
@micropython.viper from source raises SyntaxError and an armv7emsp
.mpy raises ValueError. Same code path as the Xtensa boards, no
port change needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Turn on CIRCUITPY_LOAD_NATIVE for the two Adafruit RISC-V boards. Same
rv32imc loader path as the ESP32-C5-DevKitC-1-N8R8. Both build; neither
was run on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turn CIRCUITPY_LOAD_NATIVE on in mpconfigport.mk for every espressif
chip and drop the five per-board lines. Boards without the flash space
opt out in their own mpconfigboard.mk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loader is now on by default in mpconfigport.mk, so a board file no
longer names CIRCUITPY_LOAD_NATIVE. Default it to on here too and let a
board's "= 0" turn it off, so the native sdkconfig defaults stay out of
the board sdkconfig files. CIRCUITPY_ENABLE_MPY_NATIVE = 1 still counts
on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikeysklar
mikeysklar force-pushed the espressif-load-native branch from 8bd9742 to 540f614 Compare September 15, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant