本稿はChatGPTと壁打ちしながらプログラムを作成し、その流れをベースにChatGPTに原稿を作成してもらったものです。ご承知ください。
はじめに
組み込み Rust で、ある程度規模のあるプログラムを書こうとすると、次のような壁にぶつかることがあります。
- GPIO、I2C、PWM などのペリフェラルを
複数のstructにどう渡せばよいのか分からない - 参照(
&/&mut)で渡そうとすると借用が衝突してしまう - 結果として
main関数にすべての処理が集まり、巨大になってしまう
本稿では、この問題がなぜ起きるのか、そして HAL がどのような思想を前提として設計されているのかを整理します。
結論を先に述べると、
pac::Peripheralsは「保持し続ける」ためのものではなく、
必要なペリフェラルを所有権ごと抜き出して使い、
それ以降は戻さないという使い方を前提として設計されています。
この点を理解することが重要です。
1. なぜ参照で渡そうとして詰まるのか
まず、多くの人が最初に考える設計は次のようなものではないでしょうか。
struct Board<'a> {
pac: &'a mut pac::Peripherals,
}
あるいは、
struct Device<'a> {
i2c: &'a mut I2C0,
}
しかしこの設計は、少し規模が大きくなるとすぐに破綻します。
- 同じ
pacを複数のstructが&mutで借用できません - ある
structが I2C を使っている間、他が何もできなくなります - 初期化順やライフタイムが複雑になります
これは Rust の制約が厳しいからではなく、
HAL が想定している使い方と逆方向に設計してしまっていることが原因です。
2. HAL が前提としている考え方
HAL の API をよく見ると、次のような特徴があります。
let dp = pac::Peripherals::take().unwrap();
let i2c = I2C::new(dp.I2C0, ...);
let pwm = Pwm::new(dp.PWM, ...);
ここで重要なのは、以下の点です。
-
pac::Peripheralsの各フィールドは 参照ではなく move されます - 一度 move されたフィールドは 二度と使えません
- HAL は「取り出して使い、戻さない」ことを前提に設計されています
これは偶然ではなく、HAL は次の思想に基づいています。
物理的に 1 つしか存在しないハードウェア資源は、
プログラム上でも 1 つの所有者しか持てないようにする。
3. 所有権を「抜き出す」という感覚
この挙動は、Rust の通常のコレクション型で考えると理解しやすくなります。
Vec の例
let mut v = vec![10, 20, 30];
let x = v.remove(0);
このとき、
-
xが10を所有します -
vはもう10を持っていません - 同じ要素を同時に触ることはできません
pac::Peripherals も同じ構造です
let dp = pac::Peripherals::take().unwrap();
let i2c = I2C::new(dp.I2C0, ...);
これは感覚的には、
巨大な
Vec<Peripheral>から、
I2C という要素をremove()している
のとほぼ同じです。
- I2C を所有する
i2cが生成されます -
dpは I2C を失います - 所有権は一方向に移動し、戻りません
4. Board パターン:pac を「保持しない」構造
この思想を踏まえると、Board の役割は明確になります。
-
pac::Peripheralsを保持することではありません - 初期化時に pac を分解し、完成した HAL 型だけを保持します
Board の例
pub struct Board {
pub i2c: hal::I2C<pac::I2C0>,
pub led: hal::gpio::Pin<Output<PushPull>>,
}
impl Board {
pub fn new(pac: pac::Peripherals) -> Self {
let pins = Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
pac.SIO,
&mut pac.RESETS,
);
let i2c = I2C::new(
pac.I2C0,
pins.gpio4.into_mode(),
pins.gpio5.into_mode(),
400.kHz(),
&mut pac.RESETS,
);
let led = pins.gpio25.into_push_pull_output();
Self { i2c, led }
}
}
ここで重要なのは、
-
Boardはpac::Peripheralsを 一切保持していない -
pacはnew()の中で完全に消費されます - 以降は「完成済みの部品」だけが残ります
という点です。
5. I2C を使った具体例
I2C は複数デバイスで共有されることが多いため、
この思想が特に重要になります。
単一デバイスの場合
struct Display {
i2c: I2C<pac::I2C0>,
}
impl Display {
fn new(i2c: I2C<pac::I2C0>) -> Self {
Self { i2c }
}
}
- I2C の所有権は
Displayに完全に移動します - 他の場所から I2C を触ることはできません
複数デバイスで共有する場合(概念)
- I2C 自体の所有権は 1 箇所に集約します
- 各デバイスは排他的に借用します
このときに使われるのが、
shared-buscritical_section::Mutex<RefCell<I2C>>- RTIC / embassy の Mutex
などです。
ここでも pac は一切登場しない点が重要です。
6. なぜこの設計が必要なのか
もし HAL が次のように設計されていたらどうなるでしょうか。
- I2C を参照で自由に渡せる
- pac を clone できる
その結果、
- 複数ドライバが同時に同じレジスタを操作する
- 初期化順の競合が起きる
- 割り込みとの競合が発生する
- 未定義動作に陥る
といった問題を コンパイル時に防げなくなります。
これを避けるため、HAL は
所有権を move する設計
を採用しています。
まとめ
-
pac::Peripheralsは 保持するための構造体ではありません - 各ペリフェラルを 所有権ごと抜き出して使うことが前提です
- HAL の API は、この使い方を自然に強制するよう設計されています
-
Boardは pac を持たず、完成済み HAL 型だけを束ねます - I2C などの共有は、pac とは別のレイヤで解決します
この思想を理解すると、
- なぜ参照で渡そうとするとうまくいかないのか
- なぜ
main関数が巨大になりがちなのか - なぜ HAL の example があの形になっているのか
を、一貫した形で説明できるようになります。
終わりに
これまで私は電子工作ではずっとArduinoを使っていたのですが、PCアプリ開発ではRustを使っていましたので、マイコンでもRustを使えないかと思っていました。
しかし、実際に組み込みRustに挑戦してみたのですが、本稿の冒頭のようにペリフェラルをうまくモジュールに分配することができず、無駄に大きなmain関数になってしまい、挙げ句の果てにはgpioをグローバル変数にし、そのアクセスを全部unsafeで囲うような無様なプログラムとなってしまったのです。
この反省のもと、今度はChatGPTの助けを借りて本稿で書かれているような書き方をすることで、ようやく満足いくようなプログラム構造に到達することができました。
このときの壁打ちの様子をベースにして、ChatGPTに原稿まで作成してもらったのが本稿です。まだまだ、組み込みRustが手の内になったというにはほど遠いレベルではありますが、これをきっかけに少しずつ組み込みでもRustを使っていくつもりです。
組み込みRust開発で、同じようなところで悩んでいる方の参考になれば幸いです。