トップ画像
ESP32 WROOM-32開発ボードをSPI Slaveとして用いる

執筆者: 瀕死

最終更新: 2024/06/28

ESP32 WROOM-32開発ボードをSPI Slaveとして用いる

はじめに

瀕死(ひんし)と申します。しばらくはマイコン関連の記事を投稿する予定です。 本稿でもよろしくお願いいたします。

目的

ESP32 WROOM-32開発ボード(以下ESP32開発ボード)をSPI Slaveとして用いる方法について解説する。

google検索で"esp32 spi slave"と検索しても、日本語の記事がほとんどヒットしない。 他にも検索をかけてみると、もう何件か発見できたがかなり情報が少ない。

その記事のうち1つはArduinoIDEにおいてESP32公式のESP-IDFフレームワークのSPI Slaveドライバを呼ぶ手法で、 もう1つは、それをArduinoIDEのライブラリにしたものを呼ぶ手法であり難易度が高い。

本稿では、Platform IOでESP-IDFフレームワークのSPI Slaveドライバを呼び出して使う手法を解説する。

目次

  • ESP32 WROOM-32開発ボードをSPI Slaveとして用いる
    • はじめに
    • 目的
    • 目次
    • TL;DR
    • SPI通信とは
    • ESP32SPISlaveのライブラリの注意点
    • 実践
      • PlatformIO
      • マスタ側の準備
      • スレーブ側の準備
      • ロジックアナライザによる解析
    • おわりに
    • 参考文献

TL;DR

SPI Slaveを作る場合、特に理由がなければESP32開発ボードを用いるべきではないと考える。 Raspberry Pi Pico等を用いるべきである。

ESP32開発ボードのSPI Slaveライブラリはトランザクション単位でしか値が取得できないためである。

SPI通信とは

SPI(Serial Peripheral Interface)とは同期式シリアル通信をする方法の1つである。

  • 同期式通信のため、クロック用の信号線がある
  • 1つのマスタと複数のスレーブでバスを構成する
  • 全二重通信であり、データ線を2本使うのが基本

といった特徴がある。 半二重通信のデータ線が1本のSPIもあるが今回はないものとして扱う。

配線は以下の通りである。

  • MOSI: Master Out Slave In
  • MISO: Master In Slave Out
  • SCLK: Serial Clock(CLK、SCKなどと略されることもある)
  • SS : Slave Select

SPI接続例

複数の接続の場合は以下のようにする。

SPI複数の接続例

マスタがLOWにしているSSにつながっているスレーブが選択されている。

SSはLOWで選択状態となるのが普通であるが、HIGHで選択状態になるものもあるようである。 スレーブは選択されていないときに信号線を接続してはならない。つまりHI-Z(ハイインピーダンス)にする。 SPIのバスは共有であり、短絡する危険があるためである。

近年は、Master、Slaveではなく、Controller、Peripheralと表記して、 頭文字もそれに合わせる傾向がある。またSSだけはChip SelectとしてCSと表記することもある。

SPIは全二重通信であり、受信と送信は同時に行われる。 そのため、スレーブ側は1byte前のマスタからの通信によって、今回の送信内容を決める。

SPIにはクロックの極性で2つ、位相で2つ選択しなければならない点があり、その組み合わせで4つのモードが存在する。

CPOLはClock POLarityの略で、アイドル時のクロック極性についての設定である。

  • 0:アイドル時はLOW
  • 1:アイドル時はHIHG

CPHAはClock PAHseの略で、データ線をいつサンプリングするかについての設定である。

  • 0:先行エッジで、サンプリング(キャプチャ、読み込み)後行エッジでシフト(セット、書き込み)
  • 1:先行エッジでシフト(セット、書き込み)、後行エッジでサンプリング(キャプチャ、読み込み)

エッジとはLOW->HIGHやHIGH->LOWへと変化する際の、電圧の立ち上りや立ち下りのことである。

多くの場合は、CPOLが上位ビット、CPHAが下位ビットとした値でmodeの値が決定される。 しかし、例外も多いので気を付けよう。

以下はmode3のSPI通信の流れである。

注釈付きSPI通信図
  1. SSがLOWになる
  2. SCLKの先行エッジなので書き込む
  3. SCLKの後行エッジなので読み込む
  4. 1byte分だけ繰り返したあと、それを何回か繰り返す
  5. SSがHIGHになる

1~5がSPI通信における1つのトランザクションである。

ESP32SPISlaveのライブラリの注意点

ESP32SPISlaveライブラリは、 内部でESP-IDFフレームワークのSPI Slaveドライバを呼んでいる。 しかし、SPI Slaveドライバの仕様と異なり、DMAを用いない場合もトランザクションのバイト数が4の倍数でないと送信できない。 ライブラリの中のif文で4の倍数以外ははじいているだけであるが、消しても動かなかったので要確認。

実践

実際にマイコン同士でSPI通信を行う例を示す。

今回はSPI Masterは2倍しても1byteに収まる数値を送信し、SPI Slaveはそれを2倍にした値を返すようにする。

