Embedded & IoT No. 458

Embedded Rust skill for Claude Code

no_std firmware with one owner per peripheral. Claude sets up no_std firmware with the right target triple, pinned toolchain, probe-rs runner and defmt logging, then structures concurrency as embassy tasks that own their peripherals and talk over channels. Drivers are written generically over embedded-hal 1.0 and tested on the host with mocks, memory stays static with heapless and StaticCell, and release builds are checked for size and stack headroom.

  • nameembedded-rust
  • size227 lines · ≈2,300 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes no_std Rust firmware for microcontrollers — target triples, cortex-m-rt and memory.x, embedded-hal 1.0 traits and chip HALs (embassy-rp, embassy-stm32, embassy-nrf, esp-hal), embassy async tasks and channels, RTIC as the alternative, probe-rs flashing with defmt logging, static allocation with heapless and static_cell, and size optimization. Use when starting a Rust project for RP2040/RP2350, STM32, nRF52/nRF53, or ESP32-C3/C6, when probe-rs cannot connect, a panic or HardFault appears, a driver needs to be generic over embedded-hal, or binary size and stack usage need trimming.

Try asking

  • Start an embassy project for a Raspberry Pi Pico 2 with defmt logging over probe-rs
  • Write a TMP117 driver that works with any embedded-hal 1.0 I2C bus
  • My nRF52 firmware HardFaults at boot after I changed boards — help me find why

Use it when

  • Starting Rust firmware for RP2040/RP2350, STM32, nRF, or ESP32-C chips
  • probe-rs will not connect or panics print nothing
  • Writing portable sensor drivers against embedded-hal traits
  • Choosing between embassy, RTIC, and blocking HAL code

Skip it when

  • Ownership and lifetime errors in ordinary std Rust — use rust-ownership
  • Bus wiring and signal problems below the driver layer — use serial-protocols

How to check the result

cargo run --release flashes and streams defmt logs, a deliberate panic shows file and line, clippy is clean for the target, and cargo size leaves flash headroom.

The common mistake it prevents

Mixing crate versions from different embassy or embedded-hal releases, which produces baffling trait-bound errors that look like code bugs.

~/.claude/skills/embedded-rust/SKILL.md

--- name: embedded-rust description: Writes no_std Rust firmware for microcontrollers — target triples, cortex-m-rt and memory.x, embedded-hal 1.0 traits and chip HALs… license: MIT ---

Done well means: the project builds for the exact target triple from a pinned toolchain, cargo run flashes and streams defmt logs through probe-rs, peripherals are owned by exactly one task or driver, there is no heap unless deliberately added, drivers are generic over embedded-hal traits, and release builds fit with headroom.

Ground rules

  • Match the target triple to the core, not the board:
CoreTargetExamples
Cortex-M0/M0+thumbv6m-none-eabiRP2040, STM32G0, nRF51
Cortex-M3thumbv7m-none-eabiSTM32F1
Cortex-M4F / M7Fthumbv7em-none-eabihfSTM32F4/G4/H7, nRF52
Cortex-M33 (FPU)thumbv8m.main-none-eabihfRP2350 (ARM cores), nRF5340, STM32U5
RISC-V (ESP32-C3)riscv32imc-unknown-none-elfESP32-C3
RISC-V (ESP32-C6/H2)riscv32imac-unknown-none-elfESP32-C6

Xtensa chips (ESP32, S2, S3) need Espressif’s Rust toolchain fork via espup.

  • Pin the toolchain in rust-toolchain.toml with the target listed.
  • One owner per peripheral. The HAL’s Peripherals singleton is taken once in main and split; pass owned pins/peripherals into tasks or drivers. Do not wrap everything in a global Mutex<RefCell<Option<_>>> unless an interrupt truly needs it.
  • unsafe only for register access the HAL lacks, with a // SAFETY: comment stating the invariant.
  • Check the MCU’s I/O voltage (RP2040, STM32, nRF and ESP32 are 3.3 V class; most pins not 5 V tolerant unless the datasheet marks them FT) and per-pin drive limits before wiring.

Framework choice

SituationUse
Most new projects: several concurrent activities, I/O waits, USB, networkingembassy (async executor + HALs)
Hard real-time priorities, interrupt-driven, static analysis of resource sharingRTIC v2
Single loop, tiny MCU, maximum controlBare cortex-m-rt + HAL, blocking embedded-hal
ESP32-family bare metalesp-hal (with embassy integration)
ESP32 with Wi-Fi via ESP-IDF services, stdesp-idf-svc (std, runs on FreeRTOS)

Workflow

- [ ] 1. Identify chip, core, flash/RAM sizes, debug probe (CMSIS-DAP, ST-Link, J-Link, ESP USB-JTAG)
- [ ] 2. Start from the HAL's official example or template for that chip; pin versions
- [ ] 3. Configure target, runner, memory.x (if the HAL does not provide it)
- [ ] 4. Get blinky + defmt logs working via cargo run
- [ ] 5. Add peripherals one at a time behind embedded-hal traits
- [ ] 6. Structure concurrency (embassy tasks / RTIC tasks); share via channels
- [ ] 7. Check stack and size; run on hardware under load

3. Project configuration

# rust-toolchain.toml
[toolchain]
channel = "stable"
targets = ["thumbv6m-none-eabi"]
components = ["rust-src", "llvm-tools"]
# .cargo/config.toml
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"]

[build]
target = "thumbv6m-none-eabi"

[env]
DEFMT_LOG = "debug"
# Cargo.toml (excerpt)
[profile.release]
opt-level = "s"        # "z" if flash is tight; measure both
lto = "fat"
codegen-units = 1
debug = 2              # debug info is not flashed; defmt and probe-rs need it
incremental = false

