Language Overview#
PyMCU is a compiler, not an interpreter#
This distinction shapes every design decision in PyMCU.
MicroPython / CircuitPython |
PyMCU |
|
|---|---|---|
Python runtime on MCU |
Yes — ~256 KB interpreter |
No |
Bytecode executed at runtime |
Yes |
No |
Types resolved |
At runtime |
At compile time |
Heap allocation |
Yes (garbage collected) |
No |
Flash usage |
256 KB + your code |
Your code only |
Execution speed |
Bytecode (slow) |
Native machine code |
When you run pymcu build, the compiler (running on your PC) reads your Python source,
type-checks it, and emits native AVR assembly for the ATmega328P. The MCU never sees Python
syntax — only machine instructions.
Supported Python subset#
PyMCU accepts a statically-typed, allocation-free subset of Python 3. The guiding rule is:
If the size and type of every value is not known at compile time, it cannot be compiled.
Supported statements#
if / elif / else, while, for, match / case, def, class, return, pass,
import, from … import, global, with, assert (compile-time),
try / except / finally, raise.
raise CompileError("msg") aborts compilation with a diagnostic — it never generates runtime
code and cannot be caught by try / except.
Supported expressions#
Arithmetic (+ - * / % //), comparison, bitwise, logical (and/or/not),
ternary, augmented assignment, type casts (uint8(x)), abs, min, max, len,
walrus (:=), tuple literals and unpacking, member access, array indexing, list
comprehensions (compile-time constant bounds only), and streamed f-strings
(print(f"x={v:02x}"), uart.write_str(f"...")).
Arithmetic operators promote to a wider type so same-width math does not silently
overflow, and / always yields a float (Python 3 semantics) while // is integer
division — see Type System.
MCU-specific extensions#
uint8,int8,uint16,int16,uint32,int32primitive typesptr[T]— memory-mapped I/O pointerconst[T]— compile-time constant enforcementasm("instr")— inline assembly with register constraints@inline— zero-cost function expansion@interrupt(vector)— hardware ISR handler__CHIP__/__FREQ__— conditional compilation by chip name and clock frequency
Static typing is mandatory#
Every variable must carry a type annotation at its first assignment. The compiler does not infer types — the annotation drives register width, instruction selection, and memory layout.
# Correct — annotation at first assignment
count: uint16 = 0
flag: uint8 = 0
# Correct — built-in int maps to int16, no import required
n: int = read_sensor()
# CompileError — no annotation
x = 42
The built-in int maps to int16 and requires no import. All other sized types come from
pymcu.types:
from pymcu.types import uint8, uint16, uint32, int8, int16, int32, ptr, const
No heap — everything is stack or global#
PyMCU targets microcontrollers with 32 bytes to 8 KB of SRAM. There is no malloc, no
garbage collector, and no heap. Every value must have a fixed size known at compile time:
# OK — size is known at compile time
buf: uint8[8] = [0] * 8
# CompileError — dynamic size
n = get_size()
buf: uint8[n] = [0] * n # cannot allocate variable-size array
Containers exist, but each one has a footprint fixed at compile time: list[T] runs on a
bounded bump allocator with a GC, dict / set literals bind read-only compile-time lookup
tables, and pymcu.collections.FixedDict is the mutable fixed-capacity map. What is not
available is an unbounded container that grows as far as the data takes it. See
Language Limitations for the full list with alternatives.
Module system#
PyMCU compiles multi-file projects. Imports resolve to PyMCU stdlib modules or your own source files:
from pymcu.hal.gpio import Pin # HAL module
from pymcu.types import uint8 # type definitions
from sensors import read_dht11 # your own module in src/
Third-party PyPI packages cannot be used — only the pymcu stdlib and modules you write
yourself (in PyMCU-compatible Python) are compiled.
Entry point#
PyMCU supports two entry-point styles:
Explicit def main(): (recommended):
def main():
led = Pin("PB5", Pin.OUT)
while True:
led.toggle()
delay_ms(500)
Top-level script style (MicroPython / CircuitPython compatible):
led = Pin("PB5", Pin.OUT)
while True:
led.toggle()
delay_ms(500)
The compiler synthesizes a main entry point from top-level executable statements
automatically.
Calling main() yourself. def main(): is called for you, so the call is optional —
but writing it is not an error, and it is not ignored either. It says where main’s body
runs among the module-level statements, exactly as it does in CPython:
def main():
print("work")
print("before")
main()
print("after") # before, work, after
if __name__ == "__main__": main() is the same thing: the guard is always true in the
entry file, so the call it contains is the split point. With nothing written after it,
main’s body simply runs last, which is what the bare def main(): form already does.
Two shapes are refused instead of being approximated. Calling main() twice at module
level is refused — the entry point runs once. So is a main() that can return early
when module-level code follows the call, because main’s body is spliced in where the call
is written and the return would skip that code.
Conditional compilation#
__CHIP__ and __FREQ__ are compile-time string/integer constants. Dead branches are
eliminated before code generation — no runtime cost:
if __CHIP__.arch == "avr":
asm("nop") # only emitted; dead branches removed at compile time