通信の構成は以下のとおりである。

SPIトランザクション1(送信用)

MOSI-データ, MISO-Dont care

SPIトランザクション2(受信用)

MOSI-Dont care, MISO-データ

この通信の構成はかなり無駄が多い。

SPIトランザクションが分かれているため、通信を受け取ったスレーブはデータなのか、 送信すべきであるのか判別できない。 であるから、トランザクションの受信に失敗した場合検出できず、次の受信用のトランザクションのデータを二倍にする値として処理してしまう問題がある。

以下のように、1バイト目に命令を記述して構成するように考えるかもしれない。

SPIトランザクション1(送信用)

1バイト目に命令コードのあるトランザクション

SPIトランザクション2(受信用)

1バイト目に命令のあるトランザクション

しかし、SPI Slaveドライバのリファレンスの関数一覧を見る限りトランザクション単位でしか送信や受信を処理できないので、 命令を読んでから2バイト目のデータを準備することはできない。

そのため、SPIトランザクションが有効に使える場合を除いて、ESP32開発ボードをSPI Slaveとして用いるべきではないと考える。

今回においてSPIトランザクションを用いないと仮定した場合、 通信の構成は以下のようになると考える。

1つのトランザクションで送受信

今回は命令は一種類でいいため、1バイト目に命令を記述する必要はない。 1トランザクションで完結しており、間違った値を返す可能性が低い。

SPI通信は全二重通信であり送信と受信が同時に送ることができるにもかかわらず、この例では行っていない。

この問題は、より長いトランザクションを用いることで容易に解決可能である。

長いトランザクション

他にも命令用のバイト列を先頭に配置したり、誤り訂正などのデータを含めたりすることで、より良い通信の構成にすることもできる。

また、MISOの1バイト目に関しても、デバイスIDなどの定数とすれば送信可能である。

通信の構成はそれぞれに合った物を考えるべきである。

PlatformIO

PlatformIOはVSCodeの拡張機能として追加可能な、 組み込みソフトウェア開発のためのIDEである。

PlatformIOのインストールは簡単なので詳しいことは省略するが、 ArduinoIDEよりは使いやすいとは感じている。

ESP32開発ボードを開発する場合、 フレームワークとしてArduinoとESP-IDFが選択できるが、 今回はESP-IDFを選択する。

マスタ側の準備

前章でPlatformIOというIDEを紹介したが、今回はマスタ側は部内にあったMBed LPC1768を用いるので、 Mbed Studioで開発する。

SPI Slaveと違ってSPI Masterは情報が比較的多い。

以下のコードで実行した。

#include "mbed.h"

// SPI通信に用いるピンを設定
// mosi, miso, sclk
SPI spi(p5, p6, p7); 

// SS用のピンを設定(普通のGPIO)
DigitalOut ss(p8);


// 結果表示のためのシリアル通信の設定
const size_t BUF_SIZE = 32;
char serial_txbuf[BUF_SIZE] = {0};
static BufferedSerial serial_port(USBTX, USBRX);

int main()
{
    // 結果表示のためのシリアル通信の設定
    serial_port.set_baud(115200);
    serial_port.set_format(8, BufferedSerial::None, 1);

    ss = 1; // SPIスレーブの選択解除

    // 8bit, mode 3のSPIとして設定
    spi.format(8, 3);
    spi.frequency(100000);

    int8_t num = 0x00;
    int8_t prev_num = 0x00;
    while (true) {
        num = (num + 1)% 0x70;   // 送信用の値を作成
        ss = 0; // SPIスレーブを選択
        spi.write(num);                 // 命令を送信
        ss = 1; //SPIスレーブの選択解除
        wait_us(100);
        ss = 0; // SPIスレーブを選択
        int8_t response = spi.write(0x00); // 受信用
        ss = 1; // SPIスレーブの選択解除
        sprintf(serial_txbuf, "send: %d. received: %d\n", prev_num, response);
        serial_port.write(serial_txbuf, sizeof(serial_txbuf));
        prev_num = num;
    }
}

ss = 1によってSPIトランザクションを開始して、ss = 0によってSPIトランザクションを終了している。

実は、このMBedのほうがSPI Slaveに向いている可能性がある。 上のコードからわかるように、MbedのSPIのライブラリは SPIトランザクションではなく1byteずつ記述する方法であるので、 トランザクション中に受け取ったデータを処理して同じトランザクション中に返せるためである。

スレーブ側の準備

PlatformIOで新規プロジェクトを立ち上げ、main.cを以下のように記述する。

#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"


#include "esp_log.h"
#include "driver/spi_slave.h"
#include "driver/gpio.h"

// ESP32も種類によって異なる点があるので
#ifdef CONFIG_IDF_TARGET_ESP32
#define RCV_HOST    HSPI_HOST
#else
#define RCV_HOST    SPI2_HOST
#endif

