mruby/cペリフェラルライブラリのSTM32マイコンへの実装 Chapter05: I2Cクラス実装編

[しまねソフト研究開発センター](https://www.s-itoc.jp)（略称 ITOC）にいます、東です。

[mruby/cペリフェラルライブラリのSTM32マイコンへの実装の記事](https://qiita.com/HirohitoHigashi/items/4a88aad1523455ae9857)、今回はその第5回、I2Cクラスを実装します。

# 目標

[I2CクラスのAPIガイドライン](https://github.com/mruby/microcontroller-peripheral-interface-guide/blob/main/mruby_io_I2C_ja.md) に従って、STM32マイコン（Nucleo F401RE) 向けの実装を完了させる。

# 今回の方針

* ボード上のシルク SCL/D15, SDA/D14 と書かれたピンにのみ対応する
* CubeIDEで設定したユニット I2C1 のみサポートする
* I2Cマスタのみ、7ビットアドレスのみサポートする（ガイドラインがそのように定義）
* ガイドラインの低レベルメソッド(Low level method)の実装はしない
* C言語のみで実装する


# I2Cユニットの仕様と、使用方法調査

当ボードに搭載されている STM32F401RET6 は、3つのI2Cユニットを持っています。
DS10086: Table 9. Alternate function mapping を参照すると、ピン割り当ての自由度があまり無く、例えば今回使うユニット1 (I2C1) については、SCLがPB6かPB8、SDAがPB7かPB9といったように2パターンのみです。
第1回でも述べたとおり、CubeIDEのピンアサイン、コード自動生成機能とmruby/cからのピンアサイン指示は折り合いがつけにくそうな事と、このようにハード的自由度がさほど無いことから、無理してピンアサイン対応はやめてピン番号は固定で実装します。

## HALライブラリ調査

メーカー製 HAL リファレンスマニュアル (UM1725) や、CubeIDEでのコード自動生成により、HALでI2Cを使う方法を調査します。
その結果、

* ポーリング、割り込み、DMAの3種類の方法があるが、当面は最も簡単に使えるポーリングモードで実装すればよさそう
* 送信 (HAL_I2C_Master_Transmit) と、受信 (HAL_I2C_Master_Receive) しか用意されておらず、ガイドラインの低レベルメソッドの実装は、HALでは不可能
* I2Cでとてもよく使われる `Start→Write→RepeatedStart→Read→End` というトランザクションがあるが、これも前述の送受信関数では実装不可能
* RepeatedStart をサポートした関数、`HAL_I2C_Mem_Read()` 関数があるが、I2C接続のEEPROMを想定した関数らしく、Writeが２バイトまでという制限がある

という、HALの機能不足により、なかなかに利用が難しいことが分かりました。

ガイドラインのフル実装を行うことを目指すならば、HALの使用をあきらめてLLもしくはレジスタ直接アクセスとなりますが、経験からいうと上記の制限下においても、8〜9割方のデバイスは動作してくれるので、今回は機能制限版を HALだけで実装することにします。


# 作業手順

では、実際の作業に入ります。

## 雛形の作成

[GPIOクラス実装編](https://qiita.com/HirohitoHigashi/items/9be9124f01a180fb68b1) と同様に、雛形のファイルを用意してから、これに肉付けしていきます。

```c:Core/mrubyc/stm32f4_i2c.c
#include "main.h"
#include "../mrubyc_src/mrubyc.h"

extern I2C_HandleTypeDef hi2c1;

static void c_i2c_read(mrb_vm *vm, mrb_value v[], int argc)
{
}

static void c_i2c_write(mrb_vm *vm, mrb_value v[], int argc)
{
}

void mrbc_init_class_i2c(void)
{
  mrbc_class *cls = mrbc_define_class(0, "I2C", 0);

  mrbc_define_method(0, cls, "read", c_i2c_read);
  mrbc_define_method(0, cls, "write", c_i2c_write);
}
```

I2Cも、今後作る他の機能へ提供する機能は無いので、簡略化のためにCヘッダファイルを作りません。
今回のように、かなりの部分が決め打ちになると、雛形も単純になります。
まず、他のクラスのように、コンストラクタを作る必要がありません。

```c
  // （今回必要なかったコンストラクタ定義の例）
  mrbc_define_method(0, cls, "new", c_i2c_new);
```

前回までに作ったPWMクラスなどは、コンストラクタにパラメータがあり、また各インスタンスは、ユニット番号や使用するピンなど、自分の属性を覚えておく必要があったため、コンストラクタを実装して対応しました。一方、I2Cクラスではユニットもピン番号もすべて固定のため、そのような必要がありません。

次に、`start_mrubyc()` 関数へ、今回作成したI2C初期化用関数 mrbc_init_class_i2c() をコールするよう書き足します。

```c:Core/mrubyc/start_mrubyc.c
/*! mruby/c プログラムの実行開始
*/
void start_mrubyc( void )
{
  mrbc_init(memory_pool, MRBC_MEMORY_SIZE);

  // 各クラスの初期化
  void mrbc_init_class_gpio(void);
  mrbc_init_class_gpio();
  void mrbc_init_class_adc(void);
  mrbc_init_class_adc();
  void mrbc_init_class_pwm(void);
  mrbc_init_class_pwm();
  void mrbc_init_class_i2c(void);    // 追加
  mrbc_init_class_i2c();             // 追加
```
この段階で、一度正しくビルドができるか確認します。
さらに注意深く確認するには、Rubyスクリプトでも I2C クラスが使えるようになったかを確認するコードを書いておくと良いと思います。

```ruby:Core/mrubyc/task1.rb
i2c = I2C.new()

puts "DONE"
```

## 出力データの整形

実装にあたり、ガイドラインの write メソッドの仕様を再確認します。

:::note info
writeメソッド仕様
```
write( i2c_adrs_7 , *outputs ) -> Integer
```
  * i2c_adrs_7 アドレスのデバイスに、outputs で指定したデータを書き込む。
  * 書き込みできたバイト数が戻り値として返る。
  * outputsは、Integer, Array<Integer> および String で指定する。

使用例
```ruby
i2c.write( 0x45, 0x30, 0xa2 )
i2c.write( 0x50, 0x00, 0x80, data_string )  # useful for EEPROM
i2c.write( 0x11, [0x30, 0xa2] )
```
:::

ということで、可変長引数かつ複数の型（Integer, Array, String）を許容するという、かなり複雑な仕様になっています。この仕様は、readメソッドの第3引数以降も同じ仕様ですから、解析と出力データの整形をサブルーチン化したほうが良さそうです。

今回はポリシーによりC言語だけで作りますが、別案としてC言語では単純な作りの下請け関数を作り、read,writeメソッドは mrubyで書いて、そこで引数の処理などを行ったのち、実際の処理のみ下請け関数へ任せる方法が簡単だとは思います。

以下は、C言語のみで記述した、「引数を解析して出力データとしてバッファにまとめる」ためのサブルーチン例です。

```c
//================================================================
/*! make output buffer

  @param vm     Pointer to vm
  @param v      argments
  @param argc   num of arguments
  @param start_idx  Argument parsing start position.
  @param ret_bufsiz allocated buffer size.
  @return       pointer to allocated buffer, or NULL is error.
*/
uint8_t * make_output_buffer(mrb_vm *vm, mrb_value v[], int argc,
                             int start_idx, int *ret_bufsiz)
{
  uint8_t *ret = 0;

  // calc temporary buffer size.
  int bufsiz = 0;
  for( int i = start_idx; i <= argc; i++ ) {
    switch( v[i].tt ) {
    case MRBC_TT_INTEGER:
      bufsiz += 1;
      break;

    case MRBC_TT_STRING:
      bufsiz += mrbc_string_size(&v[i]);
      break;

    case MRBC_TT_ARRAY:
      bufsiz += mrbc_array_size(&v[i]);
      break;

    default:
      goto ERROR_PARAM;
    }
  }
  *ret_bufsiz = bufsiz;
  if( bufsiz == 0 ) goto ERROR_PARAM;

  // alloc buffer and copy data
  ret = mrbc_alloc(vm, bufsiz);
  uint8_t *pbuf = ret;
  for( int i = start_idx; i <= argc; i++ ) {
    switch( v[i].tt ) {
    case MRBC_TT_INTEGER:
      *pbuf++ = mrbc_integer(v[i]);
      break;

    case MRBC_TT_STRING:
      memcpy( pbuf, mrbc_string_cstr(&v[i]), mrbc_string_size(&v[i]) );
      pbuf += mrbc_string_size(&v[i]);
      break;

    case MRBC_TT_ARRAY: {
      for( int j = 0; j < mrbc_array_size(&v[i]); j++ ) {
        mrbc_value val = mrbc_array_get(&v[i], j);
        if( val.tt != MRBC_TT_INTEGER ) goto ERROR_PARAM;
        *pbuf++ = mrbc_integer(val);
      }
    } break;

    default:
      //
    }
  }
  return ret;


 ERROR_PARAM:
  mrbc_raise(vm, MRBC_CLASS(ArgumentError), "Output parameter error.");
  if( ret != 0 ) {
    mrbc_free( vm, ret );
  }

  return 0;
}
```

このサブルーチンは、お約束の3引数 (mrb_vm *vm, mrb_value v[], int argc) とともに、引数解析開始位置 (start_idx) を渡すと、動的にバッファ用メモリを確保して出力データをコピーし、バッファへのポインタとバッファサイズ (ret_bufsiz) を返します。

## writeメソッドの実装

では、このサブルーチンを使って write メソッドを実装します。

```c
static const uint32_t I2C_TIMEOUT_ms = 3000;

static void c_i2c_write(mrb_vm *vm, mrb_value v[], int argc)
{
  uint8_t *buf = 0;
  int bufsiz = 0;

  // Get parameter
  if( argc < 1 ) goto ERROR_PARAM;
  if( v[1].tt != MRBC_TT_INTEGER ) goto ERROR_PARAM;
  int i2c_adrs_7 = mrbc_integer(v[1]);

  buf = make_output_buffer( vm, v, argc, 2, &bufsiz );
  if( !buf ) goto RETURN;

  // Start I2C communication
  HAL_StatusTypeDef sts;
  sts = HAL_I2C_Master_Transmit( &hi2c1, i2c_adrs_7 << 1,
                                 buf, bufsiz, I2C_TIMEOUT_ms );
  mrbc_free( vm, buf );

  if( sts != HAL_OK ) {
    mrbc_raisef(vm, 0, "i2c#write: HAL layer error (status code %d)", sts);
  }
  goto RETURN;


 ERROR_PARAM:
  mrbc_raise(vm, MRBC_CLASS(ArgumentError), "i2c#write: parameter error.");

 RETURN:
  SET_RETURN( mrbc_integer_value(bufsiz) );
}
```

ポイントは、次の点です。
  * 第1引数はI2Cアドレスと決まっているので、ここだけ独自に取り出す
  * HAL_I2C_Master_Transmit() 関数を使う
  * そのときの I2C アドレスは、1ビットシフトした値を使う
  * make_output_buffer()関数が動的にメモリを確保しているので、全てのケースで破綻無く解放　（mrbc_free）する

## readメソッドの実装

次にreadメソッドを実装します。readメソッドの仕様を確認すると、

:::note info
readメソッド仕様
```
read( i2c_adrs_7, read_bytes, *param ) -> String
```
  * i2c_adrs_7 アドレスのデバイスから、read_bytes バイトのデータを読み込む。
  * デバイスが途中で NAK を返す場合は、read_bytes より短い長さの String が返る可能性がある。
  * param にデータが指定されていれば、それを出力してからリピーテッドスタートを挟み読み込みを始める。
  * 出力の仕様は、write() を参照。

使用例

```ruby
s = i2c.read( 0x45, 3 )                 # case 1
s = i2c.read( 0x45, 3, 0xf3, 0x2d )     # case 2
```
:::

ということで、単純にread するだけの場合と、writeに引き続いてreadする場合と2パターンにわけられます。
単純にreadするだけの場合（case1）は、HAL関数 `HAL_I2C_Master_Receive()` を使うだけです。

問題は case2の場合です。
`HAL_I2C_Master_Transmit()` の後に `HAL_I2C_Master_Receive()` をコールすれば良さそうですが、こうすると実際のI2Cバスのシーケンスは、以下のように2つのトランザクションに分離します。[^1]
```
(HAL_I2C_Master_Transmit)
 S -- adrs(0x45) W A -- 0xf3 A -- 0x2d A -- P

(HAL_I2C_Master_Receive)
 S -- adrs(0x45) R A -- data1 A -- data2 A -- data3 N -- P

    S : Start condition
    P : Stop condition
    Sr: Repeated start condition
    A : Ack
    N : Nack
    R : Read bit
    W : Write bit
```

本来は、1つめのトランザクションと2つめのトランザクションを Repeated start condition で接続することが求められています。

```
S -- adrs(0x45) W A -- 0xf3 A -- 0x2d A -- Sr -- adrs(0x45) R A -- data1 A -- data2 A -- data3 N -- P
```

そこで、`HAL_I2C_Mem_Read()` 関数に着目します。

:::note info
HAL_I2C_Mem_Read関数
```
HAL_StatusTypeDef HAL_I2C_Mem_Read (I2C_HandleTypeDef * hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t * pData, uint16_t Size, uint32_t Timeout)
```

Function description
&nbsp;&nbsp;&nbsp;&nbsp;Read an amount of data in blocking mode from a specific memory address.
Parameters

* hi2c: Pointer to a I2C_HandleTypeDef structure that contains the configuration information for the specified I2C.
* DevAddress: Target device address: The device 7 bits address value in datasheet must be shifted to the left before calling the interface
* MemAddress: Internal memory address
* MemAddSize: Size of internal memory address
* pData: Pointer to data buffer
* Size: Amount of data to be sent
* Timeout: Timeout duration
:::

実際にこの関数を使ってテストコードを書いてみると、出力するデータを MemAddress に入れてやることで目的の I2C トランザクションが発行されます。ただし、MemAddress は2バイトまでという制限があります。これは大きな制限ではありますが、実際のデバイスでは2バイト以上の write を要求するものは希ですので、これを使って実装してみます。

```c
static void c_i2c_read(mrb_vm *vm, mrb_value v[], int argc)
{
  uint8_t *buf = 0;
  int bufsiz = 0;
  mrbc_value ret = mrbc_nil_value();

  // Get parameter
  if( argc < 2 ) goto ERROR_PARAM;
  if( v[1].tt != MRBC_TT_INTEGER ) goto ERROR_PARAM;
  int i2c_adrs_7 = mrbc_integer(v[1]);

  if( v[2].tt != MRBC_TT_INTEGER ) goto ERROR_PARAM;
  int read_bytes = mrbc_integer(v[2]);
  if( read_bytes < 0 ) goto ERROR_PARAM;

  if( argc > 2 ) {
    buf = make_output_buffer( vm, v, argc, 3, &bufsiz );
    if( !buf ) goto RETURN;
  }

  // Start I2C communication
  ret = mrbc_string_new(vm, 0, read_bytes);
  uint8_t *p = (uint8_t *)mrbc_string_cstr(&ret);
  HAL_StatusTypeDef sts;

  if( buf == 0 ) {
    sts = HAL_I2C_Master_Receive( &hi2c1, i2c_adrs_7 << 1,
                                  p, read_bytes, I2C_TIMEOUT_ms );

  } else if( bufsiz == 1 ) {
    sts = HAL_I2C_Mem_Read( &hi2c1, i2c_adrs_7 << 1, buf[0],
                I2C_MEMADD_SIZE_8BIT, p, read_bytes, I2C_TIMEOUT_ms );

  } else if( bufsiz == 2 ) {
    sts = HAL_I2C_Mem_Read( &hi2c1, i2c_adrs_7 << 1, buf[0] << 8 | buf[1],
                I2C_MEMADD_SIZE_16BIT, p, read_bytes, I2C_TIMEOUT_ms );

  } else {
    mrbc_raise(vm, 0, "i2c#read: output parameter must be less than 2 bytes.");
    goto RETURN;
  }

  if( sts != HAL_OK ) {
    mrbc_raisef(vm, 0, "i2c#read: HAL layer error (status code %d)", sts);
  }
  goto RETURN;


 ERROR_PARAM:
  mrbc_raise(vm, MRBC_CLASS(ArgumentError), "i2c#read: parameter error.");

 RETURN:
  if( buf ) mrbc_free( vm, buf );
  SET_RETURN(ret);
}
```

以上で実装が完了です。


# テスト

気圧センサー ST LPS25H を接続して、テストします。

![STM32mrbc05-LPS25H.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/0a1329a8-9c99-5cda-c24d-90e001ef5b52.jpeg)
秋月電子より、ブレイクアウトボードが発売されていますので、それを使い以下のように接続します。

![STM32mrbc05-LPS25H_Schema.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/2f37f130-e209-44cc-bc3d-e863b3eb7083.png)


## write メソッド

センサICの初期化コマンドを write するトランザクションを、アナライザで確認します。

```ruby
@bus.write( @address, 0x20, 0x90 )
```

I2Cアドレスは、0x5C です。

![STM32mrbc05-scope_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/a048731c-446c-deb3-9475-311648442361.png)

きちんと、I2Cアドレス(0x5C)+　Writeビット, 0x20, 0x90 の順で出力されています。

## read メソッド

センサICのWHO_AM_Iレジスタ(0x0F) をreadするトランザクションを、アナライザで確認します。

```ruby
@bus.read( @address, 1, 0x0f )
```

![STM32mrbc05-scope_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/899e6bd0-2e92-1a8f-512c-2ae7505e518c.png)

こちらも希望通り、WriteとReadが、RepeatedStart を挟んで一つのトランザクションになっているのを確認できました。


プログラム全体は、以下の通りです。

```ruby:Core/mrubyc/task1.rb
#
# ST LPS25H
# MEMS pressure sensor: 260-1260 hPa absolute digital output barometer
# https://www.st.com/ja/mems-and-sensors/lps25h.html
#

class LPS25H
  I2C_ADRS = 0x5c

  #
  # init instance
  #
  def initialize( i2c_bus, address = I2C_ADRS )
    @bus = i2c_bus
    @address = address
  end

  #
  # init sensor
  #
  def init()
    d = @bus.read( @address, 1, 0x0f ).bytes
    raise "Sensor not found" if d[0] != 0xbd

    @bus.write( @address, 0x20, 0x90 )
  end

  #
  # meas
  #
  def meas()
    d = @bus.read( @address, 5, 0xa8 ).bytes

    return {
      :pressure => to_uint24(d[2], d[1], d[0]).to_f / 4096,
      :temperature => 42.5 + to_int16(d[4], d[3]).to_f / 480,
    }
  end


  def to_int16( b1, b2 )
    return (b1 << 8 | b2) - ((b1 & 0x80) << 9)
  end
  def to_uint24( b1, b2, b3 )
    return b1 << 16 | b2 << 8 | b3
  end
end


puts "LPS25H Barometer"
sensor = LPS25H.new( I2C.new )
sensor.init()

while true
  sleep 1

  data = sensor.meas()
  printf( "Temp:%4.1f C  Pres:%7.2f hPa\n",
          data[:temperature], data[:pressure] )
end
```


**実行結果**
```
LPS25H Barometer
Temp:25.7 C  Pres:1005.87 hPa
Temp:25.7 C  Pres:1005.81 hPa
Temp:25.7 C  Pres:1005.89 hPa
 ...
```


# おわりに

ファイル全体は、github リポジトリにありますので、そちらをご覧ください。

https://github.com/HirohitoHigashi/mrubyc-nucleo-F401RE/tree/chapter05_i2c

今回は、I2Cクラスを実装しました。ピン番号などすべて固定という条件をつけることで、クラスの雛形はとても簡単になりましたが、実際のメソッドは可変長かつ複数の型を許すという複雑な仕様だったため、メソッドの実装は少々複雑になりました。しかし、利用者目線で考えれば自由度が上がり、使いやすいものになったと思います。
また、HALライブラリの機能不足により、限定された機能のみの実装になりましたが、それでも各種センサーやEEPROM等、多くの種類のデバイスのコントロールができると思います。
次回は、SPIクラスを実装します。


[^1]:実際には、デバイス側のデータシートで RepeatedStart を要求していても、分離したトランザクションで正しく動くデバイスも多いので、問題にならないケースも多いと思われます。