STM32F4 + Embassy(Rust) + Docker で組み込みRust開発環境

@demiplus.bsky.social

概要

STM32F4マイコンでEmbassy(Rust非同期組み込みフレームワーク)を使った開発環境をDocker上に構築してみました。環境構築からLED点滅までの手順を紹介します。

動作確認環境はUbuntu 24.04です。

動作中のSTM32F411 Black Pill


1. Embassyについて

Rustで組み込みプログラミングができると知り、コンパイル時に潜在的なバグを徹底的につぶして安全なコードを書く、という考え方をハードウェアを操作するシステムに応用するのは面白そうだ、と思ったのが出発点です。

MCUによるハードウェア制御の実行環境としては FreeRTOS(C/C++)という有名なOSがあります。 一方、Embassy は FreeRTOS のような preemptive スケジューラではなく、タスクが .await ポイントで制御を手放す協調スケジューリングを採用しています。 「タスクが自発的に yield しないと他のタスクが動かない」という制約があります。

例えば、移動ロボットの制御ループを想定すると、センサー読み取り・モーター制御・コマンド待機はいずれも Timer::after_millis() などで定期的に .await する構造になります。 タスクが preemptive でない、ということが移動ロボットの制御ソフトウェアでは問題となるのではないかという先入観がありましたが、各タスクが適切に yield する設計であれば、Embassyでも問題なく制御ループが動作するだろうと判断しました。

補足

Embassy は割り込みベースの複数優先度エグゼキュータもサポートしており、異なる優先度レベル間では .await ポイントでの preemption が可能です。今回は単一優先度の構成で使用しています。


2. 使用した環境

2.1 ハードウェア

  • ターゲットボード: STM32F411CEU6 (Black Pill)

    • スペック概要(100MHz Cortex-M4F, 512KB Flash, 128KB RAM)
  • デバッガ: Raspberry Pi Pico(picoprobeファームウェア) ※ CMSIS-DAP対応プローブ

    • USB ID: 2e8a:000c
    • ST-Link V2クローンも試したが、後述の問題があるためpicoprobeを推奨

2.2 ソフトウェア構成

コンポーネントバージョン用途
Ubuntu24.04ホストOS
Rust1.92プログラミング言語
embassy-executor0.9非同期タスクエグゼキュータ
embassy-time0.5時間管理
embassy-stm320.5STM32用HAL
probe-rs-tools0.30フラッシュ/デバッグツール
Docker24+開発環境コンテナ
defmt1.0組み込みログ

3. ホスト環境の準備

3.1 今回のホスト側環境

  • Docker / Docker Compose がインストール済み
  • Ubuntu 24.04

3.2 udevルールの設定

デバッガをroot権限なしで使うために、udevルールを設定します。 picoprobeとST-Link V2の両方を登録しておきます。 参照: Probe Setup | probe-rs

[/etc/udev/rules.d/99-stlink.rules]

# ST-Link V2
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3748", MODE="0666", GROUP="plugdev"
# ST-Link V2-1
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374b", MODE="0666", GROUP="plugdev"
# ST-Link V3
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374e", MODE="0666", GROUP="plugdev"
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374f", MODE="0666", GROUP="plugdev"

# Raspberry Pi Debugprobe / picoprobe (CMSIS-DAP)
ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="000c", MODE="0666", GROUP="plugdev"
# udevルールの更新
sudo udevadm control --reload-rules
sudo udevadm trigger

3.3 plugdevグループへの追加

# 現在のユーザーがplugdevに所属しているか確認
groups | grep plugdev

# 所属していない場合は追加(要ログアウト/ログイン)
sudo usermod -aG plugdev $USER

4. Docker開発環境の構築

4.1 ディレクトリ構成

以下のような構成にしました。

stm32-embassy/
├── Dockerfile
├── docker-compose.yml
└── projects/
    └── led-blink/
        ├── Cargo.toml
        ├── rust-toolchain.toml
        ├── .cargo/
        │   └── config.toml
        └── src/
            └── main.rs

4.2 Dockerfileのポイント

  • ベースイメージは rust:1.92-bookworm を使用
  • ビルドに必要なツールをインストール(pkg-config, libusb等)
  • Rustターゲット thumbv7em-none-eabihf を追加
  • probe-rs-toolsflip-link をインストール
  • 非rootユーザーで実行するように設定
