Skip to content

Internals

For contributors. Deep-dive into the patterns and mechanisms that make the strangler work. If you're using nanopython rather than modifying it, the Architecture page is the right level of detail; this section is one layer deeper.

In this section

  • Type Mirrors: mp_mirrors.zig, the ~40 extern struct declarations that make Zig speak MicroPython's struct layouts
  • PyError Convention: the error{PyRaise}!T propagation that replaced MicroPython's setjmp/longjmp NLR
  • Persistentcode Loader: the .mpy loader, fully Zig
  • qstr Generation: how tools/zig_qstr_shadow.py lets Zig modules participate in MicroPython's qstr/module tables
  • Wasm Composition: the wasm binary's section-by-section breakdown + the size-cut opportunity catalog (ADR 007)

Why an "internals" section

The architecture pages cover what nanopython is. The internals pages cover how specific subsystems work. They're aimed at:

  • People who want to port a new MicroPython subsystem to Zig (the patterns matter).
  • People debugging unexpected behavior (knowing the polled-sentinel mechanism helps when a MP_OBJ_NULL propagates farther than expected).
  • People extending nanopython (e.g., adding a new builtin module: the qstr-gen page covers that).

For the casual reader, the architecture + strangler-journey pages are enough.

Cross-cutting conventions

A few rules thread through all the internals:

Obj = usize

The Zig type for "any Python object" is usize. Always. No matter what the value points to (a small-int via low-bit-tagged encoding, a qstr via byte-tagged encoding, or a heap object via aligned pointer), it's stored as a usize in Zig.

Helper conversions live in mp_mirrors.zig:

const obj = mirrors.objFromQstr(MP_QSTR_print);   // qstr to Obj
const obj = mirrors.objFromPtr(&some_heap_type);  // pointer to Obj
const obj = mirrors.newSmallInt(42);              // smallint to Obj

This is REPR_A, MicroPython's default object representation. Both nano and full configs use REPR_A.

PyError!T for raisable Zig fns

If a fn can raise a Python exception, its Zig signature returns pyerr.PyError!T instead of T. The error is error.PyRaise; the exception object is stored in pyerr.pending_exc (a threadlocal usize).

fn computeImpl(arg: Obj) PyError!Obj {
    if (bad(arg)) try raiseTypeErrorBadArg();
    return result;
}

At C-ABI boundaries, pyerr.bridge catches PyError and returns the appropriate zero-sentinel:

pub export fn npy_compute(arg: Obj) callconv(.c) Obj {
    return pyerr.bridge(Obj, computeImpl(arg));
}

See PyError Convention.

callconv(.c) for ABI exports

Every Zig function the runtime expects to call (whether from C, when C still existed, or from a function-pointer table) is marked callconv(.c):

pub export fn mp_load_global(qst: Qstr) callconv(.c) Obj { ... }

Forgetting callconv(.c) is a common source of subtle bugs: Zig's default calling convention may pack arguments differently than C.

extern struct for vendor C struct mirrors

When Zig needs to read/write a struct that MicroPython C also reads/writes, the Zig declaration uses extern struct:

pub const MpRawCode = extern struct {
    proto_fun_indicator: [2]u8,
    kind: u8,
    is_generator: bool,
    fun_data: ?*const anyopaque,
    children: [*c]?*MpRawCode,
};

extern struct guarantees C-compatible field ordering and padding. Combined with @sizeOf / @offsetOf asserts in assertLayout(), it catches any drift from vendor's struct.

See Type Mirrors.

Vendor C inclusion

The build uses explicit per-arm addCSourceFiles lists. Under -Dconfig=nano with default flags, the only .c file in the compile graph is src/objects/objtype/objmodule_shim.c (86 LOC: the qstr-gen tombstone for MICROPY_REGISTERED_MODULES).

Per-feature carveouts pull individual vendor .c files back in:

Carveout Files
feature-longint=true (auto for wasm32-nano) py/objint.c, py/objint_longlong.c
feature-float=true (auto for nano) py/objfloat.c, py/formatfloat.c
-Dconfig=full py/objint.c, py/objint_mpz.c, py/mpz.c, py/modio.c, py/objstringio.c, py/builtinhelp.c, py/warning.c

The choice of "port vs re-use vendor" is per-file: small focused subsystems are ported (parser, VM, type system); large well-tuned arithmetic engines (MPZ) are re-used as vendor carveouts.

The decision record

Every architectural decision is documented in the ADR series (Architecture Decision Record):

  • ADR 001: v0.4 roadmap (closed)
  • ADR 002: feature selection (per-feature -D flags + frozen nano profile)
  • ADR 003: library + module loading (frozen-tiny + lazy-fetch)
  • ADR 004: benchmarking methodology
  • ADR 005: future optimization landscape (PROTO)
  • ADR 006: JS interop bridge
  • ADR 007: wasm fat loss
  • ADR 008: golden program tracking
  • ADR 009: v0.5 roadmap (now closed with §11 retrospective)
  • ADR 010: v0.6 roadmap (current)

For the methodology behind the strangler arc itself, read TR-001 (notes/tech-reports/TR-001.md): a self-contained scientific paper covering the byte-exact differential oracle and the two technical primitives (the type-mirror pattern and polled-sentinel propagation) that crossed the C↔Zig boundary without a shim layer.