執筆者: 瀕死
最終更新: 2024/06/28
瀕死(ひんし)と申します。しばらくはマイコン関連の記事を投稿する予定です。 本稿でもよろしくお願いいたします。
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ドライバを呼び出して使う手法を解説する。
SPI Slaveを作る場合、特に理由がなければESP32開発ボードを用いるべきではないと考える。 Raspberry Pi Pico等を用いるべきである。
ESP32開発ボードのSPI Slaveライブラリはトランザクション単位でしか値が取得できないためである。
SPI(Serial Peripheral Interface)とは同期式シリアル通信をする方法の1つである。
といった特徴がある。 半二重通信のデータ線が1本の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の略で、アイドル時のクロック極性についての設定である。
CPHAはClock PAHseの略で、データ線をいつサンプリングするかについての設定である。
エッジとはLOW->HIGHやHIGH->LOWへと変化する際の、電圧の立ち上りや立ち下りのことである。
多くの場合は、CPOLが上位ビット、CPHAが下位ビットとした値でmodeの値が決定される。 しかし、例外も多いので気を付けよう。
以下はmode3のSPI通信の流れである。
1~5がSPI通信における1つのトランザクションである。
ESP32SPISlaveライブラリは、 内部でESP-IDFフレームワークのSPI Slaveドライバを呼んでいる。 しかし、SPI Slaveドライバの仕様と異なり、DMAを用いない場合もトランザクションのバイト数が4の倍数でないと送信できない。 ライブラリの中のif文で4の倍数以外ははじいているだけであるが、消しても動かなかったので要確認。
実際にマイコン同士でSPI通信を行う例を示す。
今回はSPI Masterは2倍しても1byteに収まる数値を送信し、SPI Slaveはそれを2倍にした値を返すようにする。
通信の構成は以下のとおりである。
SPIトランザクション1(送信用)
SPIトランザクション2(受信用)
この通信の構成はかなり無駄が多い。
SPIトランザクションが分かれているため、通信を受け取ったスレーブはデータなのか、 送信すべきであるのか判別できない。 であるから、トランザクションの受信に失敗した場合検出できず、次の受信用のトランザクションのデータを二倍にする値として処理してしまう問題がある。
以下のように、1バイト目に命令を記述して構成するように考えるかもしれない。
SPIトランザクション1(送信用)
SPIトランザクション2(受信用)
しかし、SPI Slaveドライバのリファレンスの関数一覧を見る限りトランザクション単位でしか送信や受信を処理できないので、 命令を読んでから2バイト目のデータを準備することはできない。
そのため、SPIトランザクションが有効に使える場合を除いて、ESP32開発ボードをSPI Slaveとして用いるべきではないと考える。
今回においてSPIトランザクションを用いないと仮定した場合、 通信の構成は以下のようになると考える。
今回は命令は一種類でいいため、1バイト目に命令を記述する必要はない。 1トランザクションで完結しており、間違った値を返す可能性が低い。
SPI通信は全二重通信であり送信と受信が同時に送ることができるにもかかわらず、この例では行っていない。
この問題は、より長いトランザクションを用いることで容易に解決可能である。
他にも命令用のバイト列を先頭に配置したり、誤り訂正などのデータを含めたりすることで、より良い通信の構成にすることもできる。
また、MISOの1バイト目に関しても、デバイスIDなどの定数とすれば送信可能である。
通信の構成はそれぞれに合った物を考えるべきである。
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を用いて開発する方法になる予定です。たぶん短い記事になります。たぶん。
この人が書いた記事