Embassy でアプリケーションを組んでみる

Embassy の開発生活での優秀さが見たい

前回の記事では、Embassy の基本性能を測定して Rust を使って STM32 ファームウェアを書くことに好感触を得ました。では実際にアプリケーションを組んでみたらどんな感じ?というのが気になるところです。前回の記事までは、非同期処理がなんだの、コンテクスト切り替えがかんだの、メモリアロケーションがやいだの、観念的なことを言ってましたが。環境の選定ではその辺が超重要ですが、いったん決めたら、現実問題として日々の開発作業がどんな風になるかが関心ごとになります。

例題は CAN モニタ

まず確認したいのが CAN まわりのコーディングがどうなるかというところ。Analog3 は CAN がないとはじまりません。ちょうど、開発のお供として重用している CAN モニタがちょいちょい誤動作を起こすのに困っていたので Embassy でファームウェアを書き直すことにしました。どんな誤動作なのかは今回の記事のネタと少し関連があるので後で触れることにします。

CAN モニタはどういうことをするモジュールかというと

  • CAN フレームを受信するまで待つ
  • フレームを受信したら内容をシリアルポートに出力する

基本これだけです。単純ですけど、CAN と USART という二個のペリフェラルを協働させる必要がある、イベントハンドリングが必要など、アプリケーション作成の基本部分が見られるなかなか良い題材です。

アプリケーションの基本構造

CAN モニタの処理の流れは以下のようになります。単純です。現行の CAN モニタと変わったのはシリアルワーカの部分です。このワーカは、メインプログラムとは独立して走るタスクで、CAN フレーム受付部からメッセージを受け取るまで何もしないし、受け取ったらメインプログラムと並列に処理を開始します。なんでシングルコアの STM32C0 でそんなことができるのかというと、非同期プログラミングをするからです。

現行の CAN モニタでは基本構造がもう少し単純です。以下の通り

なんだ、こっちの方が簡単で良いんじゃないの?と思うかもしれません。確かにこういう風にした方がコードは単純になりますが、この方法には重大な問題があります。それは、CAN フレーム受付とシリアル出力を順次処理している点です。実はシリアル出力は通信が低速なせいか、処理がかなり遅いんです。そして、マイクロコントローラがシリアルにかかりきりで CAN フレーム受付のフェーズにいなくても、そんなこちらの都合にはお構いなしに CAN メッセージは送られてきます。その場合、使っている STM32C092 マイクロコントローラでは、ハードウェアがソフトウェアに触れることなくフレームを受け取って FIFO に入れます。ソフトウェアは「体が空いたら」戻ってきて FIFO からフレームを取り出し処理します。ところが FIFO の長さは STM32C092 では 3 しかなく、シリアル通信に手間取っている間に 3 フレームよりたくさんメッセージをため込むと取りこぼしてしまいます。これが現行のモニタがちょいちょい不具合を出す原因ではないかと疑っています。

C で書いた現行版ではなんで問題があるのに放っておいたかというと、今までの記事に何度も書いていたように、C で非同期の実行を実装するのは大変面倒だからです。返して言えば Rust / Embassy の実力を見る絶好の機会です。

アプリケーションを Rust / Embassy で書いてみた

上の設計図を Embassy フレームワークを使って Rust で書くと、予想以上にシンプルになり驚きました。読みやすいように邪魔な部分を除去すると、このページに貼り付けられるほど短いです。さすがにちょっと長いかもしれませんが貼っちゃいます。

#![no_std]
#![no_main]

use core::fmt::Write;
use defmt::error;
use embassy_executor::Spawner;
use embassy_stm32::can::{self, Can, frame::Frame};
use embassy_stm32::mode;
use embassy_stm32::peripherals;
use embassy_stm32::rcc::{Hsi, HsiKerDiv, HsiSysDiv};
use embassy_stm32::usart::{self, Uart};
use embassy_stm32::{Peripherals, bind_interrupts};
use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, channel::Channel};
use embedded_can::Id;
use heapless::String;
use {defmt_rtt as _, panic_probe as _};

static CHANNEL: Channel<ThreadModeRawMutex, Frame, 8> = Channel::new();

bind_interrupts!(struct CanIrqs {
    FDCAN1_IT0 => can::IT0InterruptHandler<peripherals::FDCAN1>;
    FDCAN1_IT1 => can::IT1InterruptHandler<peripherals::FDCAN1>;
});

