ESP32でキューを使う

古くなっている可能性があるので、最新情報はM5StickC非公式日本語リファレンスで確認してください。

キューとは

処理が必要なデータの集まりで、主に別タスクにデータ処理を依頼する場合に利用します。通常はキューの最後に追加するFIFOですが、先頭に割り込んで追加することもできます。

キューの種類

  • 通常のキュー
  • メールボックス(最後のアイテムのみ保持)

1つのメッセージしか保存しないメールボックスと呼ばれるキューがあるようです。最後の情報を表示するなどの場合はこれを使うのかな?

通常は複数のメッセージが保存できるキューを使います。FreeRTOSのキューはメッセージを送信すると、送信したデータをコピーしてキューに保存しますので送信元でそのデータを書き換えたりしても安全に利用することができます。

サンプルスケッチ

// キューの大きさ
#define QUEUE_LENGTH 4

// 通常のキュー
QueueHandle_t xQueue;

// Mailbox用のキュー
QueueHandle_t xQueueMailbox;

void setup() {
  // キューアイテム
  uint8_t data = 1;

  // デバッグ出力準備
  Serial.begin(115200);
  Serial.println("Queue Test");

  // キュー作成
  xQueue = xQueueCreate( QUEUE_LENGTH, sizeof( uint8_t ) );

  // メールボックス用途は長さ1のみ
  xQueueMailbox = xQueueCreate( 1, sizeof( uint8_t ) );

  // 最後に追加(1)
  xQueueSend(xQueue, &data, 0);
  data++;

  // 先頭に追加(2)
  xQueueSendToFront(xQueue, &data, 0);
  data++;

  // 最後に追加(3)
  xQueueSendToBack(xQueue, &data, 0);
  data++;

  // 上書き送信(4)
  xQueueOverwrite(xQueueMailbox, &data);
  data++;

  // 上書き送信(5)
  xQueueOverwrite(xQueueMailbox, &data);
  data++;

  // キューの数確認
  Serial.printf( "uxQueueMessagesWaiting = %d\n", uxQueueMessagesWaiting(xQueue) );

  // キューの追加可能数確認
  Serial.printf( "uxQueueSpacesAvailable = %d\n", uxQueueSpacesAvailable(xQueue) );

  // キューの状態取得
  Serial.printf( "xQueueIsQueueEmptyFromISR = %d\n", xQueueIsQueueEmptyFromISR(xQueue) );
  Serial.printf( "xQueueIsQueueFullFromISR = %d\n", xQueueIsQueueFullFromISR(xQueue) );
  Serial.printf( "uxQueueMessagesWaitingFromISR = %d\n", uxQueueMessagesWaitingFromISR(xQueue) );

  // Peek受信(何度受信しても同じ値)
  Serial.println( "Peek Test" );
  xQueuePeek( xQueue, &data, 0 );
  Serial.println( data );
  xQueuePeek( xQueue, &data, 0 );
  Serial.println( data );

  // 通常受信(213の順で受信し、受信成功した場合のみアイテムが更新される)
  int ret;
  Serial.println( "Receive Test" );
  ret = xQueueReceive( xQueue, &data, 0 );
  Serial.printf( "ret = %d, data = %d\n", ret, data );
  ret = xQueueReceive( xQueue, &data, 0 );
  Serial.printf( "ret = %d, data = %d\n", ret, data );
  ret = xQueueReceive( xQueue, &data, 0 );
  Serial.printf( "ret = %d, data = %d\n", ret, data );
  ret = xQueueReceive( xQueue, &data, 0 );
  Serial.printf( "ret = %d, data = %d\n", ret, data );
  ret = xQueueReceive( xQueue, &data, 0 );
  Serial.printf( "ret = %d, data = %d\n", ret, data );

  // Mailbox受信(最後に送信したデータのみ受信)
  Serial.println( "Mailbox Test" );
  xQueuePeek( xQueueMailbox, &data, 0 );
  Serial.println( data );

  // キュークリア
  Serial.println( "xQueueReset Test" );
  xQueueReset(xQueue);
  xQueueReset(xQueueMailbox);

  // キューセット
  Serial.println( "QueueSet Test" );
  QueueSetHandle_t xQueueSet;
  xQueueSet = xQueueCreateSet( QUEUE_LENGTH + 1 ); // Setするキューの合計
  xQueueAddToSet( xQueue, xQueueSet );
  xQueueAddToSet( xQueueMailbox, xQueueSet );

  // アイテム追加
  data = 100;
  xQueueSend(xQueue, &data, 0);
  data++;
  xQueueSend(xQueue, &data, 0);
  data++;
  xQueueSend(xQueueMailbox, &data, 0);

  // 取得してみる
  while(1){
    // 取得可能なキューを取得する
    QueueHandle_t queue = xQueueSelectFromSet( xQueueSet, 0 );
    if( queue == NULL ){
      // キューがなくなったので終了
      break;
    }

    // キューの種類を調べる
    if( queue == xQueue ){
      Serial.print( "xQueue " );
    } else if( queue == xQueueMailbox ) {
      Serial.print( "xQueueMailbox " );
    } else {
      Serial.print( "? " );
    }
    
    // 受信
    ret = xQueueReceive( queue, &data, 0 );
    Serial.printf( "ret = %d, data = %d\n", ret, data );
  }

  // キュー削除
  vQueueDelete(xQueue);
  vQueueDelete(xQueueMailbox);
}

void loop() {
}

実行結果

Queue Test
uxQueueMessagesWaiting = 3
uxQueueSpacesAvailable = 1
xQueueIsQueueEmptyFromISR = 0
xQueueIsQueueFullFromISR = 0
uxQueueMessagesWaitingFromISR = 3
Peek Test
2
2
Receive Test
ret = 1, data = 2
ret = 1, data = 1
ret = 1, data = 3
ret = 0, data = 3
ret = 0, data = 3
Mailbox Test
5
xQueueReset Test
QueueSet Test
xQueue ret = 1, data = 100
xQueue ret = 1, data = 101
xQueueMailbox ret = 1, data = 102

