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

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

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

# 目標

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

# 今回の方針

* ボード上のシルク SCK,MISO,MOSI は無視して CN7の1,2,3 ピンを使う
* CubeIDEで設定したユニット SPI3 のみサポートする
* SPIマスタ、8ビットモードのみサポートする（ガイドラインがそのように定義）
* チップセレクトはサポートしない（ガイドラインがそのように定義）
* C言語のみで実装する


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

当ボードに搭載されている STM32F401RET6 は、3つのSPIユニットを持っています。
DS10086: Table 9. Alternate function mapping を参照すると、ボード上のシルク SCK,MISO,MOSI に対応するのは、SPI1ユニットであることが分かりますが、SPI1のSCKを割り当てられる PA5とPB3 ピンは、このボードではそれぞれ LED, SWOへの固定的な割り当てがなされており、CubeIDE上でも SPI1ユニットは使用不可である旨が表示されます。よって、シルク印刷は無視することにして、SPI3ユニットを使う事にします（既に第1回で、そのように割り当てています）

| CN | pin | SILK | GPIO | Usage |
|-|-:|-|-|-|
| CN7 | 1 |  | PC10 | SPI3_SCK |
| CN7 | 2 |  | PC11 | SPI3_MISO |
| CN7 | 3 |  | PC12 | SPI3_MOSI |

また、同資料によるとピン割り当ての自由度があまり無いので、I2Cと同様、ピンアサイン対応はやめてピン番号は決め打ちで実装します。

クロックは、ベースが 42MHz で、これをプリスケーラによって分周した値が使われます。分周は、 $ F = 1 / 2^n $ (n=1..8) であるので、$ 1/2 $ から $ 1/256 $ までとなり、ガイドラインが規定するデフォルト値 (1MHz) は設定することができません。よって、デフォルト値は1MHzを超えない値の中で最大値、42MHz / 64 ≃ 656kHz とします。


## HALライブラリ調査

メーカー製 HAL リファレンスマニュアル (UM1725) や、CubeIDEでのコード自動生成により、HALでSPIを使う方法を調査します。
その結果、以下の手順に従えば実現できそうであることがわかりました。

  * ポーリング、割り込み、DMAの3種類の方法があるが、当面は最も簡単に使えるポーリングモードで実装すればよさそう
  * 送信 (HAL_SPI_Transmit) 、受信 (HAL_SPI_Receive)、送受信 (HAL_SPI_TransmitReceive) が用意されている
  * モード設定等は、HAL_SPI_Init() 関数を使えば、周波数、モード(CPOL,CPHA)、MSB or LSB first 等、設定できる
  * モード設定・変更時は、SPIをDISABLEにしてから変更することが必要。(RM0368)


# 作業手順

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

## SPI3の初期パラメータの調整

ガイドラインで示されたデフォルト値に、CubeIDE を使って設定します。
Project Explorer から、（プロジェクト名）.ioc をダブルクリックし、コンフィグレーション画面を開き、Connectivity > SPI3 をクリックします。
Parameter Settings 画面が表示されるので、Clock Parameters 欄を以下の通り設定します。

![STM32mrbc06-CubeIDE1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/dab623e7-1701-27d2-8dff-d61111c195c3.png)


## 雛形の作成

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

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

extern SPI_HandleTypeDef hspi3;


static void c_spi_new(mrbc_vm *vm, mrbc_value v[], int argc)
{
}

static void c_spi_setmode(mrbc_vm *vm, mrbc_value v[], int argc)
{
}

static void c_spi_read(mrbc_vm *vm, mrbc_value v[], int argc)
{
}

static void c_spi_write(mrbc_vm *vm, mrbc_value v[], int argc)
{
}

static void c_spi_transfer(mrbc_vm *vm, mrbc_value v[], int argc)
{
}

void mrbc_init_class_spi(void)
{
  mrbc_class *cls = mrbc_define_class(0, "SPI", 0);

  mrbc_define_method(0, cls, "new", c_spi_new);
  mrbc_define_method(0, cls, "setmode", c_spi_setmode);
  mrbc_define_method(0, cls, "read", c_spi_read);
  mrbc_define_method(0, cls, "write", c_spi_write);
  mrbc_define_method(0, cls, "transfer", c_spi_transfer);

  mrbc_set_class_const(cls, mrbc_str_to_symid("MSB_FIRST"), &mrbc_integer_value(0));
  mrbc_set_class_const(cls, mrbc_str_to_symid("LSB_FIRST"), &mrbc_integer_value(1));
}
```

SPIも、今後作る他の機能へ提供する機能は無いので、簡略化のためにCヘッダファイルを作りません。
また、ガイドラインで規定されたクラス定数 MSB_FIRST, LSB_FIRST を初期化関数内で定義します。数値の0, 1 に深い意味は無く、識別できれば良いです。

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

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

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

```ruby:Core/mrubyc/task1.rb
spi = SPI.new()

puts "DONE"
```

## コンストラクタの実装

今回の方針では、SPI3ユニットしか使わないので、I2Cと同様にコンストラクタは不要にしたいところですが、モード設定パラメータがあるので、そういうわけにもいきません。一方、`setmode` メソッドも存在し、コンストラクタと全く同じキーワードでSPIの動作モードを設定します。
ですので、コンストラクタではインスタンスの生成のみ行い、モード設定は `setmode` メソッドへ委譲します。

```c
static void c_spi_new(mrbc_vm *vm, mrbc_value v[], int argc)
{
  // allocate instance
  v[0] = mrbc_instance_new(vm, v[0].cls, 0);

  c_spi_setmode( vm, v, argc );
}
```

## setmode メソッドの実装

SPIのモード設定、周波数設定等を行うメソッドで、キーワード引数のみ受け付けるよう設計されています。

:::note info
キーワード引数

| param | type | description |
| --- | --- | --- |
| unit | --- | SPIユニットの指定 |
| frequency | Integer | 周波数 (default 1MHz) |
| mode | Integer | 0 to 3 (default 0) |
| first_bit | Constant | SPI::MSB_FIRST or SPI::LSB_FIRST (default MSB_FIRST) |
:::

setmode メソッドでは、これらキーワード引数を取得して、実際の処理は　spi_setmode 関数を作って委譲します。

```c
static void c_spi_setmode(mrbc_vm *vm, mrbc_value v[], int argc)
{
  MRBC_KW_ARG( unit, frequency, mode, first_bit );
  if( !MRBC_KW_END() ) goto RETURN;

  int32_t spi_freq = -1;
  int spi_mode = -1;
  int spi_first_bit = -1;

  if( MRBC_KW_ISVALID(frequency) ) spi_freq = mrbc_integer(frequency);
  if( MRBC_KW_ISVALID(mode) ) spi_mode = mrbc_integer(mode);
  if( MRBC_KW_ISVALID(first_bit) ) spi_first_bit = mrbc_integer(first_bit);

  if( spi_setmode(&hspi3, spi_freq, spi_mode, spi_first_bit) < 0 ) {
    mrbc_raise(vm, MRBC_CLASS(ArgumentError), 0);
  }

 RETURN:
  MRBC_KW_DELETE( unit, frequency, mode, first_bit );
}
```

**spi_setmode関数**
```c
//================================================================
/*! set SPI mode and clock

  @param  hspi		pointer to HAL SPI_Handle
  @param  freq		clock frequency (Hz)
  @param  mode		mode (0..3)
  @param  msb_lsb	first bit msb = 0 or lsb = 1
  @return		zero is no error.
*/
static int spi_setmode( SPI_HandleTypeDef *hspi,
			int32_t freq, int mode, int msb_lsb )
{
  static uint32_t const TBL_PRESCALER[] = {
    SPI_BAUDRATEPRESCALER_2,   SPI_BAUDRATEPRESCALER_4,
    SPI_BAUDRATEPRESCALER_8,   SPI_BAUDRATEPRESCALER_16,
    SPI_BAUDRATEPRESCALER_32,  SPI_BAUDRATEPRESCALER_64,
    SPI_BAUDRATEPRESCALER_128, SPI_BAUDRATEPRESCALER_256,
  };
  static const int NUM_TBL_PRESCALER = sizeof(TBL_PRESCALER) / sizeof(TBL_PRESCALER[0]);

  if( freq > 0 ) {
    int prescaler = 2;
    int n;
    for( n = 0; n < NUM_TBL_PRESCALER; n++, prescaler *= 2 ) {
      uint32_t fcalc = SPI_BASEFREQ / prescaler;
      if( freq >= fcalc ) break;
    }
    hspi->Init.BaudRatePrescaler = TBL_PRESCALER[n];
  }

  switch( mode ) {
  case 0:
    hspi->Init.CLKPolarity = SPI_POLARITY_LOW;
    hspi->Init.CLKPhase = SPI_PHASE_1EDGE;
    break;

  case 1:
    hspi->Init.CLKPolarity = SPI_POLARITY_LOW;
    hspi->Init.CLKPhase = SPI_PHASE_2EDGE;
    break;

  case 2:
    hspi->Init.CLKPolarity = SPI_POLARITY_HIGH;
    hspi->Init.CLKPhase = SPI_PHASE_1EDGE;
    break;

  case 3:
    hspi->Init.CLKPolarity = SPI_POLARITY_HIGH;
    hspi->Init.CLKPhase = SPI_PHASE_2EDGE;
    break;
  }

  switch( msb_lsb ) {
  case 0:
    hspi->Init.FirstBit = SPI_FIRSTBIT_MSB;
    break;

  case 1:
    hspi->Init.FirstBit = SPI_FIRSTBIT_LSB;
    break;
  }

  __HAL_SPI_DISABLE(hspi);
  if (HAL_SPI_Init(hspi) != HAL_OK) return -1;
  __HAL_SPI_ENABLE(hspi);

  return 0;
}
```
ポイントは、リファレンスマニュアルに書いてあるとおり、実際に設定変更をする前に、`__HAL_SPI_DISABLE()` でSPIをDisableにしてから `HAL_SPI_Init()` を呼んでいる事です。
また、このサブ関数を作らなくても `c_spi_setmode` メソッド関数内に処理を書くこともできますが、今後SPI3以外への適用も考えてサブ関数を作って呼ぶようにしています。


## read メソッドの実装

readメソッドの仕様を確認します。

:::note info
readメソッド仕様
```ruby
read( read_bytes ) -> String
```
  * SPIバスから read_bytes バイトのデータを読み込む。
  * 同時に出力されるデータは、0が出力される。
:::

この仕様から、`HAL_SPI_Receive` 関数を使って実装できそうです。しかし実際に実装してテストプログラムを書き、アナライザを使って確認すると、Read時にMOSI端子からゴミデータ出力がされるようです。SPI規格には恐らく違反していませんが、ガイドラインではゼロを出力することが求められていますので、`HAL_SPI_Receive` ではなく、`HAL_SPI_TransmitReceive` を使ってゼロを出力しながらリードするようにします。

```c
static void c_spi_read(mrbc_vm *vm, mrbc_value v[], int argc)
{
  if( v[1].tt != MRBC_TT_INTEGER ) {
    mrbc_raise(vm, MRBC_CLASS(ArgumentError), 0);
    return;
  }

  int read_bytes = mrbc_integer(v[1]);
  mrbc_value ret = mrbc_string_new(vm, 0, read_bytes);
  uint8_t *buf = (uint8_t *)mrbc_string_cstr(&ret);

  memset(buf, 0, read_bytes);

  HAL_StatusTypeDef sts;
  sts = HAL_SPI_TransmitReceive(&hspi3, buf, buf, read_bytes, SPI_TIMEOUT_ms );

  if( sts != HAL_OK ) {
    mrbc_raisef(vm, 0, "HAL layer error (status code %d)", sts);
  }

  SET_RETURN(ret);
}
```

出力と入力のバッファを同じにしているのが少し気がかりですが、少なくとも現バージョンのHALでは問題無いことが確認できているので、メモリ節約のためにそうしています。

## write メソッドの実装

:::note info
writeメソッド仕様

```ruby
write( *outputs ) -> nil
```
 * SPIバスへ、outputs で指定したデータを出力する。
 * outputsは、Integer, Array<Integer> もしくは String で指定する。

使用例
```ruby
spi.write( 0x30, 0xa2 )
spi.write( "\x30\xa2" )
i2c.write( 0x02, 0xee, 0xad, 0x00, data_string )  # useful for EEPROM
```
:::

この仕様は、I2Cの `write` と同じです。よって、I2Cクラス実装時に作ったサブルーチン `make_output_buffer()` を、使って実装します。
```c
uint8_t * make_output_buffer(mrb_vm *vm, mrb_value v[], int argc, int start_idx, int *ret_bufsiz);

