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 IRAM_ATTR core0IdleHook(void) {
  core0IdleCount++;
  return true;
}
// Tick数カウントアップ
void IRAM_ATTR core0TickHook(void) {
  core0TickCount++;
}
// Idle時にカウントアップ
bool IRAM_ATTR core1IdleHook(void) {
  core1IdleCount++;
  return true;
}
// Tick数カウントアップ
void IRAM_ATTR 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 IRAM_ATTR core0IdleHook(void) {
  core0IdleCount++;
  return true;
}
// Idle時にカウントアップ
bool IRAM_ATTR core1IdleHook(void) {
  core1IdleCount++;
  return true;
}
void setup() {
  Serial.begin(115200);
  // ハック関数を登録
  esp_register_freertos_idle_hook_for_cpu(&core0IdleHook, 0);
  esp_register_freertos_idle_hook_for_cpu(&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);
}

1秒に一度CPU利用率を表示して、10msごとにBluetoothSerialで文字列を送信するサンプルです。そして、実際に動かしてみてBluetoothSerialを接続したところ、無線系の処理が動いているCore0のIdle数が3000を超えます!

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

まとめ

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

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

コメント