bind_interrupts!(struct UsartIrqs {
    USART1 => usart::InterruptHandler<peripherals::USART1>;
});

// マイクロコントローラシステムの初期化
fn system_init() -> Peripherals {
    let mut config = embassy_stm32::Config::default();
    // set system clock source to HSI with 48 MHz RC oscillation
    config.rcc.hsi = Some(Hsi {
        sys_div: HsiSysDiv::DIV1,
        ker_div: HsiKerDiv::DIV1,
    });
    embassy_stm32::init(config)
}

// ペリフェラル設定
fn setup_peripherals(
    p: Peripherals,
) -> (Can<'static>, Uart<'static, mode::Async>) {

    // start USART
    let usart = Uart::new(
        p.USART1,
        p.PA8,
        p.PA9,
        UsartIrqs,
        p.DMA1_CH2,
        p.DMA1_CH3,
        usart::Config::default(),
    )
    .unwrap();

    // start the CAN controller
    let can = {
        let mut can_config = can::CanConfigurator::new(p.FDCAN1, p.PB5, p.PB6, CanIrqs);
        can_config.set_bitrate(1_000_000);
        can_config.into_normal_mode()
    };

    (can, usart)
}

// シリアルワーカ
#[embassy_executor::task]
async fn message_consumer(mut usart: Uart<'static, mode::Async>) {
    let receiver = CHANNEL.receiver();
    loop {
        let rx_frame = receiver.receive().await;
        let mut log_message: String<128> = String::new();
        match rx_frame.id() {
            Id::Standard(id) => write!(log_message, "std [ {:03x} ]:", id.as_raw()).unwrap(),
            Id::Extended(id) => write!(log_message, "ext [ {:08x} ]:", id.as_raw()).unwrap(),
        };
        for &value in &rx_frame.data()[0..rx_frame.header().len() as usize] {
            write!(log_message, " {:02x}", value).unwrap();
        }
        write!(log_message, "\r\n").unwrap();
        usart.write(log_message.as_bytes()).await.unwrap();
    }
}

// メインプログラム
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let peripherals = system_init();
    let (mut can, usart) = setup_peripherals(peripherals);

    _spawner.spawn(message_consumer(usart).unwrap());

    let sender = CHANNEL.sender();
    loop {
        match can.read().await {
            Ok(envelope) => {
                sender.send(envelope.frame).await;
            }
            Err(err) => error!("Error in frame: {:?}", err),
        }
    }
}
Rust

実際に使ったコードはこれよりもう少し長いですが、上もれっきとした動くコードです。100行ぐらいしかありません。STM32 HAL で書いた現行のコードがこれより単純な作りで 400 行を超えているのを考えると驚きです。Embassy の HAL は巧妙に作られていると思います。

このコードは、短いながらも Rust / Embassy の重要な要素をきちんと含んでいてなかなか興味深いです。

マイクロコントローラの初期化の流れ

メインプログラムでマイクロコントローラをセットアップしているのはこの二行

    let peripherals = system_init();
    let (mut can, usart) = setup_peripherals(peripherals);
Rust

コードの字面の通り、Embassy では初期化は二段階にわたって行われます

  • システムの設定と初期化
  • 使用するペリフェラルの設定と初期化

システムの初期化では、クロックや電源などを設定初期化します。結果は Peripheral という構造体として出力されます。これはペリフェラル設定を行うのに必要なオブジェクトです。以下がこのアプリで行う、CAN と USART ペリフェラルの設定

fn setup_peripherals(
    p: Peripherals,
) -> (Can<'static>, Uart<'static, mode::Async>) {

    // start USART
    let usart = Uart::new(
        p.USART1,
        p.PA8,
        p.PA9,
        UsartIrqs,
        p.DMA1_CH2,
        p.DMA1_CH3,
        usart::Config::default(),
    )
    .unwrap();

    // start the CAN controller
    let can = {
        let mut can_config = can::CanConfigurator::new(p.FDCAN1, p.PB5, p.PB6, CanIrqs);
        can_config.set_bitrate(1_000_000);
        can_config.into_normal_mode()
    };

    (can, usart)
}
Rust

このように、設定結果、ペリフェラルオブジェクトが生成され、このオブジェクトを使ってペリフェラルの操作が行われます。今回のアプリではシリアルと CAN を使うので usart と can オブジェクトを生成しています。設定中 p.USART1 (USART ユニット) や p.PA8 (ピン) などのように、ペリフェラルオブジェクトのメンバとして持っているマイクロコントローラの資源が使われています。これらは move の動作でペリフェラルオブジェクト内に渡されます。ので、資源は一度使ったら peripheral オブジェクトからは二度と使えません。そのため、誤って同じピンを別用途で使おうとするとコンパイルエラーになります。資源は使ったらなくなるのと似ていて面白いです。ちなみに、ここでペリフェラルの設定を間違えてもコンパイルエラーになります。例えば、USART にその機能がないピンを割り当てた場合などです。そんなやり方で、どの機能にどのピンを使えば良いかは教えてもらえませんが設定の間違いは検出してくれます。

ところで、CAN ペリフェラルの設定が恐ろしく簡単なのが驚きでした。使うピンとバスの周波数を指定するだけとは。もちろんこれはアヒルの水かきで API の中ではごちゃごちゃと計算と操作が行われています。CAN コントローラの設定はもれなく煩雑で正しく設定するのは至難なのです。Embassy の HAL を作った人たちはほんとに賢いのだなあと感心してしまいます。

Embassy のタスク

Embassy のタスクは Tokio のタスクと同様、非同期実行をする一本の流れで、他の言語でいうファイバーやコンテクストと同様のものです。協力的スケジューリングでコンテクストを切り替えるため誰か長く占有する人がいると構造が崩れますが非常に軽量にかつ効率よく並列実行ができます。このアプリでは、シリアルインタフェースへの表示部に使っています。こんな感じ

#[embassy_executor::task]
async fn message_consumer(mut usart: Uart<'static, mode::Async>) {
    let receiver = CHANNEL.receiver();
    loop {
        let rx_frame = receiver.receive().await;
        let mut log_message: String<128> = String::new();
        match rx_frame.id() {
            Id::Standard(id) => write!(log_message, "std [ {:03x} ]:", id.as_raw()).unwrap(),
            Id::Extended(id) => write!(log_message, "ext [ {:08x} ]:", id.as_raw()).unwrap(),
        };
        for &value in &rx_frame.data()[0..rx_frame.header().len() as usize] {
            write!(log_message, " {:02x}", value).unwrap();
        }
        write!(log_message, "\r\n").unwrap();
        usart.write(log_message.as_bytes()).await.unwrap();
    }
}
Rust

[embassy_executor::task] というアトリビュートをつけた関数はタスクとして起動できるようになります。ちなみにメインプログラムはメインタスクという特殊なタスクで [embassy_executor::main] アトリビュートをつけて宣言します。タスクの起動はメインプログラムから行っています。こんな感じ

    _spawner.spawn(message_consumer(usart).unwrap());
Rust

まだ試してませんが join や cancel などもできる模様。

上のタスクは、ループの中で、受信 CAN フレームを受け取ったらその中身をシリアルインタフェースに流すということを繰り返しています。このループの中ですることがなくなるのが、新しい CAN フレームを待っている間と、シリアルインタフェースにデータを送って送信が終了するのを待つ間です。その間は他タスク、このアプリではメインタスクしかありませんが、そこに処理を譲ります。譲る部分には await が付いているのでどこでコンテクスト切り替えが起きるかよくわかります。C の煩雑さを考えると天国です。

オブジェクト間のデータの受け渡し

このアプリケーションでいうと、CAN オブジェクトを所有している CAN 受信部(メインタスク)からシリアルインタフェースオブジェクトを所有している message_consumer タスクへのデータ渡しをどうするか、という話です。

Rust でコードを書き始めてまずわからなくて苦労したのが、一つのオブジェクトから別のオブジェクトへメッセージを渡すにはどうしたら良いかということ。Rust では mutable の変数をオーナ以外が使うのは基本無理です。タスクをまたがってオブジェクトを所有することは基本できないので、タスク間でメッセージをやり取りする場合、「あれ?送れる人がいない?」と迷うことになります。そのため例えば Tokio だったらチャネルという構造体がどうしても必要です。同じように、Embassy にもチャネルがあります。

use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, channel::Channel};

static CHANNEL: Channel<ThreadModeRawMutex, Frame, 8> = Channel::new();

Rust

チャネルは sender と receiver というオブジェクトを生成できて、sender にデータを送ると receiver でデータ受け取りイベントが発生します。そして sender はメインタスクが所有して、receiver はシリアルタスクが所有します。こうして mutable オブジェクトを共有することを避けつつデータの受け渡しが可能になります。送り側はこんな風に

// メインプログラム
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let peripherals = system_init();
    let (mut can, usart) = setup_peripherals(peripherals);

    _spawner.spawn(message_consumer(usart).unwrap());

    let sender = CHANNEL.sender();  // <-- ここで sender を生成

    loop {
        match can.read().await {
            Ok(envelope) => {
                sender.send(envelope.frame).await;  // <-- ここでフレームをチャネルに送る
            }
            Err(err) => error!("Error in frame: {:?}", err),
        }
    }
}
Rust
// シリアルワーカ
#[embassy_executor::task]
async fn message_consumer(mut usart: Uart<'static, mode::Async>) {

    let receiver = CHANNEL.receiver();  // <-- ここで receiver を生成

    loop {
        let rx_frame = receiver.receive().await;  // <-- ここでチャネルを通じてフレームが送られてくるのを待つ

        let mut log_message: String<128> = String::new();
        match rx_frame.id() {
            Id::Standard(id) => write!(log_message, "std [ {:03x} ]:", id.as_raw()).unwrap(),
            Id::Extended(id) => write!(log_message, "ext [ {:08x} ]:", id.as_raw()).unwrap(),
        };
        for &value in &rx_frame.data()[0..rx_frame.header().len() as usize] {
            write!(log_message, " {:02x}", value).unwrap();
        }
        write!(log_message, "\r\n").unwrap();
        usart.write(log_message.as_bytes()).await.unwrap();
    }
Rust

これは Tokio でアプリを書く時にも多分もれなく使う手段で、Embassy も多分同じだと思います。

タスク間のデータ受け渡し方法については、以下の Omar Hiari さんが書いたブログページで包括的に解説されていて勉強になりました。

https://blog.theembeddedrustacean.com/sharing-data-among-tasks-in-rust-embassy-synchronization-primitives

CAN メッセージが輻輳したらどうなるか?

CAN メッセージが輻輳したらハードウェアの FIFO がいっぱいになりメッセージを取りこぼしてしまうという話は最初の設計のところで書きましたが、このアプリはその問題に対してどう対処しているでしょうか?

FIFO のあふれを防ぐには、CAN フレームを受け取ったら速やかに取り出せばよいです。具体的には FIFO にある CAN フレームデータを読んでソフトウェアを使って RAM のどこかにコピーします。その操作は CAN フレーム受付部で行います。非同期プログラムでは、適切にコーディングすればコントローラ/プロセッサが何かの処理でつっかえるようなことはないので、FIFO からのフレーム取り出しは速やかに行われます。でも、CAN フレーム受付部がシリアルワーカにメッセージを送った時に、まだ他のフレームを処理中だったら、新しく来たフレームはどこへ行ってしまうのでしょうか?チャネルに滞留しています。チャネルは内部にキューが入っているので、CAN フレームが一時的に輻輳したらフレームはそこで待たせることができます。

再びチャネルの宣言を見てみると、8 という数字が入っていることがわかります。これでキューのサイズを指定しています。

static CHANNEL: Channel<ThreadModeRawMutex, Frame, 8> = Channel::new();
Rust

Embassy は組み込み用なのでヒープを使わず、メモリの確保が必要な場合大抵このように固定長でやります。ともあれつまりチャネルでも無制限にメッセージを突っ込めるわけではなく、この例では 8 フレームまでですが、FIFO の容量である 3 よりは大きくなっています。この 8 スロットが全部埋まらないうちに処理を完了すれば良いわけです。一時しのぎ感はありますがなんだかんだでよく使う手法です。

輻輳状態が長く続いてチャネルもいっぱいになったらどうなるか?それについてはまた別の記事で書けたら良いなと思います。

結論

Embassy でアプリを書くのはかなり楽でした。Tokio のフレームワークにある程度慣れていて、それとよく似ているからということもありますけれども。内部的にはだいぶん複雑なことをやっているわりにメインプログラムにはそれが現れてこないのはアプリの動作の部分に集中できるのでありがたいです。

CAN モニタのプログラムは Github で公開しました。

https://github.com/naokiiwakami/can-monitor/tree/version0

Comments

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

コメントを残す

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

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