static void c_spi_write(mrbc_vm *vm, mrbc_value v[], int argc)
{
  int bufsiz;
  uint8_t *buf = make_output_buffer(vm, v, argc, 1, &bufsiz );
  if( !buf ) return;

  HAL_StatusTypeDef sts;
  sts = HAL_SPI_Transmit(&hspi3, buf, bufsiz, SPI_TIMEOUT_ms );
  mrbc_free( vm, buf );

  if( sts != HAL_OK ) {
    mrbc_raisef(vm, 0, "HAL layer error (status code %d)", sts);
  }

  SET_NIL_RETURN();
}
```

## transfer メソッドの実装

:::note info
transferメソッド仕様
```ruby
transfer( outputs, additional_read_bytes = 0 ) -> String
```
  * SPIバスへ outputs で指定したデータを出力しながら同時に入力する（汎用転送）
  * outputs は、Integer, Array<Integer> もしくは String で指定する。
  * additional_read_bytes を指定すると、そのバイト数分の 0x00 を output に続いて出力する。

使用例
```ruby
s = spi.transfer( 0b0000_0101, 1 )  # s は 2バイトの String が返る
```
:::

writeしながら同時にreadするメソッドです。そのまま　`HAL_SPI_TransmitReceive` に相当します。先ほど実装したreadおよびwriteメソッドと同様の手段を使い、それらを組み合わせてやれば容易に実装できます。

```c
static void c_spi_transfer(mrbc_vm *vm, mrbc_value v[], int argc)
{
  uint8_t *buf = 0;
  int bufsiz;

  if( argc == 0 ) goto ERROR_ARGUMENT;

  buf = make_output_buffer(vm, v, 1, 1, &bufsiz );
  if( !buf ) return;

  if( argc >= 2 ) {
    if( v[2].tt != MRBC_TT_INTEGER ) goto ERROR_ARGUMENT;
    int additional_read_bytes = mrbc_integer(v[2]);

    uint8_t *buf2 = mrbc_realloc(vm, buf, bufsiz + additional_read_bytes);
    if( !buf2 ) {
      mrbc_free(vm, buf);
      mrbc_raise(vm, 0, 0);
      return;
    }

    buf = buf2;
    memset(buf + bufsiz, 0, additional_read_bytes);
    bufsiz += additional_read_bytes;
  }

  mrbc_value ret = mrbc_string_new_alloc(vm, buf, bufsiz);

  HAL_StatusTypeDef sts;
  sts = HAL_SPI_TransmitReceive(&hspi3, buf, buf, bufsiz, SPI_TIMEOUT_ms );

  if( sts != HAL_OK ) {
    mrbc_raisef(vm, 0, "HAL layer error (status code %d)", sts);
  }

  SET_RETURN(ret);
  return;


 ERROR_ARGUMENT:
  mrbc_raise(vm, MRBC_CLASS(ArgumentError),0);
}
```

これで実装は終了です。


# テスト

## 熱電対温度センサーIC MAX31855

MAXIM社の熱電対用温度センサーIC MAX31855 を接続してみます。
センサICのデータシートを確認すると、このICには設定はなにも無く、読めば値が返ってくる仕様です。そのため、MOSIは無く MISO のみです。
SPIのモード等を確認します。
![STM32mrbc06-MAX31855_Timing.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/871283a7-c643-09a4-ba8d-a84913a2ee92.png)

  * クロック最大周波数は、5MHz
  * アイドル時のクロックはLow、サンプリングエッジは立ち上がり[^1] => mode=0
  * MSB first で出力される

よって、すべてSPIクラスのデフォルト値でOKです。
ChipSelect には、GPIOの"PB6" 端子を使います。

[^1]: なお、旧版のデータシートには「クロックの立ち下がりでデータを読む」旨が記載されていたようですが、最新版（Revision5)では削除されており、タイミング図をみても実際の波形を確認しても、明らかに立ち上がりで読むべきです。

![STM32mrbc06-Demo01.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/504019/5235b460-fae7-bde5-0403-1cf2f9c867d7.jpeg)

```ruby:Core/mrubyc/task1.rb
#
# MAX31855
#  Cold-Junction Compensated Thermocouple-to-Digital Converter
#
#  https://www.maximintegrated.com/en/products/interface/sensor-interface/MAX31855.html
#
# Breakout board.
#  https://www.switch-science.com/catalog/864/
#
# Pin assign.
#  CN   pin     GPIO    Usage
#  CN7  1       PC10    SCK
#  CN7  2       PC11    MISO
#  CN5  3       PB6     CS (SS)

