Python to bare-metal firmware — no runtime, no interpreter, no VM.
Explore the project »
Report a bug
·
Request a feature
·
Sponsor
Important
Alpha 10 is out — v0.1.0a10 release notes. The hardware-validation release. It came out of a sustained bug hunt on a real Arduino Uno with a logic analyzer, plus a sweep of the official MicroPython quickref and CircuitPython Essentials examples — 63 projects, 53 of which compile; the rest fail on purpose with a clear diagnostic.
What that hunt found was a class of silent miscompiles: uint32(float_var) emitting
raw float bits, a global shadowing a function parameter (which had been driving the DHT
start pulse for 250 ms instead of 18), millis() counting 1024 ms per second, and a
timer's second PWM channel disconnecting the first. Each fix shipped with a regression
test. Suites at that release: 517 unit, 508 driver, 1549 AVR integration.
Alpha 10 still has bugs, and the hunt did not stop. 145 more fixes have landed since it shipped, most of them turning a case the compiler used to answer blindly into a diagnostic that names what it cannot do. That work is heading for beta 1; watch the repo if you want to hear when it lands.
So: core compilation is stable and test-covered, the alpha is usable, and you will still find edges. If you hit one, please open an issue — that is how the list above got written.
Avoid pymcu.hal.*. The native HAL is deliberately low-level and may change between
releases without a deprecation cycle. Use the MicroPython or CircuitPython compat
API instead: those track APIs specified elsewhere, which is what makes them the surface
designed to hold still.
PyMCU compiles a statically-typed subset of Python into bare-metal firmware for AVR, ARM (RP2040 / RP2350) and PIC — no runtime, no interpreter, no virtual machine. The same binary you would write in C.
A real session: 9 lines of Python → pymcu build → 150 bytes of flash → running on an Arduino Uno.
Then the delay is edited, rebuilt and reflashed — the whole loop takes seconds.
LED blink for ATmega328P @ 16 MHz — all variants do the same thing:
configure PB5 as output, then loop LED on → wait 500 ms → LED off → wait 500 ms forever.
| Source | Total flash | SRAM |
|---|---|---|
C (avr-gcc -Os) |
176 B | 0 B |
| PyMCU (native HAL) | 150 B | 0 B |
| PyMCU (MicroPython API) | 150 B | 0 B |
| PyMCU (CircuitPython API) | 152 B | 0 B |
| Arduino (IDE defaults) | 924 B | 9 B |
PyMCU produces a smaller binary than C here. Why?
Pin("PB5", Pin.OUT) and delay_ms(500) are resolved entirely at compile time — the
compiler sees through the Python objects and emits the same raw SBI/CBI port-toggle
instructions a C programmer would write by hand. The rest of the difference is the delay:
PyMCU emits one calibrated delay subroutine shared by both waits (rcall twice), where
avr-libc's _delay_ms is inlined at each call site — and there is no call main / jmp _exit
scaffolding around the program.
The interrupt vector table and startup stub are identical fixed overhead in both toolchains.
Native HAL and MicroPython API produce byte-for-byte identical firmware — both compile down to the same SBI/CBI toggle and the same delay loop. The API is a zero-cost abstraction. CircuitPython is 2 bytes larger because the Direction.OUTPUT setter clears the PORT register before setting DDR, as the CircuitPython spec requires.
These numbers are for a minimal blink. Real programs that use SRAM (global variables, buffers) will emit a small zeroing loop at startup, just like C does.
For complex drivers (custom protocols, timing-critical bit-bang): expect 2-3x flash vs hand-written C. PyMCU is not competing with C — the goal is to make microcontroller development approachable in Python you already know, without the overhead of Arduino. The output is still 100-1000x smaller than any embedded Python interpreter.
Pick the API that fits your background. Both compile to the same bare-metal firmware.
# The exact same code that runs on a Pico under CircuitPython
import board
import digitalio
import time
led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT
while True:
led.value = True
time.sleep(0.5)
led.value = False
time.sleep(0.5)# The exact same code that runs on a Pico under MicroPython
from machine import Pin
from utime import sleep_ms
led = Pin(13, Pin.OUT)
while True:
led.value(1)
sleep_ms(500)
led.value(0)
sleep_ms(500)pymcu build # → dist/firmware.hex (150 bytes flash, 0 bytes SRAM)
pymcu flash # → avrdude upload to Arduino Unopipx install --pip-args=--pre "pymcu-compiler[avr]" # AVR (ATmega / ATtiny) -- beta
pipx install --pip-args=--pre "pymcu-compiler[arm]" # RP2040 / RP2350 (Pico) -- alpha
pipx install --pip-args=--pre "pymcu-compiler[pic]" # PIC16 -- alpha
pipx install --pip-args=--pre "pymcu-compiler[all]" # all of the aboveRequires Python 3.11+ and pipx. Each extra bundles its full toolchain
(compiler backend + assembler/linker binaries) — no system packages needed.
The extras are not equally mature.
[avr]is beta;[arm]and[pic]are alpha, and[all]installs alpha backends alongside the beta one. See Supported targets for what alpha means here.
Package name: PyMCU is published as
pymcu-compileron PyPI while a PEP 541 request to reclaim thepymcuname is under review. Once approved, apymcumetapackage will aliaspymcu-compiler— installs and project configs will stay compatible.
pymcu new blink --board arduino_uno --stdlib micropython
cd blinkThat is the whole setup. pymcu new scaffolds a project that already builds: a
pyproject.toml carrying the board, frequency, toolchain and dependencies, a
src/main.py with a working blink, plus requirements.txt, a Makefile and VS Code
tasks. Pass --stdlib circuitpython for the other API, or run it with no flags and it
asks for what you left out.
Warning
pymcu new asks whether to install dependencies and defaults to no. The compat
layer you picked is one of those dependencies, so declining leaves you with a project
that scaffolds fine and a first pymcu build that fails with
ImportError: Module not found: machine. If you already said no, install them from
inside the project with uv sync, poetry install or pip install -e ..
MicroPython style (--stdlib micropython):
# src/main.py
from machine import Pin
from time import sleep_ms
led = Pin(13, Pin.OUT)
while True:
led.value(1)
sleep_ms(500)
led.value(0)
sleep_ms(500)CircuitPython style (--stdlib circuitpython):
# src/main.py
import board
import digitalio
import time
led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT
while True:
led.value = True
time.sleep(0.5)
led.value = False
time.sleep(0.5)Both compile to bare-metal firmware for the same chip. The Quick Start walks the same path in more detail, including what each scaffolded file is for.
pymcu build
# Compiling src/main.py...
# → dist/firmware.hex
pymcu flash --port /dev/cu.usbmodem*
# avrdude: flash verified| Package | API surface | Install |
|---|---|---|
pymcu-circuitpython |
digitalio, analogio, busio, pwmio, time, board, neopixel |
pip install pymcu-circuitpython |
pymcu-micropython |
machine (Pin/UART/ADC/PWM/SPI/I2C/Timer/WDT), utime |
pip install pymcu-micropython |
pymcu.hal.* |
Direct register-level HAL — lowest overhead | pymcu-stdlib (installed automatically with pymcu-compiler) |
Build against a compatibility layer, not against pymcu.hal.*.
The beta label covers the compiler frontend and the AVR backend. It is not a promise about every API those two can reach, and the difference between the three rows above matters:
pymcu-circuitpythonandpymcu-micropythonare the surfaces designed to hold still, because they do not define their own shape: they trackdigitalio/boardandmachine/utimeas specified by CircuitPython and MicroPython. That is a reason you can check, not a guarantee we have earned yet, and both packages are still pre-1.0.pymcu.hal.*is deliberately low-level, it is the layer being actively reshaped, and it may change between releases without a deprecation cycle. Reach for it when you need direct register access,@interrupt,asm()or@extern, and expect to revisit that code.
All three are deepest on the ATmega parts. board.* in particular is defined for the
Arduino boards but not for ATtiny or for the RP2040 / RP2350, where you pass the pin
number instead (digitalio.DigitalInOut(25)). On the alpha backends a compat call may
resolve to a HAL module that does not implement it yet; the compiler says so at build time
rather than emitting silently wrong code.
Not every backend is at the same maturity. The compiler frontend and the AVR backend are beta as of 0.1.0b1. ARM, PIC and RISC-V remain alpha.
| Architecture | Status | Chips |
|---|---|---|
| AVR (ATmega) | beta | ATmega48/88/168/328P, ATmega2560, ATmega32U4 |
| AVR (ATtiny) | beta | ATtiny25/45/85, ATtiny24/44/84, ATtiny13/13A, ATtiny2313/4313 |
| ARM (Cortex-M0+ / M33) | alpha | RP2040 (Pico / Pico W), RP2350 (Pico 2 / Pico 2 W) — incl. PIO and CYW43 WiFi |
| PIC (mid-range) | alpha | PIC16F84A, PIC16F877A |
What the labels mean.
- beta: the language surface below is implemented and test-covered on this backend, and it is validated on real silicon (Arduino Uno, logic analyzer). The label covers the compiler frontend and the AVR backend, not the stability of every API you can reach through them: see Choosing an API for which surface to build against.
- alpha: it builds and it runs, but parts of the language surface below are missing
on this backend, it does not carry AVR's continuous silicon validation, and the API may
change between releases. Specifically today:
list[T]is AVR-only;try / exceptis unavailable on PIC;float, f-strings, generators and@interruptare unavailable on PIC16 (PIC18 has float and generators); and PIC16F84A / PIC16F877A builds emit no configuration word, so the resulting image will not boot on real hardware until you program the fuses yourself. See Language Limitations.
How far each backend has been validated on real hardware. This is the part that most separates beta from alpha, so it is worth stating per backend rather than in one sentence:
| Backend | Hardware record |
|---|---|
| AVR | Continuously validated on silicon. A logic-analyzer harness on an Arduino Uno decodes the board's UART and diffs it against CPython running the same source, so a semantic divergence is caught rather than argued about. |
| ARM | Confirmed running on real Raspberry Pi silicon (Pico, Pico 2): blink, native f-strings, and the Python RTOS doing preemptive multitasking on the Cortex-M33, with clock and timer timing verified on a logic analyzer to better than 0.01%. What it does not yet have is the continuous differential harness AVR runs. |
| PIC | Partially exercised on silicon (PIC18 GPIO, delay_ms, UART TX); most testing is on the PicSharp emulator. For the PIC16 parts listed above the build emits no configuration word, so the image does not boot until you program the fuses yourself. |
| RISC-V | Emulation only, in qemu. It has never been run on a physical CH32V003. |
RISC-V (CH32V003/V203) has a working backend in-tree but is not published on PyPI and
has no install extra, so a pip/pipx install never provides it.
| Module | Features |
|---|---|
pymcu.hal.gpio |
Pin — high / low / toggle / irq / pulse_in |
pymcu.hal.uart |
UART — write / read / println / RX interrupt |
pymcu.hal.adc |
AnalogPin — poll + interrupt; internal temperature |
pymcu.hal.timer |
Timer(n, prescaler) — CTC mode; millis() / micros() |
pymcu.hal.pwm |
PWM — multi-channel; set_duty / set_freq |
pymcu.hal.spi |
SPI |
pymcu.hal.softspi |
SoftSPI — bit-bang, any GPIO |
pymcu.hal.i2c |
I2C |
pymcu.hal.softi2c |
SoftI2C — bit-bang, any GPIO |
pymcu.hal.eeprom |
EEPROM — write(addr, val) / read(addr) |
pymcu.hal.watchdog |
Watchdog — enable / disable / feed |
pymcu.hal.power |
sleep_idle / sleep_adc_noise / sleep_power_down / sleep_power_save / sleep_standby / sleep_extended_standby |
Drivers: DHT11, DS18B20, HD44780 LCD, SSD1306 OLED, MAX7219 8x8 matrix, BMP280, WS2812 NeoPixel.
PyMCU accepts Python syntax but enforces a strict compile-time type system.
The list below describes the AVR backend, which is the beta one. Items marked (AVR only) or with an explicit backend list are not available everywhere. See Supported targets.
Supported:
- Integer types:
uint8,int8,uint16,int16,uint32,int32— with type inference for unannotateddefparameters and returns float(IEEE-754 single) on AVR, ARM and PIC18; PIC16 has no floating point at all- Fixed arrays
buf: uint8[16]andbytearrayeverywhere; heap-bounded listsx: list[uint8] = list()(AVR only); on ARM and PIC, use a fixed array - Slices: equal-length assignment (including through
__setitem__, somicrocontroller.nvm[0:4] = b"..."compiles) and iteration with runtime bounds (for b in buf[0:n]) print()of abytearrayor a slice as the CPython repr, and of afloatwith two rounded decimals;s = "".join([chr(b) for b in buf])for bytes-to-stringfor,while,if,match / case,with,class,@inline,lambda- Generators (
yield) on AVR, ARM and PIC18;async/awaitwithasyncio.run/gatheron AVR, ARM and PIC18, both unavailable on PIC16 dict/setliterals as closed compile-time lookup tables, pluspymcu.collections.FixedDictfor mutable fixed-capacity maps — still no heap- f-strings with runtime interpolations and format specs, as stream writes or values (AVR and ARM only: PIC and RISC-V have no string-building path)
try / except / raise / finallywith cross-function propagation (AVR and ARM); on PIC, use return codes, since only theZeroDivisionErrorguard exists there@interruptISR handlers (not on PIC16),asm("...")inline assembly everywhere (with operands on ARM)- CircuitPython and MicroPython compat packages, plus
pymcu lintto vet a port
Not supported:
- Open-ended
dict/setmutation beyondFixedDict's fixed capacity (no heap hash tables) - Closures capturing mutable variables — use explicit parameters
*args/**kwargs, reflection (getattr/setattr/eval)- Anything whose size is only known at runtime: a slice read bound to a name
(
b = buf[0:n]), a runtime tuple, a comprehension filtered on a runtime condition
The compiler rejects unsupported features with a clear error at compile time — including the ones the hardware cannot honour, such as a runtime pin number, an image larger than the chip's flash, or static data that does not fit in SRAM. See the Language Limitations page for the full list.
| Command | Description |
|---|---|
pymcu new <name> |
Scaffold a new project |
pymcu build |
Compile src/ → dist/firmware.hex |
pymcu flash |
Upload via avrdude |
pymcu clean |
Remove build artifacts |
- Python on a Classic Uno on Arduino Project Hub — why the board that taught millions of us electronics never got to speak Python, and what changes once the interpreter is gone.
If PyMCU saves you time, consider sponsoring the project. The goal is $300/month, which covers the AI tooling, domains and hosting that keep development at its current pace. Sponsors at $10/month and above are listed in SPONSORS.md.
PyMCU is built by one person, and these are the people helping keep it going.
Adafruit, the home of CircuitPython, sponsors PyMCU on GitHub. Thank you, pt and ladyada.
All components are licensed under the MIT License. Your compiled firmware output is entirely yours — no runtime license, no attribution required.
See CONTRIBUTING.md and LANGUAGE_ROADMAP.md.
Special thanks to Richard Wardlow, creator of the original pyMCU project (2012). See CREDITS.md for the full acknowledgement.
