秋月 ドットディスプレイを Rust で動かしてみる – Embassyで非同期プログラミング

ちょうどいい感じの非同期プログラミング環境が見つからない

OS を使わずに STM32 で非同期のプログラミングをしたく、検討が続いています。数日前に C を使ったイベントループ環境を検討しました:

このやり方はオーバーヘッドが非常に小さく作りも単純なので実行が高速ですが、反面アプリケーションのコーディングが絶望的に複雑になってしまいます。C で書かれた非同期プログラムの金字塔 Nginx でも同じ問題があって、コード読みはまるで謎解きのようです。このアプローチで Analog3 のコードをメンテナンスできる自信がありません。もう少し頭に優しいやり方を探っています。

非同期プログラミングなら C++20 コルーチンがありますがこれは没。これについてはいつか詳しく記事に書いてみたいですが今は放っておきます。

Rust はどうだろう?

同じ Analog3 プロジェクトでも統括モジュールであるミッションコントロールはラズベリーパイ上に Rust で書いています。Tokio フレームワークを使って非同期でやってますがかなり良いです。モジュール側は主に STM32 を使い OS も載せないので Tokio は無理、でも Rust でアプリが書けるなら C++ より良いんじゃないの?と思い、組み込みでやれる方法がないか調べたら、Embassy というフレームワークが見つかりました。

https://github.com/embassy-rs/embassy

Rust で構築した HAL (Hardware Abstraction Layer) で、STM32 のサポートは特に充実しているようです。コードはどんな感じになるか見てみると、組み込みでまずやってみる LED チカチカのコードは STM32C0 向けの場合 main.rs はこんな感じになるようです。

#![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 _};

// main is itself an async function.
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    // PA5 is the onboard LED on the Nucleo F091RC
    let mut led = Output::new(p.PA5, Level::High, Speed::Low);

    loop {
        led.set_high();
        Timer::after_millis(300).await;

        led.set_low();
        Timer::after_millis(300).await;
    }
}
Rust

大変わかりやすいです。いいですね。このコードがすでに非同期ですが、前回作った C フレームワークだったらこの程度のことをやるのにも結構大ごとになります。そしてこれもいつか記事に書きたいですが、Rust のコルーチンはスタックレスでメモリの負担が少なく、マイクロコントローラでつかうにはありがたいです。

ターゲットデバイス向けのコードは Embassy フレームワークの中に隠れていてデフォルト設定から外さない限り main.rs からは見えない模様。デフォルト設定による初期化は embassy_stm32::init() で行っています。ST Micro の本家の HAL で書く main.c と比べるととてもすっきりしています。いいですね。STM32CubeMx のようにピンの割り当てを GUI で設定するような親切機能はありませんが、間違ったピンを割り当てるとコンパイルエラーになるのは面白いです。設定回りのフレームワークのコードも読んでみましたが結構わかりました大丈夫そうです。これならピン割り当て決めだけ STM32CubeMx を併用すれば大きな苦労なくアプリケーションが書けるのではないでしょうか。ちょっと試してみることにしました。

Embassy で STM32 を動かすのに必要なもの

  • ターゲットのマイクロコントローラ。僕はいつも STM32C092KC を使っています
  • プログラマ。 ST-Link v2 を使ってますが問題ありませんでした
  • Rust 環境。rustup でインストールします
  • probe-rs デバイスへのプログラミングに使うソフトウェアです

本家の Embassy 本が出ていて、環境の構築にはその本の Getting Started の項目を見るのが良いと思います。簡潔にわかりやすく書かれています。ただ、Windows では環境構築に手こずるかもしれません。僕は Windows 上でも WSL でもうまく行かなくて VirtualBox を使ってバーチャルマシンを立て Ubuntu 24.04 上に環境を作りました。

まずは LED チカチカ

本家の Embassy 本Getting Started に従えば普通にできる感じなのでこの記事では省略。非常によく書けています。

これだけだとあんまりなんでちょっとだけ。Embassy のコードexamples がついていてそこの STM32C0 向けblinky.rs を使えば良いです。ターゲットデバイスがプログラムで対象にしている STM32C031C6 と違う場合 Cargo.toml.cargo/config.toml を書き換えます。Embassy をコンパイルするにはツールチェーンの設定が必要でそれを rust-toolchain.toml ファイルとしてプロジェクトのトップディレクトリに置きます。

デバッグはできるの?