puts "MAX31855 Thermo meter."

spi = SPI.new()
cs = GPIO.new("PB6", GPIO::OUT)
cs.write( 1 )
sleep_ms 100

while true
  cs.write( 0 )
  s = spi.read(4)
  cs.write( 1 )

  # DATA[31:18] * 0.25
  temp_tc =  (((s.getbyte(0) << 6) | (s.getbyte(1) >> 2)) -
              ((s.getbyte(0) & 0x80) << 7)) * 0.25

  # DATA[15:4] * 0.0625
  temp_std = (((s.getbyte(2) << 4) | (s.getbyte(3) >> 4)) -
              ((s.getbyte(2) & 0x80) << 5)) * 0.0625

  printf "TC=%.1f ℃  std=%.1f ℃\n", temp_tc, temp_std
  sleep 1
end
```

実行結果
```
MAX31855 Thermo meter.
TC=28.5 ℃  std=29.0 ℃
TC=28.5 ℃  std=29.0 ℃
TC=28.5 ℃  std=29.0 ℃
...
```

# おわりに


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

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

今回は、SPIクラスを実装しました。一部ガイドラインと合致しない部分もありましたが、概ねmruby/c のメソッドとHALライブラリの関数が対応しており、実装は難しくありませんでした。

次回は、UARTクラスを実装します。