Embedded HAL トレイト: embassy-nrf HAL と lsm303agr ドライバーをつなぐ
これは、embedded-hal トレイトが HAL とドライバーをどのようにつなぐのかを理解するための任意のセクションです。ここでは、Twim と Lsm303agr がこのトレイトシステムを通じてどのように接続されているかを簡単に見ていきます。今は読み飛ばして、興味があれば後で戻ってきても構いません。
lsm303agr のようなドライバーは、micro:bit だけでなく、多くの異なるボードで動作するように作られています。これを可能にしているのが embedded-hal トレイトで、I2C のような共通インターフェイスを定義しています。これらのトレイトは特定のマイクロコントローラーに依存しません。実際のハードウェアサポートは embassy-nrf のような HAL が担い、micro:bit v2 で使われている nRF52833 のようなチップ向けの実装を提供します。
このセクションでは、これらのコンポーネントがどのように組み合わさるのかを見ていきます。具体的には、embassy-nrf の Twim インスタンスを lsm303agr ドライバーに渡し、embedded-hal トレイトを使って LSM303AGR センサーと通信できるようにする方法を確認します。
I2C トレイトは次の関数を定義しています。
- transaction: 一連の読み取りと書き込みを、1 回の I2C トランザクションとして実行します。これは HAL が実装しなければならない中核のメソッドです。他の関数(read、write、write_read)はこの上に構築されています。
- read: I2C スレーブからバイトを読み取ります。これにはデフォルト実装があり、内部で transaction を呼び出します。
- write: スレーブにバイトを書き込みます。これにも transaction を使うデフォルト実装があります。
- write_read: 数バイトを書き込んだ後、バスを解放せずにスレーブから読み取ります。これも transaction を使って実装されています。
トレイト定義はこちらで確認できます。
I2c トレイトと HAL の統合
これは、embedded-hal が提供するトレイトの 1 つである I2c トレイトを説明する図の例です。学んだとおり、このトレイトは 4 つの関数を定義しており、transaction 関数は HAL が実装する必要があります。
lsm303agr ドライバーは、I2c トレイトを実装している任意の I2C インターフェイスで動作します。図に示されているように、複数の HAL がこのトレイトを実装できます。たとえば、embassy-nrf HAL は Twim 構造体に対する I2c の実装を提供しており、それを lsm303agr と組み合わせて使用できます。
同様に、esp-hal(ESP32 用)のような別の HAL も I2c トレイトを実装しています。つまり、互換性のある I2C 実装を渡すだけで、同じ lsm303agr ドライバーを異なるプラットフォームで使用できます。このトレイトベースの設計によって、ドライバーはプラットフォーム非依存になり、ESP32 や nRF ベースの micro:bit のような異なるボード間で再利用できるようになります。
ドライバー(lsm303agr)側
次に、ドライバー側で何が起きているのかを例を使って見てみましょう。"sensor.acceleration()" を呼び出すと、最終的に embedded-hal-async が提供する I2c トレイトの "write_read" 関数の呼び出しにつながります(async 版を使用しているためです)。同様に、sensor.init() を呼び出すと、"write" の呼び出しにつながります。
以下は、加速度センサーを読み取るために lsm303agr クレート内部で使われている関数の簡略版です。
#![allow(unused)] fn main() { // sensor.acceleration() の呼び出し => read_accel_3_double_registers() の呼び出し => i2c.write_read() の呼び出し async fn read_3_double_registers<R: RegRead<(u16, u16, u16)>>( &mut self, address: u8, ) -> Result<R::Output, Error<E>> { let mut data = [0; 6]; // ここで、ドライバーは提供された I2C インターフェイスを使い、その "write_read" 関数を呼び出します self.i2c .write_read(address, &[R::ADDR | 0x80], &mut data) .await .map_err(Error::Comm)?; Ok(R::from_data(( u16::from_le_bytes([data[0], data[1]]), u16::from_le_bytes([data[2], data[3]]), u16::from_le_bytes([data[4], data[5]]), ))) } }
この関数は lsm303agr クレート内で定義されており、提供された I2C インターフェイスに対して "write_read" を使用しています。今回の場合、その I2C インターフェイスは embassy-nrf の Twim 構造体のインスタンスです。
HAL(embassy-nrf)側
以下は、Twim 構造体が I2c トレイトをどのように実装しているかです(これは Github repository でも確認できます)。
#![allow(unused)] fn main() { // トレイト実装 impl<'d, T: Instance> embedded_hal_async::i2c::I2c for Twim<'d, T> { async fn transaction(&mut self, address: u8, operations: &mut [Operation<'_>]) -> Result<(), Self::Error> { self.transaction(address, operations).await } } // ... // ... impl<'d, T: Instance> Twim<'d, T> { ... pub async fn transaction(&mut self, address: u8, mut operations: &mut [Operation<'_>]) -> Result<(), Error> { // 関数の完全なロジックですが、現時点では私たちにとって重要ではありません。 Ok(()) } // ... } }
ここで、疑問に思うかもしれません。Twim 型が embedded_hal_async::i2c::I2c トレイトを実装しているなら、write_read、read、write のような残りの必須関数はどこで定義されているのでしょうか。
答えは、Twim の実装内で手動では定義されていない、ということです。そして、それで問題ありません。
なぜなら、embedded-hal-async クレートは、transaction 関数だけを使ってこれらのメソッド(read、write、write_read)のデフォルト実装を提供しているからです。
つまり、Twim が transaction メソッドを実装すると、他の必要なメソッドはすべてトレイトのデフォルト実装を通じて自動的に利用可能になります。
たとえば、以下は write 関数のデフォルト実装です。
#![allow(unused)] fn main() { #[inline] async fn write(&mut self, address: A, write: &[u8]) -> Result<(), Self::Error> { self.transaction(address, &mut [Operation::Write(write)]) // => transaction を呼び出します .await } }
この設計パターンこそが、embedded Rust を非常に強力でモジュール的なものにしています。HAL クレートは最小限のロジックだけを実装すればよく、ドライバークレートは異なるプラットフォーム間で再利用可能なまま保たれます。