解説

いろいろごちゃごちゃ処理を書いてありますが、xQueueCreate()でキューを作成して、xQueueSend()で送信して、xQueueReceive()で受信できます。キューの先頭に割り込ませたい場合にはxQueueSendToFront()で送信します。

割り込みの内部から呼び出した場合にはFromISRが最後についている関数群があるのでそちらを利用します。

QueueSetは複数のタスクからキューにアクセスするときの機能なので、Arduinoでのユースケースは思いつかないです。

まとめ

おそらくは受信は通知で、送信はキューを使ってメッセージ処理をするのが王道のはずです。

あと標準のFreeRTOSと、ESP32で使えるようにしたESP-IDF用FreeRTOSと、それをArduinoで使えるようにした Arduino用FreeRTOSがあり、3つとも使える機能がちょっと違うのでわかりにくいです。

ドキュメントはESP-IDF用FreeRTOSが一番無難なんですが、Arduino版で使えない関数などがあるので、Arduino IDEで実際にコンパイルしてリファレンスは書いています。概要は日本語で公開されているFreeRTOSのオフィシャルが一番充実していますがArduinoでは使えない関数とか、ESP-IDFで拡張した機能とかがわからないです。

ESP32で別タスクに通知を送信する

最新情報はM5StickC非公式日本語リファレンスを参考にしてください。

タスク周りを調べた結果のアウトプットです。別タスクへはキューと通知がありますが、データを渡さない場合には、通知が簡単に利用できそうです。

概要

別タスクへのデータ受け渡しは、通知とキューが利用できます。通知は通信などの受信タスクに受信を通知するなどで内部利用されています。渡せるデータは少ないので実際にデータを受け渡すのではなく、その通知をトリガーに処理を再開させるなどの用途に使います。

送信タスクなどに送信データを受け渡す場合には、キューを利用したほうが適しています。

サンプルスケッチ

TaskHandle_t taskHandle;

void testTask(void *pvParameters) {
  uint32_t ulNotifiedValue;
  while (1) {
    // 通知が来るまで待機する。値のクリアはしない
    xTaskNotifyWait( 0,
                     0,
                     &ulNotifiedValue,
                     portMAX_DELAY );
    Serial.println( pcTaskGetTaskName(NULL) );
    Serial.println( ulNotifiedValue );
  }
}

void setup() {
  Serial.begin(115200);

  // Core0でタスク起動
  xTaskCreatePinnedToCore(
    testTask,
    "loopTask1",
    8192,
    NULL,
    1,
    &taskHandle,
    0
  );
}

void loop() {
  delay(500);

  // 通知送信(値は0から増えていき、100の値は無視される)
  xTaskNotify(taskHandle, 100, eIncrement );
}

xTaskNotifyWait()で通知を受信します。portMAX_DELAYを指定することで、通知が来るまでタイムアウト無しで待機します。実際にはタイムアウト処理をいれることが推奨されています。

上記のように数値を送信することができますが、何個の通知が溜まっているかぐらいの確認しかできませんので、数値に依存しない実装のほうが好ましいと思います。

数値を使わないのであればulTaskNotifyTake()の方がスッキリします。

    // 通知が来るまで待機する。値のクリアはしないので通知が受け取れている限り1のまま
    int ulNotifiedValue = ulTaskNotifyTake( pdFALSE, portMAX_DELAY );

まとめ

受信待ちや、外部からのトリガー入力を待っている場合には通知は有効だと思います。反面送信待ちなどについてはキューを利用したほうが楽に実装が可能です。

ESP32のマルチタスクとCPU利用率を調べる

現時点での情報で最新情報はM5StickC非公式日本語リファレンスを見てください。

タスク周りを調べていて、気になったので調査してみました。

タスクの仕組み

ESP32ではオープンソースのリアルタイムOSであるFreeRTOSが動いています。FreeRTOSはTickと呼ばれる単位で動いていて、環境によって違いますが通常1Tickが1msになっています。

Tick単位でタスクに割り当てるCPUリソースを分配しているので、CPU利用率を調べる場合には、単位時間あたりのTick数と、タスクで利用したTick数もしくは、利用されていなかったTick数がわかれば計算できます。

ESP32のCPUコア

ESP32はCPUコアがCore0とCore1の2つあり、Arduinoを実行した場合にはsetup()とloop()はCore1で動いています。そして無線関係の処理はCore0で動いているようです。

loop()とは非同期で動かしたい場合や、高負荷の処理を実行したい場合には別タスクで起動することで実現できます。

ウォッチドッグ

ハングアップ監視用にウォッチドッグという機能があります。CPUが利用していないときに定期的に更新するカウンターがあり、一定時間以上更新されない場合にはハングアップしたとみなして、再起動する仕組みです。

ArduinoではCore0はウォッチドッグが有効化されており、Core1は無効化されています。そのためloop()内部で無限ループを実行してもリセットされませんが、Core0に無限ループをするとウォッチドッグによりリセットされます。

ウォッチドッグの回避方法

ウォッチドッグを回避するためにはカウンターを更新するか、ウォッチドッグを停止させるかです。

void loopTest(void *pvParameters) {
  while (1) {
    Serial.print( xPortGetCoreID() ); // 動作確認用出力
    vPortYield();                     // vPortYield()ではウォッチドッグに影響しない
    yield();                          // yield()ではウォッチドッグに影響しない
    delay(0);                         // 1以上にするとウォッチドッグのリセットがなくなる
    delayMicroseconds(1000000);       // delayMicroseconds()はウォッチドッグとは関係ない
    millis();                         // millis()もウォッチドッグとは関係ない
  }
}
 
void setup() {
  Serial.begin(115200);
 
  // Core0でタスク起動
  xTaskCreateUniversal(
    loopTest,
    "loopTest",
    8192,
    NULL,
    1,
    NULL,
    0
  );

  // ウォッチドッグ停止
  //disableCore0WDT();
}
 
