Split View: 임베디드 Rust 2026 심층 — no_std·Embassy·esp-rs·RP2350·probe-rs로 본 마이크로컨트롤러 위 Rust 실전기
임베디드 Rust 2026 심층 — no_std·Embassy·esp-rs·RP2350·probe-rs로 본 마이크로컨트롤러 위 Rust 실전기
프롤로그 — "임베디드 Rust는 언제 쓸 만해지나"의 질문이 끝났다
5년 전이라면 이 글의 첫 문장은 "임베디드 Rust는 아직 실험이지만"으로 시작했을 거다. 2021년의 Embassy는 alpha였고, RP2040의 HAL은 아직 정착하지 않았고, ESP32에는 Rust가 LLVM 포크 위에서 돌아갔고, probe-rs는 OpenOCD의 보조 도구였다.
2026년 5월의 풍경은 완전히 다르다.
- Embassy 0.6이 사실상 마이크로컨트롤러 위 async 런타임의 표준으로 자리 잡았다. 메인루프 + 인터럽트 핸들러 패턴은 이제 새 프로젝트에서 자주 보이지 않는다.
- RP2350(Raspberry Pi Pico 2의 듀얼코어 Cortex-M33 + RISC-V Hazard3)의 HAL이
rp235x-hal/embassy-rp위에서 stable로 정착했다. - **
esp-rs**는 Espressif의 공식 1순위 SDK로 격상됐다. Xtensa(ESP32, ESP32-S3)는 여전히 LLVM 포크가 필요하지만, RISC-V 라인(ESP32-C3, C6, H2, P4)은 upstream Rust로 빌드된다. - **
probe-rs**가 OpenOCD를 사실상 대체했다.cargo run한 번이면 flash + RTT 로그 + 백트레이스가 한 터미널에서 흘러나온다. defmt로깅은 printf보다 빠르고, formatter는 호스트 쪽에서 돈다 — 펌웨어 바이너리는 포맷 문자열을 갖지 않는다.- non-async 임베디드 Rust도 여전히 살아 있다. Embassy를 안 쓴다고 죄가 아니다. 다만 신규 프로젝트는 대부분 Embassy를 고른다는 것뿐.
요약하면 임베디드 Rust는 2026년에 production이다. Apollo3·Arduino R4·뱃지·홈오토메이션·예술 인스톨레이션 — 코드 부족하지 않은 분야에서 실제로 돌아간다. 본 글은 이 풍경을 처음부터 끝까지, no_std의 기초부터 Embassy 첫 blinky까지 한 번에 짚는다.
1장 · 풍경 — 왜 이제 Rust가 마이크로컨트롤러에 들어가는가
1.1 임베디드의 5가지 압력
마이크로컨트롤러 펌웨어를 짜는 사람은 동시에 다섯 가지 압력을 받는다.
- 메모리 — RAM 32KB, Flash 256KB 같은 환경에서 표준 라이브러리가 못 들어간다.
- 결정성 — ISR 안에서 동적 할당하면 안 된다. 실시간성을 망친다.
- 하드웨어 추상화 — GPIO·UART·SPI·I2C·타이머를 칩 종속 코드 없이 추상화하고 싶다.
- 동시성 — 센서 폴링 + UART RX + 로그 송신 + 디스플레이 갱신을 동시에 해야 하는데 OS는 없다.
- 디버깅 — JTAG/SWD, RTT, semihosting으로 인쇄가 어렵다. 끝없는 LED 깜빡임 디버깅.
1.2 C/C++가 60년간 답이었던 이유
이 다섯 압력에 대해 C/C++는 사실 좋은 답을 갖고 있었다. static 변수 + 인터럽트 핸들러 + RTOS 또는 슈퍼루프, vendor SDK(ST의 HAL, Espressif의 ESP-IDF, Nordic의 nRF Connect SDK). 60년간 임베디드 산업이 이 위에서 돌았다.
문제는 두 가지다.
- 메모리 안전성 — null 포인터, 잘못된 캐스팅, 버퍼 오버플로우. 펌웨어 한 줄 잘못 짜면 디버깅이 무한 LED.
- 동시성 모델의 빈곤함 — RTOS 태스크 + 뮤텍스 + 큐가 표준이지만, 우선순위 역전·데드락·우선순위 상속의 함정이 끝없이 나온다.
1.3 Rust가 답하는 방식
Rust는 이 압력 각각에 언어 수준의 답을 갖고 있다.
| 압력 | Rust의 답 |
|---|---|
| 메모리 | #![no_std] — 표준 라이브러리 없이 빌드. core·alloc만 사용 |
| 결정성 | 소유권/대여 → 동적 할당 없는 상태 머신을 자연스럽게 표현 |
| 하드웨어 추상화 | embedded-hal trait 생태계 |
| 동시성 | async/await + Embassy 런타임 (또는 RTIC) |
| 디버깅 | defmt + probe-rs로 RTT/SWD 한 줄 셋업 |
각각의 답에는 진짜 작동 코드와 실제 칩 지원이 있다는 점이 5년 전과 결정적으로 다르다. 본 글의 나머지는 이 다섯 개의 답을 하나씩 풀어보는 일이다.
2장 · no_std의 본질 — core만으로 어디까지 가나
2.1 std가 못 따라오는 환경
데스크탑/서버 Rust는 std가 OS 위에서 돌아간다. std::fs는 파일시스템, std::net은 TCP 스택, std::thread는 OS 스레드, std::sync::Mutex는 pthread 위 뮤텍스. 이 모든 것이 마이크로컨트롤러에는 없다.
#![no_std] 어트리뷰트는 컴파일러에 "이 크레이트는 std를 쓰지 않는다"고 알린다. 대신 core라는 더 작은 표준 라이브러리를 쓴다.
| 영역 | std에 있음 | core에 있음 |
|---|---|---|
원시 타입(u8, f32, …) | yes | yes |
Option, Result | yes | yes |
Iterator trait | yes | yes |
Vec, String, HashMap | yes | no (alloc 별도) |
println! | yes | no |
std::fs, std::net | yes | no |
Box, Rc, Arc | yes | no (alloc 별도) |
| 스레드 / 동기화 프리미티브 | yes | no |
core만으로도 슬라이스·이터레이터·trait·매크로의 풍부함은 그대로 살아 있다. 다만 동적 할당이 필요한 모든 타입은 떨어져 나간다.
2.2 alloc을 끼우는 결정
마이크로컨트롤러에도 RAM이 충분하다면 — RP2040은 264KB SRAM, ESP32-S3는 512KB — alloc 크레이트를 추가해 Box/Vec/String을 다시 쓸 수 있다. 글로벌 allocator를 직접 지정해야 한다.
#![no_std]
#![no_main]
extern crate alloc;
use alloc::vec::Vec;
use embedded_alloc::Heap;
#[global_allocator]
static HEAP: Heap = Heap::empty();
#[entry]
fn main() -> ! {
// 8KB heap을 정적으로 잡는다
{
use core::mem::MaybeUninit;
const HEAP_SIZE: usize = 8 * 1024;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
}
let mut v: Vec<u32> = Vec::new();
v.push(42);
loop {}
}
다만 임베디드 Rust 커뮤니티의 일반적인 권고는 "ISR 안에서는 절대 할당하지 마라"이다. 메인 루프에서는 가능, 인터럽트는 사전 할당된 버퍼만.
2.3 panic_handler라는 의무
no_std 바이너리는 panic이 일어났을 때 무엇을 할지 직접 지정해야 한다. std 환경에선 자동으로 abort + 백트레이스가 찍히지만, 마이크로컨트롤러에는 그게 없다.
use panic_halt as _; // panic 시 무한 루프
// 또는
use panic_probe as _; // panic 시 RTT로 보고 후 halt (probe-rs와 함께)
// 또는
use panic_reset as _; // panic 시 워치독으로 리셋
production 펌웨어라면 보통 panic_reset + 직전 panic 정보를 비휘발 메모리에 적고 재부팅 후 보고하는 패턴.
2.4 no_main과 entry point
no_std 크레이트는 보통 no_main도 같이 단다. 표준 fn main 진입점 대신 칩별 부팅 시퀀스가 제어를 잡아야 하기 때문.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
// ! 반환 = 절대 반환하지 않음. 무한 루프 또는 wfi
loop {}
}
cortex_m_rt::entry 매크로가 리셋 핸들러를 만들고 적절한 부팅 시퀀스(스택 포인터, BSS 영역 초기화, RAM 초기값 복사)를 끼운다. RP2040은 rp2040-hal이, ESP32는 esp-hal이 그 역할을 한다.
3장 · PAC·HAL·BSP — 세 층의 추상화
임베디드 Rust 생태계는 세 층의 크레이트로 이루어진다. 위로 갈수록 추상도가 높고, 아래로 갈수록 칩 종속이다.
3.1 PAC (Peripheral Access Crate)
가장 아래층. 칩 제조사가 배포하는 SVD(System View Description) XML 파일로부터 svd2rust가 자동 생성하는 크레이트. 레지스터 비트 필드를 타입으로 노출한다.
// rp2040-pac 사용 예
use rp2040_pac as pac;
let peripherals = pac::Peripherals::take().unwrap();
let sio = peripherals.SIO;
// GPIO25 출력 비트를 set
sio.gpio_oe_set.write(|w| unsafe { w.bits(1 << 25) });
sio.gpio_out_set.write(|w| unsafe { w.bits(1 << 25) });
PAC만 쓰는 코드는 칩 데이터시트 그대로의 모양이다. 빠르지만 가독성이 떨어지고 칩 간 이식성이 없다.
3.2 HAL (Hardware Abstraction Layer)
PAC 위에 안전한 추상화를 올린 크레이트. rp2040-hal, embassy-rp, stm32f4xx-hal, esp-hal, nrf52840-hal 등.
// rp2040-hal 사용 예
use rp2040_hal::gpio::Pins;
use embedded_hal::digital::OutputPin;
let pins = Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
let mut led = pins.gpio25.into_push_pull_output();
led.set_high().unwrap();
같은 코드가 OutputPin trait 덕분에 칩이 바뀌어도 거의 그대로 동작한다.
3.3 embedded-hal — 추상화 계약
HAL이 다 같은 모양의 API를 노출하는 이유는 embedded-hal이라는 공통 trait 크레이트 덕분이다. 2026년 기준 embedded-hal 1.0이 stable로 자리 잡았고, async 버전 embedded-hal-async 1.0도 같이 나왔다.
// embedded-hal 1.0의 핵심 trait
pub trait OutputPin {
type Error;
fn set_low(&mut self) -> Result<(), Self::Error>;
fn set_high(&mut self) -> Result<(), Self::Error>;
}
pub trait SpiDevice<Word = u8>: ErrorType {
fn transaction(&mut self, operations: &mut [Operation<'_, Word>]) -> Result<(), Self::Error>;
}
pub trait I2c<A = SevenBitAddress, Word = u8>: ErrorType {
fn transaction(&mut self, address: A, operations: &mut [Operation<'_, Word>]) -> Result<(), Self::Error>;
}
센서 드라이버 크레이트(예: bme280, mpu6050, ssd1306, embedded-graphics)는 이 trait들에 대해 제네릭하게 짠다. 결과적으로 BME280 드라이버 하나가 RP2040·STM32·ESP32·nRF52에서 모두 동작한다.
3.4 BSP (Board Support Package)
특정 보드(라즈베리 파이 Pico, Adafruit Feather, STM32 Nucleo 등)에 맞춰 핀맵·전원·LED 매크로를 미리 잡아둔 크레이트. rp-pico, feather_rp2040, stm32f4-discovery 등.
// rp-pico BSP 사용 예 — LED 핀이 이미 이름이 있다
use rp_pico::hal::Clock;
use rp_pico::hal::pac;
use rp_pico::hal::gpio::Pins;
let pac = pac::Peripherals::take().unwrap();
let sio = rp_pico::hal::Sio::new(pac.SIO);
let pins = rp_pico::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
let mut led = pins.led.into_push_pull_output();
led.set_high().unwrap();
pins.led라는 명명이 의미 있다 — Pico에서 LED가 GPIO25에 붙어 있다는 사실을 BSP가 알고 있다.
4장 · Embassy — async가 기본인 임베디드 Rust
4.1 왜 임베디드에서 async인가
마이크로컨트롤러 위 동시성은 전통적으로 세 가지 패턴이었다.
- 슈퍼루프 —
loop { check_a(); check_b(); check_c(); }. 단순하지만 한 작업이 길어지면 다른 작업이 굶는다. - 인터럽트 핸들러 + 메인루프 — ISR이 일을 시작하고 메인루프가 결과를 처리. 공유 상태가 까다롭다.
- RTOS 스레드 — FreeRTOS, ChibiOS, Zephyr. 컨텍스트 스위치 비용, 스택 메모리 N×, 동기화 프리미티브 학습 비용.
Embassy는 네 번째 답이다. async/await로 협력적 동시성을 표현하고, 런타임이 인터럽트 위에서 깨어난다.
4.2 Embassy의 핵심 발상
// 두 가지 작업이 동시에 도는 코드. 스레드 아님 — async task.
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after_millis(500).await; // 500ms 동안 다른 태스크가 돈다
led.set_low();
Timer::after_millis(500).await;
}
}
#[embassy_executor::task]
async fn echo(mut uart: BufferedUart<'static, UART0>) {
let mut buf = [0u8; 64];
loop {
let n = uart.read(&mut buf).await.unwrap();
uart.write_all(&buf[..n]).await.unwrap();
}
}
Timer::after_millis(500).await이 중요한 부분이다. await 지점에서 태스크가 양보(yield)하고, 그 사이 다른 태스크가 깨어 동작할 수 있다. 타이머 인터럽트가 500ms 후 들어오면 Embassy 런타임이 이 태스크를 다시 깨운다.
uart.read(&mut buf).await도 마찬가지다 — UART RX 인터럽트가 들어올 때까지 태스크가 잠든다. 런타임은 그 사이 다른 태스크를 돌리고 idle이면 wfi(Wait For Interrupt)로 잠든다.
4.3 스택 메모리 — RTOS와 결정적으로 다른 점
RTOS 스레드는 각각 독립된 스택을 가진다. 5개 태스크 = 5×4KB = 20KB 정도. 작은 마이크로컨트롤러엔 부담이다.
Embassy의 async 태스크는 하나의 스택을 공유한다. 태스크의 상태는 자동 생성된 상태 머신 구조체로 압축된다. 결과적으로 10개 태스크가 1-2KB 스택만 써도 충분한 경우가 흔하다.
RTOS: [Stack T1: 4KB] [Stack T2: 4KB] [Stack T3: 4KB] [Stack T4: 4KB]
Embassy: [Shared Stack: 4KB] + [Task1 state: 64B] [Task2 state: 96B] [Task3 state: 32B]
4.4 Spawner와 main
Embassy main의 보일러플레이트.
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let led = Output::new(p.PIN_25, Level::Low);
spawner.spawn(blink(led)).unwrap();
}
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.toggle();
Timer::after_millis(500).await;
}
}
embassy_executor::main 매크로가 main을 async로 만들고, Spawner를 인자로 넣어준다. 거기에 spawn(...)으로 태스크를 등록.
4.5 RTIC vs Embassy — 양대 선택지
RTIC (Real-Time Interrupt-driven Concurrency)는 Embassy 이전부터 있던 또 다른 임베디드 동시성 프레임워크다. 우선순위 기반 인터럽트 스케줄링을 컴파일타임에 자원 잠금까지 정적 검증한다.
| 측면 | Embassy | RTIC |
|---|---|---|
| 모델 | async/await + executor | priority-based interrupt scheduling |
| 학습 곡선 | async를 알면 즉시 | 매크로 + 자원 모델 학습 필요 |
| 우선순위 보장 | 협력적 — 모든 태스크 동등 | 우선순위 기반, 컴파일타임 검증 |
| 메모리 | 더 작음(공유 스택) | 우선순위별 스택 |
| 인기 | 2024-2026 새 프로젝트의 대세 | 안전 크리티컬·실시간 보장 필요 시 |
새 프로젝트는 Embassy를 우선 고르되, **하드 실시간(자동차·항공)**처럼 우선순위 검증이 필수라면 RTIC.
5장 · 타깃 칩별 현재 상태 (2026년 5월)
5.1 RP2040 / RP2350 — Raspberry Pi Pico 라인
- RP2040 — Cortex-M0+ 듀얼코어, 264KB SRAM.
embassy-rp안정. Pico 1·Pico W 모두 cover. - RP2350 — Cortex-M33 듀얼코어 + RISC-V Hazard3 듀얼코어를 같이 가짐(부팅 시 둘 중 선택). 520KB SRAM, Secure Boot, ARM TrustZone.
rp235x-hal1.0,embassy-rprp235x 지원 stable. - 블루투스/Wi-Fi(Pico W, Pico 2 W) —
cyw43크레이트 +embassy-net(smoltcp 기반 TCP/IP) 조합으로 동작. PIO 어셈블리 + SPI로 BLE까지 다 됨. - PIO — RP 시리즈의 시그너처 기능. 가벼운 stateful I/O 머신.
pio-proc+embassy-rp::pio로 Rust에서도 어셈블리 작성 가능.
// Pico W에서 Wi-Fi 연결하는 단편 (요약)
use cyw43_pio::PioSpi;
use embassy_net::Stack;
let mut control = cyw43::new(state, pwr, spi, fw).await;
control.init(clm).await;
control.join_wpa2("SSID", "PASSWORD").await.unwrap();
5.2 ESP32 (esp-rs)
Espressif가 2023년부터 Rust를 공식 1순위 SDK로 격상시켰다. 2026년 현재 esp-hal 1.0이 stable.
- RISC-V 칩(ESP32-C3, C6, H2, P4) — upstream Rust toolchain으로 빌드.
cargo install espup없이도 됨. - Xtensa 칩(ESP32, ESP32-S2, ESP32-S3) — 여전히 LLVM 포크 필요.
espup install로 자동 설정. embassy-executor+esp-hal-embassy— Embassy를 ESP32에서 그대로 쓸 수 있다.- Wi-Fi/BLE —
esp-wifi크레이트. Wi-Fi 6, BLE 5.3.embassy-net과 통합.
# Cargo.toml — ESP32-C6용
[dependencies]
esp-hal = { version = "1.0", features = ["esp32c6"] }
esp-hal-embassy = { version = "0.6", features = ["esp32c6"] }
embassy-executor = { version = "0.6", features = ["task-arena-size-20480"] }
esp-wifi = { version = "0.10", features = ["esp32c6","wifi","ble"] }
ESP32-C6의 RISC-V + Wi-Fi 6 + Thread/Zigbee 라디오 조합이 2026년 임베디드 Rust의 hot spot이다. Matter 디바이스를 Rust로 짜기 가장 쉬운 길.
5.3 STM32 (stm32-rs)
STMicroelectronics의 STM32 시리즈. 임베디드 산업의 디폴트 칩셋.
stm32-rs— 모든 STM32 칩의 PAC을 SVD에서 자동 생성하는 공동 프로젝트.stm32f4xx-hal,stm32h7xx-hal,stm32l4xx-hal— 시리즈별 HAL.embassy-stm32— Embassy의 통합 HAL. 모든 시리즈를 cover,features = ["stm32f407vg"]같이 칩 선택. 2026년 신규 프로젝트의 디폴트.
STM32에서 Embassy를 고르는 강력한 이유는 인터럽트 → async waker 매핑이 거의 모든 페리퍼럴에 깔려 있다는 점. UART, SPI, I2C, ADC, DMA, USB 모두 async.
5.4 nRF52 / nRF53 / nRF54 — Nordic Semiconductor
BLE 디바이스의 사실상 표준 칩.
embassy-nrf— nRF52832, 52840, 5340, 54L 모두 stable 지원.nrf-softdevice— Nordic SoftDevice (BLE 스택)을 Rust async에서 호출하는 wrapper. peripheral·central 모두 지원.- 2026 신상황 —
trouble크레이트(순수 Rust BLE host)가 SoftDevice 의존성 없는 대안으로 성숙했다. nRF52·ESP32·CYW43에서 동작.
5.5 그 외 — 빠르게 정리
- Ambiq Apollo3 / 4 —
ambiq-hal. 초저전력(uA) MCU. 웨어러블·BLE 인기. - Arduino UNO R4 (Renesas RA4M1) —
ra4m1-hal(커뮤니티). Cortex-M4. Arduino 호환 핀맵. - CH32V — WCH의 초저가 RISC-V.
ch32-hal. $0.20대 가격. - SAMD (Microchip / Atmel) —
atsamd-hal. SAMD21(Adafruit Feather M0), SAMD51 cover. - RISC-V general —
riscv-rt로 부팅, 칩 종속 HAL은 별도.
6장 · 툴체인 — probe-rs, cargo-embed, defmt
6.1 probe-rs — OpenOCD의 후계자
OpenOCD/GDB 조합이 임베디드 디버깅의 표준이던 시대는 지나갔다. probe-rs가 사실상 대체했다.
probe-rs는 Rust로 짠 디버그 프로브 (J-Link, ST-Link, CMSIS-DAP, Raspberry Pi Debug Probe, FTDI 등) 드라이버 + flash 알고리즘 + RTT/SWO 호스트.
# 설치
cargo install probe-rs-tools
# 연결 확인
probe-rs list
# flash + run + RTT 로그 동시
cargo run --release # .cargo/config.toml에 runner = "probe-rs run" 설정 시
6.2 .cargo/config.toml 표준 셋업
RP2040 프로젝트의 표준 셋업 예시.
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040 --protocol swd"
[build]
target = "thumbv6m-none-eabi" # RP2040 (Cortex-M0+)
# target = "thumbv8m.main-none-eabihf" # RP2350 (Cortex-M33)
[env]
DEFMT_LOG = "trace"
cargo run --release만 치면 빌드 → flash → 실행 → RTT 로그 → 백트레이스가 한 명령으로 나온다.
6.3 defmt — printf보다 빠른 로깅
defmt는 println! 같은 매크로지만 핵심 트릭이 있다 — 포맷 문자열을 펌웨어 바이너리에 넣지 않고, 인덱스만 전송한다. 호스트(probe-rs)가 ELF의 .defmt 섹션을 읽어 문자열을 복원한다.
use defmt::{info, warn, error};
fn read_sensor(i: u32) {
info!("sensor read iteration={}", i);
let value = sensor.read();
if value > 100 {
warn!("sensor value high: {=u32}", value);
} else if value == 0 {
error!("sensor not responding");
}
}
{=u32} 같은 타입 힌트는 직렬화 효율을 위함. 일반적으로 한 로그가 4-8바이트로 직렬화된다. 1MHz UART로도 초당 100k 로그를 처리할 수 있다.
| 비교 | printf (semihosting) | RTT printf | defmt over RTT |
|---|---|---|---|
| 로그 1줄 비용 | 수십 µs (블로킹) | 1-2 µs | 100-500 ns |
| 펌웨어 크기 | 포맷 문자열 다 들어감 | 들어감 | 인덱스만 |
| 컴파일타임 검증 | no | no | yes |
6.4 cargo-embed — 대안 러너
probe-rs run 외에 cargo-embed도 있다. TUI 기반으로 RTT 로그 + GDB 디버거를 한 화면에 띄움.
cargo install cargo-embed
cargo embed --release
다만 2025-2026 추세는 probe-rs run이 더 가벼워서 신규 프로젝트의 디폴트. cargo-embed는 대시보드가 필요한 경우만.
6.5 GDB도 여전히 살아 있다
복잡한 메모리 손상 디버깅 등 GDB가 더 강한 영역에서는 probe-rs gdb 서버 모드를 띄우고 arm-none-eabi-gdb로 붙는다.
probe-rs gdb --chip RP2040
# 다른 터미널에서
arm-none-eabi-gdb target/thumbv6m-none-eabi/release/myfirmware
(gdb) target remote :1337
7장 · 첫 blinky를 Embassy로 — RP2040 walkthrough
이제 실제 코드. Raspberry Pi Pico에서 onboard LED(GPIO25)를 0.5초 주기로 깜빡이는 첫 프로젝트.
7.1 프로젝트 생성
cargo new --bin blinky
cd blinky
rustup target add thumbv6m-none-eabi
7.2 Cargo.toml
[package]
name = "blinky"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m","executor-thread","integrated-timers"] }
embassy-time = { version = "0.4" }
embassy-rp = { version = "0.4", features = ["rp2040","time-driver"] }
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
cortex-m-rt = "0.7"
[profile.release]
debug = 2 # defmt를 위해 디버그 심볼 유지
lto = true
codegen-units = 1
opt-level = "s"
7.3 .cargo/config.toml
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040 --protocol swd"
rustflags = [
"-C", "link-arg=--nmagic",
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
]
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "trace"
7.4 src/main.rs — 첫 blinky
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[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);
info!("blinky started");
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
7.5 빌드 + 플래시
Pico의 BOOTSEL 버튼을 누른 채 USB를 꽂으면 mass storage로 잡힌다. 그 상태에서 probe-rs 호환 디버그 프로브가 꽂혀 있다면(또는 Pico Probe firmware를 다른 Pico에 올렸다면) 한 줄이면 끝.
cargo run --release
# Compiling blinky ...
# Finished release [optimized + debuginfo] target(s)
# Running `probe-rs run --chip RP2040 --protocol swd target/thumbv6m-none-eabi/release/blinky`
# 0.000000 INFO blinky started
LED가 1초 주기로 깜빡인다. 한 줄 로그가 RTT를 통해 호스트 터미널로 흘러나온다.
7.6 한 단계 확장 — UART echo + 두 태스크 동시
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_rp::peripherals::{PIN_25, UART0};
use embassy_rp::uart::{Async, BufferedUart, Config as UartConfig, InterruptHandler};
use embassy_rp::{bind_interrupts, uart};
use embassy_time::Timer;
use embedded_io_async::{Read, Write};
use {defmt_rtt as _, panic_probe as _};
bind_interrupts!(struct Irqs {
UART0_IRQ => InterruptHandler<UART0>;
});
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// LED 태스크
let led = Output::new(p.PIN_25, Level::Low);
spawner.spawn(blink(led)).unwrap();
// UART echo 태스크 — GPIO0 TX, GPIO1 RX, 115200 baud
let mut cfg = UartConfig::default();
cfg.baudrate = 115_200;
let uart = BufferedUart::new(
p.UART0, Irqs, p.PIN_0, p.PIN_1,
cfg, &mut [0u8; 64], &mut [0u8; 64],
);
spawner.spawn(echo(uart)).unwrap();
}
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.toggle();
Timer::after_millis(500).await;
}
}
#[embassy_executor::task]
async fn echo(mut uart: BufferedUart<'static, UART0>) {
let mut buf = [0u8; 32];
info!("echo task ready");
loop {
match uart.read(&mut buf).await {
Ok(n) => {
info!("rx {} bytes", n);
let _ = uart.write_all(&buf[..n]).await;
}
Err(_) => Timer::after_millis(10).await,
}
}
}
핵심 — blink와 echo가 둘 다 살아 있다. 한쪽이 Timer::after_millis(500).await로 자는 동안 다른 쪽은 UART RX 인터럽트를 받아 깨어난다. 한 코어, 한 스택, 두 태스크.
7.7 defmt 매크로 한 줄 추가
info!("rx {} bytes, first={=u8:#04x}", n, buf[0]);
호스트 터미널에서 보이는 출력:
0.001234 INFO blinky started
0.001500 INFO echo task ready
0.012345 INFO rx 5 bytes, first=0x48
여기서 0x48은 ASCII 'H'. 그게 다였다. 첫 임베디드 Rust 프로젝트.
8장 · 실제 프로젝트 — 무엇을 짓고 있나
8.1 홈오토메이션 / Matter 디바이스
ESP32-C6 + esp-hal + rs-matter 크레이트. Matter (Thread/Wi-Fi 위 IoT 표준)의 Rust 구현체. 2024년부터 rs-matter가 v0.5에 도달하면서 BLE 커미셔닝 → Wi-Fi 또는 Thread → Matter 클러스터 노출까지 풀 코스를 Rust로 짤 수 있다.
[BLE commissioning] → [Wi-Fi/Thread] → [Matter cluster: OnOff, LevelControl, ...]
embassy-net rs-matter
Apple Home, Google Home, Amazon Alexa가 모두 같은 디바이스를 본다.
8.2 컨퍼런스 뱃지
Hackaday Supercon, Defcon, EMF Camp 등의 컨퍼런스 뱃지가 점차 Rust로 옮겨가고 있다. EMF Camp 2024 뱃지는 RP2040 + ePaper + LoRa, 펌웨어는 처음부터 Embassy 기반.
이유: ePaper 갱신 + 라디오 RX + 사용자 입력 + 배터리 모니터 — 네 가지를 동시에 처리해야 하는데, async가 표현하기 압도적으로 쉽다.
8.3 인터랙티브 아트 인스톨레이션
LED 매트릭스 수천 개 + 센서 어레이 + WiFi/Art-Net을 묶는 미디어 아트 작업. 미국 Burning Man, 일본 teamLab류, 유럽 페스티벌에서 Rust 펌웨어가 점차 표준이 되어간다. C 보일러플레이트 + 메모리 손상 디버깅에서 해방되는 게 핵심 매력.
8.4 산업 IoT — 진동/온도 모니터
STM32H7 + LoRa + 진동 센서(MEMS) — 진동 데이터를 FFT 하고 임계치 넘으면 무선 송신. STM32H7 같은 칩의 DSP 명령을 cortex-m::asm::sev 같은 wrapper로 호출해도 되고, cmsis-dsp-sys 바인딩으로 CMSIS-DSP를 부르는 패턴도 흔하다.
8.5 비휘발 메모리 키/값 저장 (sequential-storage)
플래시 메모리에 키/값 영구 저장. 펌웨어가 재부팅돼도 설정·캘리브레이션 데이터가 유지된다. embedded-storage trait 위에 sequential-storage 크레이트로 마모 평준화(wear leveling)까지. RP2040의 마지막 4KB 섹터, STM32의 EEPROM 영역, ESP32의 NVS 파티션을 다 같은 API로 쓴다.
9장 · C/C++·MicroPython·Zig embedded·Ada/SPARK와의 비교
9.1 C/C++ — 여전히 산업 표준
| 측면 | C/C++ | Rust |
|---|---|---|
| 컴파일러 | 모든 칩에 다 있음 | upstream LLVM 지원 칩만(거의 다) |
| 메모리 안전 | 본인이 보장 | 컴파일러가 검증 |
| 벤더 SDK | 1순위 (ST HAL, ESP-IDF, nRF Connect) | 2-3년 따라잡는 중 (esp-rs는 1순위) |
| 동시성 | RTOS + 인터럽트 | Embassy/RTIC |
| 디버깅 | OpenOCD + GDB | probe-rs + defmt |
| 인력 풀 | 거대 | 빠르게 자라는 중 |
| 자동차/항공 인증 | MISRA C 표준 | Ferrocene (Ferrous Systems의 Rust 인증 컴파일러) |
요약 — 레거시 칩, 인력 풀, 인증된 컴파일러가 결정적이면 C/C++. 새 칩, 새 팀, 메모리 안전과 동시성 모델이 가치라면 Rust.
9.2 MicroPython — 진입장벽과 RAM
MicroPython은 ESP32, RP2040, STM32에서 인터프리터가 돈다. 진입장벽이 압도적으로 낮다 — 5분에 첫 LED 깜빡임.
| 측면 | MicroPython | Rust |
|---|---|---|
| 진입장벽 | 매우 낮음 | 중-높음 |
| 실행 속도 | 인터프리터 (1/100-1/50 of C) | C 수준 |
| RAM 사용 | 인터프리터 + GC 부담 (~64KB 시작) | 거의 0 오버헤드 |
| 컴파일타임 검증 | 거의 없음 | 강함 |
| 적합한 곳 | 프로토타이핑, 교육, 가벼운 로직 | production, 실시간, 자원 빠듯 |
MicroPython은 절대로 죽지 않는다 — 교육·프로토타이핑 영역에서 너무 강하다. 다만 product가 되려면 보통 Rust나 C/C++로 다시 짠다.
9.3 Zig embedded
Zig는 임베디드를 처음부터 1순위 시민으로 본다. comptime이 매크로보다 강력하고, 메모리 모델이 명시적이며, C ABI 인터옵이 자연스럽다.
다만 2026년 5월 기준 — 생태계의 깊이가 Rust보다 한참 얕다. Embassy 같은 통합 async 런타임, RTIC 같은 정적 검증, 그리고 100개가 넘는 센서 드라이버 크레이트가 Rust에는 있고 Zig에는 아직 없다. 작은 펌웨어에 Zig는 매력적이지만 큰 프로젝트는 Rust가 우위.
9.4 Ada / SPARK
자동차·항공·원자력에서 수십 년간 표준이던 언어. 정적 검증이 SPARK 부분집합으로 강력하다.
Rust의 비교 — 메모리 안전은 둘 다, 동시성 안전은 Rust가 강함, 산업 인증은 Ada/SPARK가 깊음, 인력 풀은 Rust가 빠르게 커지는 중. Ferrocene이 Rust 컴파일러의 ISO 26262 / IEC 61508 인증을 받으면서 (2023년 ASIL D 대응), Rust가 안전 크리티컬 영역에도 진출하고 있다.
10장 · 어려운 부분 — 솔직한 함정들
10.1 'static 라이프타임의 압박
Embassy 태스크는 'static이어야 한다 — 즉, 함수 스택에서 빌린 참조를 태스크에 넘길 수 없다. 보통 static_cell 크레이트로 정적 메모리에 한번 초기화하는 패턴.
use static_cell::StaticCell;
static UART_BUF: StaticCell<[u8; 256]> = StaticCell::new();
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let buf: &'static mut [u8; 256] = UART_BUF.init([0; 256]);
// 이제 buf는 'static, spawn에 넘길 수 있다
}
처음 한 달은 이 패턴이 어색하다. 두 달 차엔 자연스러워진다.
10.2 인터럽트 바인딩 매크로
페리퍼럴 인터럽트 핸들러를 Embassy 런타임에 연결하는 매크로(bind_interrupts!) — 모양이 좀 어색하고 칩마다 약간씩 다르다.
bind_interrupts!(struct Irqs {
UART0_IRQ => embassy_rp::uart::InterruptHandler<UART0>;
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<USB>;
});
처음엔 "이게 왜 이렇게 생겼지"지만, 풀어쓰면 "USB 인터럽트가 들어올 때 Embassy의 USB 핸들러를 부르라"는 그냥 안전한 등록이다.
10.3 Xtensa Rust는 여전히 LLVM 포크
ESP32(원조)와 ESP32-S2/S3는 Xtensa 코어다. Xtensa는 LLVM upstream이 아니라 Espressif가 유지하는 fork가 필요하다. espup install로 자동 설치되지만, GitHub Actions 같은 CI에서 한 단계 더 들어간다.
RISC-V 칩(C3, C6, H2, P4)은 upstream Rust로 빌드되어 이 문제가 없다. 새 ESP 프로젝트는 RISC-V 라인 우선이 2026년의 상식.
10.4 디버그 빌드의 크기 문제
cargo build (디버그)는 LTO·opt-level 0이라서 코드 크기가 4-10× 부풀어 Flash에 안 들어갈 수 있다. 임베디드 Rust는 거의 항상 cargo build --release로 짠다. 다만 release에서 디버그 심볼은 debug = 2로 켜둬야 defmt/backtrace가 동작한다.
10.5 알 수 없는 칩의 PAC
생태계가 활발하지만 모든 칩이 다 cover되진 않는다. SiPEED MAIX(Kendryte K210), Bouffalo BL602 등은 PAC이 있어도 HAL은 미완. 메이저 라인(RP2xxx, ESP32, STM32, nRF5x, SAMD) 밖이면 직접 PAC 위에 HAL을 짜야 할 수 있다.
11장 · 학습 경로 — 어디서 시작할까
11.1 0주차 — Pico 사기
가장 빠른 시작: Raspberry Pi Pico 2 W (~12) + USB-C 케이블 2개. 합쳐서 $25 정도. 디버그 프로브를 따로 사는 게 핵심이다 — flash + RTT 디버깅이 한 명령으로 되는 차이.
ESP32 라인이면 ESP32-C6 DevKit (~$15)도 좋다. C6는 Wi-Fi 6 + BLE 5 + Thread 모두 가짐.
11.2 1-2주차 — embedded-hal의 trait들
embedded-hal::digital::OutputPin, embedded-hal::spi::SpiDevice, embedded-hal::i2c::I2c, embedded-hal-async::*. 이 trait들의 모양을 익히는 게 첫 1-2주의 핵심. 그러고 나면 BME280 드라이버, SSD1306 OLED 드라이버, MPU6050 IMU 드라이버가 모두 같은 모양으로 보인다.
11.3 3-4주차 — Embassy의 async
Embassy 공식 examples 폴더가 가장 빠른 학습 자료다. examples/rp 또는 examples/esp32c6 또는 examples/stm32f4 중 본인 칩 폴더를 열어 처음부터 끝까지 읽는다. 각 예제가 100-200줄 정도라 짧고, 패턴이 반복된다.
11.4 1-2개월차 — 첫 프로젝트
작은 의미 있는 프로젝트 하나. 권장 — ePaper 시계 + Wi-Fi NTP 동기화, 또는 BME280 환경 센서 → BLE/Wi-Fi 전송, 또는 로터리 인코더 + OLED 메뉴 UI. 100-500줄 사이 프로젝트가 가장 학습 효율이 좋다.
11.5 3-6개월차 — 깊이
이쯤 되면 다음 중 하나로 깊이를 정한다.
- Matter 디바이스 —
rs-matter사용. 실제 Apple/Google Home에 등록. - 자체 PCB + 자체 펌웨어 — KiCad로 보드 설계 + Rust 펌웨어.
- 저전력 최적화 — uA 단위 슬립 + 인터럽트 깨움 + 배터리 수명 계산.
- DMA + 고속 페리퍼럴 — SPI DMA로 1Gbps 가까운 데이터 흐름.
- PIO (Pico) 또는 RMT (ESP32) — 커스텀 프로토콜 (WS2812, IR, 1-wire).
에필로그 — Rust가 임베디드를 더 정직하게 만들었다
임베디드 Rust의 한 줄 요약: 컴파일러가 "이 메모리는 누구 거야?"를 같이 물어준다. 인터럽트와 메인루프가 같은 버퍼를 손대는 코드가 컴파일을 못 통과한다. async 태스크가 빌려 간 페리퍼럴을 다른 태스크가 동시에 못 쓴다. 정적 RAM 영역이 한 곳에서 초기화됐는지 검증된다.
이게 작은 것 같지만 — 임베디드 펌웨어의 디버깅 시간 중 절반 이상이 "왜 LED가 안 깜빡이지" → 사실은 메모리 손상이었던 경험을 한 번이라도 한 사람에겐 결정적이다.
C/C++가 사라지지 않는다. 60년의 코드, 인력, 벤더 SDK, 자동차 인증 체계가 거기 있다. 새 칩에서 Rust가 디폴트가 되어가는 것뿐이다. 그리고 일단 디폴트가 되면 — 다음 세대 개발자는 C로 절대 돌아가지 않을 것이다.
Embassy의 async는 5년 전 "흥미로운 실험"이었다. 2026년에는 "왜 다른 모델로 짜?"가 된다. RTIC도, 슈퍼루프도, RTOS도 살아 있지만 — 새 프로젝트의 디폴트 선택은 Embassy다.
probe-rs와 defmt는 임베디드 디버깅을 1990년대에서 2020년대로 끌고 왔다. cargo run --release 한 줄로 빌드·플래시·로깅이 끝나는 워크플로는 한번 맛보면 OpenOCD로 못 돌아간다.
큰 그림 — 임베디드 Rust는 2026년에 "써도 되는가"의 단계를 지나, "왜 안 써?"의 단계에 있다. 다음 5년은 자동차·의료·항공 같은 인증 영역으로 Ferrocene이 침투하는 시간이 될 것이다.
14개 항목 체크리스트
#![no_std]+#![no_main]이 켜져 있는가?panic_handler가 명시되어 있는가 (panic_halt/panic_probe/panic_reset)?Cargo.toml의[profile.release]에debug = 2로 디버그 심볼이 켜져 있는가?.cargo/config.toml의runner에probe-rs run --chip ...이 설정됐는가?- PAC만 직접 만지는 코드 대신 HAL/BSP를 1차로 쓰고 있는가?
- 센서/디스플레이 드라이버를
embedded-haltrait에 대해 제네릭하게 쓰고 있는가? - Embassy 태스크가
'static을 요구할 때static_cell/StaticCell로 풀고 있는가? - 인터럽트 핸들러 안에서 동적 할당을 안 하는가?
defmt::info!등info/warn/error레벨이 production 빌드에서 적절히 필터되는가?- Wi-Fi/BLE를 쓴다면
embassy-net/trouble/esp-wifi중 어떤 길로 갈지 결정했는가? - 비휘발 저장이 필요하면
sequential-storage/embedded-storage로 추상화했는가? - Xtensa(ESP32, S2, S3) 대신 가능하면 RISC-V(C3, C6, H2)를 골라 toolchain 부담을 줄였는가?
- CI에서
cargo build --release+cargo clippy --target ...이 돌고 있는가? - 디버그 프로브가 따로 있는가 (Pico Probe / J-Link / ST-Link) — RTT 디버깅의 핵심이다.
안티패턴 10가지
- PAC만 만지면서 비트 시프트로 GPIO를 다루기 — 가독성·이식성 둘 다 손해.
- Embassy 태스크 안에서 큰 동기 블로킹 함수를 호출하기 — 다른 태스크가 굶음.
- ISR 안에서
Vec::push같은 동적 할당 — 실시간성 파괴. panic_halt로 production 펌웨어를 두기 — 워치독으로 리셋되게 해야 함.cargo build(디버그) 펌웨어를 그대로 Flash — 크기·속도 둘 다 망함.- RTT 호스트 없이
defmt::info!만 잔뜩 박기 — 호스트 안 붙으면 펌웨어가 RTT 버퍼 차서 멈출 수 있음. - Xtensa(ESP32) 한 칩에 모든 기능을 집어넣기 — RISC-V로 가는 게 toolchain 부담이 훨씬 적음.
- 인터럽트와 메인루프가 같은 가변 변수를 mutex 없이 공유 — Rust가 막아주긴 하지만
unsafe로 우회한 코드는 위험. - PCB 시제품 단계에서 디버그 프로브 핀을 안 빼놓기 — flash 한 번 망치면 USB DFU로 복구 불가.
- 100% Rust 광신 — 벤더 BLE 스택(SoftDevice)이나 ESP-IDF Wi-Fi가 더 안정적이면 그걸 wrapping해서 쓰는 게 옳다.
다음 글 예고
다음 글 후보: Matter 디바이스를 Rust로 — ESP32-C6 + rs-matter 풀스택, Embassy + LoRa(SX126x)로 long-range 센서 네트워크, probe-rs deep dive — 디버그 프로브 자체를 짜기.
"임베디드 Rust는 컴파일러를 같은 편으로 만든 일이다. 메모리는 누구 거고, 인터럽트는 무엇을 깨우고, 자원은 어디서 해제되는지 — 코드가 자기 입으로 말한다."
— 임베디드 Rust 2026 심층, 끝.
참고 / References
- Embassy — Modern Embedded Rust
- Embassy GitHub — embassy-rs/embassy
- Embassy Book — Async Rust on microcontrollers
- embedded-hal — The Embedded Rust Working Group
- The Embedded Rust Book
- The Rust Embedded WG
- probe-rs — Modern debugging for embedded Rust
- probe-rs GitHub — probe-rs/probe-rs
- defmt — Efficient logging for embedded systems
- defmt GitHub — knurling-rs/defmt
- Knurling tools — Ferrous Systems
- esp-rs — Espressif Rust support
- esp-hal Documentation
- The Rust on ESP Book
- espup — ESP toolchain installer
- esp-wifi — Wi-Fi and BLE for ESP32 in Rust
- rp2040-hal — Raspberry Pi RP2040 HAL
- rp235x-hal — Raspberry Pi RP2350 HAL
- rp-pico BSP
- Raspberry Pi Pico SDK page
- Raspberry Pi Pico 2 — RP2350 product page
- stm32-rs — STM32 PACs
- embassy-stm32 — Embassy STM32 HAL
- nrf-hal — Nordic nRF5x HAL
- embassy-nrf — Embassy nRF HAL
- TrouBLE — Pure Rust BLE host
- nrf-softdevice — SoftDevice wrapper
- RTIC — Real-Time Interrupt-driven Concurrency
- rs-matter — Matter protocol in Rust
- embedded-graphics — UI for small displays
- embedded-storage trait
- sequential-storage — Wear-leveled KV on flash
- smoltcp — TCP/IP for embedded Rust
- Ferrocene — Certified Rust compiler
- Ambiq Apollo HAL
- ch32-hal — WCH CH32V
- atsamd-hal — Microchip SAMD
- The Discovery book — STM32F3
- Awesome Embedded Rust
- Matrix room — Rust Embedded
- Embassy chat — embassy-rs Matrix
- Hackaday Supercon badges
- EMF Camp 2024 badge — tildagon
Embedded Rust 2026 Deep Dive — no_std, Embassy, esp-rs, RP2350, probe-rs, and Rust on Microcontrollers in Practice
Prologue — The "Is Embedded Rust Usable Yet?" Question is Settled
Five years ago this post would have opened with "embedded Rust is still experimental, but…" Embassy in 2021 was alpha, the RP2040 HAL hadn't settled, ESP32 needed a forked LLVM, and probe-rs was a niche companion to OpenOCD.
The landscape in May 2026 is different.
- Embassy 0.6 is the de facto default async runtime on microcontrollers. New projects rarely default to the main-loop-plus-interrupt-handlers pattern anymore.
- RP2350 (Raspberry Pi Pico 2, dual Cortex-M33 plus dual RISC-V Hazard3) has stable HAL coverage under
rp235x-hal/embassy-rp. esp-rshas been elevated to Espressif's official top-tier SDK. Xtensa (ESP32, ESP32-S3) still needs a forked LLVM, but the RISC-V line (ESP32-C3, C6, H2, P4) builds with upstream Rust.probe-rshas effectively replaced OpenOCD. Onecargo runand you get flash plus RTT logs plus backtraces in a single terminal.defmtlogging is faster than printf, and the formatter runs on the host — the firmware binary does not carry the format strings.- Non-async embedded Rust is still alive. Skipping Embassy is not a sin. It just isn't what most new projects pick.
In short, embedded Rust is production-ready in 2026. Apollo3, Arduino R4, conference badges, home automation, art installations — all real domains shipping real code. This post walks from the basics of no_std all the way to your first Embassy blinky on an RP2040, in one pass.
1. The Landscape — Why Rust is Reaching the MCU Now
1.1 Five Pressures on Embedded Firmware
Anyone writing microcontroller firmware deals with five pressures simultaneously.
- Memory — environments like 32 KB RAM, 256 KB flash, no room for the standard library.
- Determinism — you do not allocate inside an ISR. It wrecks real-time guarantees.
- Hardware abstraction — you want to treat GPIO, UART, SPI, I2C, and timers without chip-specific code.
- Concurrency — sensor polling, UART RX, log transmission, display refresh, all running together with no OS.
- Debugging — JTAG/SWD, RTT, semihosting. Printing is hard. Endless blink-the-LED debugging.
1.2 Why C/C++ Was the Answer for 60 Years
C/C++ actually has a decent answer to all five. static variables plus interrupt handlers plus either an RTOS or a superloop, plus vendor SDKs (ST's HAL, Espressif's ESP-IDF, Nordic's nRF Connect SDK). The whole industry has run on this for six decades.
Two problems remain.
- Memory safety — null pointers, bad casts, buffer overruns. One bad line of firmware turns into infinite LED-blink debugging.
- Impoverished concurrency model — RTOS tasks plus mutexes plus queues are standard, but priority inversion, deadlocks, and priority inheritance traps never end.
1.3 How Rust Answers
Rust has a language-level answer to each of these pressures.
| Pressure | Rust's answer |
|---|---|
| Memory | #![no_std] — build without the standard library, use core and alloc only |
| Determinism | Ownership and borrowing express alloc-free state machines naturally |
| Hardware abstraction | The embedded-hal trait ecosystem |
| Concurrency | async/await plus the Embassy runtime (or RTIC) |
| Debugging | defmt plus probe-rs — RTT/SWD setup in one line |
What is decisively different from five years ago is that each answer now has working code and real chip support. The rest of this post unpacks those five answers one by one.
2. The Essence of no_std — How Far Does core Alone Get You?
2.1 Environments Where std Cannot Follow
On desktop and server, Rust's std runs on top of an OS. std::fs is the filesystem, std::net is the TCP stack, std::thread is OS threads, std::sync::Mutex is a pthread mutex. None of that exists on a microcontroller.
The #![no_std] attribute tells the compiler "this crate does not use std." Instead, it uses core, a smaller standard library.
| Area | In std | In core |
|---|---|---|
Primitive types (u8, f32, …) | yes | yes |
Option, Result | yes | yes |
Iterator trait | yes | yes |
Vec, String, HashMap | yes | no (separate alloc) |
println! | yes | no |
std::fs, std::net | yes | no |
Box, Rc, Arc | yes | no (separate alloc) |
| Threads, sync primitives | yes | no |
Even with only core you keep the richness of slices, iterators, traits, and macros. Only types that need dynamic allocation drop off.
2.2 The Decision to Pull in alloc
If your microcontroller has enough RAM — RP2040 has 264 KB SRAM, ESP32-S3 has 512 KB — you can add the alloc crate and bring Box, Vec, and String back. You must designate a global allocator.
#![no_std]
#![no_main]
extern crate alloc;
use alloc::vec::Vec;
use embedded_alloc::Heap;
#[global_allocator]
static HEAP: Heap = Heap::empty();
#[entry]
fn main() -> ! {
// Reserve an 8 KB heap statically.
{
use core::mem::MaybeUninit;
const HEAP_SIZE: usize = 8 * 1024;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
}
let mut v: Vec<u32> = Vec::new();
v.push(42);
loop {}
}
The community-wide recommendation is "never allocate inside an ISR." Allocation in the main loop is fine; interrupts only touch pre-allocated buffers.
2.3 panic_handler Is Mandatory
A no_std binary must specify what to do on panic. In std you get automatic abort plus backtrace, but no such mechanism exists on a microcontroller.
use panic_halt as _; // panic -> infinite loop
// or
use panic_probe as _; // panic -> report via RTT, then halt (with probe-rs)
// or
use panic_reset as _; // panic -> watchdog reset
Production firmware usually picks panic_reset plus a "save last panic info to non-volatile memory and report after reboot" pattern.
2.4 no_main and the Entry Point
A no_std crate is usually no_main as well. Instead of the standard fn main entry, the chip-specific boot sequence has to take control first.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
// `!` return means it never returns. Infinite loop or `wfi`.
loop {}
}
The cortex_m_rt::entry macro creates the reset handler and plugs in the right boot sequence (stack pointer, zeroing BSS, copying initialized RAM data). The RP2040 has rp2040-hal, the ESP32 has esp-hal, each doing the equivalent.
3. PAC, HAL, BSP — Three Layers of Abstraction
The embedded Rust ecosystem is made of three layers of crates. Higher layers are more abstract; lower layers are more chip-specific.
3.1 PAC (Peripheral Access Crate)
The lowest layer. Auto-generated from the chip vendor's SVD (System View Description) XML by svd2rust. It exposes register bit-fields as types.
// Using rp2040-pac
use rp2040_pac as pac;
let peripherals = pac::Peripherals::take().unwrap();
let sio = peripherals.SIO;
// Set GPIO25 as output and drive it high.
sio.gpio_oe_set.write(|w| unsafe { w.bits(1 << 25) });
sio.gpio_out_set.write(|w| unsafe { w.bits(1 << 25) });
PAC-only code reads like the datasheet itself. Fast but unreadable and not portable across chips.
3.2 HAL (Hardware Abstraction Layer)
A safe abstraction on top of the PAC. rp2040-hal, embassy-rp, stm32f4xx-hal, esp-hal, nrf52840-hal, and so on.
// Using rp2040-hal
use rp2040_hal::gpio::Pins;
use embedded_hal::digital::OutputPin;
let pins = Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
let mut led = pins.gpio25.into_push_pull_output();
led.set_high().unwrap();
Thanks to the OutputPin trait, the same code keeps working when the chip changes.
3.3 embedded-hal — The Abstraction Contract
The reason every HAL exposes the same shape of API is the embedded-hal trait crate. As of 2026, embedded-hal 1.0 is stable and embedded-hal-async 1.0 lands alongside it.
// Core traits from embedded-hal 1.0
pub trait OutputPin {
type Error;
fn set_low(&mut self) -> Result<(), Self::Error>;
fn set_high(&mut self) -> Result<(), Self::Error>;
}
pub trait SpiDevice<Word = u8>: ErrorType {
fn transaction(&mut self, operations: &mut [Operation<'_, Word>]) -> Result<(), Self::Error>;
}
pub trait I2c<A = SevenBitAddress, Word = u8>: ErrorType {
fn transaction(&mut self, address: A, operations: &mut [Operation<'_, Word>]) -> Result<(), Self::Error>;
}
Sensor driver crates (such as bme280, mpu6050, ssd1306, embedded-graphics) are written generically over these traits. A single BME280 driver works on RP2040, STM32, ESP32, and nRF52.
3.4 BSP (Board Support Package)
A crate that pins down the pin mapping, power, and LED macros for a specific board (Raspberry Pi Pico, Adafruit Feather, STM32 Nucleo, etc.). Examples: rp-pico, feather_rp2040, stm32f4-discovery.
// Using rp-pico BSP — the LED pin already has a name.
use rp_pico::hal::Clock;
use rp_pico::hal::pac;
use rp_pico::hal::gpio::Pins;
let pac = pac::Peripherals::take().unwrap();
let sio = rp_pico::hal::Sio::new(pac.SIO);
let pins = rp_pico::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
let mut led = pins.led.into_push_pull_output();
led.set_high().unwrap();
The naming pins.led matters — the BSP knows the Pico wires its LED to GPIO25.
4. Embassy — Async by Default in Embedded Rust
4.1 Why Async on a Microcontroller?
Concurrency on a microcontroller traditionally took one of three forms.
- Superloop — write
loop { check_a(); check_b(); check_c(); }. Simple, but one long task starves the others. - Interrupt handler plus main loop — ISR starts work, main loop processes results. Shared state is tricky.
- RTOS tasks — FreeRTOS, ChibiOS, Zephyr. Context-switch cost, N times the stack memory, learning curve for primitives.
Embassy is a fourth answer. Cooperative concurrency expressed in async/await, with a runtime that wakes on top of interrupts.
4.2 The Core Idea of Embassy
// Two tasks running concurrently. Not threads — async tasks.
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after_millis(500).await; // Other tasks run during these 500 ms.
led.set_low();
Timer::after_millis(500).await;
}
}
#[embassy_executor::task]
async fn echo(mut uart: BufferedUart<'static, UART0>) {
let mut buf = [0u8; 64];
loop {
let n = uart.read(&mut buf).await.unwrap();
uart.write_all(&buf[..n]).await.unwrap();
}
}
The interesting part is Timer::after_millis(500).await. At an await point, the task yields and another task can run. When the timer interrupt fires 500 ms later, the Embassy runtime wakes the task back up.
uart.read(&mut buf).await works the same way — the task sleeps until a UART RX interrupt arrives. In the meantime the runtime runs other tasks, and once idle it sleeps the core with wfi (Wait For Interrupt).
4.3 Stack Memory — The Decisive Difference vs RTOS
RTOS tasks each have their own stack. Five tasks at 4 KB each gives you 20 KB. That hurts on small microcontrollers.
Embassy's async tasks share a single stack. The state of each task is compressed into a compiler-generated state-machine struct. As a result, ten tasks frequently fit in 1-2 KB of stack.
RTOS: [Stack T1: 4KB] [Stack T2: 4KB] [Stack T3: 4KB] [Stack T4: 4KB]
Embassy: [Shared Stack: 4KB] + [Task1 state: 64B] [Task2 state: 96B] [Task3 state: 32B]
4.4 Spawner and main
Standard Embassy main-function boilerplate.
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let led = Output::new(p.PIN_25, Level::Low);
spawner.spawn(blink(led)).unwrap();
}
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.toggle();
Timer::after_millis(500).await;
}
}
The embassy_executor::main macro makes main an async function and injects a Spawner argument. Tasks register through spawner.spawn(...).
4.5 RTIC vs Embassy — The Two Main Choices
RTIC (Real-Time Interrupt-driven Concurrency) is another embedded concurrency framework that predates Embassy. It schedules priority-based interrupt-driven tasks, with resource locking statically verified at compile time.
| Aspect | Embassy | RTIC |
|---|---|---|
| Model | async/await plus executor | Priority-based interrupt scheduling |
| Learning curve | If you know async, immediate | Macros and resource model to learn |
| Priority guarantees | Cooperative — all tasks equal | Priority-based, compile-time verified |
| Memory | Smaller (shared stack) | Per-priority stack |
| Popularity | The default for new projects, 2024-2026 | When safety-critical or hard real-time guarantees are required |
Pick Embassy first for new projects. Pick RTIC for hard real-time (automotive, avionics) where priority verification is mandatory.
5. Per-Target Status as of May 2026
5.1 RP2040 / RP2350 — Raspberry Pi Pico Family
- RP2040 — Dual Cortex-M0+, 264 KB SRAM.
embassy-rpis stable. Covers Pico 1 and Pico W. - RP2350 — Dual Cortex-M33 plus dual RISC-V Hazard3 (choose at boot), 520 KB SRAM, secure boot, ARM TrustZone.
rp235x-hal1.0 andembassy-rprp235x support are stable. - Bluetooth and Wi-Fi (Pico W, Pico 2 W) —
cyw43crate plusembassy-net(smoltcp-based TCP/IP). Even BLE works over PIO plus SPI. - PIO — the signature feature of the RP series. A lightweight stateful I/O machine. Write PIO assembly in Rust with
pio-procplusembassy-rp::pio.
// Snippet: Wi-Fi on Pico W (abridged)
use cyw43_pio::PioSpi;
use embassy_net::Stack;
let mut control = cyw43::new(state, pwr, spi, fw).await;
control.init(clm).await;
control.join_wpa2("SSID", "PASSWORD").await.unwrap();
5.2 ESP32 (esp-rs)
Espressif elevated Rust to its official top-tier SDK in 2023. As of 2026, esp-hal 1.0 is stable.
- RISC-V chips (ESP32-C3, C6, H2, P4) — built with upstream Rust toolchain. No
espupneeded. - Xtensa chips (ESP32, ESP32-S2, ESP32-S3) — still need a forked LLVM. Auto-configured by
espup install. embassy-executorplusesp-hal-embassy— run Embassy on ESP32 as-is.- Wi-Fi / BLE —
esp-wificrate. Wi-Fi 6 and BLE 5.3. Integrates withembassy-net.
# Cargo.toml — for ESP32-C6
[dependencies]
esp-hal = { version = "1.0", features = ["esp32c6"] }
esp-hal-embassy = { version = "0.6", features = ["esp32c6"] }
embassy-executor = { version = "0.6", features = ["task-arena-size-20480"] }
esp-wifi = { version = "0.10", features = ["esp32c6","wifi","ble"] }
The ESP32-C6 — RISC-V plus Wi-Fi 6 plus a Thread/Zigbee radio — is the hot spot of embedded Rust in 2026. It is the easiest path to a Matter device written in Rust.
5.3 STM32 (stm32-rs)
STMicroelectronics's STM32 family. The default chipset of the embedded industry.
stm32-rs— a joint project that auto-generates PACs for every STM32 chip from SVD.stm32f4xx-hal,stm32h7xx-hal,stm32l4xx-hal— per-family HALs.embassy-stm32— the unified Embassy HAL. Covers every family, pick a chip withfeatures = ["stm32f407vg"]. The default for new STM32 projects in 2026.
The compelling reason to pick Embassy on STM32 is that the interrupt-to-async-waker plumbing exists for nearly every peripheral. UART, SPI, I2C, ADC, DMA, USB — all async.
5.4 nRF52 / nRF53 / nRF54 — Nordic Semiconductor
The de facto standard for BLE devices.
embassy-nrf— stable for nRF52832, 52840, 5340, and 54L.nrf-softdevice— wrapper that calls Nordic's SoftDevice (BLE stack) from Rust async. Both peripheral and central role.- 2026 update —
trouble(pure Rust BLE host) has matured into a SoftDevice-free alternative. Runs on nRF52, ESP32, and CYW43.
5.5 Others — Briefly
- Ambiq Apollo3 / 4 —
ambiq-hal. Ultra-low-power (uA) MCUs. Popular for wearables and BLE. - Arduino UNO R4 (Renesas RA4M1) —
ra4m1-hal(community). Cortex-M4. Arduino-compatible pinout. - CH32V — WCH's ultra-cheap RISC-V.
ch32-hal. Price tag around 20 cents. - SAMD (Microchip / Atmel) —
atsamd-hal. Covers SAMD21 (Adafruit Feather M0) and SAMD51. - Generic RISC-V —
riscv-rtfor boot, chip-specific HAL separately.
6. Tooling — probe-rs, cargo-embed, defmt
6.1 probe-rs — The Successor to OpenOCD
The era where OpenOCD plus GDB was the standard for embedded debugging is over. probe-rs has effectively replaced it.
probe-rs is a Rust-written driver for debug probes (J-Link, ST-Link, CMSIS-DAP, Raspberry Pi Debug Probe, FTDI, etc.), plus flash algorithms, plus the RTT/SWO host.
# Install
cargo install probe-rs-tools
# List connections
probe-rs list
# Flash, run, and stream RTT logs in one command.
cargo run --release # with runner = "probe-rs run" set in .cargo/config.toml
6.2 Standard .cargo/config.toml
A standard setup for an RP2040 project.
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040 --protocol swd"
[build]
target = "thumbv6m-none-eabi" # RP2040 (Cortex-M0+)
# target = "thumbv8m.main-none-eabihf" # RP2350 (Cortex-M33)
[env]
DEFMT_LOG = "trace"
Type cargo run --release and you get build, flash, run, RTT logs, and backtraces in one command.
6.3 defmt — Logging Faster Than printf
defmt is a macro like println!, but with a key trick — the format string never lives in the firmware binary, only an index does. The host (probe-rs) reads the .defmt section of the ELF to reconstruct strings.
use defmt::{info, warn, error};
fn read_sensor(i: u32) {
info!("sensor read iteration={}", i);
let value = sensor.read();
if value > 100 {
warn!("sensor value high: {=u32}", value);
} else if value == 0 {
error!("sensor not responding");
}
}
Type hints like {=u32} exist for serialization efficiency. A single log typically serializes to 4-8 bytes. A 1 MHz UART can sustain 100k logs per second.
| Comparison | printf (semihosting) | RTT printf | defmt over RTT |
|---|---|---|---|
| Per-log cost | Tens of µs (blocking) | 1-2 µs | 100-500 ns |
| Firmware size | Format strings included | Included | Index only |
| Compile-time format check | no | no | yes |
6.4 cargo-embed — An Alternative Runner
Besides probe-rs run there is cargo-embed. It provides a TUI with both RTT logs and a GDB debugger in one screen.
cargo install cargo-embed
cargo embed --release
The 2025-2026 trend favors probe-rs run since it is lighter and is now the default for new projects. Use cargo-embed when you want the dashboard.
6.5 GDB Is Still Alive
For situations where GDB is stronger (intricate memory corruption, for example), launch probe-rs gdb as a server and attach with arm-none-eabi-gdb.
probe-rs gdb --chip RP2040
# In another terminal
arm-none-eabi-gdb target/thumbv6m-none-eabi/release/myfirmware
(gdb) target remote :1337
7. Your First Blinky with Embassy — RP2040 Walkthrough
Time for real code. The onboard LED of a Raspberry Pi Pico (GPIO25) blinking every 0.5 seconds.
7.1 Create the Project
cargo new --bin blinky
cd blinky
rustup target add thumbv6m-none-eabi
7.2 Cargo.toml
[package]
name = "blinky"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m","executor-thread","integrated-timers"] }
embassy-time = { version = "0.4" }
embassy-rp = { version = "0.4", features = ["rp2040","time-driver"] }
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
cortex-m-rt = "0.7"
[profile.release]
debug = 2 # keep debug symbols for defmt
lto = true
codegen-units = 1
opt-level = "s"
7.3 .cargo/config.toml
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040 --protocol swd"
rustflags = [
"-C", "link-arg=--nmagic",
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
]
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "trace"
7.4 src/main.rs — First Blinky
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[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);
info!("blinky started");
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
7.5 Build and Flash
Hold BOOTSEL on the Pico while plugging in USB and the board enumerates as a mass storage device. If you also have a probe-rs-compatible debug probe attached (or a Pico Probe firmware on a second Pico), one command does the rest.
cargo run --release
# Compiling blinky ...
# Finished release [optimized + debuginfo] target(s)
# Running `probe-rs run --chip RP2040 --protocol swd target/thumbv6m-none-eabi/release/blinky`
# 0.000000 INFO blinky started
The LED blinks every second. A single log line streams to the host terminal over RTT.
7.6 One Step Up — UART Echo Plus Two Concurrent Tasks
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_rp::peripherals::{PIN_25, UART0};
use embassy_rp::uart::{Async, BufferedUart, Config as UartConfig, InterruptHandler};
use embassy_rp::{bind_interrupts, uart};
use embassy_time::Timer;
use embedded_io_async::{Read, Write};
use {defmt_rtt as _, panic_probe as _};
bind_interrupts!(struct Irqs {
UART0_IRQ => InterruptHandler<UART0>;
});
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// LED task
let led = Output::new(p.PIN_25, Level::Low);
spawner.spawn(blink(led)).unwrap();
// UART echo task — GPIO0 TX, GPIO1 RX, 115200 baud
let mut cfg = UartConfig::default();
cfg.baudrate = 115_200;
let uart = BufferedUart::new(
p.UART0, Irqs, p.PIN_0, p.PIN_1,
cfg, &mut [0u8; 64], &mut [0u8; 64],
);
spawner.spawn(echo(uart)).unwrap();
}
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.toggle();
Timer::after_millis(500).await;
}
}
#[embassy_executor::task]
async fn echo(mut uart: BufferedUart<'static, UART0>) {
let mut buf = [0u8; 32];
info!("echo task ready");
loop {
match uart.read(&mut buf).await {
Ok(n) => {
info!("rx {} bytes", n);
let _ = uart.write_all(&buf[..n]).await;
}
Err(_) => Timer::after_millis(10).await,
}
}
}
The point — blink and echo both stay alive. While one is asleep on Timer::after_millis(500).await, the other can wake on a UART RX interrupt. One core, one stack, two tasks.
7.7 One More defmt Line
info!("rx {} bytes, first={=u8:#04x}", n, buf[0]);
Host terminal output:
0.001234 INFO blinky started
0.001500 INFO echo task ready
0.012345 INFO rx 5 bytes, first=0x48
The 0x48 is ASCII 'H'. That was the entire first embedded Rust project.
8. Real Projects — What People Are Building
8.1 Home Automation / Matter Devices
ESP32-C6 plus esp-hal plus the rs-matter crate. A Rust implementation of Matter (the IoT standard over Thread or Wi-Fi). Since 2024, rs-matter has reached v0.5, and you can write the full path — BLE commissioning, Wi-Fi or Thread, exposing Matter clusters — in Rust.
[BLE commissioning] -> [Wi-Fi/Thread] -> [Matter cluster: OnOff, LevelControl, ...]
embassy-net rs-matter
Apple Home, Google Home, and Amazon Alexa all see the same device.
8.2 Conference Badges
Hackaday Supercon, Defcon, EMF Camp, and other conference badges have been shifting toward Rust. The EMF Camp 2024 badge — RP2040 plus ePaper plus LoRa — was Embassy-based from day one.
The reason: refreshing ePaper, RX on the radio, user input, and battery monitoring all concurrently — async expresses this much more cleanly than the alternatives.
8.3 Interactive Art Installations
Media-art work that ties together thousands of LED matrices, sensor arrays, and Wi-Fi/Art-Net. Rust firmware is becoming standard at Burning Man, teamLab-style projects in Japan, and European festivals. The decisive selling point is escaping the C boilerplate and the memory-corruption debugging.
8.4 Industrial IoT — Vibration / Temperature Monitors
STM32H7 plus LoRa plus a MEMS vibration sensor — FFT the vibration data, transmit wirelessly when a threshold is exceeded. On chips like the STM32H7, you can call DSP instructions through wrappers like cortex-m::asm::sev, or use cmsis-dsp-sys bindings to CMSIS-DSP.
8.5 Non-Volatile Key/Value Storage (sequential-storage)
Persist key/value data in flash so the firmware can keep config and calibration across reboots. Built on the embedded-storage trait, with sequential-storage providing wear leveling. The last 4 KB sector of an RP2040, the EEPROM region of an STM32, and the NVS partition of an ESP32 are all driven by the same API.
9. Compared to C/C++, MicroPython, Zig Embedded, and Ada/SPARK
9.1 C/C++ — Still the Industry Standard
| Aspect | C/C++ | Rust |
|---|---|---|
| Compiler | Available on every chip | Only chips with upstream LLVM support (almost all) |
| Memory safety | You guarantee it | The compiler verifies it |
| Vendor SDK | First-class (ST HAL, ESP-IDF, nRF Connect) | Catching up over 2-3 years (esp-rs is first-class) |
| Concurrency | RTOS plus interrupts | Embassy / RTIC |
| Debugging | OpenOCD plus GDB | probe-rs plus defmt |
| Talent pool | Massive | Growing fast |
| Auto / aerospace certification | MISRA C standard | Ferrocene (Ferrous Systems's certified Rust compiler) |
In short — pick C/C++ if legacy chips, talent pool, or a certified compiler are decisive. Pick Rust if a new chip, a new team, and the value of memory safety plus a concurrency model matter more.
9.2 MicroPython — Entry Barrier and RAM
MicroPython runs an interpreter on ESP32, RP2040, and STM32. The entry barrier is unbeatably low — first LED blink in five minutes.
| Aspect | MicroPython | Rust |
|---|---|---|
| Entry barrier | Very low | Medium to high |
| Execution speed | Interpreted (1/100 to 1/50 of C) | At C performance |
| RAM use | Interpreter and GC overhead, ~64 KB minimum | Near zero overhead |
| Compile-time checks | Almost none | Strong |
| Best fit | Prototyping, education, light logic | Production, real-time, tight resources |
MicroPython will not die — it is too strong in education and prototyping. But to turn a prototype into a product, the rewrite is usually into Rust or C/C++.
9.3 Zig Embedded
Zig treats embedded as a first-class citizen from day one. comptime is more powerful than macros, the memory model is explicit, and the C ABI interop is natural.
But as of May 2026, the ecosystem depth is far behind Rust. There is no unified async runtime like Embassy, no static verification like RTIC, no library of more than a hundred sensor driver crates. Zig is appealing on small firmware; for big projects Rust is ahead.
9.4 Ada / SPARK
The standard for decades in automotive, aerospace, and nuclear. Static verification through the SPARK subset is powerful.
Versus Rust — memory safety, both. Concurrency safety, Rust ahead. Industry certification depth, Ada/SPARK ahead. Talent pool growth, Rust faster. With Ferrocene certifying the Rust compiler to ISO 26262 / IEC 61508 (ASIL D in 2023), Rust is moving into safety-critical territory too.
10. The Hard Parts — Honest Traps
10.1 The Pressure of the 'static Lifetime
Embassy tasks must be 'static — that is, you cannot pass a stack-borrowed reference to a task. The common pattern is to initialize once in static memory via the static_cell crate.
use static_cell::StaticCell;
static UART_BUF: StaticCell<[u8; 256]> = StaticCell::new();
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let buf: &'static mut [u8; 256] = UART_BUF.init([0; 256]);
// Now buf is 'static and can be passed to spawn.
}
The pattern feels awkward for the first month. By month two it is natural.
10.2 The Interrupt Binding Macro
The macro that wires peripheral interrupt handlers into the Embassy runtime, bind_interrupts!, has an odd-looking shape that differs slightly per chip.
bind_interrupts!(struct Irqs {
UART0_IRQ => embassy_rp::uart::InterruptHandler<UART0>;
USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<USB>;
});
At first this looks "why does it look like this," but unpacked it is just the safe registration "when the USB interrupt fires, call Embassy's USB handler."
10.3 Xtensa Rust Still Needs a Forked LLVM
The original ESP32 and ESP32-S2/S3 are Xtensa cores. Xtensa is not upstream LLVM; Espressif maintains a fork. espup install handles the setup, but CI like GitHub Actions has one extra step.
The RISC-V chips (C3, C6, H2, P4) build with upstream Rust and do not have this issue. Prefer the RISC-V line for new ESP projects is the conventional wisdom in 2026.
10.4 Debug Build Size
cargo build (debug) has no LTO and opt-level 0, so the code can balloon 4 to 10 times and may not fit in flash. Embedded Rust is almost always written against cargo build --release. Keep debug symbols on with debug = 2 in release so that defmt and backtraces still work.
10.5 PACs for Unknown Chips
The ecosystem is active but does not cover everything. SiPEED MAIX (Kendryte K210), Bouffalo BL602, and similar boards may have a PAC but only a partial HAL. Outside the major lines (RP2xxx, ESP32, STM32, nRF5x, SAMD) you may have to write a HAL on top of the PAC yourself.
11. A Learning Path — Where to Start
11.1 Week Zero — Buy a Pico
Fastest start: Raspberry Pi Pico 2 W (~12) plus two USB-C cables. Around $25 all in. Buying the debug probe separately is the key — it is what unlocks flash plus RTT debugging from one command.
For the ESP32 line, ESP32-C6 DevKit (~$15) is a great choice. C6 has Wi-Fi 6 plus BLE 5 plus Thread.
11.2 Weeks 1-2 — embedded-hal Traits
embedded-hal::digital::OutputPin, embedded-hal::spi::SpiDevice, embedded-hal::i2c::I2c, embedded-hal-async::*. The first one to two weeks should focus on the shape of these traits. After that, the BME280 driver, the SSD1306 OLED driver, and the MPU6050 IMU driver all look the same.
11.3 Weeks 3-4 — Embassy's Async
Embassy's official examples folder is the fastest learning material. Open examples/rp, examples/esp32c6, or examples/stm32f4 — whichever matches your chip — and read end to end. Each example is 100-200 lines, short, with repeated patterns.
11.4 Months 1-2 — First Project
One small meaningful project. Recommended — ePaper clock plus Wi-Fi NTP sync, BME280 environment sensor over BLE or Wi-Fi, or rotary encoder plus OLED menu UI. Projects between 100 and 500 lines yield the best learning efficiency.
11.5 Months 3-6 — Depth
At this stage pick one direction for depth.
- Matter device — using
rs-matter. Actually register with Apple or Google Home. - Own PCB plus own firmware — KiCad for the board, Rust for the firmware.
- Low power — uA-level sleep plus interrupt wake-up plus battery life math.
- DMA plus high-speed peripherals — SPI DMA pushing close to 1 Gbps.
- PIO (Pico) or RMT (ESP32) — custom protocols (WS2812, IR, 1-wire).
Epilogue — Rust Made Embedded More Honest
The one-line summary of embedded Rust — the compiler asks "whose memory is this?" with you. Code where an interrupt and the main loop touch the same buffer fails to compile. An async task that borrowed a peripheral keeps another task from using it concurrently. Static RAM is verified to be initialized in exactly one place.
This sounds small, but if you have ever spent half your firmware-debugging time on "why won't the LED blink" only to discover it was memory corruption, it is decisive.
C/C++ are not going away. Sixty years of code, talent, vendor SDKs, and automotive certification live there. What is changing is that Rust is becoming the default on new chips. And once it becomes the default — the next generation of developers will not go back to C.
Embassy's async was an "interesting experiment" five years ago. In 2026 it is "why would you write it any other way?" RTIC, superloops, and RTOSes are still alive — but the default choice for new projects is Embassy.
probe-rs and defmt have dragged embedded debugging from the 1990s into the 2020s. Once you taste the workflow of one cargo run --release doing build, flash, and logging, you cannot go back to OpenOCD.
The big picture — embedded Rust in 2026 has passed the stage of "is it usable?" and reached "why aren't you using it?" The next five years will be Ferrocene penetrating certification-heavy areas like automotive, medical, and aerospace.
14-Item Checklist
- Are
#![no_std]and#![no_main]enabled? - Is a
panic_handlerdeclared (panic_halt/panic_probe/panic_reset)? - Are debug symbols kept in
Cargo.toml's[profile.release]withdebug = 2? - Does
.cargo/config.toml'srunneruseprobe-rs run --chip ...? - Are you using HAL/BSP first instead of code that touches the PAC directly?
- Are sensor and display drivers used generically against
embedded-haltraits? - When an Embassy task needs
'static, do you handle it withstatic_cell/StaticCell? - Are you avoiding dynamic allocation inside interrupt handlers?
- Are
defmt::info!,warn, anderrorlevels filtered appropriately for production? - If using Wi-Fi or BLE, have you decided between
embassy-net,trouble, oresp-wifi? - For non-volatile storage, are you abstracting via
sequential-storage/embedded-storage? - Where possible, have you preferred RISC-V (C3, C6, H2) over Xtensa (ESP32, S2, S3) to reduce toolchain burden?
- Does CI run
cargo build --releasepluscargo clippy --target ...? - Do you have a separate debug probe (Pico Probe / J-Link / ST-Link)? It is the heart of RTT debugging.
Ten Anti-Patterns
- Driving GPIO with bit-shifts on the PAC only — loses both readability and portability.
- Calling a large synchronous blocking function from inside an Embassy task — starves other tasks.
- Dynamic allocation like
Vec::pushinside an ISR — wrecks real-time guarantees. - Shipping production firmware with
panic_halt— should reset via watchdog instead. - Flashing the
cargo build(debug) firmware as-is — both size and speed suffer. - Spraying
defmt::info!calls without an RTT host attached — the RTT buffer can fill up and stall the firmware. - Cramming every feature into a single Xtensa chip (ESP32) — RISC-V has far less toolchain burden.
- Sharing the same mutable variable between an interrupt and the main loop without a mutex — Rust will block you, but code that bypassed safety with
unsafeis dangerous. - Forgetting to expose debug probe pins on a prototype PCB — one bad flash and USB DFU may not be enough to recover.
- 100 percent Rust purism — if a vendor BLE stack (SoftDevice) or ESP-IDF Wi-Fi is more stable, wrap it and use it.
Next Post Preview
Candidates for the next post: Matter device in Rust — ESP32-C6 plus rs-matter full stack, Embassy plus LoRa (SX126x) for a long-range sensor network, probe-rs deep dive — writing a debug probe of your own.
"Embedded Rust is the act of making the compiler your ally. Whose memory it is, what an interrupt wakes, where a resource is released — the code says it out loud."
— Embedded Rust 2026 deep dive, end.
References
- Embassy — Modern Embedded Rust
- Embassy GitHub — embassy-rs/embassy
- Embassy Book — Async Rust on microcontrollers
- embedded-hal — The Embedded Rust Working Group
- The Embedded Rust Book
- The Rust Embedded WG
- probe-rs — Modern debugging for embedded Rust
- probe-rs GitHub — probe-rs/probe-rs
- defmt — Efficient logging for embedded systems
- defmt GitHub — knurling-rs/defmt
- Knurling tools — Ferrous Systems
- esp-rs — Espressif Rust support
- esp-hal Documentation
- The Rust on ESP Book
- espup — ESP toolchain installer
- esp-wifi — Wi-Fi and BLE for ESP32 in Rust
- rp2040-hal — Raspberry Pi RP2040 HAL
- rp235x-hal — Raspberry Pi RP2350 HAL
- rp-pico BSP
- Raspberry Pi Pico SDK page
- Raspberry Pi Pico 2 — RP2350 product page
- stm32-rs — STM32 PACs
- embassy-stm32 — Embassy STM32 HAL
- nrf-hal — Nordic nRF5x HAL
- embassy-nrf — Embassy nRF HAL
- TrouBLE — Pure Rust BLE host
- nrf-softdevice — SoftDevice wrapper
- RTIC — Real-Time Interrupt-driven Concurrency
- rs-matter — Matter protocol in Rust
- embedded-graphics — UI for small displays
- embedded-storage trait
- sequential-storage — Wear-leveled KV on flash
- smoltcp — TCP/IP for embedded Rust
- Ferrocene — Certified Rust compiler
- Ambiq Apollo HAL
- ch32-hal — WCH CH32V
- atsamd-hal — Microchip SAMD
- The Discovery book — STM32F3
- Awesome Embedded Rust
- Matrix room — Rust Embedded
- Embassy chat — embassy-rs Matrix
- Hackaday Supercon badges
- EMF Camp 2024 badge — tildagon