Bare-metal Mojo on a $4 microcontroller — a pure-Mojo RP2040 firmware + SDK

Mojo is usually pointed at GPUs and AI kernels. I wanted to see how far the other direction goes, so I wrote a bare-metal firmware and peripheral SDK for the Raspberry Pi Pico (RP2040, a dual Cortex-M0+) in pure Mojo — no OS, no C application layer. The whole application plus SDK is Mojo; startup is ~60 lines of assembly and one linker script.

Repo: GitHub - Hundo1018/inmojomni: Bare-metal Raspberry Pi Pico (RP2040) and Pico 2 (RP2350) firmware, written in Mojo. · GitHub


[IMAGE 1 — docs/assets/hw-tests.png] (hero: the on-hardware test suite, all green)

A complete blink is 780 bytes, and it turns out that’s byte-for-byte the size of the same blink compiled with clang -O2 on the identical rig. Blinking an LED looks like this:

import pico
from pico import Pin, pins, sleep_ms


@export("mojo_main")
def start() abi("C"):
    pico.init()

    var led = Pin[pins.LED]()   # pin number is a compile-time parameter;
    led.make_output()           # Pin[99]() is a compile-time error

    while True:
        led.toggle()
        sleep_ms(250)

The trick that makes it possible


[IMAGE 2 — docs/assets/pipeline.png] (the retarget pipeline)

The bundled Mojo LLVM has no 32-bit ARM backend, so mojo build can’t target
Cortex-M0+ directly. The workaround is a source-to-source-ish pipeline at the
IR level:

  1. mojo build --emit=llvm targeting riscv32 — it shares the exact ILP32
    little-endian data model of ARMv6-M, so the emitted IR is layout-compatible.
  2. Rewrite the target triple + datalayout to thumbv6m-none-eabi, and downgrade
    the handful of IR constructs the system LLVM (18) doesn’t understand yet
    (captures(none), #dbg_* records, f0x float literals, single-arg
    lifetime intrinsics).
  3. System opt -O2 / llc -O2 → Cortex-M0+ object.
  4. arm-none-eabi-gcc links it with crt0 + linker script + boot2 + libgcc.

The retarget pass is guarded by unit tests, opt -verify, and a check that the number of volatile ops is preserved end to end. When a nightly emits new IR syntax, supporting it is usually one more rewrite rule. (The whole pipeline driver is itself a Mojo program, not a shell script.)

What’s measured


[IMAGE 3 — docs/assets/benchmarks.png] (9 workloads × 4 languages, normalized to clang C)

I didn’t want to hand-wave the performance claim, so the benchmark builds the same 9 workloads four ways — Mojo, gcc -O2, clang -O2 (same LLVM backend as Mojo, which is the fair yardstick for language overhead), and Rust opt-level=2 — on the same board, crt0, clocks and 1 MHz timer, three runs each, with checksums verified identical across all four languages.

  • Register-loop microbenchmarks: Mojo == clang C to the microsecond (the 200k-round xorshift is 102,501 µs in all three of Mojo / clang / Rust).
  • Larger workloads (CRC-32, quicksort, 16×16 matmul, recursive fib): Mojo lands within ±13% of clang C, ahead on some, behind on others — ordinary optimizer variance, no systematic “language tax”, no cliff as programs grow.

Full methodology and the honest caveats (Rust’s own compiler-builtins skew the division/float rows; XIP flash alignment adds noise on tight loops) are in the repo’s BENCHMARKS.md.

What the SDK does today

GPIO (pins as compile-time-checked types), PIO (assembler written as method calls, with side-set, forward labels, and compile-time assembly — a PIO program can be a comptime value so the instruction words become flash constants, with comptime assert turning an invalid program into a build error), PWM, ADC + on-die temperature sensor, UART, NVIC interrupts, RTT
logging, dual-core launch, SIO hardware spinlocks and inter-core FIFO.

Everything is backed by 26 on-target tests that run on real silicon (results written to a RAM mailbox, read back over SWD) — including a genuinely contended dual-core spinlock test (2×20k increments == exactly 40,000) and full F5 breakpoint debugging validated over the DAP protocol. There’s a scheduled CI job that re-tests against the newest nightly daily.