void loop() {
}

上記がサンプルコードですが、このまま実行するとウォッチドッグでリセットがかかると思います。回避するためにはdelay(0)の数字を1ms以上にするか、disableCore0WDT()でウォッチドッグを停止しましょう。ただ1msにすると他のタスクがそのCPUコアで実行する余裕がないので、可能な範囲でなるべく大きい数字を入れたほうがいいと思います。

ウォッチドッグ自体は長時間かかる処理をするときに、途中にdelay(1)を追加できない処理をする場合にのみ一時的に無効にして、その処理が終わった有効に戻すのが推奨の動作だと思います。

delay()以外にウォッチドッグを回避できる関数はvTaskDelay()やvTaskDelayUntil()がありますが、こちらはdelay()の内部で使われている関数であり、実時間ではなくTick数を指定するので、通常はdelay()でよいと思います。

CPU利用率の計算

2つのCPUコアにタスクを分散しても、どれぐらい余裕が残っているのかがわからないと思いますので、CPU利用率を計算してみたいと思います。

#include "esp_freertos_hooks.h"

TaskHandle_t taskHandle[2];

long core0IdleCount = 0;
long core0TickCount = 0;
long core1IdleCount = 0;
long core1TickCount = 0;

void testTask(void *pvParameters) {
  while (1) {
    // 1-500msのCPU時間を消費
    delayMicroseconds( random( 1, 500000 ) );

    // 1-500msの待機
    delay( random( 1, 500 ) );
  }
}

// Idle時にカウントアップ
bool core0IdleHook(void) {
  core0IdleCount++;
  return true;
}

// Tick数カウントアップ
void core0TickHook(void) {
  core0TickCount++;
}

// Idle時にカウントアップ
bool core1IdleHook(void) {
  core1IdleCount++;
  return true;
}

// Tick数カウントアップ
void core1TickHook(void) {
  core1TickCount++;
}

void setup() {
  Serial.begin(115200);

  // ハック関数を登録
  esp_register_freertos_idle_hook_for_cpu(&core0IdleHook, 0);
  esp_register_freertos_tick_hook_for_cpu(&core0TickHook, 0);
  esp_register_freertos_idle_hook_for_cpu(&core1IdleHook, 1);
  esp_register_freertos_tick_hook_for_cpu(&core1TickHook, 1);

  // Core0でタスク起動
  xTaskCreateUniversal(
    testTask,
    "testTask1",
    8192,
    NULL,
    1,
    &taskHandle[0],
    0
  );

  // core1でタスク起動
  xTaskCreateUniversal(
    testTask,
    "testTask2",
    8192,
    NULL,
    1,
    &taskHandle[1],
    1
  );
}

void loop() {
  // カウント出力
  Serial.printf("===============================================================\n");
  Serial.printf("core0IdleCount  = %7ld, core0TickCount  = %7ld, Idle = %5.1f %%\n", core0IdleCount, core0TickCount, core0IdleCount * 100.0 / core0TickCount );
  Serial.printf("core1IdleCount  = %7ld, core1TickCount  = %7ld, Idle = %5.1f %%\n", core1IdleCount, core1TickCount, core1IdleCount * 100.0 / core1TickCount );

  // カウント初期化
  core0IdleCount = 0;
  core0TickCount = 0;
  core1IdleCount = 0;
  core1TickCount = 0;

  // 2秒に一度表示する
  delay(2000);
}

ちょっと長いのですが、esp_freertos_hooks.hを読み込んでFreeRTOSのHook系の関数を使えるようにします。

Tick毎に呼ばれるコールバック関数と、他のタスクが動いていない場合に実行されるIdleタスクから呼ばれるコールバック関数を登録して、カウントをしています。

タスクはCPUコア別に2つ動かして、50%ぐらいのCPU利用をするような処理を書いてあります。

実行すると、loop()でdelay(2000)しているので約2秒ごとにCPU利用率が表示されます。1Tickは1msなのでTickCountは2000で、IdleCountはだいたい1000前後の数字になっています。

ただし無線を使うと。。。

#include "esp_freertos_hooks.h"

#include <BluetoothSerial.h>

BluetoothSerial SerialBT;

long core0IdleCount = 0;
long core1IdleCount = 0;

// Idle時にカウントアップ
bool core0IdleHook(void) {
  core0IdleCount++;
  return true;
}

// Idle時にカウントアップ
bool core1IdleHook(void) {
  core1IdleCount++;
  return true;
}

void setup() {
  Serial.begin(115200);

  // ハック関数を登録
  esp_register_freertos_idle_hook_for_cpu(&amp;core0IdleHook, 0);
  esp_register_freertos_idle_hook_for_cpu(&amp;core1IdleHook, 1);

  SerialBT.begin("ESP32");
}

int lastDisp = 0;

void loop() {
  if( lastDisp + 1000 < millis() ){
    lastDisp = millis();
    
    // カウント出力
    Serial.printf("core0IdleCount = %7ld, core1IdleCount = %7ld\n", core0IdleCount, core1IdleCount );
  
    // カウント初期化
    core0IdleCount = 0;
    core1IdleCount = 0;
  }

  SerialBT.println(millis());

  delay(10);
}

ここからが本題で、無線を使った場合にどれぐらいのCPU利用率かを調べたかったのですが、結論からいうと調べることができませんでした。

まず、無線を利用しながらTickに対するコールバック関数を登録するとハングアップします。Idleのみであれば動いたので、上記のコードはIdleのみに減らしたものです。1秒に一度CPU利用率を表示して、10msごとにBluetoothSerialで文字列を送信するサンプルです。

そして、実際に動かしてみてBluetoothSerialを接続したところ、無線系の処理が動いているCore0のIdle数が3000を超えます!

Tick数的には1秒なので1000以下のはずですが、なぜか増えます。。。おそらく無線系の処理の中でウォッチドッグでリセットされるのを防ぐために、Idleタスクを定期的に呼び出す処理があり、そのためカウンターが上がっているように思えます。