できます。この記事は盛りだくさんなので詳細は省いてしまいますが、デバッグは Embassy でなく probe-rs の機能で、そのドキュメントにデバッガの設定の仕方が書かれています。Visual Studio Code を使う前提で書かれていますが、元々 Rust は VSCode を使って書いているので文句ありませんというか感謝です。デバッグはブレークポイントの設定だけでなく、デバイスからデバッグプリントしたものをプログラマを介して PC に表示する機能があってこれをやると実行が遅くなりますがデバッグには大変便利です。Embassy 内でもがんがん使われています。

秋月 128×64 ドットディスプレイに挑戦

本当はここが書きたかっただけなのに前置きが長くなってしまいました。ドライバは前回と同様自分で書かなくて済むように crate を探しました。

Crate ssd1306

ソースコードはこちら

https://github.com/rust-embedded-community/ssd1306

何をしてくれるかクレート名から一目瞭然です。汎用のグラフィックライブラリと繋いでたいていの描画ができるようになっているようです。幸運なことに Embassy とつないで使えるようにできているようです。これを使って前回と同様の描画をしてみたいと思います。Embassy と ssd1306 のソースコードについている examples を読みながら切り貼りしてなんとか動くところまで持ってきました。AI にやらせりゃすぐだったかも?わかったことが

  • ソースコードも大事だが Cargo.toml の設定も非常に大事
  • ssd1306 クレイトを動かすには同期モードと非同期モードがあってコンパイル時に決めるため併用はできない
  • Embassy のドキュメントでは実行は何故かかならず cargo run –release とリリース版からの実行になっているがこれは守った方が良い。一つ言えるのはデバッグでビルドしたバイナリはでかくて書き込みにうんざりするほど時間がかかる

というわけで、同期モードと非同期モードの選択に迫られましたが、今後同期モードを使ってSSD1306 を動かすことはほぼないと思われるのでいきなり非同期で行きました。Rust では同期も非同期もコーディング自体は大差ありません。というわけで、メインプログラムはこんな感じになりました。長いですけれども、ざっと読むと大体の流れがわかると思います。Hello World を表示してから3秒後に馬が走るアニメーションを表示します。

#![no_std]
#![no_main]

pub mod horses;

use crate::horses::HORSES;
// use defmt::*;
use defmt_rtt as _;
use embassy_executor::Spawner;
use embassy_stm32::i2c::{Config, I2c};
// use embassy_stm32::rcc::{Hsi, HsiKerDiv, HsiSysDiv};
use embassy_stm32::time::Hertz;
use embassy_stm32::{bind_interrupts, i2c, peripherals};
use embassy_time::Timer;
use embedded_graphics::image::Image;
use embedded_graphics::{
    mono_font::{MonoTextStyleBuilder, ascii::FONT_10X20},
    pixelcolor::BinaryColor,
    prelude::*,
    text::{Baseline, Text},
};
use panic_probe as _;
use ssd1306::Ssd1306Async;
use ssd1306::{I2CDisplayInterface, prelude::*};

// I2C 割り込みの設定
bind_interrupts!(struct Irqs {
    I2C2 => i2c::EventInterruptHandler<peripherals::I2C2>, i2c::ErrorInterruptHandler<peripherals::I2C2>;
});

#[embassy_executor::main]
async fn main(_spawner: Spawner) -> ! {
    // embassy 全体の初期化
    let peripherals = embassy_stm32::init(Default::default());

    // I2C の設定
    let i2c_peri = peripherals.I2C2;
    let scl = peripherals.PA7;
    let sda = peripherals.PA6;

    let mut i2c_config = Config::default();
    i2c_config.frequency = Hertz(400_000);

    // Wait for slave device to be ready
    Timer::after_millis(100).await;

    let tx_dma = peripherals.DMA1_CH2;
    let rx_dma = peripherals.DMA1_CH3;

    let i2c = I2c::new(i2c_peri, scl, sda, Irqs, tx_dma, rx_dma, i2c_config);

    // SSD1306 ディスプレイを作成
    let interface = I2CDisplayInterface::new(i2c);
    let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();

    // ディスプレイ初期化
    display.init().await.unwrap();

    // Hello World
    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_10X20)
        .text_color(BinaryColor::On)
        .build();

    Text::with_baseline("Hello world!", Point::zero(), text_style, Baseline::Top)
        .draw(&mut display)
        .unwrap();

    Text::with_baseline("Hello Rust!", Point::new(0, 20), text_style, Baseline::Top)
        .draw(&mut display)
        .unwrap();

    // 画面更新
    display.flush().await.unwrap();

    Timer::after_millis(3000).await;

    // 馬のアニメーション
    let mut index: usize = 0;
    loop {
        // フレームを作って...
        let image = Image::new(&HORSES[index % 8], Point::zero());
        image.draw(&mut display).unwrap();
        // 画面更新
        display.flush().await.unwrap();
        index += 1;
    }
}
Rust