FROM rust:1.92-bookworm

ENV DEBIAN_FRONTEND=noninteractive

# Build tools and dependencies
RUN apt-get update && apt-get install -y \
    pkg-config \
    libusb-1.0-0-dev \
    libudev-dev \
    gdb-multiarch \
    openocd \
    && rm -rf /var/lib/apt/lists/*

# Rust target for Cortex-M4F (STM32F4)
RUN rustup target add thumbv7em-none-eabihf
RUN rustup component add rust-src rust-analyzer

# Embedded Rust tools
RUN cargo install probe-rs-tools --locked
RUN cargo install flip-link --locked

# Create developer user
ARG UID=1000
ARG GID=1000
RUN groupadd -g ${GID} developer && \
    useradd -m -u ${UID} -g ${GID} -G dialout,plugdev developer

# Copy cargo and rustup to developer's home
RUN cp -r /usr/local/cargo /home/developer/.cargo && \
    cp -r /usr/local/rustup /home/developer/.rustup && \
    chown -R developer:developer /home/developer/.cargo /home/developer/.rustup

USER developer
ENV CARGO_HOME=/home/developer/.cargo
ENV RUSTUP_HOME=/home/developer/.rustup
ENV PATH="${CARGO_HOME}/bin:${PATH}"

WORKDIR /projects

CMD ["/bin/bash"]

4.3 docker-compose.ymlのポイント

  • ボリュームマウントの設計
    • ./projects をコンテナの /projects にマウント
    • Cargoキャッシュを名前付きボリュームで永続化(これがないとビルドが毎回遅い)
  • USBデバイス(デバッガ)のパススルー設定
    • volumes/dev/bus/usb をバインドマウント: libusb がデバイスツリーを親方向に辿るために必要
    • devices/dev/bus/usb を指定: Docker へのデバイスノード宣言
    • 両方必要。devices だけだと probe-rs が "Could not determine a suitable packet size" で失敗する
  • privileged: true が必要
services:
  embassy-dev:
    build:
      context: .
      args:
        UID: ${UID:-1000}
        GID: ${GID:-1000}
    image: stm32f4-embassy-dev
    container_name: stm32-embassy-dev
    volumes:
      - ./projects:/projects
      - cargo-cache:/home/developer/.cargo/registry
      - cargo-git:/home/developer/.cargo/git
      - rustup-toolchains:/home/developer/.rustup
      - /dev/bus/usb:/dev/bus/usb   # libusb のデバイスツリー探索に必要
    devices:
      - /dev/bus/usb:/dev/bus/usb
    privileged: true
    tty: true
    stdin_open: true
    working_dir: /projects
    environment:
      - TERM=xterm-256color

volumes:
  cargo-cache:
  cargo-git:
  rustup-toolchains:

4.4 イメージのビルド

# 基本的なビルドコマンド
docker compose build

# UID/GIDをカスタマイズする場合
UID=$(id -u) GID=$(id -g) docker compose build

初回ビルドは5〜10分程度かかります。2回目以降はキャッシュが効いて速くなります。


5. プロジェクトの構成

5.1 Cargo.toml

各依存クレートの役割:

  • embassy-executor: 非同期タスクランナー
  • embassy-time: 時間管理(Timer::after_millis等)
  • embassy-stm32: STM32用HAL(featuresでターゲットチップを指定)
  • defmt / defmt-rtt: RTT経由の軽量ログ
  • panic-probe: パニック時のdefmt出力
  • cortex-m / cortex-m-rt: Cortex-Mランタイム
[package]
name = "led-blink"
version = "0.1.0"
edition = "2021"

[dependencies]
embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread"] }
embassy-time = { version = "0.5", features = ["tick-hz-32_768"] }
embassy-stm32 = { version = "0.5", features = [
    "stm32f411ce",
    "time-driver-any",
    "memory-x",
]}

defmt = "1.0"
defmt-rtt = "1.0"
panic-probe = { version = "1.0", features = ["print-defmt"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"

[profile.dev]
opt-level = 1

[profile.release]
debug = 2
lto = true
opt-level = "s"
codegen-units = 1   # single unit + LTO で最大限の最適化

5.2 .cargo/config.toml

  • ビルドターゲットの指定
  • probe-rsランナーの設定(picoprobeを優先
  • リンカの設定
    • flip-link: スタックオーバーフロー検出用リンカラッパー。静的変数をRAM先頭に配置しスタックを上位方向に伸ばすことで、オーバーフロー時に HardFault を即時発生させる
    • link.x / defmt.x: cortex-m-rt / defmt のリンカスクリプト
  • DEFMT_LOG環境変数でログレベルを設定
[target.thumbv7em-none-eabihf]
# picoprobe (CMSIS-DAP) - BOOT0操作不要
runner = "probe-rs run --chip STM32F411CEUx --probe 2e8a:000c"
# ST-Link V2 - 毎回BOOT0+RESET操作が必要
# runner = "probe-rs run --chip STM32F411CEUx --connect-under-reset"

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = [
    "-C", "linker=flip-link",
    "-C", "link-arg=-Tlink.x",
    "-C", "link-arg=-Tdefmt.x",
]

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

[env]
DEFMT_LOG = "debug"

5.3 rust-toolchain.toml

ツールチェインをピン留めしておくと、環境の一貫性が保てます。

[toolchain]
channel = "1.92"
components = ["rust-src", "rust-analyzer"]
targets = ["thumbv7em-none-eabihf"]

6. LED点滅サンプル (led-blink)

6.1 コード全文

#![no_std]
#![no_main]

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

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let config = embassy_stm32::Config::default();
    let p = embassy_stm32::init(config);
    info!("Embassy STM32F4 LED Blink started!");

    // PC13: onboard LED (active low)
    let mut led = Output::new(p.PC13, Level::High, Speed::Low);

    loop {
        led.toggle();
        info!("LED toggled");
        Timer::after_millis(1000).await;
    }
}

6.2 公式サンプルとの相違点

公式の blinky.rs(STM32F4) をベースに、Black Pill 向けに調整しました。

項目公式 blinky.rs今回の main.rs理由
初期化init(Default::default())Config::default() を変数に分けて init(config)コメントを付けやすくするため
LED ピンPB7PC13Black Pill のオンボード LED は PC13
トグル方法set_high() / set_low() を交互にtoggle()より簡潔
点滅間隔300ms × 2(High/Low 個別)1000ms視認しやすい 1秒間隔
ログ"high" / "low" を個別出力"LED toggled" を1回シンプル化

構造(#![no_std]#![no_main]・インポート・マクロ)は公式と同一で、ハードウェアの違いと可読性のための微調整のみ。

6.3 コードのポイント

  • #![no_std] / #![no_main]: OSのない環境向けの必須設定
  • embassy_stm32::init(config) でペリフェラルを初期化し、ピンを所有権で管理
  • enable_debug_during_sleep の設定は 不要(picoprobeなら問題ない)
    • ST-Link V2クローンを使う場合も効果がない(詳細はハマったところセクション)
  • Output::new(p.PC13, Level::High, Speed::Low): Active Lowなので初期値をHigh(消灯)に
  • loop { ... Timer::after_millis(1000).await; }: 非同期スリープ。この間CPUはWFEでスリープ
  • info!("LED toggled"): RTT経由でdefmtログが出力される

6.4 picoprobeの配線

picoprobeは SWD 3本 だけで動作します。Black PillをUSBで給電する場合は電源線不要です。

picoprobe - Black Pill 配線図

Pico GPIOPico ピン番号Black Pill説明
GP2Pin 4PA14 (SWCLK)SWDクロック
GP3Pin 5PA13 (SWDIO)SWDデータ
GNDPin 38 などGNDグランド
3V3Pin 363V3電源 3.3V

6.5 ビルドと実行

# コンテナ起動
docker compose run --rm embassy-dev

# プロジェクト led-blink のディレクトリに移動してビルド
cd led-blink
cargo build --release

# フラッシュ&実行(picoprobeが接続されていれば一発で完了)
cargo run --release

実行するとLEDが1秒間隔で点滅し、ターミナルにdefmtログが表示されます:

INFO  Embassy STM32F4 LED Blink started!
INFO  LED toggled
INFO  LED toggled
...

7. デバッグとログ

7.1 defmtによるログ出力

defmt は RTT (Real-Time Transfer) 経由でホストにログを転送する軽量ログシステムです。 cargo run 実行中に probe-rs がRTTを自動的にホストに転送し、ターミナルに表示されます。

use defmt::*;

info!("Value: {}", some_value);     // INFO レベル
warn!("Something happened");        // WARN レベル
error!("Error: {}", err_code);      // ERROR レベル

ログレベルは .cargo/config.tomlDEFMT_LOG で設定:

[env]
DEFMT_LOG = "debug"   # trace / debug / info / warn / error

7.2 probe-rsの基本操作

# 接続プローブの確認
probe-rs list

# バージョン確認(-v は verbose フラグなので --version を使う)
probe-rs --version

# ターゲットへの接続確認
probe-rs info --chip STM32F411CEUx --probe 2e8a:000c

8. ハマったところ

ST-Link V2クローン + embassy 0.9 で JtagNoDeviceConnected になる問題

当初、プログラマとしてST-Link V2クローンを使うことを想定していました。 しかし、二回目以降のダウンロードで失敗する現象が発生。

症状:

  • 初回の cargo run --release は成功する
  • 2回目以降(WFEスリープに入った後)で JtagNoDeviceConnected エラー

原因:

embassy-executor 0.9 でアイドル実装が WFI から WFE に変更されました。 STM32F411 では WFE スリープ中に AHB バスマトリクスが無効化 され、SWD接続が切断されます。

enable_debug_during_sleep = trueDBGMCU_CR = 0x7)を設定しても効果がなく、ST-Link V2クローンでは根本的な回避策がなさそうです。

解決策(推奨): picoprobeへの切り替え

Raspberry Pi Picoを使って"picoprobe"を作成し、プログラマとして使用することにしました。

picoprobe(CMSIS-DAP)では probe-rs が SWD のビットレベル操作を直接制御でき、 WFE スリープ中でも SWD ライン・リセットシーケンスで DP を再初期化して接続を確立できます。

.cargo/config.tomlrunner--probe 2e8a:000c を指定するだけで動作します。

一時対処: BOOT0ボタン(ST-Link V2を使う場合)

書き込みのたびに以下の手順が必要:

  1. Black PillのBOOT0ボタンを押しながら
  2. RESETボタンを押して離す
  3. BOOT0を離す
  4. cargo run --release を実行

リカバリーモードでの復旧

何らかの原因で JtagNoDeviceConnected で繋がらなくなったときの復旧方法(上記のBOOT0操作と同じ同じ手順):

  • BOOT0ピンをHIGHにしてリセット → システムメモリブートで起動
  • この状態でプローブからFlashに書き込めるようになる

Docker内で probe-rs が "Could not determine a suitable packet size" になる問題

docker-compose.ymlvolumes から /dev/bus/usb:/dev/bus/usb を削除すると発生する。

症状:

Error: Failed to open probe: Failed to open the debug probe.
Caused by:
    Could not determine a suitable packet size for this probe.

原因:

probe-rs が内部で使用する libusb はデバイスファイルを開く際にUSBデバイスツリーを親方向に辿る。 devices エントリはデバイスノードを渡すだけで、ツリー全体は渡さないため、libusb がデバイス階層を参照できない。

解決策:

volumesdevices の両方に /dev/bus/usb:/dev/bus/usb を記述する(4.3節の設定参照)。 なお probe-rs listprobe-rs info 実行時に表示される Couldn't get parent device は libusb の非致命的な警告で、接続自体には影響しない。

picoprobe の USB を抜き差しすると直ることがある

cargo run --release が突然失敗するようになった場合、picoprobe が不安定な状態になっていることがある。 picoprobe の USB ケーブルをホスト側で抜き差しして再試行すると解決することが多い。


9. まとめ

  • EmbassyとDockerを組み合わせた再現性のある組み込みRust開発環境を構築できた
  • LED点滅で非同期プログラミング(async/await)の基礎を確認
  • デバッガはpicoprobeを推奨: ST-Link V2クローンはembassy 0.9以降のWFEスリープと相性が悪い
  • defmtのRTTログが非常に便利で、printfデバッグと同様に使える

参考にしたサイト

demiplus.bsky.social
demiplus

@demiplus.bsky.social

Robot, Art, Games
white wind -> https://whtwnd.com/demiplus.bsky.social

Artist account -> @demimanga.bsky.social

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)