まとめ

CPU利用率は無線を使うと難しそうでした。コンパイルオプションとかを変えることで統計情報とかを利用したvTaskGetRunTimeStats()とかを使えるのですが、標準では使えないようです。

タスク周りは通知周りはリファレンスように検証しましたが、キューとかイベントとかまだ調べないといけないことがたくさんありそうです。

ESP32のesp32-hal.h周りのマルチタスクを調べる

M5StickC非公式日本語リファレンスで項目を書くためにesp32-halの項目を調べました。

サンプルコード

void loopTest(void *pvParameters) {
  while (1) {
    Serial.print( xPortGetCoreID() ); // 動作確認用出力
    vPortYield();                     // vPortYield()ではウォッチドッグに影響しない
    yield();                          // yield()ではウォッチドッグに影響しない
    delay(0);                         // 1以上にするとウォッチドッグのリセットがなくなる
    delayMicroseconds(1000000);       // delayMicroseconds()はウォッチドッグとは関係ない
  }
}

void setup() {
  Serial.begin(115200);

  // Core0でタスク起動
  xTaskCreateUniversal(
    loopTest,
    "loopTest0",
    8192,
    NULL,
    1,
    NULL,
    0
  );

  // Core1でタスク起動
  xTaskCreateUniversal(
    loopTest,
    "loopTest1",
    8192,
    NULL,
    1,
    NULL,
    1
  );

  // ウォッチドッグ停止
  //disableCore0WDT();
  //disableCore1WDT();  // 起動直後は有効化されていないのでエラーがでる

  // ウォッチドッグ起動
  //enableCore0WDT();
  //enableCore1WDT();
}

void loop() {
}

解説

他サイトのサンプルですとxTaskCreatePinnedToCore()かxTaskCreate()を使っているケースばかりでした。

ESP32の1.0.2からxTaskCreateUniversal()が増えたようでして、内部でxTaskCreatePinnedToCore()かxTaskCreate()を呼び出しています。

xTaskCreate()はシングルコア用の古い関数で、無効なcore番号を指定すると内部で呼び出されてCore0でタスクが起動していました。

通常はCore1でloop()などが実行されており、ウォッチドッグ は無効です。Core0はウォッチドッグが有効化されており、長時間ブロックしているとハングアップしたとみなされ、リセットが入ります。

リセットを回避する方法はdisableCore0WDT()でウォッチドッグを無効化するか、処理の中で定期的にdelay(1)を呼び出すかが必要です。delay(0)とかyield()では回避できませんでした。

まとめ

リファレンスで関数群が大体モーラできたかと思っていましたが、xTaskとかvPort系の関数が抜けていました。どんどん対象が広がっていきます、、、

M5StickCライブラリ0.1.0リリース

ちょっと遅くなりましたが、やっと調べることができました。もろもろリファレンスを更新せなば、、、

現時点の情報なので、最新情報はM5StickC非公式日本語リファレンスを参考にしてください。

変更点概要

  • MPU6866からMPU6886に名称変更
  • LCDの明るさの上限が12に制限(7-12の間)
  • setRotation(2)で逆さ表示した場合のオフセット座標修正
  • 充電が100mAから200mAに変更
  • 全般的にコメント追加

変更点詳細

\utility\ST7735_Rotation.h

setRotation(2)で逆さ表示した場合のオフセット座標修正。

class MPU6886

名称をMPU6866からMPU6886に変更。過去のライブラリとの互換性がなくなっているので注意が必要。

class M5StickC

クラスメンバをMPU6866からMPU6886に変更。

AXP192::begin()

LEDを3.3Vから3.0Vに制限追加。充電を100mAから200mAに変更。バッテリ充電高温警報設定レジスタ設定追加。バックアップ電池充電制御レジスタ設定追加。

AXP192::ScreenBreath()

最大値を12(3.0V)に制限。LDO3の設定値を壊さないように修正。

IMU::getAccelAdc(), getAccelData(), getGyroAdc(), getGyroData(), getTempAdc(), getTempData()

キャスト方法変更。

まとめ

急にクラス名を変更したのでびっくりしました。Twitterでつぶやいたの公式が拾ってくれて、すぐにコミットされたのでその時に気がついたのかな?

あとGitHubでプルリクエストを投げると、わりとマージしてくれるので気になったところは投げて見るといいと思います!

Arduino公式ライブラリのDoxygen作成

勢いで作ってみました

概要

Arduino IDEでライブラリを選択しても、いまいち良くわからなかったので全部落としてきて、一覧ページとDoxygenでドキュメントを作って見まいた。

生成物

Arduinoライブラリ一覧

作成内容

http://downloads.arduino.cc/libraries/library_index.json

上記がArduino IDEでの公式ライブラリ一覧なので、パースして全ZIPファイルをダウンロードして、Doxygenを生成しています。

やっていることは非常に単純ですが、ブラウザの拡張機能みたいに利用者人数とかわかると同じようなライブラリでどれ使えばいいのかわかりやすいのですが、公式だとそーいう機能はなさそうですね。

注意点

私が使っているレンタルサーバーはファイル数制限があって、ひっかかりました!

最終的に検索を標準JavaScriptからサーバーサイドのPHPにオプションを変更することで、半分ぐらいのファイル数に減ってなんとか収まりました。

まとめ

Sqlite3のライブラリがすごくって、1ファイルで20万行ぐらいあって、Doxygenが止まったかと思いました。

ファイル更新は結構時間がかかるので、月一回ぐらい自動更新をしようかなと思っています。

SHIMANOの資料は手で作業して更新している部分が結構あるのですが、こっちはなんとか全自動化できました。

M5StickCのIMUがMPU6886になった!

ライブラリに追加されたので、変わるのかなーって思っていましたが本当に変わるとは。。。

届いたもの

スイッチサイエンスさんの在庫が増えないので、追加で本家サイトから購入しました。その後にスイッチサイエンスさんの在庫が増えて、一瞬で売り切れたのでまあよかったです。。。

買ったのは本家限定の環境センサとスピーカーセットと、単品でプロトハットにPIRセンサーに2個目のスピーカー!

プロトハットは日本ではまだ買えないのかな? 他のハットは高いのと個人的に使わなそうなので、パスしました。

本家から購入しても技適マーク付きが届くの確認してから頼んだのですが、まさかIMUが変わっているとは、、、

ちなみに本家の販売ページではIMUは昔のままの表記です。。。

M5StickCでSPI接続のSDを使う(3線)

※本ブログは現時点での情報です、最新情報はM5StickC非公式日本語リファレンスを参照してください。

SPI接続のディスプレイはSPI以外に制御線が必要だったので、Grove端子も使っていましたが、純粋なSPI接続だと3線あればできるのですっきりと接続が可能でした!

接続図

GPIO36が入力専用pinなのでMISOに使っています。CSはSPIデバイスが1つだけなのでGNDに接続しています。

デバイス

前回と同じで、SPI接続のSDカードであればどれでも使えると思います。ただし抵抗とかの部品がついていない素のブレークアウトは他に部品が必要なので注意しましょう。

接続方法

M5StickCSD
3V33V3
GNDGND
0CLK
36MISO
26MOSI
GNDCS

MISOは入力専用の36にアサインして、それ以外は自由に設定できます。

サンプルスケッチ

#include <M5StickC.h>
#include "SD.h"

// PIN配置
enum { spi_sck = 0, spi_miso = 36, spi_mosi = 26, spi_ss = -1 };

void setup()
{
  // M5StickC初期化
  M5.begin();
  M5.Lcd.setRotation(3);

  // SPI初期化
  SPI.begin(spi_sck, spi_miso, spi_mosi, spi_ss);

  // SDカード初期化
  if (!SD.begin(spi_ss)) {
    M5.Lcd.println("Card Mount Failed");
    return;
  }

  // SDカード種別取得
  uint8_t cardType = SD.cardType();
  if (cardType == CARD_NONE) {
    M5.Lcd.println("None SD Card");
    return;
  }
  M5.Lcd.print("SD Card Type: ");
  if (cardType == CARD_MMC) {
    M5.Lcd.println("MMC");
  } else if (cardType == CARD_SD) {
    M5.Lcd.println("SDSC");
  } else if (cardType == CARD_SDHC) {
    M5.Lcd.println("SDHC");
  } else {
    M5.Lcd.println("UNKNOWN");
  }

  // SDカード容量取得
  uint64_t cardSize = SD.cardSize() / (1024 * 1024);
  M5.Lcd.printf("SD Card Size: %lluMB\n", cardSize);
}

void loop() {
}

SDの種類と容量を表示するだけのコードです。

まとめ

SPIが3pinだけで動かせるのが確認できたので、EXT IOはSPI接続と、Grove端子にI2C接続ってのが最大限の拡張性かな?

使ってみたいSPIデバイスがSPI以外にも制御線が必要なので、I2CのI/O拡張とか使って制御してみようかな。

M5StickC(ESP32)の赤外線(RMT)受信を調べた

※本ブログは現時点での情報です、最新情報はM5StickC非公式日本語リファレンスを参照してください。

赤外線送信を実験するために、受信をまずは調べました。思ったより情報が少なくてハマりました。。。

関数について

driver/rmt.hを利用するもの

rmt_driver_install()で利用を開始するものです。一般的な作例は、すべてこちらの関数群を利用していました。ESP-IDFで使われている関数群なのでリファレンスやサンプルがそれなりにあります。

利用するためには明示的にinclude “driver/rmt.h”を追加する必要があります。

esp32-hal-rmt.hを利用するもの

Arduino IDEで宣言なしで利用できる関数群です。analogRead()などのArduino互換のための関数群で、一般的にはこちらのほうが標準的なはずですが、rmt関係に関してはまったく情報がないです!

最初動かないと思って、諦めかけたのですが内部ソースをじっくり解読してなんとか受信ができました。

環境

センサーとリモコンはElegooのセットに入っていた物を利用しました。

センサー単体は非常に安いのですが、データシートを見た限り抵抗などを繋げないといけないので、モジュールになっているものを利用したほうが楽そうでした。

pinは26にデータを、電源は3.3Vに接続し、あとはGND同士を繋げています。

リモコンがあったほうがテストがしやすいので、キット付属のリモコンを使いました。どんなリモコンでもいいのですが、リモコンによって送信フォーマットが違うので注意してください。

この付属リモコンはNECフォーマットでした。

サンプルスケッチ

driver/rmt.hの場合

#include "driver/rmt.h"

RingbufHandle_t buffer = NULL;  // リングバッファ

void setup() {
  Serial.begin(115200);

  setCpuFrequencyMhz(getXtalFrequencyMhz() / 4);   // CPU周波数(最低速に設定)

  // 赤外線リモコン初期設定
  rmt_config_t rmtConfig;
  rmtConfig.rmt_mode = RMT_MODE_RX;                // 受信
  rmtConfig.channel = RMT_CHANNEL_0;               // チャンネル0(0-3:受信, 4-7:送信)
  rmtConfig.clk_div = getApbFrequency() / 1000000; // ペリフェラル周波数
  rmtConfig.gpio_num = GPIO_NUM_26;                // 26pinに赤外線レシーバーを接続
  rmtConfig.mem_block_num = 1;                     // メモリブロック数(1-255:1ブロックあたり64ペアの送受信)
  rmtConfig.rx_config.filter_en = 1;               // フィルター有効フラグ(ONに設定)
  rmtConfig.rx_config.filter_ticks_thresh = 255;   // フィルターしきい値(この設定より短いパルスを除外)
  rmtConfig.rx_config.idle_threshold = 10000;      // フィルターしきい値(この設定より長いパルスを除外)

  // 初期化
  rmt_config(&amp;rmtConfig);                          // RMTコンフィグ設定
  rmt_driver_install(rmtConfig.channel, 2048, 0);  // RMTドライバー初期化

  // 受信スタート
  rmt_get_ringbuf_handle(RMT_CHANNEL_0, &amp;buffer);
  rmt_rx_start(RMT_CHANNEL_0, 1);
}