memory.x (for cortex-m-rt when the HAL does not ship one) declares FLASH and RAM origins and lengths from the datasheet; RP2040 also needs the boot2 section, which embassy-rp/rp-hal templates include. Wrong memory.x values give HardFaults at boot.

Copy dependency versions from the HAL’s examples for the commit/release you pin; embassy crates (embassy-executor, embassy-time, embassy-rp, …) must come from compatible releases, and mismatches produce confusing trait errors.

4. probe-rs and defmt

cargo install probe-rs-tools --locked    # provides probe-rs (run, attach, erase, info)
probe-rs list                            # connected probes
probe-rs info --chip RP2040              # can it talk to the target?
cargo run --release                      # build, flash, stream defmt logs
probe-rs attach --chip RP2040 target/thumbv6m-none-eabi/release/app
  • probe-rs erase wipes the chip including any stored data/config; confirm with the user first.
  • Linux needs udev rules for the probe; “permission denied” is that, not the firmware.
  • If the target is stuck in a low-power or faulted state, connect under reset (--connect-under-reset) or hold BOOT/reset while attaching.
  • Use defmt::info!, defmt::unwrap!, panic-probe with the print-defmt feature so panics print location and message over RTT.

6. Embassy structure

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, channel::Channel};
use embassy_time::{Duration, Ticker};
use {defmt_rtt as _, panic_probe as _};

static READINGS: Channel<CriticalSectionRawMutex, u16, 8> = Channel::new();

#[embassy_executor::task]
async fn sampler(mut adc_pin_reader: Sampler) {
    let mut ticker = Ticker::every(Duration::from_millis(100));
    loop {
        let v = adc_pin_reader.read().await;
        if READINGS.try_send(v).is_err() {
            defmt::warn!("consumer behind; dropping sample");
        }
        ticker.next().await;
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    let mut led = Output::new(p.PIN_25, Level::Low);
    // Hand owned peripherals to the task. Where the Result/unwrap sits differs between
    // embassy-executor releases; copy the form from your pinned version's examples.
    spawner.spawn(sampler(Sampler::new(p.ADC, p.PIN_26))).unwrap();
    loop {
        let v = READINGS.receive().await;
        led.set_level(if v > 2048 { Level::High } else { Level::Low });
        info!("reading {}", v);
    }
}

(Sampler stands for your own driver type wrapping the HAL ADC.)

  • Tasks are statically allocated; task arguments must be 'static (owned peripherals, &'static refs from static_cell::StaticCell).
  • A task that never .awaits blocks the whole executor. Long computations need yield_now().await breaks or a higher-priority InterruptExecutor.
  • Share state through Channel, Signal, Watch, or Mutex from embassy-sync; choose CriticalSectionRawMutex when interrupts touch it, ThreadModeRawMutex when only thread-mode tasks do.
  • Use Ticker for periodic work (no drift), Timer::after for one-off delays, with_timeout around every external wait.

Generic drivers over embedded-hal 1.0

use embedded_hal::i2c::I2c;

pub struct Tmp117<I> { i2c: I, addr: u8 }

impl<I: I2c> Tmp117<I> {
    pub fn new(i2c: I, addr: u8) -> Self { Self { i2c, addr } }
    pub fn read_temp_milli_c(&mut self) -> Result<i32, I::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.addr, &[0x00], &mut buf)?;
        Ok(i16::from_be_bytes(buf) as i32 * 625 / 80)   // 7.8125 m°C per LSB, no i32 overflow
    }
    pub fn release(self) -> I { self.i2c }
}
  • For async drivers implement against embedded_hal_async::i2c::I2c.
  • Share one bus among several drivers with embedded-hal-bus (RefCellDevice, CriticalSectionDevice) or embassy’s shared-bus helpers.
  • Test drivers on the host with embedded-hal-mock.

Memory without a heap

  • Collections: heapless::Vec<T, N>, heapless::String<N>, heapless::spsc::Queue.
  • Statics initialized at runtime: static CELL: StaticCell<T> = StaticCell::new(); then CELL.init(value) returns &'static mut T once.
  • Need a heap anyway (e.g. a network stack): embedded-alloc with an explicit size, and track high-water usage.
  • Stack overflow on Cortex-M silently corrupts statics unless you use flip-link (moves the stack below .data so overflow faults instead). Add it as the linker in .cargo/config.toml for new projects.

Debugging

SymptomCheck
Error: no probe was foundUSB/udev, cable, probe firmware
Connects but cannot haltChip in sleep/lockout: connect under reset, check SWD wiring
HardFault at bootWrong memory.x, wrong target triple, missing boot2 (RP2040)
Panics with no outputMissing panic-probe/defmt-rtt link, or DEFMT_LOG filters it
Trait bound errors between cratesMixed embassy/HAL versions or embedded-hal 0.2 vs 1.0
Executor freezesA task blocks without awaiting; a blocking delay used inside async
Binary too largecargo size --release -- -A, cargo bloat --release; remove core::fmt use (prefer defmt)

Verification

  1. cargo build --release and cargo clippy --target <triple> -- -D warnings clean.
  2. cargo run --release flashes and logs; a deliberate defmt::panic! shows file:line.
  3. cargo size --release leaves ≥ 15% flash free; stack painting or flip-link shows no overflow under load.
  4. Host tests for drivers pass (cargo test on the host target with mocks).
  5. Run the device under worst-case load for an hour; channel-drop warnings stay at zero or are within the stated budget.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc