Embassy・embedded-hal・probe-rsで実践するRust no_std組み込み開発2026
Rustのno_std環境を使った組み込み開発は、C/C++に代わるメモリ安全な選択肢として注目を集めています。2026年現在、Embassy(非同期ランタイム)、embedded-hal(ハードウェア抽象化)、probe-rs(デバッグツール)の3本柱が成熟し、実用的な開発が可能になりました。
本記事では、MLエンジニアがエッジAI推論デバイスやセンサーデバイスの開発に取り組む際に必要な、Rust no_std組み込み開発の実践知識を体系的に解説します。
この記事でわかること
- Rustの
no_std環境とは何か、std環境との違いと使い分け - Embassy async/awaitによるヒープ不要の非同期タスク管理の実装方法
- embedded-halを使ったポータブルなドライバ設計パターン
- probe-rs + defmtによる効率的なデバッグ・ログ出力の構築方法
- heaplessクレートで実現するヒープ不要のデータ構造活用
対象読者
- 想定読者: Rustの基礎文法を理解しているMLエンジニア
-
必要な前提知識:
- Rustの所有権・ライフタイム・トレイトの基本理解
- Pythonでの組み込み/IoT開発経験があると望ましい(MicroPython等)
- マイコン(MCU)の概念(GPIO、UART、SPI、I2C)の基礎知識
MLエンジニア向けの補足: 組み込み開発はPythonの
numpyやtorchの世界とは大きく異なります。OSが存在しない「ベアメタル」環境では、メモリアロケータもファイルシステムもありません。Pythonでいうimportに相当する標準ライブラリの大半が使えない世界です。本記事では、こうした違いを意識しながら解説を進めます。
結論・成果
Rust no_std環境での組み込み開発により、以下の効果が報告されています。
- メモリ使用量: C実装と比較して同等〜30%削減(所有権システムによる不要なバッファ排除)
- バグ検出: コンパイル時にメモリ安全性エラーの大半を検出(nullポインタ参照、データ競合、use-after-freeを言語仕様で排除)
-
開発効率:
cargoによるビルド・依存管理の一元化で、C/C++のMakefile + CMake環境と比較してビルド設定の工数を削減 - デバッグ効率: defmtによるログ出力はフラッシュ消費を従来のフォーマット文字列方式と比較して大幅に削減(フォーマット文字列をホスト側で保持する仕組み)
no_std環境の基礎を理解する
stdとno_stdの違い
Rustの標準ライブラリは3層構造になっています。no_std環境では最下層のcoreクレートのみが利用可能です。
| レイヤ | 内容 | no_stdでの利用 |
|---|---|---|
std |
ファイルI/O、ネットワーク、スレッド、ヒープ割当 | 利用不可 |
alloc |
Vec、String、Box(ヒープ割当が必要) |
アロケータ設定で利用可(非推奨) |
core |
プリミティブ型、Option、Result、イテレータ、トレイト |
利用可 |
MLエンジニア向けの補足: Pythonでいえば、
stdはnumpyやosモジュール込みのフル環境、coreはbuiltinsのみの環境に相当します。no_stdではヒープメモリ(Pythonのlistやdictが内部で使う動的メモリ)が原則使えません。
no_std環境の最小構成は以下のようになります。
// main.rs - no_std最小構成
#![no_std]
#![no_main]
use panic_halt as _; // パニック時にCPUを停止
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
// 組み込みでは main は永久ループ(戻り値なし = ! 型)
loop {
cortex_m::asm::wfi(); // Wait For Interrupt: 割込みまでCPUをスリープ
}
}
#![no_std]は「標準ライブラリを使わない」宣言です。#![no_main]は「OSのエントリポイント(main関数の呼び出し規約)を使わない」宣言で、代わりに#[entry]マクロでエントリポイントを指定します。戻り値の!型(never型)は、この関数が決してリターンしないことを型レベルで保証しています。
なぜno_stdが必要なのか
組み込みマイコンには以下の制約があります。
- RAM: 数KB〜数百KB(STM32F4で最大192KB、ESP32-C3で400KB)
- フラッシュ: 数百KB〜数MB
- OS: 存在しない(ベアメタル)
- MMU: 多くのマイコンには仮想メモリ機構がない
stdライブラリはOSのシステムコール(ファイル操作、スレッド生成、ネットワーク通信)に依存しているため、OSのないベアメタル環境では使用できません。no_stdはこの制約に対応するRustの仕組みです。
よくある間違い: 「no_stdではRustの強みである安全性が犠牲になる」と誤解されがちですが、これは正しくありません。所有権システム、ライフタイム、パターンマッチングなどRustの安全性を支える機能はすべてcoreクレートに含まれており、no_stdでもフルに活用できます。犠牲になるのはヒープアロケーションと、それに依存するコレクション型(Vec、String等)だけです。
開発環境のセットアップ
Cortex-Mマイコン(STM32等)向けの開発環境を構築してみましょう。
# Rustツールチェインのインストール(未インストールの場合)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Cortex-M4F(FPU付き)ターゲットの追加
# Pythonでいう仮想環境のターゲット指定に相当
rustup target add thumbv7em-none-eabihf
# デバッグツール probe-rs のインストール
cargo install probe-rs-tools
# プロジェクトテンプレートの生成
cargo install cargo-generate
cargo generate --git https://github.com/rust-embedded/cortex-m-quickstart
ターゲット名の命名規則は以下のとおりです。
| ターゲット | 対応アーキテクチャ | 代表的なマイコン |
|---|---|---|
thumbv6m-none-eabi |
Cortex-M0 / M0+ | STM32F0, RP2040 |
thumbv7m-none-eabi |
Cortex-M3 | STM32F1, STM32F2 |
thumbv7em-none-eabi |
Cortex-M4 / M7(FPUなし) | STM32F3(一部) |
thumbv7em-none-eabihf |
Cortex-M4F / M7F(FPU付き) | STM32F4, STM32H7 |
riscv32imc-unknown-none-elf |
RISC-V | ESP32-C3 |
注意:
eabiとeabihfの違いはFPU(浮動小数点演算ユニット)のハードウェアサポートの有無です。eabihfを指定するとハードウェアFPUを使った浮動小数点演算が可能になり、ML推論のような数値計算では性能差が顕著になります。ターゲットマイコンのデータシートでFPUの有無を確認してください。
Embassyで非同期組み込み開発を実装する
Embassyとは
Embassyは、Rust のasync/awaitを組み込み環境で実現するフレームワークです。従来の組み込み開発ではRTOS(リアルタイムOS)を使ってマルチタスクを実現していましたが、Embassyはコンパイル時にタスクをステートマシンに変換するasync/awaitの仕組みを活用し、ヒープ割り当てなし・スタック共有で効率的なマルチタスクを実現します。
MLエンジニア向けの補足: Pythonの
asyncioと概念は似ていますが、決定的な違いがあります。Pythonのasyncioはヒープ上にコルーチンオブジェクトを生成しますが、Embassyのタスクはコンパイル時にサイズが確定し、静的メモリ上に配置されます。ランタイムオーバーヘッドがほぼゼロです。
Embassyプロジェクトのセットアップ
Embassy を使う STM32F4 プロジェクトのCargo.tomlは以下のようになります。
# Cargo.toml
[package]
name = "embassy-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.7", features = ["arch-cortex-m", "executor-thread"] }
embassy-time = { version = "0.4", features = ["tick-hz-32_768"] }
embassy-stm32 = { version = "0.3", features = ["stm32f411ce", "time-driver-any", "memory-x"] }
cortex-m = { version = "0.7" }
cortex-m-rt = "0.7"
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
非同期タスクの実装
LEDの点滅とセンサー読み取りを並行実行する例を見てみましょう。
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::Timer;
use defmt::info;
use {defmt_rtt as _, panic_probe as _};
// LED点滅タスク: 500msごとに点滅を繰り返す
#[embassy_executor::task]
async fn blink_task(mut led: Output<'static>) {
loop {
led.set_high();
info!("LED ON");
Timer::after_millis(500).await;
led.set_low();
info!("LED OFF");
Timer::after_millis(500).await;
}
}
// センサー読み取りタスク: 1秒ごとに温度データを取得
#[embassy_executor::task]
async fn sensor_task(mut adc: embassy_stm32::adc::Adc<'static, embassy_stm32::peripherals::ADC1>) {
let mut pin = unsafe { embassy_stm32::peripherals::PA0::steal() };
loop {
let raw_value = adc.blocking_read(&mut pin);
// 12bit ADC: 0-4095 → 温度換算(例: LM35の場合 10mV/℃)
let voltage_mv = (raw_value as f32) * 3300.0 / 4095.0;
let temperature = voltage_mv / 10.0;
info!("Temperature: {} C (raw: {})", temperature, raw_value);
Timer::after_secs(1).await;
}
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
let led = Output::new(p.PC13, Level::Low, Speed::Low);
// ADCの初期化
let adc = embassy_stm32::adc::Adc::new(p.ADC1);
// タスクを並行起動
spawner.spawn(blink_task(led)).unwrap();
spawner.spawn(sensor_task(adc)).unwrap();
}
なぜEmbassyを選んだか:
- ヒープ不要: タスクのメモリはコンパイル時に静的に確保される。RTOSのようにスタックサイズを手動で見積もる必要がない
- 型安全: ペリフェラル(GPIO、ADC等)の所有権がRustの型システムで管理される。同じピンを2つのタスクから同時に操作しようとするとコンパイルエラーになる
-
効率性:
await時にCPUはスリープ状態になり、割込みで復帰する。ビジーウェイトと比べて消費電力を削減できる
注意点:
Embassyのタスクは
'staticライフタイムを要求します。これはタスクがプログラム全体の寿命を持つことを意味し、ローカル変数の参照をタスクに渡すことはできません。ペリフェラルはmain関数で初期化し、タスクに所有権ごと移動(move)させる設計が基本です。
EmbassyとRTICの使い分け
Rust組み込みにはEmbassy以外にRTIC(Real-Time Interrupt-driven Concurrency)というフレームワークもあります。用途に応じた使い分けが重要です。
| 観点 | Embassy | RTIC |
|---|---|---|
| プログラミングモデル | async/await(協調的) | 割込み駆動(プリエンプティブ) |
| リアルタイム性 | ソフトリアルタイム | ハードリアルタイム |
| メモリ管理 | 静的タスク割当 | 静的リソース管理 |
| 学習コスト | async/awaitに慣れていれば低い | 割込み優先度の理解が必要 |
| 適用場面 | IoTセンサー、通信デバイス | モーター制御、産業制御 |
| デッドロック | 設計次第で発生しうる | 静的解析でデッドロックフリーを保証 |
トレードオフ: Embassyは記述の容易さと生産性に優れますが、ハードリアルタイム制約(マイクロ秒単位の応答保証)が必要な場合はRTICが適しています。RTICは静的優先度解析により、デッドロックが発生しないことをコンパイル時に保証します。
embedded-halでポータブルなドライバを設計する
embedded-halの役割
embedded-halは、マイコンのペリフェラル(GPIO、SPI、I2C、UART等)に対する共通のトレイト(インターフェース)を定義するクレートです。
MLエンジニア向けの補足: Pythonのプロトコル(
__iter__、__len__等)やABC(Abstract Base Class)に相当します。「SPI通信ができるデバイスならこのメソッドを持つ」という契約を定義し、ドライバがハードウェアの具体的な実装に依存しないようにします。
この設計により、ドライバを一度書けば STM32、ESP32、nRF52 など異なるマイコンで再利用できます。
I2Cセンサードライバの実装例
BME280(温湿度・気圧センサー)のドライバをembedded-halのトレイトを使って実装してみましょう。
// bme280.rs - embedded-halトレイトに依存したポータブルなドライバ
use embedded_hal::i2c::I2c;
const BME280_ADDR: u8 = 0x76;
const REG_CHIP_ID: u8 = 0xD0;
const REG_CTRL_MEAS: u8 = 0xF4;
const REG_PRESS_MSB: u8 = 0xF7;
pub struct Bme280<I2C> {
i2c: I2C,
address: u8,
}
#[derive(Debug)]
pub struct SensorData {
pub temperature_c: f32,
pub pressure_hpa: f32,
pub humidity_pct: f32,
}
#[derive(Debug)]
pub enum Bme280Error<E> {
I2cError(E),
InvalidChipId(u8),
}
impl<E> From<E> for Bme280Error<E> {
fn from(e: E) -> Self {
Bme280Error::I2cError(e)
}
}
impl<I2C: I2c> Bme280<I2C> {
pub fn new(i2c: I2C) -> Self {
Self {
i2c,
address: BME280_ADDR,
}
}
pub fn init(&mut self) -> Result<(), Bme280Error<I2C::Error>> {
// チップIDの確認(0x60 が正しい値)
let mut buf = [0u8; 1];
self.i2c.write_read(self.address, &[REG_CHIP_ID], &mut buf)?;
if buf[0] != 0x60 {
return Err(Bme280Error::InvalidChipId(buf[0]));
}
// 測定モードの設定: 温度x1 / 気圧x1 / 通常モード
self.i2c.write(self.address, &[REG_CTRL_MEAS, 0b00100111])?;
Ok(())
}
pub fn read_sensor(&mut self) -> Result<SensorData, Bme280Error<I2C::Error>> {
let mut buf = [0u8; 8];
self.i2c.write_read(self.address, &[REG_PRESS_MSB], &mut buf)?;
// 生データから物理値への変換(補正係数の適用は簡略化)
let press_raw = ((buf[0] as u32) << 12) | ((buf[1] as u32) << 4) | ((buf[2] as u32) >> 4);
let temp_raw = ((buf[3] as u32) << 12) | ((buf[4] as u32) << 4) | ((buf[5] as u32) >> 4);
let hum_raw = ((buf[6] as u16) << 8) | (buf[7] as u16);
Ok(SensorData {
temperature_c: (temp_raw as f32) / 16384.0 - 25.0,
pressure_hpa: (press_raw as f32) / 256.0,
humidity_pct: (hum_raw as f32) / 1024.0 * 100.0,
})
}
}
なぜこの設計か:
-
ジェネリクスによる抽象化:
I2C: I2cというトレイト境界により、STM32のI2Cでも、ESP32のI2Cでも、テスト用のモックI2Cでも動作する - ゼロコスト抽象化: Rustのジェネリクスはモノモーフィゼーション(使用される具体型ごとに特殊化されたコードを生成)されるため、仮想関数テーブル(vtable)のオーバーヘッドがない
-
エラー型の伝搬:
Bme280Error<E>でI2Cエラーをラップし、ドライバ固有のエラー(InvalidChipId)と区別できる
マイコン固有HALとの接続
上記のドライバをSTM32F4で使用する場合の接続コードです。
use embassy_stm32::i2c::I2c;
use embassy_stm32::time::Hertz;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
// I2Cペリフェラルの初期化(SCL=PB6, SDA=PB7, 100kHz)
let i2c = I2c::new_blocking(
p.I2C1,
p.PB6, // SCL
p.PB7, // SDA
Hertz(100_000),
Default::default(),
);
// embedded-halトレイトを実装したI2Cインスタンスをドライバに渡す
let mut bme280 = Bme280::new(i2c);
bme280.init().unwrap();
loop {
match bme280.read_sensor() {
Ok(data) => {
defmt::info!(
"Temp: {} C, Press: {} hPa, Hum: {} %",
data.temperature_c,
data.pressure_hpa,
data.humidity_pct
);
}
Err(e) => {
defmt::error!("Sensor read failed: {:?}", e);
}
}
Timer::after_secs(2).await;
}
}
ハマりポイント: I2Cのプルアップ抵抗を忘れると通信が不安定になります。多くの開発ボードにはプルアップ抵抗が搭載されていますが、ブレッドボード配線では4.7kΩの外部プルアップ抵抗が必要です。通信エラーが頻発する場合、まずハードウェア配線を確認してください。
probe-rsとdefmtで効率的にデバッグする
defmtによる軽量ログ出力
組み込み環境ではprintln!が使えません。代わりにdefmt(deferred formatting)を使います。defmtはフォーマット文字列をホストPC側に保持し、デバイスからは文字列のインデックスと変数データのみを送信する仕組みです。
従来のsprintfベースのログでは、フォーマット文字列("Temperature: %d C"等)がすべてフラッシュメモリに格納されます。defmtではフォーマット文字列はELFバイナリのメタデータとしてホストPC側に保持されるため、デバイスのフラッシュ消費を抑えられます。
defmtの使い方
use defmt::{info, warn, error, debug, trace};
use defmt_rtt as _; // RTT(Real-Time Transfer)経由での出力を有効化
// 基本的なログ出力
info!("System initialized");
info!("ADC value: {}", adc_value);
// 構造体のフォーマット(defmt::Format トレイトを derive)
#[derive(defmt::Format)]
struct SensorReading {
channel: u8,
value: u16,
timestamp_ms: u32,
}
let reading = SensorReading {
channel: 0,
value: 2048,
timestamp_ms: 1500,
};
info!("Sensor: {:?}", reading);
// ログレベルによるフィルタリング
// DEFMT_LOG=info でビルドすると debug/trace は除外される
debug!("Detailed state: buffer_len={}", buf.len());
trace!("Raw bytes: {:x}", &raw_data[..]);
// タイムスタンプの自動付与
// defmt-rtt と embassy-time を連携させると自動でタイムスタンプが付く
probe-rsによるフラッシュ書き込みとログ確認
# ビルド + フラッシュ書き込み + RTTログ表示を1コマンドで実行
cargo run --release
# .cargo/config.toml に以下を設定しておく
# [target.thumbv7em-none-eabihf]
# runner = "probe-rs run --chip STM32F411CEUx"
.cargo/config.tomlの設定例です。
# .cargo/config.toml
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411CEUx"
rustflags = [
"-C", "link-arg=-Tlink.x", # リンカスクリプト
"-C", "link-arg=-Tdefmt.x", # defmtのシンボル情報
]
[build]
target = "thumbv7em-none-eabihf"
[env]
DEFMT_LOG = "info" # ログレベルの設定
なぜprobe-rs + defmtを選んだか:
- 統合ツールチェイン: フラッシュ書き込み・RTTログ・デバッグを1つのツールで完結。OpenOCD + GDB + カスタムログ実装という従来のC開発と比較して、セットアップが大幅に簡素化される
-
型安全なログ: defmtのフォーマット文字列はコンパイル時に型チェックされる。Cの
printfでの%dと%sの取り違えのような実行時エラーが発生しない - VS Code統合: probe-rs VS Code拡張により、ブレークポイント、変数ウォッチ、ステップ実行がGUI上で可能
制約: probe-rsが対応するデバッグプローブはST-Link、J-Link、CMSIS-DAPです。中華製の安価なST-Link互換品でも動作しますが、ファームウェアが古い場合は更新が必要な場合があります。
heaplessクレートでヒープ不要のデータ構造を活用する
なぜheaplessが必要か
no_std環境ではヒープアロケータがデフォルトで存在しないため、std::vec::Vecやstd::string::Stringが使えません。heaplessクレートは、コンパイル時に容量が確定する固定サイズのデータ構造を提供します。
| std型 | heapless型 | 違い |
|---|---|---|
Vec<T> |
heapless::Vec<T, N> |
最大N要素の固定容量。push時にfullならErrを返す |
String |
heapless::String<N> |
最大Nバイトの固定容量文字列 |
VecDeque<T> |
heapless::Deque<T, N> |
固定容量の両端キュー |
HashMap<K, V> |
heapless::LinearMap<K, V, N> |
線形探索のマップ(小規模向け) |
BinaryHeap<T> |
heapless::BinaryHeap<T, _, N> |
固定容量の優先度キュー |
MLエンジニア向けの補足: NumPyの固定サイズ配列(
np.zeros(100))に近い概念です。Pythonのlistは動的にサイズが変わりますが、heaplessのVecは最大容量が型パラメータで決まり、それを超えるとエラーが返ります。メモリの動的確保が不要なため、処理時間が予測可能(ハードリアルタイム向き)です。
実装例: センサーデータのリングバッファ
use heapless::Vec;
use heapless::String;
use heapless::spsc::Queue;
use core::fmt::Write;
// 固定容量のセンサーデータバッファ
struct SensorBuffer {
// 最大32個のf32値を格納
readings: Vec<f32, 32>,
// 最大128バイトのログメッセージ
log_message: String<128>,
}
impl SensorBuffer {
fn new() -> Self {
Self {
readings: Vec::new(),
log_message: String::new(),
}
}
fn add_reading(&mut self, value: f32) -> Result<(), f32> {
// 容量超過時はErrで通知(パニックしない)
self.readings.push(value)
}
fn average(&self) -> Option<f32> {
if self.readings.is_empty() {
return None;
}
let sum: f32 = self.readings.iter().sum();
Some(sum / self.readings.len() as f32)
}
fn format_status(&mut self) -> &str {
self.log_message.clear();
// core::fmt::Write トレイトで文字列フォーマット
write!(
self.log_message,
"readings={}, avg={:.1}",
self.readings.len(),
self.average().unwrap_or(0.0)
)
.ok();
self.log_message.as_str()
}
}
// タスク間通信: SPSC(Single Producer Single Consumer)キュー
// 割込みハンドラ → メインタスクのデータ受け渡しに使用
static SENSOR_QUEUE: Queue<f32, 16> = Queue::new();
// 割込みハンドラ側(プロデューサー)
fn adc_interrupt_handler(value: f32) {
let mut producer = unsafe { SENSOR_QUEUE.split().0 };
let _ = producer.enqueue(value); // fullなら破棄
}
// メインタスク側(コンシューマー)
async fn process_sensor_data() {
let mut consumer = unsafe { SENSOR_QUEUE.split().1 };
let mut buffer = SensorBuffer::new();
loop {
while let Some(value) = consumer.dequeue() {
let _ = buffer.add_reading(value);
}
if buffer.readings.len() >= 32 {
defmt::info!("{}", buffer.format_status());
buffer.readings.clear();
}
Timer::after_millis(100).await;
}
}
注意点:
heapless::Vecのpushはstd::vec::Vecと異なり、容量超過時にパニックではなくErrを返します。これは組み込み環境では望ましい動作です。パニックが発生するとシステム全体が停止するため、エラーハンドリングで回復可能にしておくことが重要です。
SPSCキューは単一プロデューサー・単一コンシューマー専用です。複数の割込みハンドラからデータを送る場合は
heapless::mpmc::MpMcQueueを使用してください。ただし、MPMCキューはCAS(Compare-And-Swap)操作を使うため、アーキテクチャがアトミック操作をサポートしている必要があります(Cortex-M3以上)。
実践的なプロジェクト構成とビルド設定を整備する
プロジェクトのディレクトリ構成
実用的なno_stdプロジェクトの推奨構成です。
my-embedded-project/
├── .cargo/
│ └── config.toml # ターゲット・ランナー設定
├── src/
│ ├── main.rs # エントリポイント・タスク定義
│ ├── drivers/
│ │ ├── mod.rs
│ │ ├── bme280.rs # センサードライバ
│ │ └── display.rs # ディスプレイドライバ
│ └── tasks/
│ ├── mod.rs
│ ├── sensor.rs # センサー読取タスク
│ └── communication.rs # 通信タスク
├── memory.x # メモリレイアウト定義
├── Cargo.toml
├── build.rs # ビルドスクリプト
└── Embed.toml # probe-rs設定(任意)
メモリレイアウトの定義
マイコンのフラッシュとRAMのアドレス・サイズをmemory.xで定義します。
/* memory.x - STM32F411CE のメモリレイアウト */
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}
ハマりポイント:
memory.xのアドレスとサイズはマイコンのデータシートと正確に一致させる必要があります。RAMサイズを実際より大きく設定すると、実行時にスタックオーバーフローが発生し、原因の特定が困難になります。STM32の場合、STM32CubeMXでメモリマップを確認できます。
ビルドプロファイルの最適化
組み込みではバイナリサイズの最適化が重要です。
# Cargo.toml のプロファイル設定
[profile.release]
opt-level = "s" # サイズ最適化("z"はさらに小さいが遅い場合がある)
lto = true # Link-Time Optimization: 不要コードの除去
codegen-units = 1 # 単一コンパイル単位: LTOの効果を最大化
debug = 2 # リリースビルドでもデバッグ情報を保持(defmtに必要)
overflow-checks = false # 整数オーバーフローチェックを無効化(サイズ削減)
[profile.dev]
opt-level = 1 # デバッグビルドでも最低限の最適化(サイズ対策)
| opt-level | バイナリサイズ(目安) | 実行速度 | 用途 |
|---|---|---|---|
"0" |
最大 | 最遅(最適化なし) | デバッグ専用 |
"1" |
大 | 中程度 | dev(デバッグ+最低限の最適化) |
"2" |
中 | 速い | 速度重視のリリース |
"s" |
小 | 中〜速い | サイズ重視のリリース(推奨) |
"z" |
最小 | 遅い場合がある | フラッシュが極めて限られる場合 |
トレードオフ: opt-level = "z"は最小バイナリサイズを実現しますが、ループの展開やインライン化が抑制されるため、特にDSP処理やMLの推論ループでは"s"より遅くなることがあります。ベンチマークで確認してから採用してください。
よくある問題と解決方法
組み込みRust開発で遭遇しやすいトラブルとその対処法をまとめます。
| 問題 | 原因 | 解決方法 |
|---|---|---|
can't find crate for 'std' |
no_std環境で依存クレートがstdを使っている | 依存クレートのdefault-features = falseを確認。features = ["no_std"]等のフラグを有効化 |
| フラッシュ書き込み失敗 | デバッグプローブの接続不良・チップ名の誤り |
probe-rs infoで接続確認。--chipオプションの型番を正確に指定 |
| バイナリが大きすぎてフラッシュに収まらない | 最適化不足・不要なクレートの依存 |
opt-level = "s" + lto = trueを設定。cargo bloatでサイズ分析 |
| スタックオーバーフロー | 再帰やスタック上の大きな配列 |
flip-linkを導入(スタックオーバーフローを検出可能にする)。大きなバッファはstaticに置く |
| 割込みハンドラ内でのパニック | ロック取得の失敗・アサーション | 割込みハンドラではtry_lockを使い、失敗時はスキップ。panic_probeでパニック位置を特定 |
| I2C/SPI通信が不安定 | プルアップ抵抗の欠如・クロック速度の不一致 | ハードウェア配線確認。クロック速度を下げてテスト(100kHz → 10kHz) |
cargo-bloatによるバイナリサイズ解析
バイナリサイズが問題になった場合、cargo-bloatで関数ごとのサイズを確認できます。
# インストール
cargo install cargo-bloat
# 関数ごとのサイズ上位20件を表示
cargo bloat --release -n 20
# クレートごとのサイズを表示
cargo bloat --release --crates
フォーマット関連(core::fmt)が大きなサイズを占める場合があります。defmtを使うことでcore::fmtへの依存を排除し、バイナリサイズを削減できます。
まとめと次のステップ
まとめ:
-
no_std環境はOSのないマイコン向けのRust開発モード。
coreクレートの範囲でRustの安全性機能をフル活用できる -
Embassyは
async/awaitベースの非同期ランタイム。ヒープ不要・静的タスク割当でRTOS代替として実用的 - embedded-halはハードウェア抽象化の標準トレイト。ドライバをマイコン非依存で設計でき、エコシステムの再利用性を高める
- probe-rs + defmtはデバッグの統合ツールチェイン。フラッシュ書き込み・ログ・ステップ実行を1つのツールで完結
- heaplessクレートはヒープ不要の固定容量データ構造。リアルタイム制約のある環境で予測可能なメモリ使用を実現
次にやるべきこと:
-
The Embedded Rust Bookを一読し、
cortex-m-quickstartテンプレートでLED点滅を動かす - Embassy Bookのチュートリアルで非同期タスクの基本を習得する
- 実際のセンサー(BME280等)をI2C接続し、embedded-halベースのドライバを自作する
制約と留意点:
- Rustの組み込みエコシステムはC/C++と比較してまだ発展途上であり、特定のマイコン向けHALクレートの完成度にはばらつきがあります
- コンパイル時間がC/C++より長くなる傾向があり、
cargo build --releaseで数分かかる場合があります - 商用RTOSが必要な認証(DO-178C等)に対応するRustツールチェインの認定は進行中ですが、2026年時点では限定的です
参考
- The Embedded Rust Book - 公式組み込みRust入門ドキュメント
- Embassy公式サイト - 非同期組み込みフレームワーク
- Embassy GitHub リポジトリ - ソースコードとサンプル
- embedded-hal GitHub リポジトリ - ハードウェア抽象化レイヤ
- probe-rs公式サイト - デバッグツールキット
- heapless GitHub リポジトリ - ヒープ不要データ構造
- RTIC公式サイト - リアルタイム割込み駆動フレームワーク
- esp-rs - The Rust on ESP Book - ESP32 Rust開発
- Rust for Embedded Systems: Current State and Open Problems - 学術論文
- Intro to Embedded Rust: defmt and Step-through Debugging (DigiKey, 2026) - defmtチュートリアル
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。