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.
- name
embedded-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.
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:
| Core | Target | Examples |
|---|---|---|
| Cortex-M0/M0+ | thumbv6m-none-eabi | RP2040, STM32G0, nRF51 |
| Cortex-M3 | thumbv7m-none-eabi | STM32F1 |
| Cortex-M4F / M7F | thumbv7em-none-eabihf | STM32F4/G4/H7, nRF52 |
| Cortex-M33 (FPU) | thumbv8m.main-none-eabihf | RP2350 (ARM cores), nRF5340, STM32U5 |
| RISC-V (ESP32-C3) | riscv32imc-unknown-none-elf | ESP32-C3 |
| RISC-V (ESP32-C6/H2) | riscv32imac-unknown-none-elf | ESP32-C6 |
Xtensa chips (ESP32, S2, S3) need Espressif’s Rust toolchain fork via espup.
- Pin the toolchain in
rust-toolchain.tomlwith the target listed. - One owner per peripheral. The HAL’s
Peripheralssingleton is taken once inmainand split; pass owned pins/peripherals into tasks or drivers. Do not wrap everything in a globalMutex<RefCell<Option<_>>>unless an interrupt truly needs it. unsafeonly 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
| Situation | Use |
|---|---|
| Most new projects: several concurrent activities, I/O waits, USB, networking | embassy (async executor + HALs) |
| Hard real-time priorities, interrupt-driven, static analysis of resource sharing | RTIC v2 |
| Single loop, tiny MCU, maximum control | Bare cortex-m-rt + HAL, blocking embedded-hal |
| ESP32-family bare metal | esp-hal (with embassy integration) |
| ESP32 with Wi-Fi via ESP-IDF services, std | esp-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 erasewipes 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-probewith theprint-defmtfeature 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,&'staticrefs fromstatic_cell::StaticCell). - A task that never
.awaits blocks the whole executor. Long computations needyield_now().awaitbreaks or a higher-priorityInterruptExecutor. - Share state through
Channel,Signal,Watch, orMutexfromembassy-sync; chooseCriticalSectionRawMutexwhen interrupts touch it,ThreadModeRawMutexwhen only thread-mode tasks do. - Use
Tickerfor periodic work (no drift),Timer::afterfor one-off delays,with_timeoutaround 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();thenCELL.init(value)returns&'static mut Tonce. - Need a heap anyway (e.g. a network stack):
embedded-allocwith 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.tomlfor new projects.
Debugging
| Symptom | Check |
|---|---|
Error: no probe was found | USB/udev, cable, probe firmware |
| Connects but cannot halt | Chip in sleep/lockout: connect under reset, check SWD wiring |
| HardFault at boot | Wrong memory.x, wrong target triple, missing boot2 (RP2040) |
| Panics with no output | Missing panic-probe/defmt-rtt link, or DEFMT_LOG filters it |
| Trait bound errors between crates | Mixed embassy/HAL versions or embedded-hal 0.2 vs 1.0 |
| Executor freezes | A task blocks without awaiting; a blocking delay used inside async |
| Binary too large | cargo size --release -- -A, cargo bloat --release; remove core::fmt use (prefer defmt) |
Verification
cargo build --releaseandcargo clippy --target <triple> -- -D warningsclean.cargo run --releaseflashes and logs; a deliberatedefmt::panic!shows file:line.cargo size --releaseleaves ≥ 15% flash free; stack painting orflip-linkshows no overflow under load.- Host tests for drivers pass (
cargo teston the host target with mocks). - 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.