M5StickC(ESP32)でのマルチタスク利用例

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

概要

原理はいろいろ説明していますが、実際の利用例があまりなかったので作ってみました。排他制御にキューを利用しての、マルチタスク例になります。

マルチタスクとは?

複数の処理を同時に実行しようとした場合にはマルチタスクにする必要があります。通常のloop()関数だけですと、長時間処理を行うと他の処理ができなくなってしまうことがあります。

loop()関数のみで実行しているのはシングルタスクで、複数のタスクで同時に実行することをマルチタスクといいます。ただし、タスクには優先順位がありますので、優先順位が高いタスクが長時間CPUを独占すると、他のタスクが実行できなくなってしまうことがあります。

キューとは?

マルチタスクの場合には、複数のタスクが特定の変数を同時に更新してしまうことがあります。そのため排他処理などが必要になります。キューも排他処理の一種で、他のタスクへ安全に変数などを送信して、処理をしてもらうことができます。

スケッチ例

#include <M5StickC.h>

// キューの大きさ
#define QUEUE1_LENGTH 8
#define QUEUE2_LENGTH 1

// キュー
QueueHandle_t xQueue1;
QueueHandle_t xQueue2;

// タスク管理
TaskHandle_t task1Handle;
TaskHandle_t task2Handle;

// 受け渡しデータ
struct Queue1Data {
  int type;
  int value;
};

// タスク1
void task1(void *pvParameters) {
  while (1) {
    Queue1Data data1;
    if (xQueueReceive(xQueue1, &data1, 0) == pdTRUE) {
      // キューを受信
      Serial.printf("[task1]type = %d, value = %d\n", data1.type, data1.value);

      // 画面の色を変える
      M5.Lcd.fillScreen((data1.value << 2) & 0xffff);

      // 秒間1件程度しか処理しないためのWait
      delay(1000);
    }

    // Wait(1ミリ秒以上を推奨)
    delay(1);
  }
}

// タスク2
void task2(void *pvParameters) {
  while (1) {
    int32_t data2;

    // キューを受信するまで待つ(キューは消さないので新規送信が失敗する)
    xQueuePeek(xQueue2, &data2, portMAX_DELAY);

    Serial.printf("[task2]data2 = %d\n", data2);

    // Lチカする
    digitalWrite(10, LOW);
    delay(2000);
    digitalWrite(10, HIGH);
    delay(1000);

    // キューを消す(以降キューの送信が可能になる)
    xQueueReceive(xQueue2, &data2, portMAX_DELAY);
    Serial.printf("[task2]unlock\n");
  }
}

void setup() {
  M5.begin();

  // 赤色LED
  pinMode(10, OUTPUT_OPEN_DRAIN);
  digitalWrite(10, HIGH);

  // キュー作成
  xQueue1 = xQueueCreate(QUEUE1_LENGTH, sizeof(Queue1Data));
  xQueue2 = xQueueCreate(QUEUE2_LENGTH, sizeof(int32_t));

  // Core1の優先度3でタスク1起動
  xTaskCreateUniversal(
    task1,          // タスク関数
    "task1",        // タスク名(あまり意味はない)
    8192,           // スタックサイズ
    NULL,           // 引数
    3,              // 優先度(大きい方が高い)
    &task1Handle,   // タスクハンドル
    APP_CPU_NUM     // 実行するCPU(PRO_CPU_NUM or APP_CPU_NUM)
  );

  // Core0の優先度3でタスク2起動
  xTaskCreateUniversal(
    task2,          // タスク関数
    "task2",        // タスク名(あまり意味はない)
    8192,           // スタックサイズ
    NULL,           // 引数
    3,              // 優先度(大きい方が高い)
    &task2Handle,   // タスクハンドル
    PRO_CPU_NUM     // 実行するCPU(PRO_CPU_NUM or APP_CPU_NUM)
  );
}

void loop() {
  // ボタンの状態更新
  M5.update();

  // ボタンA処理
  if (M5.BtnA.wasPressed()) {
    // 送信するデータ
    Queue1Data data1;
    data1.type = 0;
    data1.value = millis();

    // キューを送信
    int ret = xQueueSend(xQueue1, &data1, 0);

    if (ret) {
      // キュー送信成功
      Serial.println("xQueueSend(xQueue1) : OK");
    } else {
      // 未処理のキューが上限を超えていると送信失敗する
      Serial.println("xQueueSend(xQueue1) : NG");
    }
  }

  // ボタンB処理
  if (M5.BtnB.wasPressed()) {
    // 送信するデータ
    int32_t data2;
    data2 = millis();

    // キューを送信
    int ret = xQueueSend(xQueue2, &data2, 0);

    if (ret) {
      // キュー送信成功
      Serial.println("xQueueSend(xQueue2) : OK");
    } else {
      // 未処理のキューが上限を超えていると送信失敗する
      Serial.println("xQueueSend(xQueue2) : NG");
    }
  }

  // Wait
  delay(1);
}