Rust で非同期実行は非同期関数を呼んで await で待つだけなのであっさり流していますが C コールバックによる非同期プログラミングではループを書くのはとても大変でこれだけ書くにも数日かかりそうな勢いです。コルーチンの恩恵は大きいです。

ソースコードに加えて大事なのが Cargo.toml です。ここが間違っているとビルドできなかったりきちんと動作しなかったりします。こんな風に設定しました。

[package]
name = "hello-ssd1306"
version = "0.1.0"
edition = "2024"

[dependencies]
embassy-stm32 = { version = "0.5.0", features = [
    "defmt",
    "time-driver-any",
    "stm32c092kc",
    "memory-x",
    "unstable-pac",
    "exti",
    "chrono",
] }
embassy-sync = { version = "0.7.2", features = [] }
embassy-executor = { version = "0.9.0", features = [
    "arch-cortex-m",
    "executor-thread",
    "defmt",
] }
embassy-time = { version = "0.5.0", features = [
    "defmt",
    "defmt-timestamp-uptime",
    "tick-hz-32_768",
] }

defmt = "1.0.1"
defmt-rtt = "1.0.0"

cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7.0"
embedded-hal = "0.2.6"
panic-probe = { version = "1.0.0", features = [] }
heapless = { version = "0.8", default-features = false }
chrono = { version = "^0.4", default-features = false }
ssd1306 = { version = "0.10.0", features = [
    "graphics",
    "embedded-graphics-core",
    "async", # <- 非同期モードにするにはこれが必要
] }
embedded-graphics = "0.8.1"

[profile.release]
debug = 2

[package.metadata.embassy]
build = [{ target = "thumbv6m-none-eabi" }]
TOML

.cargo/config.toml ファイルも重要です。以下のように設定

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
# replace STM32G071C8Rx with your chip as listed in `probe-rs chip list`
runner = "probe-rs run --speed 4000 --chip STM32C092CC"

[build]
target = "thumbv6m-none-eabi"

[env]
DEFMT_LOG = "trace"
TOML

早速実行してみます。

