Driver CLI — pymcu#
The pymcu command-line tool manages PyMCU projects: creation, building, and flashing.
pymcu new <project_name>#
Creates a new PyMCU project.
pymcu new my_project
Interactive prompts:
Target microcontroller (ATmega328P, PIC16F84A, etc.)
Package manager (
uv,poetry, orpip)
Generated files:
File |
Contents |
|---|---|
|
Starter firmware with a blink template |
|
Project config with |
|
VS Code Build / Flash tasks |
|
Git ignore rules |
AVR pyproject.toml#
[tool.pymcu]
chip = "atmega328p"
frequency = 16000000
[tool.pymcu.flash]
programmer = "avrdude"
files = ["font5x8.bin", "data/*.bin"] under [tool.pymcu] embeds files into the
firmware as flash blobs (RFC 0008 romfs): a compile-time open("font5x8.bin", "rb")
then reads them straight out of flash. Files under sources named by a literal
open() are embedded automatically even without the key.
PIC pyproject.toml#
[tool.pymcu]
chip = "pic16f84a"
frequency = 4000000
[tool.pymcu.toolchain]
name = "gputils"
[tool.pymcu.flash]
programmer = "pk2cmd"
pymcu build#
Compiles the project to an Intel HEX file.
pymcu build
pymcu build -v # verbose — prints assembler output and full build log
Output files:
File |
Description |
|---|---|
|
Intel HEX — flash this to the MCU |
|
AVR assembly listing with source annotations |
|
Mid-level IR (useful for debugging code-gen issues) |
Requirements:
Valid
pyproject.tomlin the project rootAll dependencies installed (
uv syncorpip install pymcu-compiler)
Profile-guided optimisation (experimental)#
pymcu build --profile <path> (or the PYMCU_PROFILE environment variable) feeds a
per-block cycle profile – produced by pymcu profile --pgo – into the compiler.
PGO is experimental (RFC 0010) and off by default: enable it with
pgo = true under [tool.pymcu.experimental] in pyproject.toml, or set
PYMCU_EXPERIMENTAL_PGO=1 for CI and scripts. With the flag off, a profiled build
request stops with a one-line error naming the flag and nothing is built; the
driver never passes --profile to pymcuc nor --emit-blockmap to the backend.
The flag is driver policy only – pymcuc --profile invoked directly on the
compiler keeps working regardless.
A profiled build also passes --profile to pymcuc-avr, where the profile
orders the R2-R15 register homes by dynamic use: each variable’s uses count the
profiled execution count of the MIR block containing them, so variables hot in
the workload win callee-saved registers ahead of statically-busy cold ones.
Only the order changes – eligibility and fallbacks are untouched – and flash
may grow a few percent when a statically-cheap variable loses its home to a
dynamically-hot one. A backend that does not declare --profile in its own
--help is refused rather than silently building unprofiled.
Compiler error output#
When the compiler detects an error, it prints a human-readable diagnostic with source
context, line numbers, and a ^~~ underline pointing to the offending token:
src/main.py:12:9: error: TypeError: cannot assign float to uint8
11 | count: uint8 = 0
12 | count = 3.14
^~~~
13 | led.toggle()
The header line (
file:line:col: severity: ErrorType: message) matches the VS Code problem matcher pattern — errors appear inline in the editor.Lines N−1 and N+1 are shown as context (dimmed).
The
^points to the start of the token;~spans the rest of its length.No caret is drawn when the compiler does not know the column. Many checks run long after parsing and know only which statement is at fault, not which character. Those diagnostics print the header and the source context and stop, rather than aim the caret at column 1: an arrow under a character is read as a claim about that character, and a wrong one costs the reader more than its absence. The header keeps a column field in every case, because the editor integrations require one to match.
Where the line is indented with tabs, the caret line is padded with the same tabs, so it lands under the right character whatever tab width the terminal uses.
All formatting uses ANSI colour when stderr is a TTY (red header and underline, dim context lines). Plain text is output when stderr is redirected (e.g. in CI logs).
pymcu flash#
Uploads the firmware built by pymcu build to the connected device.
pymcu flash
pymcu flash --port /dev/cu.usbmodem* # macOS
pymcu flash --port /dev/ttyACM0 # Linux
pymcu flash --port COM3 # Windows
Firmware artifact#
The file uploaded depends on the target, and must exist before flashing:
Target |
Artifact |
|---|---|
AVR, PIC |
|
RP2040, RP2350 |
|
Supported programmers#
The programmer defaults to the one for the target family (avrdude for AVR,
pk2cmd for PIC, rp2040 for the RP boards) and can be overridden. The PIC
programmers ship with the pymcu-pic package – installed by the pic extra:
pk2cmd drives the PICkit 2, PICkit 3 and PKOB through the pk2cmd-minus
fork, and pymcuprog drives the Curiosity Nano’s on-board nEDBG debugger.
ipecmd remains available for driving a PICkit 3 through MPLAB X:
AVR (Arduino Uno):
[tool.pymcu.flash]
programmer = "avrdude"
port = "/dev/cu.usbmodem14101" # optional; --port overrides it
baud = 115200 # optional
PIC (PICkit 2, PICkit 3 or PKOB):
[tool.pymcu.flash]
programmer = "pk2cmd" # pk2cmd-minus from pymcu-pic, auto-downloaded on first use
PIC (PICkit 3 via MPLAB X):
[tool.pymcu.flash]
programmer = "ipecmd" # MPLAB X IPE command line
# ipecmd_power = "5.0" # only if the PICkit powers the target board
ipecmd ships with MPLAB X. Install v6.20 or older — v6.25 dropped PICkit 3
support, so the latest MPLAB X cannot talk to it. PyMCU looks for ipecmd in
PYMCU_IPECMD, then on PATH, then inside the MPLAB X installations it can
find, preferring versions that still support the PICkit 3. Omit ipecmd_power
when the board has its own supply.
Raspberry Pi Pico / Pico 2:
[tool.pymcu.flash]
programmer = "rp2040" # picotool, or UF2 drag-and-drop to the RPI-RP2 volume
Note
[tool.pymcu.programmer] with a name key is the pre-0.15 spelling. It is still
honoured as a fallback, with a deprecation warning — move it to
[tool.pymcu.flash].
pymcu monitor#
Opens a serial console on the board: what the firmware prints over UART is written to stdout, and what you type is forwarded back to the board. Stop with Ctrl+C.
pymcu monitor
pymcu monitor --port /dev/cu.usbmodem14101 --baud 115200
pymcu monitor --no-send # read only: stdin is not forwarded
Port resolution order, the same as pymcu flash:
--port/-Pon the command lineport = "..."in[tool.pymcu.flash]ofpyproject.tomlAuto-detection, when exactly one USB-serial device is connected
Baud resolution order:
--baud/-bon the command linestdout_baud = ...in[tool.pymcu]ofpyproject.toml, the speedprint()runs at115200
Firmware bytes are the only thing on stdout, so pymcu monitor | tee log
captures exactly what the board said. The exit code is 0 on a clean stop, 2
when the board disappears mid-stream, and 1 on a usage error. The command
works outside a project too: with --port there is nothing to read from
pyproject.toml. On POSIX the monitor uses termios directly and needs no
extra dependency; on Windows it needs pyserial (pip install pyserial).
pymcu clean#
Removes the dist/ directory and all build artifacts.
pymcu clean
pymcu install <library>#
Installs a third-party library into this project. Names are resolved against the curated PyMCU index, not against PyPI at large: PyPI is full of Python that cannot compile for a microcontroller, and an install that succeeds and then breaks the build is worse than one that refuses.
pymcu install dht11
What it does, in order:
resolves the name in the index (cached under
~/.pymcu/,--refreshto update);refuses, before downloading anything, if the measured matrix says the library does not build for this project’s chip, if it belongs to a compat layer this project does not declare, or if it needs a newer language level;
installs into the project’s
.venvwithuvorpip— never globally;re-checks the manifest that actually landed on disk, and with
--verify(the default when the library ships an example) compiles that example for this chip;records the dependency in
pyproject.toml.
Anything that fails after step 3 rolls the installation back, so a refused library leaves neither files nor a dependency line behind.
Option |
Effect |
|---|---|
|
Skip the index and install this distribution directly. The manifest is still required. |
|
Skip the verification build. |
|
Re-download the index before resolving. |
|
Exclude pre-release versions. |
pymcu uninstall <library>#
Removes the library from the project’s environment and from pyproject.toml.
pymcu libraries#
Lists the libraries installed in this project, with a verdict for the current chip.
--all also shows the ones that do not apply to it, and why.
pymcu search [text]#
Searches the index. Without --all, only libraries usable on this project’s chip are
listed.
pymcu lint --library <package_dir>#
Checks a library package before publication: manifest validity, ASCII-only sources
(non-ASCII inside a string is an error — the lexer accepts it and then encodes it as
ASCII, corrupting the byte), a match __CHIP__.arch whose default branch raises instead
of returning a sentinel, and the public API surface against api-surface.lock.
pymcu lint --library src/pymcu_lib_dht11 # check
pymcu lint --library src/pymcu_lib_dht11 --write-surface # record the surface
The surface check is what catches a package growing a public function without its version moving — two different wheels shipping under one version number.
See Writing a PyMCU Library for the full authoring guide.
pymcu natmod#
Compiles the project into a CircuitPython/MicroPython native module (.mpy) that the
interpreter imports at runtime — for the RP2040 and RP2350, through the ARM backend.
pymcu natmod --circuitpython ../circuitpython
pymcu natmod -m plasma -o dist/ -v
It is a command of its own rather than a flag on build because it produces a different
artifact for a different consumer: build makes the image that IS the firmware, natmod
makes a relocatable object that another runtime loads beside its own code. There is no
entry point and nothing to flash — the module’s entry point is the loader’s mpy_init.
What is exported: every top-level function of the entry file (the compiler’s
--library mode roots them; @inline functions and interrupt handlers are skipped).
Each function needs a full signature — annotated parameters and an annotated return.
The boundary carries integers (int, int8, uint8, int16, uint16, int32,
uint32, bool) and buffers (bytearray read-write, bytes read-only). Everything
else is refused at the def that declares it: float, str, tuple/list/dict/
set, class instances, an unannotated parameter, a missing return annotation, returning
a buffer, *args/defaults, and async def.
What the adapter guarantees: the C adapter between the interpreter and the kernels is
generated from the annotations, never written by hand. It unboxes each argument by its
declared type — an integer outside its width raises ValueError naming the function and
the parameter — calls the PyMCU symbol, and boxes the result. A buffer argument is passed
with its own length, which len() returns inside the kernel; indexing past that length is
not checked, so every count derived from len() is the kernel author’s contract, as with
viper’s pointer types. A module that keeps state of its own (a rebound global, a write
through a module-level table’s subscript) is refused before any tool runs.
Note
A bare int is 16 bits in PyMCU and unbounded in the interpreter calling in. The build
prints which exports that applies to; annotate int32 for a wider one.
Requirements:
Requirement |
Why |
|---|---|
CircuitPython source tree |
supplies |
|
emits the relocatable kernel object ( |
|
compiles the generated adapter against |
|
needed by |
Output: dist/<module>.mpy (the module name defaults to the project name). Copy it
to the board’s CIRCUITPY drive and import <module>.
pymcu index (index maintainers)#
Builds the curated index by compiling, not by reading declarations. Run by the CI of
the pymcu-libraries repository; useful locally when working on the index itself.
pymcu index build --from libraries.txt --output index.json # install, measure, write
pymcu index verify --venv .venv # measure what is installed
Each library’s example is compiled for one chip per architecture — including architectures
it does not declare, because “does not build there” is exactly what the index has to be
able to state, and because a library that builds somewhere it never claimed means nobody
is maintaining its supports.arch. The build runs with PYMCU_LIBRARY_FILTER=0 so the
usual compatibility filter does not pre-empt the compiler; filtering first would only
measure the manifest.
--strict exits non-zero when a measurement contradicts a manifest, which is what turns
the submission check into a gate.
pymcu profile#
Compiles the project, assembles it, simulates it with the cycle-accurate AVR simulator, and writes a Speedscope flamegraph JSON.
pymcu profile # simulate 100 ms, write profile.speedscope.json
pymcu profile --ms 500 # simulate 500 ms
pymcu profile --cycles 800000 # simulate exactly 800,000 cycles
pymcu profile -o my_run.speedscope.json # custom output path
pymcu profile --open # open speedscope.app in the browser afterwards
pymcu profile --freq 8000000 # override clock (e.g. 8 MHz Lilypad)
pymcu profile --assert-cycles-lt 50000 # fail with exit code 1 if ≥ 50,000 cycles
pymcu profile -v # verbose build + simulation output
Output files:
File |
Description |
|---|---|
|
Speedscope evented flamegraph — drag to speedscope.app |
|
Symbol map used to annotate frames (auto-generated) |
Options:
Flag |
Default |
Description |
|---|---|---|
|
— |
Simulate exactly N clock cycles |
|
100 |
Simulate N milliseconds of firmware execution |
|
|
Output JSON path |
|
off |
Open speedscope.app in the browser after profiling |
|
from |
Override the clock frequency used for cycle→ms conversion |
|
— |
Exit with code 1 if total simulated cycles ≥ N (CI regression guard) |
|
off |
Experimental: run the declared |
|
off |
Show full build and profiler output |
Note
--cycles and --ms are mutually exclusive. If neither is provided, the profiler
simulates 100 ms by default.
Note
--pgo is experimental: it requires pgo = true under [tool.pymcu.experimental]
in pyproject.toml or PYMCU_EXPERIMENTAL_PGO=1. With the flag off it refuses
before anything is built. Reading workload.yaml needs pyyaml, the optional pgo
extra (pip install 'pymcu-compiler[pgo]'); no other command needs it. See
Profile-guided optimisation (experimental) under pymcu build for the consuming half.
CI example — enforce a cycle budget:
# .github/workflows/ci.yml
- name: Profile and check cycle budget
run: pymcu profile --assert-cycles-lt 200000
pymcu bench#
Like pymcu profile, but instead of writing a flamegraph file it prints a Rich table of
per-function cycle statistics directly to the terminal. Useful for quick performance
investigations without opening an external tool.
pymcu bench # simulate 100 ms, show all functions
pymcu bench --ms 500 # simulate 500 ms
pymcu bench --top 10 # show only the top 10 hottest functions
pymcu bench --cycles 100000 # simulate exactly 100,000 cycles
pymcu bench --freq 8000000 # override clock frequency
pymcu bench -v # verbose build + simulation output
Example output:
Simulated 100.0 ms (1,600,000 cycles @ 16 MHz)
┌──────────────────────┬───────┬────────┬────────┬──────────┬────────┐
│ Function │ Calls │ Self │ Self% │ Avg/call │ Incl% │
├──────────────────────┼───────┼────────┼────────┼──────────┼────────┤
│ crc8_step │ 2048 │ 850.2k │ 53.1% │ 3.2k │ 53.1% │
│ compute_checksum │ 8 │ 200.1k │ 12.5% │ 131.3k │ 65.6% │
│ main │ 1 │ 40.0k │ 2.5% │ 1.6M │ 100.0% │
│ delay_ms │ 8 │ 510.0k │ 31.9% │ 63.8k │ 31.9% │
└──────────────────────┴───────┴────────┴────────┴──────────┴────────┘
Self% colours: red ≥ 30%, yellow ≥ 10%.
Options:
Flag |
Default |
Description |
|---|---|---|
|
— |
Simulate exactly N clock cycles |
|
100 |
Simulate N milliseconds |
|
from |
Override clock frequency |
|
0 (all) |
Limit output to the top N functions by self-time |
|
off |
Show full build and profiler output |
Column definitions:
Column |
Meaning |
|---|---|
Calls |
Number of times the function was called during simulation |
Self |
Cycles spent inside this function, excluding callees |
Self% |
Self cycles as a percentage of total simulation cycles |
Avg/call |
Average inclusive cycles per call (includes callees) |
Incl% |
Total inclusive cycles as a percentage of total cycles |
Toolchain auto-detection#
PyMCU auto-detects and configures the appropriate toolchain for the selected chip:
AVR: Uses the built-in PyMCU AVR backend (no external assembler required)
PIC14/14E: Uses
gputils(auto-detected from PATH)
C/C++ interop configuration#
[tool.pymcu.ffi]
sources = ["src/sensor.c", "src/ArduinoLib.cpp"]
include_dirs = ["src/include"]
cflags = ["-O2"]
C sources use avr-gcc. C++ sources (.cpp, .cc, .cxx) use avr-g++ with
-fno-exceptions -fno-rtti, enabling use of Arduino libraries from PyMCU firmware.
Troubleshooting#
“Command not found”:
uv tool install pymcu-compiler # install via uv (recommended)
# — or —
pipx install pymcu-compiler # install via pipx
pipx ensurepath # add pipx bin to PATH
source ~/.zshrc # reload shell config
“avrdude: stk500_recv(): programmer is not responding”:
Check
--portmatches your Arduino’s serial devicemacOS:
/dev/cu.usbmodem*(note:cu.nottty.)Linux:
/dev/ttyACM0or/dev/ttyUSB0; add user todialoutgroup:sudo usermod -a -G dialout $USER
Build errors:
Run uv sync (or pip install pymcu-compiler) to ensure all dependencies are installed.
“pymcuc-avr-profiler not found” (profile / bench):
The profiler binary ships with pymcu-compiler starting from v0.12. If you are running
from source, build it manually:
dotnet publish extensions/pymcu-avr/src/csharp/profiler/ \
-c Release -o build/bin --nologo