Skip to content

Commit fb700f7

Browse files
tyethclaude
andcommitted
zephyr-cp: freeze .mpy modules named in circuitpython.toml
The port had no frozen-module support: its Python-driven build never runs make, and the freeze pipeline lives in py/circuitpy_mpconfig.mk. Reimplement the pipeline in cptools/build_circuitpython.py behind a per-board opt-in, FROZEN_MPY_DIRS = ["frozen/<lib>", ...] in circuitpython.toml (paths relative to the repository root, as $(TOP)/... is in mpconfigboard.mk): - tools/preprocess_frozen_modules.py stages the trees into <build>/frozen_mpy (repo directory dropped, __version__ filled in, examples and tests left out), before the qstr pass; - MICROPY_QSTR_EXTRA_POOL / MICROPY_MODULE_FROZEN_MPY are added to the flags for the qstr pass as well as the compile -- Q(.frozen) and the sys.path entry that uses it are behind MICROPY_MODULE_FROZEN; - after genhdr/qstrdefs.generated.h and root_pointers.h exist, mpy-cross compiles each module (-s with the module path, so mpy-tool derives the frozen name from it) and tools/mpy-tool.py -f -q emits frozen_content.c, fed the same collected qstr list that produced the generated header so the frozen pool numbers from MP_QSTRnumber_of correctly. tools/makemanifest.py is bypassed: it insists on genhdr/qstrdefs.preprocessed.h, which this builder never produces. Only MPY freezing: MICROPY_MODULE_FROZEN_STR would reference the mp_frozen_str_* tables makemanifest emits. - frozen_content.c is compiled with the no-qstr sources (it defines its own MP_QSTR_* enum values and must never go through extraction). pre_zephyr_build_prep.py builds mpy-cross first when a board freezes modules (honouring MICROPY_MPYCROSS), and tools/ci_fetch_deps.py learns the toml key so CI initialises the right frozen/ submodules (it had a TODO for this). The Pico W opts in with adafruit_ble: it has ~42 KB of heap and a BLE node cannot otherwise load the library. Measured on a Pico 2 W (same core, same flags): importing adafruit_ble plus its advertising.standard and services.nordic modules costs 10,384 B of heap frozen vs 21,792 B from .mpy files on CIRCUITPY (gc.mem_alloc() delta after gc.collect(); gc.mem_free() is not usable here, the split heap grows on demand), 0.055 s vs 0.132 s. Freezing the 20 modules adds 28,336 B of flash and no static RAM on the Pico W (1,208,636 -> 1,236,972 B with the rest of this series). The Pico 2 W is not opted in: it has the heap, and a frozen copy would pin the library version for everyone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8ea8f3f commit fb700f7

4 files changed

Lines changed: 120 additions & 2 deletions

File tree

ports/zephyr-cp/boards/raspberrypi/rpi_pico_w_zephyr/circuitpython.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@ BLOBS=["hal_infineon"]
33

44
# Non-Zephyr build of the same board; nvm and CIRCUITPY must sit where it puts them.
55
counterpart = "raspberrypi/raspberry_pi_pico_w"
6+
7+
# Frozen into flash: the Pico W has ~42 KB of heap, and adafruit_ble alone takes
8+
# ~30 KB of it when loaded from CIRCUITPY as .mpy. Freezing keeps the bytecode in
9+
# flash so a BLE node fits.
10+
FROZEN_MPY_DIRS = ["frozen/Adafruit_CircuitPython_BLE"]

ports/zephyr-cp/cptools/build_circuitpython.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import asyncio
22
import logging
33
import os
4+
import os
45
import pathlib
6+
import shutil
7+
import subprocess
58
import pickle
69
import sys
710

@@ -356,6 +359,77 @@ def determine_enabled_modules(board_info, portdir, srcdir):
356359
return enabled_modules, module_reasons
357360

358361