The stack, end to end

  • Language & toolchain: Mojo nightly, managed by pixi (pinned lock for reproducibility, plus a daily CI job that re-tests against the newest nightly so breakage surfaces within a day).
  • Build: mojo build --emit=llvmtools/retarget.mojo → system LLVM 18 opt/llcarm-none-eabi-gcc with a 60-line crt0.S, one linker script, and boot2 assembled from vendored pico-sdk source (checksummed at build time, regression-tested byte-identical to the reference).
  • The tooling is Mojo, too: the pipeline driver, IR retarget pass, ELF verifier, benchmark driver and chart generator are all Mojo programs — Python survives only as the test orchestrator and DAP protocol client.
  • Debugging: probe-rs + VS Code — press F5 and you get breakpoints, stepping, variables, CPU registers and live SVD peripheral views in the .mojo sources, on a $4 MCU. RTT logging over the same probe, no UART wires.
  • Tests & CI: Mojo host unit tests (retarget rules, PIO encodings, boot2 provenance) + the 26-test on-target suite over SWD; GitHub Actions runs the host suite on every push and the newest-nightly job on a schedule.
  • Packaging: the SDK installs into any pixi project via the pixi-build-mojo backend — pixi add --git https://github.com/Hundo1018/inmojomni.git` inmojomni, then import pico`. (Verified end-to-end from a fresh environment.)

Where I could use the team’s input (embedded feedback)

A few things came up that I think are relevant to Mojo reaching freestanding / embedded targets, and I’d love to know what’s on the radar:

  1. A 32-bit ARM (thumbv6m/v7m) backend would remove the entire retarget hack. The riscv32 detour works precisely because the data models match, but it’s a workaround.
  2. Discarded volatile loads. _ = read32(mmio_addr) (a volatile load whose result is unused) is dropped by the elaborator before it reaches LLVM, so the volatile access never happens. On an MCU this is a real footgun — I hit it as a hang draining a hardware FIFO and had to route the value into a branch with a side effect to keep the load alive. Is eliminating an unused-but-volatile load intended? (Happy to file a minimal repro.)
  3. A no-std / freestanding profile. The bounds-check/assert path pulls in a small printf/formatting machinery; on bare metal I stub those symbols to a bkpt trap. A supported freestanding profile (and a way to opt the assert I/O into a user-provided trap) would make this cleaner.

On the flip side, comptime is a joy here — folding an entire register map to immediates, and assembling PIO programs at compile time with real assertions, are exactly the ergonomics that make this fun rather than painful.

It’s experimental and pinned to nightly (the language isn’t 1.0), but every claim in the README has a test behind it. Feedback, questions, and “you’re holding LLVM wrong” corrections all very welcome.


Disclosure of AI-generated content:
In line with community guidelines, I wish to disclose that this article was entirely written by Claude to convey my intended message.

Glad to hear that comptime is as useful for MCUs as it is for GPUs!

I also have a few questions:

  • How did you do checksums on GPIO writes?
  • Did you avoid the external symbols that the runtime pulls in or just stub them out?

<Mod hat on>

While this is a very cool project, I’m seeing a few LLM-isms and we do require disclosure for LLM-generated text.

<Mod hat off>

This is super cool, I’m thrilled to see this. This will also be so much easier with Mojo OSS, we’re still on track for this year (not long now)!

Thanks for the questions, I’ve add the disclosure!

Q1: What I do verify for the GPIO benchmark here is a different thing: does toggle() actually flip the pin? and poll it from the pad, and I found out that GCC -O2 does not unroll the volatile GPIO store loop (4.7× slower in this case). So I didn’t checksum GPIO, but added the same backend (clang) to the benchmark.

Q2: Yes, I simply stub them out to a bkpt(if there is a debugger like a Raspberry Pi debug probe) otherwise hardfault instead.

Is this using UnsafePointer.load[volatile=True]? If so, a repro would be greatly appreciated!

-Chris

Yes, plain UnsafePointer[UInt32, MutUntrackedOrigin].load[volatile=True](), result discarded via _ =
Issue here

At --emit=llvm (before any opt pass), a load[volatile=True] whose result is unused doesn’t appear in the IR at all — the function lowers to { ret void }. The identical load keeps its load volatile the moment the value is used (fed into a store[volatile=True]). Reproduces on both the default host target and riscv32-unknown-none-elf, so it’s in the elaborator, not target lowering.
Thanks for taking a look!

