Embedded Rust with Embassy skill for Codex

Async firmware in Rust without the yak shaving. Codex sets up no_std Rust firmware with the right target triple, memory.x, probe-rs runner and defmt logging, then structures it as Embassy tasks that never block the executor. Drivers are written against embedded-hal 1.0 traits and tested on the host with mocks, shared state uses embassy-sync instead of static mut, and release profiles are tuned and measured for flash size.

  • Embedded & IoT
  • $embedded-rust
  • 193 lines
  • 2 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- embedded-rust

Installs to ~/.agents/skills/embedded-rust/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $embedded-rust, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds no_std embedded Rust firmware with Embassy async executors and HALs (embassy-stm32, embassy-nrf, embassy-rp, esp-hal), embedded-hal 1.0 driver traits, probe-rs flashing with defmt logging, memory.x and target selection, shared state with embassy-sync and static_cell, and size-tuned release profiles. Use when starting a Rust firmware project, porting a C driver to embedded-hal, an async task starves others, a build fails on target or linker errors, or flash is too large.

Use it when

  • Starting or restructuring a no_std Rust firmware project
  • Writing a portable driver on embedded-hal 1.0 traits
  • An async task starves others or shared state needs a safe pattern
  • Linker, target or memory.x errors, or flash size is too large

Use something else when

  • The question is Rust borrowing and lifetimes in general rather than firmware: use rust-ownership
  • The firmware is C on the STM32 Cube HAL: use stm32-hal

How to check the result

Build with --locked for the target, run driver tests on the host with embedded-hal-mock, compare cargo size with the baseline, and check the probe-rs defmt log on hardware.

The mistake it prevents

Calling a blocking delay or driver inside an Embassy task, which silently stalls every other task on the same executor.

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

--- name: embedded-rust description: Builds no_std embedded Rust firmware with Embassy async executors and HALs (embassy-stm32, embassy-nrf, embassy-rp, esp-hal), embedded-hal 1.0 driver… license: MIT ---

Done well means: the firmware builds for the right target from a pinned toolchain and lockfile, drivers are generic over embedded-hal traits and tested on the host, tasks never block the executor, and defmt logs plus probe-rs give a working debug loop on hardware.

Inspect first

  1. Read AGENTS.md, Cargo.toml, Cargo.lock, rust-toolchain.toml, .cargo/config.toml (target, runner, rustflags) and memory.x or build.rs.
  2. Note the Embassy crate versions and the HAL’s chip feature (for example embassy-stm32 = { features = ["stm32f411re", "time-driver-any", ...] }). Embassy APIs move between releases; follow the examples from the tag matching the lockfile, not main.
  3. Build once: cargo build --release and cargo size --release -- -A (cargo-binutils) for a baseline.

Adding targets and crates needs network (rustup target add, crates.io). In the sandbox, request approval or check whether the registry is vendored.

Targets

CoreTarget tripleExamples
Cortex-M0 / M0+thumbv6m-none-eabiRP2040, STM32G0, nRF51
Cortex-M3thumbv7m-none-eabiSTM32F1
Cortex-M4F / M7Fthumbv7em-none-eabihfSTM32F4, nRF52, STM32H7
Cortex-M33Fthumbv8m.main-none-eabihfnRF5340, RP2350, STM32U5
RISC-V ESP32-C3 / C6riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elfesp-hal
Xtensa ESP32 / S3xtensa-esp32-none-elf (needs the espup toolchain)esp-hal

Using eabi instead of eabihf on an FPU core works but wastes the FPU; the reverse fails to link against soft-float code.

Project wiring

# .cargo/config.toml
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip STM32F411RETx"

[build]
target = "thumbv7em-none-eabihf"

[env]
DEFMT_LOG = "info"
# Cargo.toml (excerpt)
[profile.release]
opt-level = "s"          # or "z" when flash is tight; measure both
lto = "fat"
codegen-units = 1
debug = 2                # symbols live in the ELF, not in flash; keeps defmt and backtraces useful
incremental = false

memory.x must match the chip (flash and RAM origin and length); a wrong RAM size links fine and then corrupts the stack. With embassy-stm32’s memory-x feature the HAL generates it.