void loop() {
  size_t rxSize = 0;

  // リングバッファ取得
  rmt_data_t *item = (rmt_data_t *)xRingbufferReceive(buffer, &amp;rxSize, 10000);

  // データがある場合処理
  if (item) {
    // 受信した生データを出力
    Serial.printf("receive_data(size:%d) :\n", rxSize);
    for (int i = 0 ; i < rxSize ; i++) {
      Serial.printf(" %d %d %d %d\n", item[i].duration0, item[i].level0, item[i].duration1, item[i].level1 );
    }
    Serial.println();

    // リングバッファ開放
    vRingbufferReturnItem(buffer, (void*) item);
  }
}

非常にサンプルがたくさんあるので、サクッと組めます。

チェンネルは0-8まであって受信は0-3の空いている場所を指定します。clk_divがわかりにくいのですが、 サンプルのコードだと80固定が多かったですが、CPU速度を落として、ペリフェラル周波数を下げると不安定になったので、ペリフェラル周波数を指定するのが正しい気がします。

フィルターは2種類あって、短いパルスデータを除外するfilter_ticks_threshと長いパルスデータを除外するidle_thresholdがあります。

ノイズとして短いパルスを受信しちゃうことがあるので、filter_ticks_threshは設定したほうがいいです。NECフォーマットだと最低でも562us以上の長さなので255以下を除外しています。

長い方は、受信完了を識別するための長さになります。NECフォーマットだとリーダーが最長で9000usですので、10000を指定している例が多かったです。この数字を大きくすると、ボタンを押している間に送信されるリピートも受信してしまって、ボタンを離すまで1つの受信データとして処理されます。

利用するリモコンによって、フォーマットが違うのでこの辺の数字は用途に合わせて変更しましょう。最初は生データで受信確認してみてから、フォーマットを調べて実装したほうがよいと思います。

driver/rmt.hの場合(NECフォーマットのパース)

#include "driver/rmt.h"

RingbufHandle_t buffer = NULL;  // リングバッファ

// 赤外線リモコンデータ構造体
typedef struct {
  int type;
  int repeat;
  uint16_t customer;
  uint8_t data;
} IrData;

void setup() {
  Serial.begin(115200);

  setCpuFrequencyMhz(getXtalFrequencyMhz() / 4);   // CPU周波数(最低速に設定)

  // 赤外線リモコン初期設定
  rmt_config_t rmtConfig;
  rmtConfig.rmt_mode = RMT_MODE_RX;                // 受信
  rmtConfig.channel = RMT_CHANNEL_0;               // チャンネル0(0-3:受信, 4-7:送信)
  rmtConfig.clk_div = getApbFrequency() / 1000000; // ペリフェラル周波数
  rmtConfig.gpio_num = GPIO_NUM_26;                // 26pinに赤外線レシーバーを接続
  rmtConfig.mem_block_num = 1;                     // メモリブロック数(1-255:1ブロックあたり64ペアの送受信)
  rmtConfig.rx_config.filter_en = 1;               // フィルター有効フラグ(ONに設定)
  rmtConfig.rx_config.filter_ticks_thresh = 255;   // フィルターしきい値(この設定より短いパルスを除外)
  rmtConfig.rx_config.idle_threshold = 10000;      // フィルターしきい値(この設定より長いパルスを除外)

  // 初期化
  rmt_config(&amp;rmtConfig);                          // RMTコンフィグ設定
  rmt_driver_install(rmtConfig.channel, 2048, 0);  // RMTドライバー初期化

  // 受信スタート
  rmt_get_ringbuf_handle(RMT_CHANNEL_0, &amp;buffer);
  rmt_rx_start(RMT_CHANNEL_0, 1);
}

void loop() {
  size_t rxSize = 0;

  // リングバッファ取得
  rmt_data_t *item = (rmt_data_t *)xRingbufferReceive(buffer, &amp;rxSize, 10000);

  // データがある場合処理
  if (item) {
    // データパース
    IrData data;
    parseIr( item, rxSize, &amp;data );

    // 出力(type=0は解析エラー)
    Serial.printf( "Recv(%d)\n", rxSize );
    Serial.printf( " type     : %d\n", data.type );
    Serial.printf( " repeat   : %d\n", data.repeat );
    Serial.printf( " customer : %04x\n", data.customer );
    Serial.printf( " data     : %02x\n", data.data );
    Serial.printf( "\n" );

    // リングバッファ開放
    vRingbufferReturnItem(buffer, (void*) item);
  }
}

// リモコンデータ1Byte解析
uint8_t parseData(rmt_data_t *item ) {
  // 8bit分処理をする
  uint8_t data = 0;
  for ( int i = 0 ; i < 8 ; i++ ) {
    int t3time = item[i].duration0 * 3;         // 3Tの時間
    int errorRange = t3time * 0.3;              // 30%までの誤差許容

    if ( abs( item[i].duration1 - t3time ) < errorRange ) {
      data += 1 << i;
    }
  }

  return data;
}

// リモコンデータ解析
void parseIr(rmt_data_t *item, int rxSize, IrData* data) {
  int necframe = 9000;
  int errorRange = necframe * 0.3; // 30%までの誤差許容

  // 判定誤差より小さければNEC
  if ( abs( item[0].duration0 - necframe ) < errorRange ) {
    data->type = 1; // NEC

    // リピートチェック(長さはフレームの半分)
    necframe /= 2;
    errorRange /= 2;
    if ( abs( item[0].duration1 - necframe ) < errorRange ) {
      // リピートでは無いデータ
      data->repeat = 0;
    } else {
      // リピートなのでここで終わり
      data->repeat = 1;
      return;
    }
  }
  item++;

  // データ解析
  if ( data->type == 1 ) {
    // NEC

    // カスタマー取得
    data->customer = parseData(item) << 8;
    item += 8;
    data->customer += parseData(item);
    item += 8;

    // データ取得
    uint8_t data1 = parseData(item);
    item += 8;
    uint8_t data2 = parseData(item);
    item += 8;

    // データ整合性確認
    if ( data1 == ( data2 ^ 0xff ) ) {
      data->data = data1;
    } else {
      // データがおかしいので全部0にセット
      memset( data, 0, sizeof( IrData ) );
    }
  }
}