$ cargo run --release
      Erasing  100% [####################]  48.00 KiB @   3.07 KiB/s (took 16s)
  Programming   96% [####################]  46.00 KiB @   2.99 KiB/s (ETA 1s)
Bash

バイナリがでかい!でも無事に書き込みがすんで、無事画面に描画が行われました。

おおっ!と感動します。次は馬のアニメーションです。うまく表示されるかな?

できたけど遅っ!なにこれ?

実行速度改善に挑戦

これは遅すぎます。何が起きているんでしょう?ひとつ気になるのが、実行中に大量のデバッグメッセージがターミナルに表示されます。

204.671600 [TRACE] interrupt: stopf (embassy_stm32 src/i2c/v2.rs:56)
204.671783 [TRACE] interrupt: tc (embassy_stm32 src/i2c/v2.rs:50)
204.671936 [TRACE] interrupt: stopf (embassy_stm32 src/i2c/v2.rs:56)
204.672851 [TRACE] interrupt: tc (embassy_stm32 src/i2c/v2.rs:50)
204.673034 [TRACE] interrupt: stopf (embassy_stm32 src/i2c/v2.rs:56)
204.673217 [TRACE] interrupt: tc (embassy_stm32 src/i2c/v2.rs:50)
Bash

これはマイクロコントローラから送ってきているわけで、すごいんだけど間違いなく速度低下の原因になっています。何やらログレベルが表示されていますが、普段のデバッグでも、よほどのことがなければデバッグレベルで良いんじゃないの?ということで、.cargo/config.toml の環境変数設定を以下のように変えます

[env]
DEFMT_LOG = "debug"
TOML

この環境変数は probe-rs が読んで表示するデバッグレベルを決めるもののようです。あれ?そうするとこれを変えてもマイクロコントローラは依然デバッグメッセージをせっせと生成しているのでは?ならば、デバッグメッセージ生成をやめさせた方が良いかもしれません。調べたところ、この機能は defmt という feature で、これを Cargo.toml から除去すれば関連コードはコンパイル時に除去されるようです。というわけで、取っ払いますが、似た名前の依存 crate に defmt_rtt というものがありますがこれは除去すると正常動作しなくなるので残しました。あと、リリースビルドなのにデバッグフラグが付いているのも気になるので取っ払います。

変更後の Cargo.toml

[package]
name = "hello-ssd1306"
version = "0.1.0"
edition = "2024"

[dependencies]
embassy-stm32 = { version = "0.5.0", features = [
    # "defmt",
    "time-driver-any",
    "stm32c092kc",
    "memory-x",
    "unstable-pac",
    "exti",
    "chrono",
] }
embassy-sync = { version = "0.7.2", features = [] }
embassy-executor = { version = "0.9.0", features = [
    "arch-cortex-m",
    "executor-thread",
    "defmt",
] }
embassy-time = { version = "0.5.0", features = [
    # "defmt",
    "defmt-timestamp-uptime",
    "tick-hz-32_768",
] }

# defmt = "1.0.1"
defmt-rtt = "1.0.0"

cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7.0"
embedded-hal = "0.2.6"
panic-probe = { version = "1.0.0", features = [] }
heapless = { version = "0.8", default-features = false }
chrono = { version = "^0.4", default-features = false }
ssd1306 = { version = "0.10.0", features = [
    "graphics",
    "embedded-graphics-core",
    "async", # <- 非同期モードにするにはこれが必要
] }
embedded-graphics = "0.8.1"

[profile.release]
# debug = 2
opt-level = 3

[package.metadata.embassy]
build = [{ target = "thumbv6m-none-eabi" }]
TOML

あともう一つ、I2C のクロック周波数が 400 kHz なのも気になります。前回は 1MHz まで試したので今回も試したい。デバッグプリントを有効にしたままにしておくと、起動時にクロックの設定がプリントされます

0.000000 [DEBUG] rcc: Clocks { hclk1: MaybeHertz(12000000), hsiker: MaybeHertz(16000000), lse: MaybeHertz(0), pclk1: MaybeHertz(12000000), pclk1_tim: MaybeHertz(12000000), rtc: MaybeHertz(32000), sys: MaybeHertz(12000000) } (embassy_stm32 src/rcc/mod.rs:81)
Bash

システムクロックは 12 MHz と表示されています。クロックの設定部分の embassy-stm32 ソースコードを詳しく見てみたら、48 MHz の RC クロックソースにシステムクロックは 4 分周をかけるのがデフォルトなことがわかりました。このままだと I2C に 1MHz クロックは設定できない気がするので、システムのクロック周波数を上げる設定をします。それには、main.rs 内でのそっけない初期化

    // embassy 全体の初期化
    let peripherals = embassy_stm32::init(Default::default());
Rust

ここを変更します。

use embassy_stm32::rcc::{Hsi, HsiKerDiv, HsiSysDiv};

...

    // embassy 全体の初期化
    let mut sysconfig = embassy_stm32::Config::default();
    sysconfig.rcc.hsi = Some(Hsi {
        sys_div: HsiSysDiv::DIV1,
        ker_div: HsiKerDiv::DIV1,
    });
    let peripherals = embassy_stm32::init(sysconfig);
Rust

そして、I2C のクロック設定も変更します

    i2c_config.frequency = Hertz(1_000_000);
Rust

これで、手っ取り早く速度改善できる点は直しました。早速実行

大分改善しましたが、前回 C で表示させたスピードと比べるとかなり遅いです。前回は以下ぐらいの速さでした。絵的には今回の速さの方が良いですがそういう問題じゃないです。同期と非同期の違いかと思いましたが、同期も試してみたところスピードに大差ありませんでした。

結論

Rust Embassy を使えば、非同期プログラミングはかなり楽にできそうです。ですが、実行速度に難ありです。Embassy プロジェクトでは実行速度は「非常に速い」とうたっていますが、鵜吞みにはできません。というか C で書いたプログラムに比べて明らかに遅いです。これがSSD1306 クレイトのせいなのか Embassy 全体の問題なのかで話は大きく変わります。SSD1306 クレイトが遅いなら自前の単純なコードを書くなりライブラリの使い方を工夫するなりライブラリを改良するなりやりようがありそうですが、Embassy 自体が遅いようでしたら中身の規模から考えて手におえないかもしれません。Analog3 プロジェクトでは一旦開発プラットフォームを決めたら同じコードを繰り返し使うことになりあとで変えるのはだいぶん無理なので時間を使いすぎないようにしつつここは慎重に決めたいところでもあります。

この記事で作ったプロジェクトを Github プロジェクトに加えました。よければ参考にどうぞ

https://github.com/naokiiwakami/hello-ssd1306

Comments

No comments yet. Why don’t you start the discussion?

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください