- Published on
組み込み Rust 2026 深掘り — no_std・Embassy・esp-rs・RP2350・probe-rs で見るマイクロコントローラ上の Rust 実戦記
- Authors

- Name
- Youngju Kim
- @fjvbn20031
プロローグ — 「組み込み 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 の公式第一級 SDK に格上げされた。Xtensa(ESP32, ESP32-S3)は依然 LLVM フォークが必要だが、RISC-V ライン(ESP32-C3, C6, H2, P4)は upstream Rust でビルドできる。probe-rsが OpenOCD を実質的に置き換えた。cargo run一回で flash + RTT ログ + バックトレースが一つのターミナルから流れてくる。defmtロギングは printf より速く、フォーマッタはホスト側で動く — ファームウェアバイナリにフォーマット文字列が乗らない。- 非 async の組み込み Rust も健在。Embassy を使わないのは罪ではない。ただ新規プロジェクトはたいてい Embassy を選ぶというだけ。
要するに 組み込み Rust は 2026 年に production だ。 Apollo3・Arduino R4・バッジ・ホームオートメーション・アートインスタレーション — コードに困らない分野で実際に動いている。本稿はこの風景を頭から尻尾まで、no_std の基礎から Embassy の最初の blinky まで一気に押さえる。
1 章 · 風景 — なぜいま Rust がマイクロコントローラに入るのか
1.1 組み込みの 5 つの圧力
マイクロコントローラのファームウェアを書く人は同時に 5 つの圧力を受ける。
- メモリ — RAM 32KB、Flash 256KB の環境 で標準ライブラリが入らない。
- 決定性 — ISR の中で動的割り当てしてはいけない。 リアルタイム性が壊れる。
- ハードウェア抽象化 — GPIO・UART・SPI・I2C・タイマーをチップ固有のコードなしに扱いたい。
- 並行性 — センサポーリング + UART RX + ログ送信 + ディスプレイ更新を OS なしで同時にやらねばならない。
- デバッグ — JTAG/SWD・RTT・semihosting で印字が難しい。延々と LED 点滅デバッグ。
1.2 C/C++ が 60 年間答えだった理由
この 5 つの圧力に対して C/C++ は実は良い答えを持っていた。static 変数 + 割り込みハンドラ + RTOS あるいはスーパーループ、vendor SDK(ST の HAL、Espressif の ESP-IDF、Nordic の nRF Connect SDK)。組み込み産業全体が 60 年間この上で動いてきた。
問題は二つ。
- メモリ安全性 — null ポインタ、不正なキャスト、バッファオーバーフロー。ファームウェアを 1 行間違えるとデバッグが無限 LED。
- 並行性モデルの貧しさ — RTOS タスク + ミューテックス + キューが標準だが、優先度逆転・デッドロック・優先度継承の落とし穴が尽きない。
1.3 Rust の答え方
Rust はこれら一つ一つの圧力に対して 言語レベルの答え を持っている。
| 圧力 | Rust の答え |
|---|---|
| メモリ | #![no_std] — 標準ライブラリなしでビルド。core・alloc のみ使う |
| 決定性 | 所有権/借用 → 動的割り当てなしの状態機械を自然に表現 |
| ハードウェア抽象化 | embedded-hal trait エコシステム |
| 並行性 | async/await + Embassy ランタイム(または RTIC) |
| デバッグ | defmt + probe-rs で RTT/SWD のセットアップが一行 |
5 年前と決定的に違うのは、これらの答え一つ一つに実働するコードと実チップのサポートがあることだ。本稿の残りはこの 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 とエントリポイント
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 層のクレートで成り立っている。上にいくほど抽象度が高く、下にいくほどチップ固有だ。
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 を出力にして High に
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 ドライバ 1 個が RP2040・STM32・ESP32・nRF52 すべてで動く。
3.4 BSP(Board Support Package)
特定ボード(Raspberry Pi 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 タスク。
#[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 どちらもカバー。 - RP2350 — Cortex-M33 デュアルコア + RISC-V Hazard3 デュアルコアを同時に持つ(ブート時にどちらかを選ぶ)。520KB SRAM、Secure Boot、ARM TrustZone。
rp235x-hal1.0、embassy-rpの rp235x 対応 stable。 - Bluetooth/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 を公式第一級 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。全シリーズをカバー、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 から呼ぶラッパ。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。20 セント台の値段。 - SAMD(Microchip / Atmel) —
atsamd-hal。SAMD21(Adafruit Feather M0)、SAMD51 カバー。 - 汎用 RISC-V —
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} のような型ヒントはシリアライズ効率のため。一般に 1 ログが 4-8 バイトにシリアライズされる。1MHz の UART でも秒 10 万ログをさばける。
| 比較 | 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 章 · Embassy で最初の blinky — RP2040 walkthrough
ここから実際のコード。Raspberry Pi Pico のオンボード 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 に Pico Probe ファームを焼いていれば)、一行で完了。
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 + 2 タスク同時
#![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 割り込みで目を覚ます。1 コア、1 スタック、2 タスク。
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 + ユーザ入力 + バッテリーモニター — 4 つを同時に扱う必要があり、async で表現するのが圧倒的に楽だから。
8.3 インタラクティブアートインスタレーション
LED マトリクス数千個 + センサアレイ + Wi-Fi/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 不揮発メモリ Key/Value ストレージ(sequential-storage)
フラッシュメモリに key/value を永続保存。ファームウェアが再起動しても設定・キャリブレーションデータが残る。embedded-storage trait の上に sequential-storage クレートでウェアレベリングまで。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 | 第一級(ST HAL、ESP-IDF、nRF Connect) | 2-3 年で追いつき中(esp-rs は第一級) |
| 並行性 | 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 |
|---|---|---|
| 参入障壁 | 非常に低い | 中〜高 |
| 実行速度 | インタープリタ(C の 1/100〜1/50) | C 並み |
| RAM 使用 | インタープリタ + GC のオーバーヘッド(〜64KB スタート) | ほぼ 0 オーバーヘッド |
| コンパイル時検証 | ほぼなし | 強い |
| 向く場所 | プロトタイピング、教育、軽いロジック | production、リアルタイム、リソース逼迫 |
MicroPython は決して死なない — 教育・プロトタイピング領域で強すぎる。ただ製品になるならたいてい Rust か C/C++ で書き直す。
9.3 Zig embedded
Zig は組み込みを最初から第一級市民として見ている。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 に渡せる
}
最初の 1 ヶ月はこのパターンが不自然。2 ヶ月目には自然になる。
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 が維持するフォークが必要。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
エコシステムは活発だが全チップをカバーしているわけではない。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::*。最初の 1-2 週はこれら trait の形に慣れる。そのあと、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 を一次的に使っているか?
- センサ/ディスプレイのドライバを
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 負担がはるかに少ない。
- 割り込みとメインループが同じ可変変数をミューテックスなしで共有 — Rust が止めてくれるとはいえ、
unsafeで迂回したコードは危険。 - PCB 試作段階でデバッグプローブピンを引き出さない — flash を一度壊すと USB DFU で復旧できないことがある。
- 100% Rust 原理主義 — ベンダの BLE スタック(SoftDevice)や ESP-IDF Wi-Fi の方が安定しているならそれをラップして使うのが正しい。
次回予告
次回候補: 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