ざっくりとパースしてみました。

誤差をどこまで許容するかは環境によって難しいところです。広げすぎると、似たようなフォーマットのリモコンを間違ってパースしてしまうことになります。

esp32-hal-rmt.hの場合

rmt_obj_t* rmt_recv = NULL;
int realTick;

// 受信コールバック関数
void receive_data(uint32_t *data, size_t len) {
  rmt_data_t* it = (rmt_data_t*)data;

  // 長さ0は処理しない
  if ( len == 0 ) {
    return;
  }

  // 受信した生データを出力
  Serial.printf("receive_data(size:%d) :\n", len);
  for (int i = 0 ; i < len ; i++) {
    Serial.printf(" %d %d %d %d\n", it[i].duration0 * realTick, it[i].level0, it[i].duration1 * realTick, it[i].level1 );
  }
  Serial.println();
}

void setup() {
  Serial.begin(115200);

  // 初期化
  if ((rmt_recv = rmtInit(GPIO_NUM_26, false, RMT_MEM_192)) != NULL) {
    Serial.println("Init Receiver");
  }

  // 1Tickを80usに設定
  realTick = rmtSetTick(rmt_recv, 80000) / 1000;
  Serial.printf("real tick set to: %dus\n", realTick);

  // フィルターしきい値(この設定より短いパルスを除外)
  rmtSetFilter(rmt_recv, true, 255);

  // フィルターしきい値(この設定より長いパルスを除外)
  rmtSetRxThreshold(rmt_recv, 10000 / realTick );

  // 受信開始
  rmtRead(rmt_recv, receive_data);
}

void loop() {
  delay(500);
}

コールバック以外の受信関数はなんと受信したデータサイズがわかりません!

受信も安定していないので、rmtRead()以外の受信関数は使わないほうがいいと思います。そしてrmtRead()も使わないほうが、、、

コード自体は非常にすっきりして、使いやすそうにみえます。しかしながらここまで来るのに相当苦労しました。

まずrmtSetTick()関数が非常に重要で、オフィシャルのサンプルスケッチだと80とか100が指定されていますが、その値だとちゃんと受信できません。80000を指定すると内部で1000で割れれて、80になってdriver/rmt.hと同じような動きになりました。

ちなみにこちらはCPU速度を落としても、動きに影響がなかったので80000固定で大丈夫そうです。

driver/rmt.hの場合には受信データはus単位の実時間が戻ってきましたが、esp32-hal-rmt.hの場合にはTick数が戻って来ますので、実時間にするにはTickの単位をかける必要があります。rmtSetTick()の戻り値はnsなので1000で割ってusにしてからduration0 * realTickで実時間に変換しています。

rmtSetRxThreshold()関数もTick単位みたいで10000を指定すると、内部で800000us(0.8s)となり、後続のリピートの受信までしてしまうのと、最終受信から0.8s経過後に受信コールバックが呼び出されるので、非常にレスポンスが悪くなります。

rmtSetFilter()関数は255のままで動いているので、そのまま設定しています。このフィルターがないと、照明などのノイズをたまに拾ってしまうことがあるので、何らかの数値を設定したほうがいいと思います。

esp32-hal-rmt.hの場合 (NECフォーマットのパース)

rmt_obj_t* rmt_recv = NULL;
int realTick;

// 赤外線リモコンデータ構造体
typedef struct {
  int type;
  int repeat;
  uint16_t customer;
  uint8_t data;
} IrData;

// 受信コールバック関数
void receive_data(uint32_t *data, size_t len) {
  rmt_data_t* it = (rmt_data_t*)data;

  // 長さ2未満は処理しない
  if ( len < 2 ) {
    return;
  }

  // データパース
  IrData irdata;
  parseIr( it, len, &amp;irdata );

  // 出力(type=0は解析エラー)
  Serial.printf( "Recv(%d)\n", len );
  Serial.printf( " type     : %d\n", irdata.type );
  Serial.printf( " repeat   : %d\n", irdata.repeat );
  Serial.printf( " customer : %04x\n", irdata.customer );
  Serial.printf( " data     : %02x\n", irdata.data );
  Serial.printf( "\n" );
}

// リモコンデータ1Byte解析
uint8_t parseData(rmt_data_t *item ) {
  // 8bit分処理をする
  uint8_t data = 0;
  for ( int i = 0 ; i < 8 ; i++ ) {
    int t3time = item[i].duration0 * 3;         // 3Tの時間
    int errorRange = t3time * 0.3;              // 30%までの誤差許容

    if( abs( item[i].duration1 - t3time ) < errorRange ){
      data += 1 << i;
    }
  }

  return data;
}

// リモコンデータ解析
void parseIr(rmt_data_t *item, int rxSize, IrData* data) {
  int necframe = 9000 / realTick;
  int errorRange = necframe * 0.3; // 30%までの誤差許容

  // 判定誤差より小さければNEC
  if( abs( item[0].duration0 - necframe ) < errorRange ){
    data->type = 1; // NEC

    // リピートチェック(長さはリーダーの半分)
    necframe /= 2;
    errorRange /= 2;
    if( abs( item[0].duration1 - necframe ) < errorRange ){
      // リピートでは無いデータ
      data->repeat = 0;
    } else {
      // リピートなのでここで終わり
      data->repeat = 1;
      return;
    }
  }
  item++;

  // データ解析
  if ( data->type == 1 ) {
    // NEC

    // カスタマー取得
    data->customer = parseData(item) << 8;
    item += 8;
    data->customer += parseData(item);
    item += 8;

    // データ取得
    uint8_t data1 = parseData(item);
    item += 8;
    uint8_t data2 = parseData(item);
    item += 8;

    // データ整合性確認
    if ( data1 == ( data2 ^ 0xff ) ) {
      data->data = data1;
    } else {
      // データがおかしいので全部0にセット
      memset( data, 0, sizeof( IrData ) );
    }
  }
}

