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, or pip)

Generated files:

File

Contents

src/main.py

Starter firmware with a blink template

pyproject.toml

Project config with [tool.pymcu] section

.vscode/tasks.json

VS Code Build / Flash tasks

.gitignore

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

dist/firmware.hex

Intel HEX — flash this to the MCU

dist/firmware.asm

AVR assembly listing with source annotations

dist/firmware.mir

Mid-level IR (useful for debugging code-gen issues)

Requirements:

  • Valid pyproject.toml in the project root

  • All dependencies installed (uv sync or pip 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

dist/firmware.hex

RP2040, RP2350

dist/firmware.uf2 (packed by the build), falling back to dist/firmware.bin

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:

  1. --port / -P on the command line

  2. port = "..." in [tool.pymcu.flash] of pyproject.toml

  3. Auto-detection, when exactly one USB-serial device is connected

Baud resolution order:

  1. --baud / -b on the command line

  2. stdout_baud = ... in [tool.pymcu] of pyproject.toml, the speed print() runs at

  3. 115200

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:

  1. resolves the name in the index (cached under ~/.pymcu/, --refresh to update);

  2. 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;

  3. installs into the project’s .venv with uv or pip — never globally;

  4. 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;

  5. 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

--from-pypi

Skip the index and install this distribution directly. The manifest is still required.

--no-verify

Skip the verification build.

--refresh

Re-download the index before resolving.

--no-pre

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 tools/mpy_ld.py and py/dynruntime.h; must be the tag the board runs. Pass --circuitpython, set PYMCU_CIRCUITPYTHON, or add [tool.pymcu.natmod] circuitpython = "..."

pymcu-arm with native-module mode

emits the relocatable kernel object (-relocation-model=pic, .text/.rodata only)

arm-none-eabi-gcc

compiles the generated adapter against py/dynruntime.h (or set PYMCU_NATMOD_CC)

pyelftools

needed by mpy_ld.py, on whichever interpreter has it

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

profile.speedscope.json

Speedscope evented flamegraph — drag to speedscope.app

dist/firmware.symbols.json

Symbol map used to annotate frames (auto-generated)

Options:

Flag

Default

Description

--cycles N

—

Simulate exactly N clock cycles

--ms N

100

Simulate N milliseconds of firmware execution

-o PATH

profile.speedscope.json

Output JSON path

--open

off

Open speedscope.app in the browser after profiling

--freq HZ

from pyproject.toml

Override the clock frequency used for cycle→ms conversion

--assert-cycles-lt N

—

Exit with code 1 if total simulated cycles ≥ N (CI regression guard)

--pgo

off

Experimental: run the declared workload.yaml scenarios and write dist/profile.json for pymcu build --profile (RFC 0010)

-v / --verbose

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

--cycles N

—

Simulate exactly N clock cycles

--ms N

100

Simulate N milliseconds

--freq HZ

from pyproject.toml

Override clock frequency

--top N

0 (all)

Limit output to the top N functions by self-time

-v / --verbose

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 --port matches your Arduino’s serial device

  • macOS: /dev/cu.usbmodem* (note: cu. not tty.)

  • Linux: /dev/ttyACM0 or /dev/ttyUSB0; add user to dialout group: 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