// ピン配置の設定
#define GPIO_MOSI 23
#define GPIO_MISO 19
#define GPIO_SCLK 18
#define GPIO_CS 5


void app_main() {
    esp_err_t ret;

    // SPIバスの設定
    spi_bus_config_t buscfg = {
        .mosi_io_num = GPIO_MOSI,
        .miso_io_num = GPIO_MISO,
        .sclk_io_num = GPIO_SCLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
    };

    // SPI Slaveの設定
    spi_slave_interface_config_t slvcfg = {
        .mode = 3,  // SPIのモード
        .spics_io_num = GPIO_CS,
        .queue_size = 3,
        .flags = 0,
        .post_setup_cb = NULL,  // 割り込み処理が必要な場合は記入
        .post_trans_cb = NULL
    };

    // マスタが接続されていないときのためのノイズ対策でプルアップ
    gpio_set_pull_mode(GPIO_MOSI, GPIO_PULLUP_ONLY);
    gpio_set_pull_mode(GPIO_SCLK, GPIO_PULLUP_ONLY);
    gpio_set_pull_mode(GPIO_CS, GPIO_PULLUP_ONLY);

    // 上の設定で初期化する
    ret = spi_slave_initialize(RCV_HOST, &buscfg, &slvcfg, SPI_DMA_DISABLED);
    assert(ret == ESP_OK);

    // トランザクションを記述する構造体
    spi_slave_transaction_t t;
    memset(&t, 0, sizeof(t));

    while (1) {
        
        uint8_t rx_buf;         // 受信バッファ
        t.length = 1 * 8;       // トランザクションの長さの設定 単位:bit
        t.tx_buffer = NULL;     // 送信はしない
        t.rx_buffer = &rx_buf;  // 受信バッファの設定

        // トランザクションの実行
        ret = spi_slave_transmit(RCV_HOST, &t, portMAX_DELAY);

        //2倍にする
        uint8_t tx_buf = rx_buf*2;

        t.length = 1 * 8;
        t.tx_buffer = &tx_buf;
        t.rx_buffer = NULL;

        ret = spi_slave_transmit(RCV_HOST, &t, portMAX_DELAY);
    }

}

まずSPI通信のホストとしてSPIコントローラーを指定する。これはハードウェアによって異なるので、注意する。以下は公式のサンプルプログラムの例である。

#ifdef CONFIG_IDF_TARGET_ESP32
#define RCV_HOST    HSPI_HOST
#else
#define RCV_HOST    SPI2_HOST
#endif

SPIバスのピン配置の設定のためにspi_bus_config_t型の構造体を作成する。

spi_bus_config_t buscfg = {
  .mosi_io_num = GPIO_MOSI,
  .miso_io_num = GPIO_MISO,
  .sclk_io_num = GPIO_SCLK,
  .quadwp_io_num = -1,
  .quadhd_io_num = -1,
};

SPIのスレーブの設定のためにspi_slave_interface_config_t型の構造体を作成する。

spi_slave_interface_config_t slvcfg = {
  .mode = 3,  // SPIのモード
  .spics_io_num = GPIO_CS, 
  .queue_size = 3,        // トランザクションのキューのサイズ
  .flags = 0,             // 不明 0でも動く
  .post_setup_cb = NULL,  // 新しいデータがロードされた時の割り込み設定
  .post_trans_cb = NULL   // トランザクション完了後の割り込み設定
};

それらの構造体を使ってSPIを初期化する。

ret = spi_slave_initialize(RCV_HOST, &buscfg, &slvcfg, SPI_DMA_DISABLED);

高速でCPUに負荷を掛けないデータの転送方法としてDMAが使えるが、 DMAを用いる場合は、シリコンにバグがあるので注意する。

あとはトランザクションのために構造体を作成して、メモリを確保しておく。

spi_slave_transaction_t t;
memset(&t, 0, sizeof(t));

メンバを設定する。

  • size_t length:トランザクションのサイズでbit単位
  • const void *tx_bufer:送信用バッファへのポインタかNULL
  • void *rx_buffer:受信用バッファへのポインタかNULL

あとはspi_slave_transmit(RCV_HOST, &t, portMAX_DELAY);すればよい。

3つめの引数はキューに空きがない場合に、どれだけ待機するかである。 portMAX_DELAYではタイムアウトしない設定となる。

ロジックアナライザによる解析

以上のように準備したら、配線を行い通信を行う。

ロジックアナライザ利用の様子

上のPCの解析結果は失敗ですが、 成功すると波形は以下のようになる。

二倍にした値が返ってくる波形

なぜか、1byte目にスレーブからなにか送信されているが、Don't careとする。

マスタが送信した0x62(98)の二倍として、 0xC4(196)がスレーブから送信されているので成功である。

おわりに

読んでいただきありがとうございました。 次回はRaspberry Pi Pico互換のRP2040開発ボードをRaspberry Pi Pico C/C++ SDKを用いて開発する方法になる予定です。たぶん短い記事になります。たぶん。

参考文献

取得に失敗しました

2024年度 入部