Skip to content

✍️ 필사 모드: 임베디드 Rust 2026 심층 — no_std·Embassy·esp-rs·RP2350·probe-rs로 본 마이크로컨트롤러 위 Rust 실전기

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

프롤로그 — "임베디드 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가지 압력

마이크로컨트롤러 펌웨어를 짜는 사람은 동시에 다섯 가지 압력을 받는다.

  1. 메모리 — RAM 32KB, Flash 256KB 같은 환경에서 표준 라이브러리가 못 들어간다.
  2. 결정성 — ISR 안에서 동적 할당하면 안 된다. 실시간성을 망친다.
  3. 하드웨어 추상화 — GPIO·UART·SPI·I2C·타이머를 칩 종속 코드 없이 추상화하고 싶다.
  4. 동시성 — 센서 폴링 + UART RX + 로그 송신 + 디스플레이 갱신을 동시에 해야 하는데 OS는 없다.
  5. 디버깅 — 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, …)yesyes
Option, Resultyesyes
Iterator traityesyes
Vec, String, HashMapyesno (alloc 별도)
println!yesno
std::fs, std::netyesno
Box, Rc, Arcyesno (alloc 별도)
스레드 / 동기화 프리미티브yesno

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 이전부터 있던 또 다른 임베디드 동시성 프레임워크다. 우선순위 기반 인터럽트 스케줄링을 컴파일타임에 자원 잠금까지 정적 검증한다.

측면EmbassyRTIC
모델async/await + executorpriority-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-hal 1.0, embassy-rp rp235x 지원 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/BLEesp-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 / 4ambiq-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 generalriscv-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보다 빠른 로깅

defmtprintln! 같은 매크로지만 핵심 트릭이 있다 — 포맷 문자열을 펌웨어 바이너리에 넣지 않고, 인덱스만 전송한다. 호스트(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 printfdefmt over RTT
로그 1줄 비용수십 µs (블로킹)1-2 µs100-500 ns
펌웨어 크기포맷 문자열 다 들어감들어감인덱스만
컴파일타임 검증nonoyes

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,
        }
    }
}

핵심 — blinkecho둘 다 살아 있다. 한쪽이 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 지원 칩만(거의 다)
메모리 안전본인이 보장컴파일러가 검증
벤더 SDK1순위 (ST HAL, ESP-IDF, nRF Connect)2-3년 따라잡는 중 (esp-rs는 1순위)
동시성RTOS + 인터럽트Embassy/RTIC
디버깅OpenOCD + GDBprobe-rs + defmt
인력 풀거대빠르게 자라는 중
자동차/항공 인증MISRA C 표준Ferrocene (Ferrous Systems의 Rust 인증 컴파일러)

요약 — 레거시 칩, 인력 풀, 인증된 컴파일러가 결정적이면 C/C++. 새 칩, 새 팀, 메모리 안전과 동시성 모델이 가치라면 Rust.

9.2 MicroPython — 진입장벽과 RAM

MicroPython은 ESP32, RP2040, STM32에서 인터프리터가 돈다. 진입장벽이 압도적으로 낮다 — 5분에 첫 LED 깜빡임.

측면MicroPythonRust
진입장벽매우 낮음중-높음
실행 속도인터프리터 (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 (~7)+RaspberryPiDebugProbe( 7)** + **Raspberry Pi Debug Probe (~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-rsdefmt는 임베디드 디버깅을 1990년대에서 2020년대로 끌고 왔다. cargo run --release 한 줄로 빌드·플래시·로깅이 끝나는 워크플로는 한번 맛보면 OpenOCD로 못 돌아간다.

큰 그림 — 임베디드 Rust는 2026년에 "써도 되는가"의 단계를 지나, "왜 안 써?"의 단계에 있다. 다음 5년은 자동차·의료·항공 같은 인증 영역으로 Ferrocene이 침투하는 시간이 될 것이다.

14개 항목 체크리스트

  1. #![no_std] + #![no_main]이 켜져 있는가?
  2. panic_handler가 명시되어 있는가 (panic_halt/panic_probe/panic_reset)?
  3. Cargo.toml[profile.release]debug = 2로 디버그 심볼이 켜져 있는가?
  4. .cargo/config.tomlrunnerprobe-rs run --chip ...이 설정됐는가?
  5. PAC만 직접 만지는 코드 대신 HAL/BSP를 1차로 쓰고 있는가?
  6. 센서/디스플레이 드라이버를 embedded-hal trait에 대해 제네릭하게 쓰고 있는가?
  7. Embassy 태스크가 'static을 요구할 때 static_cell/StaticCell로 풀고 있는가?
  8. 인터럽트 핸들러 안에서 동적 할당을 안 하는가?
  9. defmt::info!info/warn/error 레벨이 production 빌드에서 적절히 필터되는가?
  10. Wi-Fi/BLE를 쓴다면 embassy-net/trouble/esp-wifi 중 어떤 길로 갈지 결정했는가?
  11. 비휘발 저장이 필요하면 sequential-storage/embedded-storage로 추상화했는가?
  12. Xtensa(ESP32, S2, S3) 대신 가능하면 RISC-V(C3, C6, H2)를 골라 toolchain 부담을 줄였는가?
  13. CI에서 cargo build --release + cargo clippy --target ...이 돌고 있는가?
  14. 디버그 프로브가 따로 있는가 (Pico Probe / J-Link / ST-Link) — RTT 디버깅의 핵심이다.

안티패턴 10가지

  1. PAC만 만지면서 비트 시프트로 GPIO를 다루기 — 가독성·이식성 둘 다 손해.
  2. Embassy 태스크 안에서 큰 동기 블로킹 함수를 호출하기 — 다른 태스크가 굶음.
  3. ISR 안에서 Vec::push 같은 동적 할당 — 실시간성 파괴.
  4. panic_halt로 production 펌웨어를 두기 — 워치독으로 리셋되게 해야 함.
  5. cargo build (디버그) 펌웨어를 그대로 Flash — 크기·속도 둘 다 망함.
  6. RTT 호스트 없이 defmt::info!만 잔뜩 박기 — 호스트 안 붙으면 펌웨어가 RTT 버퍼 차서 멈출 수 있음.
  7. Xtensa(ESP32) 한 칩에 모든 기능을 집어넣기 — RISC-V로 가는 게 toolchain 부담이 훨씬 적음.
  8. 인터럽트와 메인루프가 같은 가변 변수를 mutex 없이 공유 — Rust가 막아주긴 하지만 unsafe로 우회한 코드는 위험.
  9. PCB 시제품 단계에서 디버그 프로브 핀을 안 빼놓기 — flash 한 번 망치면 USB DFU로 복구 불가.
  10. 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

현재 단락 (1/483)

5년 전이라면 이 글의 첫 문장은 "임베디드 Rust는 아직 실험이지만"으로 시작했을 거다. 2021년의 Embassy는 alpha였고, RP2040의 HAL은 아직 정착하지 않았...

작성 글자: 0원문 글자: 24,724작성 단락: 0/483