🧑‍🤝‍🧑

STM32 HALでDMAを使ってSPIのスレーブデバイスを作る

2022/01/28に公開

STM32のデバイスにおいてHALを使ったSPIのスレーブモードに関する知見です。HALのSPIはポーリング、IT、DMAの3種類の利用形態がありますが、今回はDMAを使った方法です。

記事中ではスレーブ側デバイスとして NUCLEO-F303K8(STM32F303K8)、マスタ側デバイスとして Arduino Uno(ATmega328P) を使います。今回は全二重モード(Full-Duplex Slave)でSPIを使います。

今回は「マスタが1バイトのデータを送り、スレーブがそれを受け取って送り返す」ことを1回の通信として想定します。

結論

  • STM32を使った全二重モードにおいて、NSS(スレーブセレクト)がActiveのまま連続して送受信を行う場合、直前の送受信の終了の後に、STM32のクロック周波数に応じた適切な待ち時間を入れる 必要があります。
    • これはHALではなくSTM32の制約です。

環境

ピンアサイン

NUCLEO-F303K8にはPA15のピンが物理的に存在しないため、SPI1_NSSは必ずPA4にアサインします。

SPIとDMA設定

全二重スレーブ、NSS Signal TypeをInput Hardware、Data Sizeを8 Bitsにします。
DMAはRXとTXの両方を有効にします。チャネルは空いているものであればどれでも構いません。


初期化コード

SPIとDMAの初期化コードは以下のようになります。

spi.c 長いのでクリックで表示
#include "spi.h"

SPI_HandleTypeDef hspi1;
DMA_HandleTypeDef hdma_spi1_rx;
DMA_HandleTypeDef hdma_spi1_tx;

void MX_SPI1_Init(void) {
  hspi1.Instance = SPI1;
  hspi1.Init.Mode = SPI_MODE_SLAVE;
  hspi1.Init.Direction = SPI_DIRECTION_2LINES;
  hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
  hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
  hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
  hspi1.Init.NSS = SPI_NSS_HARD_INPUT;
  hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
  hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
  hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
  hspi1.Init.CRCPolynomial = 7;
  hspi1.Init.CRCLength = SPI_CRC_LENGTH_DATASIZE;
  hspi1.Init.NSSPMode = SPI_NSS_PULSE_DISABLE;

  if (HAL_SPI_Init(&hspi1) != HAL_OK) {
    Error_Handler();
  }
}

void HAL_SPI_MspInit(SPI_HandleTypeDef* spiHandle) {
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if (spiHandle->Instance == SPI1) {
    __HAL_RCC_SPI1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    /** SPI1 GPIO Configuration
      PA4     ------> SPI1_NSS
      PA5     ------> SPI1_SCK
      PA6     ------> SPI1_MISO
      PA7     ------> SPI1_MOSI
    */
    GPIO_InitStruct.Pin = GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF5_SPI1;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    hdma_spi1_rx.Instance = DMA1_Channel2;
    hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_spi1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi1_rx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_spi1_rx.Init.Mode = DMA_NORMAL;
    hdma_spi1_rx.Init.Priority = DMA_PRIORITY_LOW;
    if (HAL_DMA_Init(&hdma_spi1_rx) != HAL_OK) {
      Error_Handler();
    }

    __HAL_LINKDMA(spiHandle,hdmarx,hdma_spi1_rx);

    hdma_spi1_tx.Instance = DMA1_Channel5;
    hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_spi1_tx.Init.Mode = DMA_NORMAL;
    hdma_spi1_tx.Init.Priority = DMA_PRIORITY_LOW;
    if (HAL_DMA_Init(&hdma_spi1_tx) != HAL_OK) {
      Error_Handler();
    }

    __HAL_DMA_REMAP_CHANNEL_ENABLE(HAL_REMAPDMA_SPI1_TX_DMA1_CH5);
    __HAL_LINKDMA(spiHandle, hdmatx, hdma_spi1_tx);
  }
}

ArduinoとSTM32の結線

