barepy
Python as an elegant coding bridge to bare-metal microcontroller C.
A hybrid optimizing transpiler with source-mapped debugging.
Write firmware in a clean Python subset. barepy transpiles it to readable C, compiles it for your board (or a native simulator), and — crucially — maps every compiler and runtime error back to the exact Python line and column.
import barepy as bp
from barepy.boards import STM32F4
bp.init(STM32F4, clock="168MHz")
led = bp.Pin(13, bp.OUTPUT)
btn = bp.Pin(0, bp.INPUT_PULLUP)
@bp.setup
def setup():
led.off()
@bp.loop
def loop():
led.toggle() if btn.pressed() else led.off()
bp.delay(200)
barepy sim examples/blink.py # transpile -> C -> compile -> simulate
✨ Highlights
| 🐍 Python in, C out | A static, zero-overhead subset transpiles to small, fast, readable C — not an on-chip interpreter. |
| 🔩 Real cross-compilation | --target arm genuinely cross-compiles: arm-none-eabi-gcc/avr-gcc, a per-board linker script sized from real flash/RAM, a startup file with the actual vector table — not a host binary that only compile-checks the register writes. Built-in boards span two real architectures: STM32F4, STM32F1 (Cortex-M, different GPIO/clock peripheral), ArduinoUno (8-bit AVR, different toolchain entirely). |
🧵 @bp.dynamic on bare hardware |
Toolchain packagings with no newlib still get the full dynamic tier + mark-sweep GC: barepy brings its own setjmp/longjmp, string/mem routines, and a bump+freelist heap (barepy_freestanding.c), all sized down from the board's actual RAM so a 20KB part doesn't get sim-sized (8192-object) pool defaults. |
| 🎯 Source-mapped everything | Compiler, board, and runtime errors surface against your Python with a caret — never the generated C. |
| ⚖️ Hybrid tiers | Most code is zero-cost static. Genuinely dynamic code opts into a small boxed-value runtime via @bp.dynamic. Pay only for what you use. |
| 🔬 Real optimizer | Fix-point const-fold/prop, algebraic identities, branch-fold, DCE — every rewrite auditable with optimization-table. |
| 🛡️ Panic-free | Every pipeline stage runs inside a fault barrier; a 1,500-case fuzzer asserts zero escapes. Runtime faults degrade gracefully. |
| 🔥 Hot-code analysis | Ranks patterns dangerous on an MCU: blocking in an ISR, while True with no exit, div-by-zero, 32-bit overflow, busy loops. |
| 🔌 Everything is a plugin | Boards, backends, passes, tiers, CLI/TUI commands, lints, flashers — all extension points on a plugin fabric. |
| 🖥️ Textual studio | Cockpit TUI around your IDE: diagnostics (incl. C-level errors mapped to Python) · cost · optimizations · hot-code · generated C · sim · debug-C memory profiler · flash · test suite · live activity monitor. |
Why it exists
A transpiler, not an on-chip interpreter — the output is native C, so it is
fast and small. It is hybrid: most code is a static, zero-overhead subset;
genuinely dynamic code (config parsing, protocol work) can opt into a small
boxed-value runtime with @bp.dynamic. You pay only for what you use, and the
build report tells you the cost.
Install
pip install barepy # core (from PyPI)
pip install 'barepy[studio]' # + Textual TUI
# from source
pip install -e .
pip install -e '.[studio]'
Requires Python 3.11+ and a C compiler: cc/clang/gcc for the native
sim, arm-none-eabi-gcc for Cortex-M boards (STM32F4/F1), avr-gcc for AVR
boards (ArduinoUno).
Commands
| command | does |
|---|---|
barepy transpile f.py [--show] [--no-opt] |
emit C + .map.json, no compile |
barepy build f.py [--target sim|arm] |
transpile + optimize + compile |
barepy sim f.py [--ticks N] |
build native + run the simulator |
barepy debug f.py |
build; render any error with a caret on Python source |
barepy optimization-table f.py |
table of every optimization applied |
barepy hot-code f.py [--strict] |
ranked report of fragile / dangerous code |
barepy board list / board show <name|file> |
inspect boards |
barepy plugins list |
show discovered plugins + extension points |
barepy studio [f.py] |
launch the Textual TUI |
barepy startproject <name> [--board B] [--here] [--force] |
scaffold main.py, barepy.toml, .gitignore, README, tests/ |
The compiler
The pipeline is a real optimizing compiler, not a translator:
-
Type inference (M1) — flow-forward inference over the static tier assigns a concrete C type to every local (
int32_t x,bool, …) and reports type errors against Python spans. -
Optimizer (M2) — fix-point passes: constant folding, constant propagation, algebraic identities (
x*1,x+0,x*0), branch folding (if True), and dead-code elimination.barepy optimization-tableshows each rewrite:line pass kind before -> after 14 const_fold arith (2 + 12) -> 14 15 algebraic x*1 (x * 1) -> x 17 branch_fold if-true if <true> -> <then branch> 19 const_fold arith (14 + 100) -> 114 -
SSA layer (M2+): inlining · monomorphization · copy-prop — a whole-program pass inlines single-return
@bp.statichelpers at their call sites (binding parameters to the actual argument trees), monomorphizes un-annotated helpers per call-site type signature, and propagates single-definition (trivially SSA) copies into their uses. A fully-inlined helper emits no C function at all — it costs nothing:line pass kind before -> after 24 monomorphize specialize dbl(...) -> dbl<int> 24 inline expand dbl(...) -> ((21 * 2) + 1) 25 monomorphize specialize dbl(...) -> dbl<bool> 26 copy_prop propagate a -> ((21 * 2) + 1) 26 const_fold arith (42 + 1) -> 43 -
Hybrid dynamic tier (M4) —
@bp.dynamicfunctions transpile to C over a boxed-value runtime (Obj*, bump arena). dict / list /for/ unpack /int()/.split()/.get()all work. The static caller unboxes scalar results and resets the arena at the boundary — you pay only for what you use. Generated dynamic C is portable C11 — container literals are hoisted into ordinary statements, so nothing depends on GCC/Clang statement-expressions. -
Escape analysis → region inference → GC (M4+) — escape analysis classifies every dynamic allocation into a region (
arenafor non-escaping,gcfor escaping/cyclic), surfaced inoptimization-table. Opt into the cycle-collecting heap withbp.init(..., gc="mark-sweep"): allocations move onto a precise mark-sweep collector, generated code roots its live locals, andbp.gc_collect()reclaims unreachable objects — including reference cycles the bump arena never could:@bp.dynamic def churn(n: int) -> int: i = 0 while i < n: d = {} d[b"self"] = d # a reference cycle bp.gc_collect() # reclaimed each iteration; live memory stays flat i = i + 1 return bp.gc_live() # bounded, no matter how large n is
Hot-code analysis
barepy hot-code ranks patterns that are dangerous on a microcontroller:
blocking or allocating in an ISR, while True with no exit, division by zero,
32-bit overflow, ValueError-prone unpacking, busy loops, and more.
🟥 CRIT scary.py:22 [isr-blocking] in on_button()
blocking bp.delay() inside ISR 'on_button' stalls the whole system
fix: ISRs must return fast; set a flag instead
Fault tolerance (aerospace-grade)
The transpiler is panic-free: every pipeline stage runs inside a fault
barrier, so any internal bug becomes a contained ICE diagnostic, never a crash.
A 1,500-case fuzzer of malformed / adversarial / binary input asserts zero
escapes (tests/test_robustness.py). At runtime, dynamic-tier faults
(bad int(), unpack mismatch, KeyError) print a source-mapped error and the
program degrades gracefully instead of hard-faulting.
Source-mapped errors
barepy emits #line directives into the generated C and keeps a span sidecar,
so a mistake surfaces against your Python, not the generated C:
$ barepy debug examples/bad_blink.py
examples/bad_blink.py:18:5 [static]
btn.toggle()
^^^^^^^^^^^^
error: 'btn.toggle()' needs an OUTPUT pin, but 'btn' is INPUT_PULLUP
hint: declare btn = bp.Pin(0, bp.OUTPUT)
Board-level constraints map back too:
$ barepy build examples/blink.py --board examples/custom_board.board.toml
examples/blink.py:4:1 [board]
bp.init(STM32F4, clock="168MHz")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: clock 168MHz exceeds MyTinyBoard max 48MHz
Define your own board
Three ways, all producing the same Board object.
1. Data file (*.board.toml) — shareable, no code:
name = "MyTinyBoard"
[mcu]
core = "cortex-m0"
flash = { base = 0x08000000, size = "64KB" }
ram = { base = 0x20000000, size = "8KB" }
[clock]
max_hz = 48_000_000
[peripherals]
GPIOA = 0x48000000
[[pins]]
id = 13
port = "GPIOA"
bit = 5
barepy build blink.py --board my.board.toml
2. Python class — full control (see barepy/boards/__init__.py).
3. Plugin package — ship a board (and flasher, backend, passes…) on PyPI.
Hardware bring-up (real clock / RCC / SysTick)
The ARM target no longer assumes a hand-configured clock. Each build emits a
barepy_board.h derived from the Board definition, and the runtime runs the
real bring-up sequence:
- PLL / RCC — from the board clock tree (
HSE, PLLm/n/p) barepy computes the core frequency (e.g. STM32F4:8 MHz × 336 / (8 × 2) = 168 MHz), sets the flash wait states, programs the PLL, and switchesSYSCLKover. - GPIO clocks — only the ports your pins actually use are enabled, via an
RCC AHB1ENRmask computed at build time. - SysTick timebase —
bp.delay(ms)waits on a real millisecond SysTick counter (WFIbetween ticks), not a tuned busy-loop.
// generated barepy_board.h (STM32F4 @ clock="168MHz")
#define BP_CORE_HZ 168000000u
#define BP_RCC_BASE 0x40023800u
#define BP_FLASH_WS 5u
#define BP_GPIO_ENR_MASK 0x00000005u // GPIOA + GPIOC (the pins in use)
#define BP_HAS_PLL 1
#define BP_PLL_M 8u #define BP_PLL_N 336u #define BP_PLL_P 2u
Plugins — bring your own chip, as an ordinary pip package
A plugin is a package exposing a Plugin through the barepy.plugins
entry-point group. It can register boards, compile backends, MIR passes,
runtime tiers, CLI/TUI commands, lints, and flashers — pip install it and it
shows up automatically, no core changes. (There's no barepy[runtimes-x]
extras syntax — extras only select this package's own optional deps; a
separate installable package + entry point is the real, working mechanism.)
The interesting hook for a new chip family is register_backend: a Backend
is whatever turns generated C into a flashable binary for one board.family.
Built-in families (cortex-m4/stm32f1 → ArmBackend, avr → AvrBackend,
see barepy/backends.py) are registered exactly the same way — a plugin can
reuse one of them under a new family key (a different chip, same electrical
family) or subclass Backend for a wholly new architecture:
from barepy.ext import Plugin, hookimpl
from barepy.backends import Backend, AvrBackend
from barepy.hal import Board
class MyBoard(Board):
family = "my-chip" # any key you like
class XtensaBackend(Backend):
def compile(self, driver, res, outdir, stem, uses_dynamic):
... # set res.bin_path, or res.ok = False + res.cc_stderr
class plugin(Plugin):
@hookimpl
def register_boards(self, reg): reg.add(MyBoard)
@hookimpl
def register_backend(self, reg): reg.add("my-chip", AvrBackend()) # reuse
# or: reg.add("xtensa", XtensaBackend()) # new arch
@hookimpl
def register_flasher(self, reg): reg.add("my-chip", my_flash_fn)
See examples/plugins/barepy_tinyavr/ for a full worked example (a distinct
board reusing the built-in AVR backend under its own family key) —
barepy plugins list shows everything currently registered, built-in and
plugin alike.
Architecture
Python source
-> frontend tokenize/AST -> IR (+ spans) barepy/frontend.py
-> analysis effect/tier/pin checks barepy/frontend.py:analyze
-> board resolve + validate barepy/hal.py
-> codegen IR -> C + #line + source map barepy/codegen.py
-> driver write artifacts, compile barepy/driver.py
-> runtime T0 static core (native | arm) barepy/runtime/
The IR carries a Span on every node — the source-map spine that lets any
failure at any layer point back to Python. Later milestones lower the IR
through SSA/MIR (optimizer) and LIR (boxing, coroutines, ISR lowering) without
changing this pipeline shape.
Known limitations
barepy 0.1.0 is the M0 vertical slice — honest about its edges:
- Inlining is limited to single-return
@bp.statichelpers. General multi-statement static functions with parameters are inlined only in that expression form; a full MIR call-lowering is future work. - The GC is a stop-the-world mark-sweep with a fixed object pool. It handles cycles correctly, but there is no generational / incremental collection yet, and collection runs only at safe points (never mid-expression).
- PLL / RCC bring-up targets the STM32F4 register layout. Other families
compile against the same generated
barepy_board.h, but non-F4 clock trees may need a board-specific recipe (ship one via a plugin backend). - Supported Python is a subset. Classes, generators, exceptions, and arbitrary imports are out of scope by design — see the frontend for the exact grammar.
Status & roadmap
- frontend → IR with spans (for, bool ops, bytes, dict/list, subscript, unpack)
- static tier → C,
#line+.map.json - effect/pin/board diagnostics with caret rendering
- native simulator + STM32 ARM runtime
- board system: Python class, TOML, validation
- plugin fabric (boards/backends/passes/tiers/commands/lints/flashers)
- Textual studio (diagnostics · C · cost · optimizations · hot-code · sim · debug-C memory profiler · flash · test suite · live activity monitor)
- M1 type inference (flow-forward, C-type assignment, type errors)
- M2 optimizer (const-fold/prop, algebraic, branch-fold, DCE) +
optimization-table - M4 hybrid dynamic tier (boxed
Obj*runtime, bump arena, boundary marshalling) - hot-code risk analysis (
hot-code) - aerospace-grade fault barrier (panic-free, fuzz-verified) + mapped runtime faults
- M2+ SSA layer: inlining, monomorphization, copy-propagation
- M4+ escape analysis → region inference → cycle-collecting mark-sweep GC
- portable dynamic codegen (C11, no statement-expressions)
- real ARM clock/RCC bring-up + SysTick
bp.delay(generatedbarepy_board.h) - M2++ full MIR call-lowering (general static functions), CSE/GVN
- M4++ generational / incremental GC
- M5 async state machines · M6 DMA ownership · M8 LLVM backend (RISC-V/ESP)
M0 is the working vertical slice this repo ships. Each later milestone is
independently useful.
Metadata
Release files for barepy 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| barepy-0.2.0.tar.gz | 96.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| barepy-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 191.8 kB
Release files / barepy-0.2.0.tar.gz
| Download URL | barepy-0.2.0.tar.gz |
|---|---|
| Size | 96.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ba59da2d0b8f63701d662c2e8047df0de2f31a3be07b561c9375bec5f42387f4
|
|
BLAKE2b-256 checksum How to use checksums |
14a810496ade6f06827c9d0a3904ac22ba7d53414d91c2fba32f43fa0a717b06
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.6
|
Release files / barepy-0.2.0-py3-none-any.whl
| Download URL | barepy-0.2.0-py3-none-any.whl |
|---|---|
| Size | 94.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
078bec52ce18d2f6f3e9c73fd00065dd4ceddec55766f5350cdf28cab7997a18
|
|
BLAKE2b-256 checksum How to use checksums |
8b1818eb05a09cb25d79f25b8df0d22e19dc9c2c71bc322757eb00fbd3bb6d3a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.6
|