void setup() {
  Serial.begin(115200);

  // 初期化
  if ((rmt_recv = rmtInit(GPIO_NUM_26, false, RMT_MEM_192)) != NULL) {
    Serial.println("Init Receiver");
  }

  // 1Tickを80usに設定
  realTick = rmtSetTick(rmt_recv, 80000) / 1000;
  Serial.printf("real tick set to: %dus\n", realTick);

  // フィルターしきい値(この設定より短いパルスを除外)
  rmtSetFilter(rmt_recv, true, 255);

  // フィルターしきい値(この設定より長いパルスを除外)
  rmtSetRxThreshold(rmt_recv, 10000 / realTick);

  // 受信開始
  rmtRead(rmt_recv, receive_data);
}

void loop() {
  delay(100);
}

同じようにパースしてみました。

まとめ

リファレンスを書くために、調査していましたがesp32-hal-rmt.hの関数群は資料がないのと、動作確認を全部自分でしないと使うことができないので、おすすめしません。

情報が多いdriver/rmt.hか、そもそもライブラリ化されているものをそのまま使ったほうがおすすめです!

受信ができたので、やっと送信側の検証ができます、、、

M5StickC(ESP32)のCPU関連関数を調べた

※本ブログは現時点での情報です、最新情報はM5StickC非公式日本語リファレンスを参照してください。

esp32-hal-cpu.hに定義されている関数を調べました。

概要

M5StickCは周波数40MHzのクリスタルを搭載しており、CPUは10から240MHzで動作することが可能です。

SPIやフラッシュなどのペリフェラルは80MHzが最大で、CPU周波数が下がるとともに下がります。

PWMのようにペリフェラル周波数に依存して制御が必要な機能についてはコールバック関数を設定し、周波数変更した場合の処理を設定することが可能です。

CPU周波数(MHz)ペリフェラル周波数(Hz)クリスタル周波数(MHz)
2408000000040
1608000000040
808000000040
404000000040
202000000040
101000000040

CPU周波数設定

標準は240MHzで動いているので、下げることで省電力動作が可能になります。

ただし通信をする場合にはCPU速度を落とすより、Wi-FiなどをONにしている時間を短くするほうが省電力になることがあるので、消費電力を実測しながら調整しましょう。

サンプルスケッチ

volatile int g_ev_cnt = 0;
volatile apb_change_ev_t b_ev_type;
volatile apb_change_ev_t a_ev_type;
volatile uint32_t b_old_apb;
volatile uint32_t b_new_apb;
volatile uint32_t a_old_apb;
volatile uint32_t a_new_apb;

static void _on_apb_change(void * arg, apb_change_ev_t ev_type, uint32_t old_apb, uint32_t new_apb) {
  g_ev_cnt++;
  if ( ev_type == APB_BEFORE_CHANGE ) {
    b_ev_type = ev_type;
    b_old_apb = old_apb;
    b_new_apb = new_apb;
  } else if ( APB_AFTER_CHANGE ) {
    a_ev_type = ev_type;
    a_old_apb = old_apb;
    a_new_apb = new_apb;
  }
}

uint32_t iarg = 1;

void setup() {
  Serial.begin(115200);

  Serial.printf( "getXtalFrequencyMhz() : %d\n", getXtalFrequencyMhz() );
  Serial.printf( "getCpuFrequencyMhz() : %d\n", getCpuFrequencyMhz() );
  Serial.printf( "getApbFrequency() : %d\n", getApbFrequency() );

  Serial.printf( "\n" );

  Serial.printf( "addApbChangeCallback() : %d\n", addApbChangeCallback((void*)iarg, _on_apb_change) );

  Serial.printf( "g_ev_cnt : %d, b_ev_type : %d, b_old_apb : %d, b_new_apb : %d\n", g_ev_cnt, b_ev_type, b_old_apb, b_new_apb );
  Serial.printf( "g_ev_cnt : %d, a_ev_type : %d, a_old_apb : %d, a_new_apb : %d\n", g_ev_cnt, a_ev_type, a_old_apb, a_new_apb );

  Serial.printf( "\n" );

  for ( int i = 0 ; i <= 500 ; i++ ) {
    int ret = setCpuFrequencyMhz(i);
    if ( ret ) {
      Serial.printf( "setCpuFrequencyMhz(%d) : %d\n", i, ret );
      Serial.printf( "getCpuFrequencyMhz() : %d\n", getCpuFrequencyMhz() );
      Serial.printf( "getApbFrequency() : %d\n", getApbFrequency() );
      Serial.printf( "getXtalFrequencyMhz() : %d\n", getXtalFrequencyMhz() );
      Serial.printf( "g_ev_cnt : %d, b_ev_type : %d, b_old_apb : %d, b_new_apb : %d\n", g_ev_cnt, b_ev_type, b_old_apb, b_new_apb );
      Serial.printf( "g_ev_cnt : %d, a_ev_type : %d, a_old_apb : %d, a_new_apb : %d\n", g_ev_cnt, a_ev_type, a_old_apb, a_new_apb );
      Serial.printf( "\n" );
    }
  }

  Serial.printf( "removeApbChangeCallback() : %d\n", removeApbChangeCallback((void*)iarg, _on_apb_change) );
}

void loop() {
}

まとめ

リファレンスでは全部の関数を調べてみましたが、普段使うのはsetCpuFrequencyMhz()だけだと思います。

コールバックもPWMの内部で使っていますが、普通の人は使わないかな。。。