Arduino UNOはロジックレベルが5Vのため レベルシフタ を使いました(注意:PA4~PA7は5Vトレラントピンではありません)。Arduino UNOの5VピンはレベルシフタのHighVoltage側と、NUCLEO-F303K8のVINに繋げてください。

通信中にNSSが切り替わる場合の送受信

NSSが一度の送受信ごとに一旦HIGHに戻る場合であれば、スレーブ側は受信→送信のDMA開始を繰り返すだけで実現できます。ポーリングモードで関数 HAL_SPI_Transmit および HAL_SPI_Receive を使う場合もこの方法で実現できます。
ただしこのように1回の通信中(トランザクション)でNSSが切り替わるのはSPIデバイスとしては特殊ケースで、当然ながらマスタ側もこれを承知で実装する必要があります。

Arduino(マスタ側)の実装 クリックで表示
#include <SPI.h>

const int slaveSelectPin = 10;

void setup() {
  pinMode(slaveSelectPin, OUTPUT);
  digitalWrite(slaveSelectPin, HIGH);
  Serial.begin(9600);
  SPI.begin();
}

void loop() {
  static byte outgoingByte = 0;
  byte incomingByte;
  
  Serial.print("Master: ");
  Serial.print(incomingByte, BIN);
  Serial.print(" (");
  Serial.print(incomingByte, DEC);
  Serial.print(")");
  
  incomingByte = spiPing(outgoingByte);
 
  Serial.print(", Slave: ");
  Serial.print(value, BIN);
  Serial.print(" (");
  Serial.print(value, DEC);
  Serial.println(")");

  outgoingByte++;
  delay(1000);
}

byte spiPing(byte value) {
  byte returnValue;

  SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
  digitalWrite(slaveSelectPin, LOW);
  SPI.transfer(value);
  digitalWrite(slaveSelectPin, HIGH);

  digitalWrite(slaveSelectPin, LOW);
  returnValue = SPI.transfer(0);
  digitalWrite(slaveSelectPin, HIGH);
  SPI.endTransaction();

  return returnValue;
}
STM32(スレーブ側)の実装 クリックで表示
#include "main.h"

extern SPI_HandleTypeDef hspi1;
uint8_t rxBuffer[4] = {};
uint8_t txBuffer[4] = {};

int main(void) {
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_DMA_Init();
  MX_SPI1_Init();
  
  HAL_SPI_TransmitReceive_DMA(&hspi1, txBuffer, rxBuffer, 1);  // Tx & Rx DMA start
  
  while (1) { }
}

void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
  txBuffer[0] = rxBuffer[0];
  HAL_SPI_Transmit_DMA(hspi, txBuffer, 1);  // Tx DMA start
}

void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) {
  txBuffer[0] = 0;
  rxBuffer[0] = 0;
  HAL_SPI_TransmitReceive_DMA(&hspi1, txBuffer, rxBuffer, 1);  // Tx & Rx DMA (re)start
}