ボタンAを押すと画面の色が変わり、ボタンBを押すとLEDが光るサンプルスケッチです。

キューの大きさ

// キューの大きさ
#define QUEUE1_LENGTH 8
#define QUEUE2_LENGTH 1

何個のキューを保存できるかを指定します。一瞬で終わる処理であればそれほど大きくなくてもよいと思います。実行中は他の処理を受け取らないのであれば1に設定します。

受け渡しデータ

// 受け渡しデータ
struct Queue1Data {
  int type;
  int value;
};

キューで受け渡すデータ用の構造体です。構造体を利用せずにint32_tなどを利用することも可能です。

キューの作成

  // キュー作成
  xQueue1 = xQueueCreate(QUEUE1_LENGTH, sizeof(Queue1Data));
  xQueue2 = xQueueCreate(QUEUE2_LENGTH, sizeof(int32_t));

キューの受け渡しデータのサイズと、個数を指定して作成します。

タスクの作成

  // Core1の優先度3でタスク1起動
  xTaskCreateUniversal(
    task1,          // タスク関数
    "task1",        // タスク名(あまり意味はない)
    8192,           // スタックサイズ
    NULL,           // 引数
    3,              // 優先度(大きい方が高い)
    &task1Handle,   // タスクハンドル
    APP_CPU_NUM     // 実行するCPU(PRO_CPU_NUM or APP_CPU_NUM)
  );

タスクを作成します。重要な項目のみ説明したいと思います。

スタックサイズ

標準的な8192を指定してあります。タスクの中で利用する変数などに利用できるメモリサイズになります。タスクの中で大きい変数を利用した場合にリセットがかかる場合には、スタックサイズが足りていない事が多いので、もう少し大きな数値に変更してみてください。

優先度

loop()関数が2になります。loop()関数より優先する場合には3、優先させない場合には1などを指定してください。

実行するCPU

ESP32はCore0(PRO_CPU_NUM)とCore1(APP_CPU_NUM)の2つのCPUコアがあります。loop()関数はCore1(APP_CPU_NUM)で動いています。

同じCPUコアで複数のタスクが動いている場合には、優先度が大きい順に実行されていきます。delay()関数でWaitを入れることで、より低い優先度のタスクが実行できるようになります。そのため、一定の処理が終わったdelay(1)などを実行して他のタスクが実行できるようにしてあげてください。

無線を利用した場合にはCore0(PRO_CPU_NUM)のかなり高い優先度で、無線系の内部処理が動いているので気をつけてください。

キューの送信

  // ボタンA処理
  if (M5.BtnA.wasPressed()) {
    // 送信するデータ
    Queue1Data data1;
    data1.type = 0;
    data1.value = millis();

    // キューを送信
    int ret = xQueueSend(xQueue1, &data1, 0);

    if (ret) {
      // キュー送信成功
      Serial.println("xQueueSend(xQueue1) : OK");
    } else {
      // 未処理のキューが上限を超えていると送信失敗する
      Serial.println("xQueueSend(xQueue1) : NG");
    }
  }

受け渡し用のデータを作成して、xQueueSend()関数で送信します。戻り値を確認することで、キューが満杯の場合には送信が失敗したことがわかります。

キューの送信は一瞬で終わるので、ブロックすることなく次の処理が実行可能です。

タスク1(複数のキューを順番に処理)

// タスク1
void task1(void *pvParameters) {
  while (1) {
    Queue1Data data1;
    if (xQueueReceive(xQueue1, &data1, 0) == pdTRUE) {
      // キューを受信
      Serial.printf("[task1]type = %d, value = %d\n", data1.type, data1.value);

      // 画面の色を変える
      M5.Lcd.fillScreen((data1.value << 2) & 0xffff);

      // 秒間1件程度しか処理しないためのWait
      delay(1000);
    }

    // Wait(1ミリ秒以上を推奨)
    delay(1);
  }
}

通信系は送信に時間がかかる場合があるので、この手の処理でキューを実装して送信することが多いです。

キューの受信

    if (xQueueReceive(xQueue1, &data1, 0) == pdTRUE) {

上記で受信をしています。3つめの引数の0がタイムアウト時間です。この例では0のためキューがない場合には即時に失敗を返却します。

複数のキューを確認する場合などは0で即時に失敗させて、他の処理をすることが可能です。

処理

      // キューを受信
      Serial.printf("[task1]type = %d, value = %d\n", data1.type, data1.value);

      // 画面の色を変える
      M5.Lcd.fillScreen((data1.value << 2) & 0xffff);

      // 秒間1件程度しか処理しないためのWait
      delay(1000);

処理自体はあまり意味がありませんが、受け渡し変数に処理内容をセットして、処理を依頼するイメージになります。キューをためたいために最後にWaitを入れていますが、即時に終わるような処理であればキューがたまることは、ほとんどないと思います。

Wait

    // Wait(1ミリ秒以上を推奨)
    delay(1);

キューがたまっていない場合には、即時終了して無限ループみたいなことになってしまうので、最低限のWaitをいれます。

動作解説

タスク1は、キューを送信すると、処理に1秒かかります。そのためボタンAを連打するとキューにたまり、順番に処理されていきます。キューにたまりすぎると送信が失敗してしまいます。

xQueueSend()関数の3つ目の引数の0をportMAX_DELAYにすることで、送信成功するまでブロックして待つことも可能です。他の処理が止まってもよい場合にはマルチタスクにしない事が多いので、送信成功まで待つよりはエラーを表示した方がいい場合が多いです。

タスク2(同時に1件しかキューを受け付けない)

// タスク2
void task2(void *pvParameters) {
  while (1) {
    int32_t data2;

    // キューを受信するまで待つ(キューは消さないので新規送信が失敗する)
    xQueuePeek(xQueue2, &data2, portMAX_DELAY);

    Serial.printf("[task2]data2 = %d\n", data2);

    // Lチカする
    digitalWrite(10, LOW);
    delay(2000);
    digitalWrite(10, HIGH);
    delay(1000);

    // キューを消す(以降キューの送信が可能になる)
    xQueueReceive(xQueue2, &data2, portMAX_DELAY);
    Serial.printf("[task2]unlock\n");
  }
}

警告表示などや、音声関係は一定時間受け付けない処理が必要になる場合があります。

キューの確認

    // キューを受信するまで待つ(キューは消さないので新規送信が失敗する)
    xQueuePeek(xQueue2, &data2, portMAX_DELAY);

xQueuePeek()関数の場合にはキューの中身を確認だけして、実際のキューは消しません。この例の場合にはキューは1個だけなので、まだ次のキューを送信することができません。

また、3つ目の引数がportMAX_DELAYとなっているので、キューが送信するまでブロックして待ちます。0で即時終了してループするか、portMAX_DELAYで受信待ちをするかはどちらでも構わないと思います。

処理

    Serial.printf("[task2]data2 = %d\n", data2);

    // Lチカする
    digitalWrite(10, LOW);
    delay(2000);
    digitalWrite(10, HIGH);
    delay(1000);

ここの処理もあまり意味はありません。受け渡しで使ったint32_tも実際には中身を使っていません。受け渡し変数が必要ない場合には通知という簡易的な機能もあるのですが、キューでダミーの変数を渡すのでも問題ありません。

キューを消す

    // キューを消す(以降キューの送信が可能になる)
    xQueueReceive(xQueue2, &data2, portMAX_DELAY);
    Serial.printf("[task2]unlock\n");

キューを受信して、消します。1個だけのキューでしたので、これ以降は新規のキューを送信することが可能になります。

Wait

タスク2はWaitはなくても、portMAX_DELAYで受信するまで待機しているので問題ありません。ただし、念の為delay(1)を入れておいてもよいと思います。

動作解説

タスク2は処理に3秒かかります。キューが1個しかなく、処理が終わるまでキューを消さないので完全に処理が終わりまでは、次のキューが送信できません。

詳細解説

この記事だとざっくりとした説明ですので、より詳しく知りたい場合には上記の記事などを参考にしてください。

まとめ

マルチタスクはいろいろ気をつけないといけないことが多いので、セマフォやミューテックスを使うよりは、キューで処理を依頼する形にしたほうがわかりやすいと思います。

この他にも使い方で悩んだ場合にはコメント欄などに書き込んでもらえれば、サンプルを追加していきたいと思います。

コメント

タイトルとURLをコピーしました