Minimal Embassy application

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Input, Level, Output, Pull, Speed};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
    loop {
        led.toggle();
        Timer::after_millis(500).await;
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let led = Output::new(p.PA5, Level::Low, Speed::Low);
    let button = Input::new(p.PC13, Pull::Up);

    // embassy-executor 0.10+: the task call returns a Result; older versions use
    // spawner.spawn(blink(led)).unwrap()
    spawner.spawn(blink(led).unwrap());

    loop {
        info!("button {}", button.is_high());
        Timer::after_secs(1).await;
    }
}

Check the executor version in Cargo.lock and use the spawn form that matches it. Pin types like p.PA5 are chip-specific; read the HAL’s examples for the exact chip family.

Async rules

  • Never block inside a task: no busy loops, no blocking driver calls, no cortex_m::asm::delay for long waits. One blocking task stalls every task on that executor.
  • Each #[embassy_executor::task] has a static pool; spawning the same task twice needs #[embassy_executor::task(pool_size = 2)].
  • Time-critical work goes on a higher-priority interrupt executor (InterruptExecutor), not on the thread-mode executor with everything else.
  • Timeouts: wrap I/O in embassy_time::with_timeout(Duration::from_millis(50), fut).

Sharing state

NeedUse
Pass messages between tasksembassy_sync::channel::Channel<CriticalSectionRawMutex, T, N>
Latest-value signal (no queue)embassy_sync::signal::Signal or watch::Watch
Shared peripheral or bus across tasksembassy_sync::mutex::Mutex (async), or embassy-embedded-hal shared bus types
'static data built at runtimestatic_cell::StaticCell (CELL.init(value))
Data shared with a raw interrupt handlercritical_section::Mutex<RefCell<T>>

Avoid static mut; references to it are denied by default in edition 2024 and easily become undefined behaviour under aliasing anyway.

Drivers on embedded-hal 1.0

Write drivers generic over the traits so they work on any HAL and on the host:

use embedded_hal_async::i2c::I2c;

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

impl<I: I2c> Bme280<I> {
    pub fn new(i2c: I, addr: u8) -> Self { Self { i2c, addr } }

    pub async fn chip_id(&mut self) -> Result<u8, I::Error> {
        let mut id = [0u8; 1];
        self.i2c.write_read(self.addr, &[0xD0], &mut id).await?;
        Ok(id[0])
    }
}
  • Use SpiDevice (with chip select handled by the bus manager) rather than SpiBus in device drivers.
  • Test drivers on the host with embedded-hal-mock (enable its async feature for async traits) in a separate crate or a cfg(test) module built for the host target, since cargo test cannot run on thumbv* targets.
  • Keep the driver crate #![no_std] and free of HAL dependencies.

Debug loop

cargo run --release                         # runner: flash with probe-rs and stream defmt logs
probe-rs list                               # attached probes
probe-rs run --chip nRF52840_xxAA target/thumbv7em-none-eabihf/release/app
cargo size --release -- -A                  # section sizes
cargo bloat --release -n 20                 # biggest functions (cargo-bloat)

The probe is attached to the user’s machine; give them the command and ask for the log. panic-probe prints the panic message and a backtrace through defmt; set DEFMT_LOG=debug per build, not in code.

Size and panics

  • panic_probe or panic_halt in release; formatting with core::fmt in panics and logs adds kilobytes. defmt defers formatting to the host and is far smaller.
  • Index with .get() and handle None in hot paths; every [] index carries a panic path.
  • Check that lto and codegen-units = 1 are set before hand-optimising anything.

Verification

  • cargo build --release for the target with --locked; cargo clippy clean
  • Host tests for drivers and logic pass (cargo test on the host target)
  • cargo size compared with the baseline; RAM includes executor task arenas
  • Hardware log via probe-rs run shows all tasks running (user-provided)
  • A forced panic prints a readable defmt message and location

Report

**Target:** STM32F411 (thumbv7em-none-eabihf), embassy-stm32 0.4, embassy-executor 0.10, Rust 1.90
**Change:** BME280 driver generic over embedded-hal-async I2c; sensor task + channel to uplink task
**Size:** .text 41.8 KB -> 38.2 KB after lto=fat + codegen-units=1; RAM 9.1 KB
**Tests:** 7 host tests with embedded-hal-mock; probe-rs log shows both tasks at expected rates

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Embedded Rust with Embassy"
  short_description: "Async firmware in Rust without the yak shaving"
  default_prompt: "$embedded-rust Start an Embassy project for my STM32F411 Nucleo with defmt logging"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc