Embassy では一筋縄では LED が点かない

C++ で書いていた Analog3 のファームウェアを Rust/Embassy に移植中です。C++ では実装が難しくて苦労した部分が簡単に書けてしまったり、C++ では息をするように簡単にできることが Rust では厄介だったりして、さすが変態言語だと感心します。最初に移植したのが LED インジケータですけれどもそこからもうつまづきました。ああもうめんどくさいと思いつつも、なんでこんなことでめんどくささを強いられるのか考えると興味深くもあるのです。

LED なんてただチカればいいだけでは?

AVR や Arduino はそうですし、STM32 の HAL とか、PSoC の API でもそうですけれども。

前提となる Analog3 の設計ですが、Analog3 はどのモジュールも赤青の2色 LED を持ち、ミッションコントロールとの接続状態の表示など動作が統一されています。こういった Analog3 モジュール共通の動作コードを再利用するために、Analog3 システムでは、Analog3 というクラスを持っています。

C++ の実装では今回の記事と関連する部分だけ残して極限まで削ってしまうと以下のような感じ

class HwController;

class Analog3 {
 private:
  HwController *hw_controller_;
  
 public:
  void SetBlueLed() {
    hw_controller_->SetBlueLed();
  }
  void SetRedLed() {
    hw_controller_->SetRedLed();
  }
  void BlinkBlueLed() {
    hw_controller_->BlinkBlueLed(3, 70);
  }
};

class HwController {
 public:
  virtual void SetBlueLed() = 0;
  virtual void SetRedLed() = 0;
  virtual void ResetBlueLed() = 0;
  virtual void ResetRedLed() = 0;
  virtual void BlinkBlueLed(uint32_t times, uint32_t interval) = 0;
  virtual void BlinkRedLed(uint32_t times, uint32_t interval) = 0;
}
C++

Analog3 クラスは共通動作を実装しています。SetRedLed() とか BlinkBlueLed() のようなメソッドがそうですけれども、モジュールがどのデバイスに実装されるかわからないしピン番号もモジュールによって違ったりするので、ハードウェア依存の部分の動作は HwController という別クラスに任せます。Analog3 クラスは再利用される部分でライブラリに入っていますが、HwController は抽象クラスで定義されていて実装は個別のモジュール側にあります。

こういうデザインパタンは delegate と呼ばれています。あと、ライブラリ実装時には未知の実装を内部で使うために取り込むこのような手法を dependency injection とも呼びます。dependency injection は Rust と大変に相性が悪い、というのがこの記事の大筋です。

LED 点灯消灯の実装は、例えば STM32 HAL を使うなら下のリストにあるように一群のグローバル関数 HAL_GPIO_* を使って実装します。基本的に API 関数を一個呼ぶ程度の非常に簡単な実装です。LED チカるだけですからね。最後の BlinkBlueIndicator だけは非同期の実行が必要になってやや複雑ですけれども理解できないほどは難しくありません。

class Stm32Controller : public analog3::HwController {
 public:
  void SetBlueIndicator() const {
    HAL_GPIO_WritePin(A3_IND_BLUE_GPIO_Port, A3_IND_BLUE_Pin, GPIO_PIN_SET);
  }

  void ResetBlueIndicator() const {
    HAL_GPIO_WritePin(A3_IND_BLUE_GPIO_Port, A3_IND_BLUE_Pin, GPIO_PIN_RESET);
  }

  void BlinkBlueIndicator(uint32_t times, uint32_t interval) const {
    auto state = new BlinkState(A3_IND_BLUE_GPIO_Port, A3_IND_BLUE_Pin, times, interval);
    if (SubmitTask(BlinkIndicator, state) != 0) {
      delete state;
    }
  }
};
C++

こんな風に LED 点灯を構造化していますけれども、LED 操作はグローバル関数ですし、実のところ、別のところから LED 操作を横取りしてさっと点灯、なんてこともできてしまいます。後で収拾がつかなくなるからやらない方が良いとは思いますがコンパイラは特に何も言ってこないし点灯もできます。

こんな感じで、C++ では、そうですね、LED は簡単にチカれます。

Rust でも簡単に…はチカれません

Rust/Embassy ではどうでしょうか?まず、ファームウェアのどこからでもさっと点灯できるか?できません。まずはそれが何故なのかの話。Embassy では、ペリフェラルはオブジェクトとして定義され、ペリフェラルに対する操作はオブジェクトのメソッドとして実装されています。したがってグローバル関数ではありません。ここが重要なところ。前節の C++ では自主的にペリフェラルへのインタフェースを構造化しましたが、Embassy ではフレームワークから構造化されています。この点がまず一つ。そして、Rust のオブジェクトは C++ や類似の言語と大きく違う点があって、オブジェクトの使用に以下のような制限がかかります

  • 更新の操作は所有者が行うか所有者から「借りた」人が行うかしか許されない
  • オブジェクトの所有者は一人に限って共有してはいけない
  • オブジェクトへのアクセスを二か所から同時に行うことは許されない

このことを LED のつけ消しに当てはめてみると、LED の点灯・消灯の操作は「点灯しているかどうか」の状態を変えることなのでオブジェクトの更新にあたります。そして一定時間点滅する、といった時間のかかる操作があるのでそれを実行管理する人が必要になります。具体的には、タスクが LED を所有することになります。そうすると Analog3 オブジェクトが LED をつけ消ししたい時どうやって操作するか?ペリフェラルのメソッドを直接呼ぶことは所有者ではないのでできません。じゃあ借りてくる?それもできません。Analog3 も別のタスクにいるのですがタスク間でオブジェクトの貸し借りをすると同時アクセスを防げなくなるのでコンパイラが許してくれません。

こんな風に、LED は状態のあるステイトマシンであるため Rust では簡単には扱えません。Rust で書いているとよくはまりがちな問題です。

では、どうやればチカるの?

Rust でプログラムを組んでみてわかってきたことが、元の C++ の設計でやったような Dependency Injection は基本やらない方が良いというかちょっとやそってではできないということ。しかし LED 制御のオブジェクトを作ってタスクがそれを所有しているとして、外から何かの依頼をしないと意味のある動作はできません。どうするか?考え方を変える必要があります。

Dependency Injection はいわば自宅に素性のわからない人を入れて働かせるようなこと。これは Rust では違法です。ですが外注はして良いことになっています。そこで「注文書」を送り付けて先方で働いてもらいます。

Rust ではこういった「発注」が頻繁に起こるので、Embassy はそれ用の便利な仕組みをいくつも用意しています。今回はその中で Channel を使います。Channel は以前書いた Embassy での単純なアプリケーションについての記事でも使っています。Embassy の最重要部品の一つです。

LED だって一国一城の主

LED を点けるのは LED コントローラさんというタスクがする仕事でコントローラさんだけが LED をつけ消しできますが、LED コントローラさんはフリーランスで注文を受けて働いています。という形なら動かせます。というわけで、こんな実装になりました。

struct A3Indicator {
    a3_red_led: Output<'static>,   // 赤 LED 用のピン
    a3_blue_led: Output<'static>,  // 青 LED 用のピン
    request_receiver: // チャネルの受け側
        channel::Receiver<'static, ThreadModeRawMutex, analog3::request::IndicatorRequest, 4>,
}

impl A3Indicator {
    async fn run(&mut self) {
        loop {
            let request = self.request_receiver.receive().await; // 「注文」が来るまで待つ
            // 「注文」をさばく
            match request {
                IndicatorRequest::SetRedLed => self.a3_red_led.set_high(),
                IndicatorRequest::ResetRedLed => self.a3_red_led.set_low(),
                IndicatorRequest::SetBlueLed => self.a3_blue_led.set_high(),
                IndicatorRequest::ResetBlueLed => self.a3_blue_led.set_low(),
                IndicatorRequest::BlinkRedLed { blinks, interval } => {
                    Self::blink_led(&mut self.a3_red_led, blinks, interval).await
                }
                IndicatorRequest::BlinkBlueLed { blinks, interval } => {
                    Self::blink_led(&mut self.a3_blue_led, blinks, interval).await
                }
            }
        }
    }

    async fn blink_led(led: &mut Output<'static>, blinks: u8, interval: u16) {
        for _ in 0..(blinks* 2) {
            Timer::after_millis(interval as u64).await;
            led.toggle();
        }
    }
}

#[embassy_executor::task]
async fn start_a3_indicator_controller(mut a3_dummy: A3Indicator) {
    a3_dummy.run().await;
}
Rust

LED インジケータは、便利に呼べるメソッド集ではなく、独立して走るタスクとして実装します。そしてインジケータ用の LED を所有しています。オブジェクトの構成は単純で、LED の点灯に使うピンと外部からの依頼を受け付けるためのチャネルの受け側からできています。外部のオブジェクトは Channel を介してこのオブジェクトに操作依頼を送ることによってインジケータを動かします。操作依頼 IndicatorRequest は以下のような感じ

pub enum IndicatorRequest {
    SetRedLed,
    ResetRedLed,
    BlinkRedLed { blinks: u8, interval: u16 },
    SetBlueLed,
    ResetBlueLed,
    BlinkBlueLed { blinks: u8, interval: u16 },
}
Rust

依頼側のコードは例えばこんな感じになります。ping をミッションコントロールから受けたら LED を点滅させるコードです。チャネルを介して Led Indicator に依頼を送りそれで完了。あとは先方でよきに計らってくれます。

    async fn handle_ping(&self, data: &[u8]) {
        self.ping_reply().await;
        self.indicator_req_sender
            .send(IndicatorRequest::BlinkBlueLed {
                blinks: 3,
                interval: 70,
            })
            .await;
    }
Rust

それほど構造が複雑になるわけではありませんが、メソッドを直に呼ぶほどには直感的ではないかもしれません。Channel を使う際には、Channel から Sender と Receiver というオブジェクトを発行してもらい、これらを送り側と受け側各々が所有します。Sender からデータを送ると Receiver にそれが届くため、Channel を通して「依頼書」を送ると Rust の所有者ルールを破らずにオブジェクトの操作が可能になります。

これって何か得があるの?

Rust はソフトウェアの構造を自由に選べるわけではなくて、このようにメモリ所有のルールが大変厳格なため取れる構造に限りがあり、そのあたりが柔軟性が高くて何でも書けてしまう C とは違います。でもこの頑固さによる利点はちゃんとあると思います。

一番の利点は、書いたプログラムが状態の遷移がいつどこで起きるか非常に明確になること。Channel に送り込むメッセージはイミュータブルで一旦生成したら内容は変わりません。また、依頼側がチャネルに何かを送ったからといって状態が変わることもありません。例えば前節の例では、ping 処理部の状態は LED 点滅依頼を送る前と後で同じです。メッセージを送ったがために状態が変わるのは受け側のみです。Dependency Injection をするとそこが違って、点滅依頼をすると自分自身の状態も変わってしまいます。そして「依頼側」と「受け側」の区別も明確ではありません。Rust のスタイルの方が変化が起きる場所とタイミングが明確でデバッグが簡単です。

もう一つ、変更の衝突が起きにくいという利点もあります。メソッドを直接呼ぶ方法では、例えば一か所で点滅を開始したのと同時に別の場所で同じ LED の消灯のメソッドが呼ばれたりすると LED インジケータはよくわからない状態になってしまいます。これを防ぐにはロックを使ったりセマフォを使ったりシグナルを使ったりと実装が面倒になります。これは C++ で開発していた時に実際時々起こっていた問題で起こるとデバッグが面倒でした。一方で Rust の上の例のような実装では、操作依頼が同時に出たとしてもそれはどちらかの順番で一旦チャネルに入って、LED 制御部は依頼を一個一個順番に処理するので処理の衝突は起こりません。不思議ですがソフトウェアで魔法が使えるわけはなく、衝突防止の仕組みは実のところチャネルに入っています。つまり備え付けの仕組みに織り込み済みで自分では衝突防止用のコードを書かなくて良いわけです。

Rust プログラミングは C++ とは違うゲーム

C++ から Rust に引っ越したのはこれで二度目なのですが、二度ともプログラミングの注力点が大きく変わりました。それが良くて積極的に移行しています、

C++ は「何でも書けちゃう」C 出身の言語なのでやっぱり何でも書けてしまい、あまりうるさく言わないコンパイラの元、ソフトウェアの規模が小さいときには速く書けて楽なのですが、あまり設計に手を抜くと規模が大きくなるにつれあちらこちらで誤動作が出がちです。あとになってから設計の大きな問題に気づき書き直しを余儀なくされたりするなど、開発の後半に来ると進捗が鈍りがちです。そのため、開発の最初の段階でかなりの時間をとって注意深く設計を行う必要があります。でも PoC (Proof of Concept) プロジェクトなどでは開始時に全体が見通せているわけではなく、ある程度で見切り発車して考えながら進めたりします。そういう場合ある時点で設計が行き詰まって先に進めなくなることもよくあり、プログラミングはいかにきちんと設計するかのゲームになります。あとそういう言語は開発開始時には仕様が決まっているウォーターフロー開発向きかもしれませんね。

一方 Rust ではダメな設計は頑固コンパイラの危険検知に引っかかってコンパイルを通してもらえず、コンパイルできるようになるまでいくつかのやり方を試す必要がありますが、実のところ Rust で選べる設計手法は限られていて慣れてくるといくつか取れる方法から選ぶだけになってきます。そして一旦コンパイルが通ってしまうと設計はあまり崩れません。どちらにしても設計はきちんとできていないといけないことが前提ですけれども Rust を書く時には安全面はコンパイラがかなり面倒を見てくれるので注意はむしろアプリケーションの機能や実行の効率に向くことになります。そういうわけで後で書き直しが必要になってしまうことが少なく開発が一定ペースで進んで行きます。見切り発車の開発でも後半しんどくならないし、仕様変更も比較的容易です。アジャイル開発向きの言語なのかもしれません。

Comments

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

コメントを残す

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

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