I fixed the “volatile load” issue over the weekend, plz give it a try in a new nightly when you get a chance. Thanks for reporting this!

I can’t test your latest fix yet because of another issue I ran into.

Starting with dev2026071105, RISC-V code generation appears to be disabled. riscv32 and riscv64 still show up in --print-supported-targets, but any --emit=llvm, --emit=asm, or --emit=object fails with:

error: no compiler backend is registered for target 'riscv32-unknown-none-elf'; this target is not supported by this build.

I bisected the nightlies, and dev2026071006 is the last version where RISC-V emission still works. dev2026071105 is the first one that fails, and every nightly since then behaves the same.

Until that regression is fixed, I can’t build my RP2040 firmware, so unfortunately I can’t verify your latest change.

@Hundo this is super cool.

On the no-std discussion point, I know that’s the direction rust mostly went for embedded and linux kernel work (that and pluggable allocator trait). I’m curious how often you’d see truly bare metal MCU (with no runtime support) in practice vs building on something like FreeRTOS or Zyphir (e.g. on STM32 or ESP32 MCU families). One approach I could imagine exploring might be std lib implementation variants that target these (e.g. AsyncRT mapped on to FreeRTOS scheduling). The std lib already has conditions for things like `sys.is_gpu` and nvidia vs amd vs apple.

Aside from that, I’m curious if you’re hoping to extend this project to other MCU families like SMT, ESP, atmega or is this primarily meant to be a motivating proof of concept?

@Hundo You chose to create a mojo pico SDK from scratch. This is fascinating btw! But would it be possible to re-use the official C/C++ pico SDK inside the mojo project? Could the MLIR infrastructure be useful for this?

Bare-metal & RTOS
In my own surroundings bare-metal actually shows up more often, though that’s probably a bias of where I sit: education and rapid prototyping, small single-purpose applications. I have considered an RTOS, though. The idea of swapping out AsyncRT, replacing Mojo’s runtime with something RTOS-shaped, is genuinely interesting and worth a deep discussion on its own. An allocator trait is also something I’ve wanted for a long time. My hesitation is that I suspect it would be a real burden on the team’s road to 1.0: it likely touches more than one layer at once (MLIR, the compiler, and possibly other factors I can’t see), so I think it fits better as something to plan after the compiler is open-sourced.

Other MCU families
It started purely as a proof of concept, but along the way I found that Mojo’s parametrization strikes a nice balance between ergonomics and performance. So yes, I’m genuinely interested in expanding. Right now I’m working with the RP2350 / STM / ESP dev boards I have on hand, plus some other hardware, trying to build a more comprehensive automated verification loop.

That said, I’d very much welcome anyone more capable or more specialized in this area to write their own repo, fork this, or contribute directly. The MCU families are enormous, and boards vs. MCUs need to be cleanly separated, so before I commit to that breadth alone I’d want to confirm there’s real demand, and put the effort into writing proper guides and separating the toolchain, rather than trying to maintain all of it by myself.

Good question. Short version: reusing the C SDK is technically feasible, and the project already does a small piece of it, but MLIR isn’t really the key that makes it work.

Reusing the C/C++ Pico SDK. At the linker level there’s nothing special stopping you. The build already compiles one part of the pico-sdk verbatim (the boot2_w25q080.S second-stage bootloader) with the Arm toolchain and links it into the image. You could compile more of the C SDK the same way (clang, then object, then link) and then call into the C ABI through Mojo’s FFI. So “reuse the C SDK” is a perfectly workable option.

The reason I didn’t is that the point of this project is the opposite: to show the SDK layer (GPIO, PIO, PWM, ADC, UART, interrupts) can be written entirely in Mojo, with no C application layer, borrowing only the register definitions from upstream (the SVD, plus that one boot2 block). Pulling in the C HAL would defeat that: it becomes “Mojo calling C” rather than “Mojo firmware.” So yes, it’s possible, but you’d still have to steer around AsyncRT.

On MLIR. The retargeting I do is a textual rewrite at the LLVM IR level (triple and data layout), not an MLIR-level transform, and Mojo doesn’t currently expose a way to plug a custom MLIR pass into that pipeline. Bridging to the C SDK wouldn’t go through MLIR at all: it’s just ordinary cross-compilation and linking against C objects. So MLIR is genuinely central to Mojo itself, but it isn’t the mechanism that lets you reuse the C SDK.