An opinionated Embedded Rust Setup

Setup Embedded Rust Environment (host computer only)

Two years ago we documented an opinionated MicroPython setup before a hackathon. This is the Rust equivalent — same intent, very different machinery.

Embedded Rust on the ESP32 is a midway with two main attractions — and most projects want a ticket for both.

  • esp-hal — bare-metal, no_std, no operating system underneath.
  • esp-idf — Espressif's std-flavored runtime with FreeRTOS, threads, and anyhow.

The two stacks produce incompatible build artifacts.

Does this ring any bells? One minute of cargo watching paint dry, then:

Finished `release` profile [optimized] target(s) in 1m 01s
Error: multiple IDF-built bootloaders found for target "riscv32imc-esp-espidf" profile "release".
Run: cargo clean -p esp-idf-sys, or remove unused esp-idf-sys-* build directories.

This setup lets you easily juggle the two stacks — flip from bare-metal to ESP-IDF without paying the rebuild tax. Sharing a single target/ directory between them otherwise means a full rebuild every time you switch. This setup keeps them isolated and — optionally — moves the build cache onto a RAM disk so Rust's heavy write load stops chewing through your SSD.

The reference workspace for this post is datenkollektiv/rustyfarian-ws2812.

Install the Rust toolchain

Install rustup if you don't have it yet:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Warning: Piping a remote shell script straight into sh is still spooky in 2026 — you're running whatever sh.rustup.rs serves at exactly that moment, before you ever read it. If that makes you twitch (it should), download the script first, skim it, then run it locally:

sh curl --proto '=https' --tlsv1.2 -sSfO https://sh.rustup.rs/rustup-init.sh less rustup-init.sh sh rustup-init.sh

Or use your package manager: brew install rustup-init on macOS, apt install rustup on recent Debian/Ubuntu.

Add the bare-metal RISC-V target for ESP32-C6 and ESP32-H2:

rustup target add riscv32imac-unknown-none-elf

For ESP32-C3 (no atomics) add the second target:

rustup target add riscv32imc-unknown-none-elf

Install the ESP toolchain (espup)

The esp-idf stack and the Xtensa-based original ESP32 both need Espressif's fork of Rust. The espup tool installs everything: the +esp toolchain, LLVM with Xtensa support, and the ESP-IDF helper crates.

cargo install espup
espup install

After install, source the export file in your shell profile (path printed by espup install). This activates the +esp toolchain so cargo +esp build ... resolves cleanly.

Install espflash for talking to the chip:

cargo install espflash

And ldproxy — the linker shim that ESP-IDF builds invoke:

cargo install ldproxy

Prepare the stage: target isolation in .cargo/config.toml

The workspace defaults to the bare-metal target so IDE tooling (RustRover, rust-analyzer) and pure-crate checks succeed without an ESP toolchain. IDE writes and pure-crate cargo invocations land in target/ide, separate from the embedded build outputs.

[build]
target = "riscv32imac-unknown-none-elf"
target-dir = "target/ide"

The interesting bit is splitting esp-hal (no_std) and esp-idf (std) into their own target directories. This goes into the project justfile:

ramdisk := "/Volumes/RustBuilds"
hal_dir := if path_exists(ramdisk + "/targets/hal") == "true" { ramdisk + "/targets/hal/" + file_name(justfile_directory()) } else { "target/hal" }
idf_dir := if path_exists(ramdisk + "/targets/idf") == "true" { ramdisk + "/targets/idf/" + file_name(justfile_directory()) } else { "target/idf" }

Every HAL recipe passes --target-dir {{ hal_dir }}, every IDF recipe passes --target-dir {{ idf_dir }}. Switching from esp-hal to esp-idf no longer triggers a full rebuild.

Tip: path_exists resolves at parse time, so the RAM disk is auto-detected without any shell setup or direnv.

Optional: A RAM disk for the build cache (macOS)

Rust writes a lot to target/. On macOS, an hdiutil-backed RAM disk eliminates SSD wear and speeds up cold builds. The workspace ships a script wrapped in a just recipe:

just ramdisk attach

This creates a 6 GB HFS+ volume at /Volumes/RustBuilds with the expected targets/hal/ and targets/idf/ subdirectories. The path_exists check above now resolves to the RAM disk paths and every HAL/IDF cargo invocation writes there.

To free the memory:

just ramdisk detach

Warning: The RAM disk vanishes on reboot. That's intentional — target/ is ephemeral and safe to lose. When you reattach, the first build is cold but subsequent ones are fast.

Optional: sccache for cross-build caching

A shared sccache survives reboots and serves both runtimes from one cache.

brew install sccache

Then in your shell profile:

export RUSTC_WRAPPER=sccache

Verify the setup with just doctor

The workspace includes a status booth — it doesn't take tickets, it just tells you which rides are open:

just doctor

With the RAM disk attached and sccache configured you should see:

  ramdisk    ok       /Volumes/RustBuilds
  hal target ok       /Volumes/RustBuilds/targets/hal/rustyfarian-ws2812
  idf target ok       /Volumes/RustBuilds/targets/idf/rustyfarian-ws2812
  sccache    ok       sccache 0.8.1

Without the RAM disk it falls back to the on-disk paths and isolation is preserved — just slower:

  ramdisk    MISSING  run: just ramdisk attach
  hal target fallback target/hal
  idf target fallback target/idf
  sccache    MISSING  run: brew install sccache  (optional, speeds up cold builds)

Step right up: build and flash

Clone the reference workspace:

git clone https://github.com/datenkollektiv/rustyfarian-ws2812.git
cd rustyfarian-ws2812

Plug in an ESP32-C6 with a WS2812 strip on GPIO18 and flash the bare-metal pulse effect:

just run hal_c6_pulse

For the esp-idf flavor on the same chip:

just run idf_c6_effects

The example name encodes the driver and chip: hal_* builds the bare-metal driver, idf_* the ESP-IDF one. Both end up on separate target directories — no rebuild churn when you alternate.

Tips

Check just the bare-metal crate without touching the ESP toolchain:

just check-hal

Check just the ESP-IDF crate (requires the +esp toolchain from espup):

just check-idf

If sdkconfig.defaults changes seem to have no effect, the esp-idf-sys build script has cached itself. Clear the stale state with:

just clean-idf

To wipe everything — the IDE cache, the HAL dir, and the IDF dir:

just clean

Why this matters

The MicroPython setup was about getting one Python interpreter onto one board. Rust on the ESP32 is two distinct universes that happen to target the same silicon. Treat them as such, give each one its own target/ directory, and the day-to-day loop stays fast.

The RAM disk and sccache are the speed-pass upgrades — nice, not essential. Target isolation is the price of admission. Without it, every switch between no_std experiments and ESP-IDF examples burns minutes you'd rather spend at the wheel, the lights, the rides.

See you on the midway!