関数 HAL_SPI_Receive_DMA ではなく HAL_SPI_TransmitReceive_DMA を使っているのは、今回はスレーブ側の1バイト目で必ず 0 を送信するためです。関数 HAL_SPI_Receive_DMA を使った際に何が送信されるかは不定(don't care)です。

関数 HAL_SPI_RxCpltCallback および HAL_SPI_TxCpltCallback__weak 属性が与えられているため、任意の場所で再宣言できます。これらの関数はDMAを使ったSPIの受信および送信完了時に呼び出されます。

通信中にNSSが切り替わらない場合の送受信

NSSが受信→送信の間切り替わらずに連続して送受信を行う場合は、次の送受信までに待ち時間が必要です。

Arduino(マスタ側)の実装 クリックで表示
#include <SPI.h>

const int slaveSelectPin = 10;

void setup() {
  pinMode(slaveSelectPin, OUTPUT);
  digitalWrite(slaveSelectPin, HIGH);
  Serial.begin(9600);
  SPI.begin();
}

void loop() {
  static byte outgoingByte = 0;
  byte incomingByte;
  
  Serial.print("Master: ");
  Serial.print(incomingByte, BIN);
  Serial.print(" (");
  Serial.print(incomingByte, DEC);
  Serial.print(")");
  
  incomingByte = spiPing(outgoingByte);
 
  Serial.print(", Slave: ");
  Serial.print(value, BIN);
  Serial.print(" (");
  Serial.print(value, DEC);
  Serial.println(")");

  outgoingByte++;
  delay(1000);
}

byte spiPing(byte value) {
  byte returnValue;

  SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
  digitalWrite(slaveSelectPin, LOW);
  SPI.transfer(value);

  delayMicroseconds(15);  // 追加: 15μs 待つ

  returnValue = SPI.transfer(0);
  digitalWrite(slaveSelectPin, HIGH);
  SPI.endTransaction();

  return returnValue;
}

変更ポイントとして、マスタ側は関数 SPI.transfer で送受信したら再度呼び出す前に、今回は 15μs 待つようにしました。スレーブ側に変更はありません。

Arduino(マスタ側)の実装
byte spiPing(byte value) {
  byte returnValue;

  SPI.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
  digitalWrite(slaveSelectPin, LOW);
  SPI.transfer(value);

  delayMicroseconds(15);  // 追加: 15μs 待つ

  returnValue = SPI.transfer(0);
  digitalWrite(slaveSelectPin, HIGH);
  SPI.endTransaction();

  return returnValue;
}

実際の回路を組み、待ち時間を 12μs としたときの波形を示します。画像内の波形は上から NSS, SCK, MOSI, MISO です。NUCLEO-F303K8は72MHzで駆動、SPIのクロック周波数は4MHzです。

待ち時間を入れる必要がある理由

すべての SPI データトランザクションは、バイトで編成された組込み FIFO を通
過します。SPI 書込みデータレジスタに書込みアクセスすると、送信 FIFO の送
信キューの最後に書込みデータが保存されます。SPI 読出しデータ レジスタに
読出しアクセスすると、受信 FIFO に保管され、まだ読み出しされていない一番
古い値が返されます。

通信速度が速くデータフレームが短すぎる場合、特にクロック信号が連続しかつ
全二重モードが使用されている場合、正しいデータフローの確保が困難になる
ことがあります。
マスタによって提供されるすべてのトランザクションタイミングに
適切に従い、データオーバーランまたはアンダーラン状態を防止する必要があ
るスレーブノードは、より重要です。

今回のように1バイト目の送受信を行った直後に連続して後続のクロックが来てもSTM32は処理できず、なおかつエラーにもなりません。これはHALの実装ではなく、STM32のハードウェア的な仕様になります。

全二重モードを使いつつ後続の送受信で欠損を防ぐには、適切な待ち時間が必要になります。

対策

STM32のクロック周波数を上げる

今回は検証のためにNUCLEO-F303K8のクロック周波数を72MHzに上げました。しかしその場合でも 10μs より大きい待ち時間が必要で、これ以下では通信が破綻してしまいました。

仮に 10μs の待ち時間であると720クロックとなります。これはNUCLEO-F303K8のクロック周波数が16MHzの場合は 45μs になります。

SPIのクロック周波数を下げる

SPIのクロック1周期分が、STM32が必要とする待ち時間720クロックとちょうど一致する周波数は

16 MHz のとき 64 MHz のとき 72 MHz のとき
22,222 Hz 88,889 Hz 100,000 Hz

となり、NUCLEO-F303K8のクロック周波数を72MHzに上げたとしても、待ち時間なしで使えるのはSPIのクロックを 100kHz 未満 に落とさなくてはなりません(ただし未検証)。

一度に送受信するデータ量を増やす

今回は1バイトずつの送受信でしたが、SPIのクロック周波数をそのままにして、一度に送受信するデータ量を増やせば通信効率が上がります。幸い、関数 HAL_SPI_Receive_DMAHAL_SPI_TransmitReceive_DMA の受信バッファとそのサイズは送受信ごとに指定ができるので、たとえば最初に命令1バイトを受け取り、続く受信ではバッファを切り替えて大量のデータを受け取るという実装も可能です。


参考

Discussion