362+
def stage_frozen_modules(frozen_dirs, srcdir, builddir):
363+
"""Copy the boards' frozen library trees into builddir/frozen_mpy.
364+
365+
Mirrors the ``$(BUILD)/frozen_mpy`` step of py/circuitpy_mpconfig.mk: the
366+
repo-name directory is dropped, ``__version__`` is filled in and examples,
367+
docs and tests are left out. Returns the staged .py files, relative to the
368+
staging directory, in a stable order.
369+
"""
370+
staging = builddir / "frozen_mpy"
371+
if staging.exists():
372+
shutil.rmtree(staging)
373+
staging.mkdir(parents=True)
374+
env = dict(os.environ)
375+
env["PYTHONPATH"] = str(srcdir / "tools" / "python-semver")
376+
subprocess.run(
377+
[
378+
sys.executable,
379+
srcdir / "tools" / "preprocess_frozen_modules.py",
380+
"-o",
381+
staging,
382+
*[srcdir / d for d in frozen_dirs],
383+
],
384+
cwd=srcdir,
385+
env=env,
386+
check=True,
387+
)
388+
return sorted(p.relative_to(staging) for p in staging.rglob("*.py"))
389+
390+
391+
def freeze_modules(frozen_sources, srcdir, builddir, mpy_cross, qstr_defs):
392+
"""Compile the staged modules with mpy-cross and emit frozen_content.c.
393+
394+
The make flow drives this through tools/makemanifest.py; that script insists
395+
on ``$(BUILD)/genhdr/qstrdefs.preprocessed.h``, which this builder never
396+
produces (it feeds the collected qstrs straight to makeqstrdata.py), so the
397+
two tools it wraps are invoked directly. ``qstr_defs`` must be the exact
398+
file that produced genhdr/qstrdefs.generated.h: mpy-tool numbers the frozen
399+
modules' extra qstrs from MP_QSTRnumber_of onwards, so the two pools must be
400+
computed from the same set.
401+
"""
402+
staging = builddir / "frozen_mpy"
403+
mpy_files = []
404+
for rel in frozen_sources:
405+
out = staging / rel.with_suffix(".mpy")
406+
# -s records the module path (not the staging path) as the source name,
407+
# which is what mpy-tool derives the frozen module name from.
408+
subprocess.run(
409+
[mpy_cross, "-s", str(rel), "-o", out, staging / rel],
410+
cwd=staging,
411+
check=True,
412+
)
413+
mpy_files.append(out)
414+
frozen_content = builddir / "frozen_content.c"
415+
with frozen_content.open("w") as f:
416+
subprocess.run(
417+
[
418+
sys.executable,
419+
srcdir / "tools" / "mpy-tool.py",
420+
"-f",
421+
"-q",
422+
qstr_defs,
423+
"-mlongint-impl=mpz",
424+
*mpy_files,
425+
],
426+
cwd=srcdir,
427+
stdout=f,
428+
check=True,
429+
)
430+
return frozen_content
431+
432+
359433
async def build_circuitpython(): # noqa: C901
360434
circuitpython_flags = ["-DCIRCUITPY"]
361435
port_flags = []
@@ -404,6 +478,22 @@ async def build_circuitpython(): # noqa: C901
404478
if mpconfigboard_fn is not None and mpconfigboard_fn.exists():
405479
with mpconfigboard_fn.open("rb") as f:
406480
mpconfigboard.update(tomllib.load(f))
481+
# Frozen modules (opt-in per board: FROZEN_MPY_DIRS in circuitpython.toml,
482+
# paths relative to the repository root, as $(TOP)/... is in mpconfigboard.mk).
483+
# The flags have to be present for the qstr pass as well as the compile:
484+
# MICROPY_MODULE_FROZEN gates both Q(.frozen) and the sys.path entry that
485+
# uses it, and the extra pool is how frozen qstrs get their numbers.
486+
frozen_dirs = mpconfigboard.get("FROZEN_MPY_DIRS", [])
487+
frozen_sources = []
488+
if frozen_dirs:
489+
circuitpython_flags.append("-DMICROPY_QSTR_EXTRA_POOL=mp_qstr_frozen_const_pool")
490+
# Only .mpy freezing: MICROPY_MODULE_FROZEN_STR would make frozenmod.c
491+
# reference the mp_frozen_str_* tables that tools/makemanifest.py
492+
# emits, and mpy-tool alone does not.
493+
circuitpython_flags.append("-DMICROPY_MODULE_FROZEN_MPY=1")
494+
frozen_sources = stage_frozen_modules(frozen_dirs, srcdir, builddir)
495+
logger.info(f"Freezing {len(frozen_sources)} modules from {', '.join(frozen_dirs)}")
496+
407497
async with asyncio.TaskGroup() as tg:
408498
tg.create_task(
409499
cpbuild.run_command(
@@ -736,6 +826,14 @@ async def build_circuitpython(): # noqa: C901
736826

737827
# This file is generated by the QSTR/translation process.
738828
source_files.append(builddir / f"translations-{translation}.c")
829+
if frozen_dirs:
830+
# Needs genhdr/qstrdefs.generated.h and root_pointers.h from the task
831+
# group above. frozen_content.c defines its own MP_QSTR_* enum values, so
832+
# it must never go through the qstr extraction pass.
833+
mpy_cross = os.environ.get("MICROPY_MPYCROSS", str(srcdir / "mpy-cross" / "build" / "mpy-cross"))
834+
source_files.append(
835+
freeze_modules(frozen_sources, srcdir, builddir, mpy_cross, builddir / "qstrdefs.collected")
836+
)
739837
# These files don't include unique QSTRs. They just need to be compiled.
740838
source_files.append(portdir / "supervisor" / "flash.c")
741839
source_files.append(portdir / "supervisor" / "port.c")

ports/zephyr-cp/cptools/pre_zephyr_build_prep.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Called by the Makefile before calling out to `west`.
2+
import os
23
import pathlib
34
import subprocess
45
import sys
@@ -20,6 +21,14 @@
2021
args = blob_fetch_args.get(blob, [])
2122
subprocess.run(["west", "blobs", "fetch", blob, *args], check=True)
2223

24+
# Frozen modules need the host mpy-cross; build it up front, where make is
25+
# already in use, rather than from inside the CMake-driven CircuitPython step.
26+
if mpconfigboard.get("FROZEN_MPY_DIRS") and "MICROPY_MPYCROSS" not in os.environ:
27+
subprocess.run(
28+
["make", "-C", str(portdir.parent.parent / "mpy-cross"), "USER_C_MODULES="],
29+
check=True,
30+
)
31+
2332
if board.endswith("bsim"):
2433
subprocess.run(
2534
["make", "everything", "-j", "8"],

tools/ci_fetch_deps.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pathlib
66
import re
77
import subprocess
8+
import tomllib
89

910
TOP = pathlib.Path(__file__).parent.parent
1011

@@ -254,8 +255,13 @@ def main(target):
254255
lib_folder = "/".join(lib_folder[:2])
255256
submodules.append(lib_folder)
256257
else:
257-
# TODO: Add a way to specify frozen modules in circuitpython.toml
258-
pass
258+
# ports/zephyr-cp: FROZEN_MPY_DIRS = ["frozen/<lib>", ...] in circuitpython.toml
259+
with config.open("rb") as f:
260+
board_config = tomllib.load(f)
261+
for lib_folder in board_config.get("FROZEN_MPY_DIRS", []):
262+
if lib_folder.count("/") > 1:
263+
lib_folder = "/".join(lib_folder.split("/", maxsplit=2)[:2])
264+
submodules.append(lib_folder)
259265

260266
print("Submodules:", " ".join(submodules))
261267

0 commit comments

Comments
 (0)