ESP32のFreeRTOS入門 その4 割り込みと通知

概要

前回はマルチタスクを説明しました。今回は実際にマルチタスクを利用していくときに注意しなければならない割り込みと、排他制御の一つである通知を説明します。

割り込みとは?

タスクは定期的に動いていますが、何かをトリガーにして動くものを割り込みといいます。ハードウエア的な割り込みと、ソフトウエア的な割り込みがあります。

ESP32のハードウエア割り込みの場合にはCPUの内部にあるタイマーや、外部にあるGPIOやタッチセンサなどの区分があります。ソフトウエア割り込みは、タスクなどの中でトリガー条件を確認し、トリガーが発生すると呼び出すような処理になります。

ハードウエア割り込みは、基本的には優先度が一番高く、他のタスクが実行していても割り込み処理が優先して動きます。ソフトウエア割り込みは、トリガーを監視しているタスクの優先度に依存しますので、低い優先度で監視していると短時間のトリガー条件の場合、監視漏れが発生する可能性があります。

GPIO入力割り込み

volatile byte state = LOW;
void IRAM_ATTR onButton() {
  state = !state;
  Serial.println(state);
}
void setup() {
  Serial.begin(115200);
  delay(50);  // Serial Init Wait
  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}
void loop() {
  delay(1);
}

一番一般的なGPIOの入力レベルをトリガーにしたハードウエア割り込みの例を紹介します。onButton()関数がトリガーが発生した場合に実行される関数でISR(Interrupt Service Routine)などと呼ばれます。割り込みサービスルーチンや、割り込みハンドラなどとも呼ばれます。

void IRAM_ATTR onButton() {

ESP32はフラッシュ領域からメモリ(IRAM)にプログラムをロードして実行しますが、プログラムサイズが大きくなってくると、メモリにロードせずに、フラッシュにあるプログラムを直接実行することがあります。onButton()関数についているIRAM_ATTR属性は、フラッシュからではなく、メモリにロードして実行することを指示しています。

フラッシュ上にある関数を呼び出した場合には、呼び出しまでに時間がかかったり、他のプログラムがフラッシュにアクセスをしていた場合には呼び出しがキャンセルされる場合があるようです。

ただ、IRAM_ATTR属性をつけ忘れても、多くの場合フレッシュには配置されることは少なく、メモリにロードされてから実行されるので動いてしまいます。

このように割り込みやマルチタスクでは、属性を指定し忘れても多くの場合動いてしまうので注意してください。なんとなく動いているだけで、条件が変わると急に動かなくなります。その場合まったく関係ない場所を変更しているのに、割り込みやマルチタスクが動かなくなるので、原因を把握するのが非常に難しくなります。

volatile byte state = LOW;

次に、state変数にvolatile修飾子がついていますが、こちらも同じような理由でコンパイラの最適化を防ぐのと、変数の置き場所を指定しています。

上記によると、ISRの場合に変更する変数にはすべてvolatile修飾子をつけるように指示があります。

ただし、マルチタスクなどからも同時に更新がかかる可能性がある変数の場合には、他の排他制御を利用したほうが安全です。

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);

次に、実際に割り込みの設定をしているのが上記になります。外部プルアップされているGPIO37にボタンを接続して、ボタンを押すとLOWになる回路がつながっているM5StickCを利用した例です。

ISRとしてonButton()関数を指定して、トリガー条件としてFALLINGを指定しています。

Arduino DefineTechnical Reference Manualトリガー条件
0x00DISABLEDGPIO interrupt disable無効
0x01RISINGrising edge triggerLOWからHIGHに変化
0x02FALLINGfalling edge triggerHIGHからLOWに変化
0x03CHANGEany edge trigger信号レベルが変化
0x04ONLOWlow level triggerLOWのときに常に発生
0x05ONHIGHhigh level triggerHIGHのときに常に発生
0x0CONLOW_WEおそらくONLOWと同じ
0x0DONHIGH_WEおそらくONHIGHと同じ

上記がトリガー条件です。DISABLEDは実質使いませんので、RISINGかFALLING、CHANGEあたりをよく使います。ONLOWとONHIGHは常に割り込みが発生するので、緊急停止的なトリガー条件で使うものだと思います。

ONLOW_WEとONHIGH_WEは検索しても、使われている形跡がありませんでした。調べてみたところ、本来トリガー条件が3ビットなのですが、そのさらに上位1ビットも一緒に指定しようとしています。

GPIO_PINn_WAKEUP_ENABLE GPIO wake-up enable will only wake up the CPU from Light-sleep.(R/W)

上記がそのビットなのですが、ライトスリープからの復帰に使うためのフラグです。ただし、Arduinoのソースファイルを見たところ、トリガー条件は3ビットしか見ていませんでしたので、このフラグは無視されると思います。そのため、_WEのついている条件は指定しないでください。

割り込みの利点

#include <M5StickC.h>
byte state = LOW;
void setup() {
  M5.begin();
}
void loop() {
  M5.update();
  if ( M5.BtnA.wasReleased() ) {
    state = !state;
    Serial.println(state);
  }
  delay(100);
}

さて、M5StickCの例ですが割り込みを利用しない場合のサンプルです。ほとんど同じ動きですが、delay(100)ですので100ミリ秒に1度しかボタンの判定をしていません。そのためボタンを素早く連打すると判定漏れが発生します。

delay()の数値を小さくすることで、判定漏れが減りますが他に優先度のタスクがある場合には、そのタスクが重い処理をしていると、判定漏れがでてしまう可能性があります。このようにタスクでクリティカルな処理を行おうとすると、他のタスクの影響を受けてしまいます。そこで割り込みを利用することで、確実に判定をすることができます。

割り込みの盲点

上記のTickerクラスは、タイマー割り込みを使ったコールバックのように思えますが、内部的にはタイマー割り込みが発生したところで、セマフォという排他制御を使ってタイマー割り込みがあったことを保存し、別のタイマータスクで保存したタイマー割り込みの情報をもとに、コールバック関数を呼び出します。

このTickerクラスの場合には、複雑な排他処理を内部で実装してくれているのですが、最終的にはソフトウエア割り込みとして、タスクで処理されています。そのため、通信で利用しているタスクは、タイマータスクより優先順位が高いので通信の処理が優先され、タイマータスクは遅延して実行されます。

    int ret = xTaskCreatePinnedToCore(&timer_task, "esp_timer",
            ESP_TASK_TIMER_STACK, NULL, ESP_TASK_TIMER_PRIO, &s_timer_task, PRO_CPU_NUM);

上記がタイマータスクの作成部分のソースです。これを見るとPRO_CPUで優先度がESP_TASK_TIMER_PRIOで作成されています。

#define ESP_TASK_TIMER_PRIO           (ESP_TASK_PRIO_MAX - 3)

定義をみてみるとESP_TASK_PRIO_MAX(25)-3なので22ですね。無線系のタスクの優先順位が23なので、負けてしまいます。

/* Bt contoller Task */
/* controller */
#define ESP_TASK_BT_CONTROLLER_PRIO   (ESP_TASK_PRIO_MAX - 2)

みたところ、ライブラリの中では上記の無線が一番高い優先順位みたいです。(ESP_TASK_PRIO_MAX – 1)の24の最高優先順位でタスクを作らない限り、PRO_CPUでは無線のタスクが最上位になります。

割り込みの欠点

#include <M5StickC.h>
void IRAM_ATTR onButton() {
  Serial.println(M5.Axp.GetBatVoltage());
}
void setup() {
  M5.begin();
  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}
void loop() {
  delay(1);
}

また、M5StickCの例ですみませんが、割り込みが発生したらI2Cで電源管理ICからバッテリー電圧を取得するスケッチです。

M5StickC initializing...OK
Guru Meditation Error: Core  1 panic'ed (Coprocessor exception)
Core 1 register dump:
PC      : 0x400d0ee6  PS      : 0x00060a31  A0      : 0x80080ec1  A1      : 0x3ffbe6b0  
A2      : 0x3ffc024c  A3      : 0x00000001  A4      : 0x00000020  A5      : 0x00000000  
A6      : 0x00000000  A7      : 0x3ffb8058  A8      : 0x800d0ee3  A9      : 0x3ffbe740  
A10     : 0x000010ef  A11     : 0x00000078  A12     : 0x80089b38  A13     : 0x3ffb1ee0  
A14     : 0x00000003  A15     : 0x00000000  SAR     : 0x00000017  EXCCAUSE: 0x00000004  
EXCVADDR: 0x00000000  LBEG    : 0x4000c46c  LEND    : 0x4000c477  LCOUNT  : 0x00000000  
Core 1 was running in ISR context:
EPC1    : 0x400d0ee6  EPC2    : 0x00000000  EPC3    : 0x00000000  EPC4    : 0x40086e7b
Backtrace: 0x400d0ee6:0x3ffbe6b0 0x40080ebe:0x3ffbe770 0x40080ebe:0x3ffbe790 0x40080f41:0x3ffbe7b0 0x40084ddd:0x3ffbe7d0 0x400ecb6b:0x3ffbc570 0x400d7663:0x3ffbc590 0x4008a1a6:0x3ffbc5b0 0x40088a05:0x3ffbc5d0
Rebooting...

上記が実行結果です。パニックを起こしてリブートがかかっています。便利な割り込みですが、ISRの関数からI2Cなどの複雑な処理を実行しようとするとパニックが発生して、リブートします。

Tickerクラスも、直接タイマー割り込みに対して、ユーザーが登録したコールバック関数をISRとして呼び出すと、I2Cアクセスなどで同じようにリブートしてしまうので、一度排他制御を行い、タスクから呼び出しているのだと思います。

割り込みの掟

  • ISRの関数にはIRAM_ATTR属性を必ずつける
  • ISRの関数からアクセスする変数にはvolatile修飾子を必ずつける
  • 必要最低限の処理だけを行う
  • 高度な処理は排他制御を使って保存し、別タスクで実行する
  • 呼び出す関数群にFromISRがついている関数があったらそちらを使う

上記が守るべきことです。特に重要なのが最低限の処理だけを行うことです。割り込み中は他のタスクの動作が中断されているので、とにかく素早く処理を終了させることが重要です。

また、高度な処理や時間がかかる処理については別タスクで実行をします。別タスクに情報を渡すためには、排他処理を利用する必要があります。排他処理にも種類がたくさんあり、種類ごとに特徴が違うので今後何回かにわけて紹介をしたいと思います。

今回は割り込みに関してですが、マルチタスクでも同じように排他処理は重要です。とにかく素早く処理を回すタスクと、外部への通信などのように時間がかかる処理を別タスクに分離し、タスク間のデータ共有のために排他処理を利用します。

FromISRについては、次の通知で説明します。

通知 – TaskNotify

タスクにはTaskNotifyという通知する機能があります。通知以外にも、便利な排他制御の仕組みがあるのですが一番シンプルな通知から説明したいと思います。

#include <M5StickC.h>
TaskHandle_t taskHandle;
void IRAM_ATTR onButton() {
  BaseType_t taskWoken;
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, &taskWoken);
}
void task1(void *pvParameters) {
  uint32_t ulNotifiedValue;
  while (1) {
    xTaskNotifyWait(0, 0, &ulNotifiedValue, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}
void setup() {
  M5.begin();
  // Core1の優先度1で通知受信用タスク起動
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    &taskHandle,
    APP_CPU_NUM
  );
  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}
void loop() {
  delay(1);
}

割り込みからI2Cにデータ取得してリブートがかかってしまったスケッチの通知利用版です。setup()関数から通知受信用タスクを作成しています。

void IRAM_ATTR onButton() {
  BaseType_t taskWoken;
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, &taskWoken);
}

ボタンを押したときの割り込み関数は上記の処理に変わっています。呼び出している関数にFromISRがついているので、割り込みのISRの関数からの呼び出し用の関数になります。通常はxTaskNotify()関数が別にあります。

ISR関数の特徴として、最後に引数が一つ追加されています。これは、通知した先のタスクがすぐに実行できる場合にはpdTRUEが入っています。通知先のタスクがあるCPUコアで、現在実行中のタスクと通知を受け取るタスクの優先順位を比べた結果みたいです。

通知の場合には、通知を受信するタスクの優先順位が低い場合には、なかなか通知を受信できない可能性があるので注意してください。

void task1(void *pvParameters) {
  uint32_t ulNotifiedValue;
  while (1) {
    xTaskNotifyWait(0, 0, &ulNotifiedValue, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

通知を受信するタスクです。xTaskNotifyWait()関数で通知の受信待ちをしています。引数がいろいろありますが、最後のportMAX_DELAYが重要で通知が来るまでブロックして待ち続ける設定です。

ここにはミリ秒単位で待つ設定ができますので、0を指定すると通知がないと即終了する関数にもなります。portMAX_DELAYの場合には通知が来た場合に、すぐに次の行が実行されるので、即時性が重要な場合には高めの優先順位にしてportMAX_DELAYで受信待ちをするのがよいと思います。

void task1(void *pvParameters) {
  while (1) {
    if (xTaskNotifyWait(0, 0, NULL, pdMS_TO_TICKS(0)) == pdTRUE) {
      Serial.println(M5.Axp.GetBatVoltage());
    }
    delay(1);
  }
}

ノンブロックで処理をする場合には、portMAX_DELAYの変わりに0を設定します。ここはミリ秒ではなくTick数を指定するので、本来はpdMS_TO_TICKS()関数を使って、ミリ秒からTick数に変換するほうが正しいFreeRTOSのお作法です。実際問題ESP32は1Tickが1ミリ秒固定なので、直値を書く場合が多いみたいです。また、0ミリ秒の場合にはどんな環境でも0Tickになるのもあると思います。

通知で受け渡しができる値

通知関数と通知受信待ち関数に引数がありましたが、受け渡しができる値の説明をしたいと思います。とはいえ、基本的には値は受け渡さない方が好ましいと思います。

通知側設定

変数名動作
0eNoAction通知の値を更新しません
1eSetBits通知の値にビットを設定
2eIncrement通知の値をインクリメント
3eSetValueWithOverwrite前回の通知を受け取っていない場合でも上書きします
4eSetValueWithoutOverwrite前回の通知を受け取っていた場合のみ上書きします

このeからはじまる変数みたいなのはFreeRTOSのenum定義です。スケッチ例ではeIncrementを指定しています。この場合通知関数でどんな通知を設定しても、通知を呼び出した回数がカウントアップされて渡されます。その他のオプションもありますが、数値の受け渡しをしたいのであれば通知以外の機能を使ったほうが楽なので、無理に使う必要はありません。

通知受信側設定

2つの数値を引数で設定しています。1つ目が実行前にクリアするビットで、2つ目が実行後にクリアするビットです。この2つの引数でxorすることで該当ビットのデータを落としているのだと思います。

eIncrementで使うのであれば両方変更無しの0で問題ありません。

簡易設定

void IRAM_ATTR onButton() {
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
}
void task1(void *pvParameters) {
  while (1) {
    xTaskNotifyWait(0, 0, NULL, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

引数のところはNULLに設定することで、値の受け渡しを行わなくすることができます。この他にtakeやgiveなどの引数が簡略化されているラッパー関数もありますが、あまり使う必要はないと思います。

通知の欠点

xTaskNotify()関数などで、通知を連続して送信しても1度しか通知は受け取ることができない場合があります。通知に間隔があいていれば複数回受信することも可能ですが、基本的には最後の通知を受信すると思って使ってください。

void IRAM_ATTR onButton() {
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
  xTaskNotifyFromISR(taskHandle, 0, eIncrement, NULL);
}

上記のような通知は、5回通知を受信するのではなく最後の通知のみ受信すると思ってください。通知は簡易的な仕組みなので、通知を受け取ったらデータを更新するみたいな用途には適しています。反面、通知を受け取った回数だけ処理をするみたいな場合には取りこぼしが発生する可能性があります。

データの受け渡しも苦手ですので、その場合にはキューなどを利用した方がかんたんに処理が実装できます。

資料

日本語リファレンス

関連ブログ

まとめ

割り込みは非常に難しいですが、便利な機能です。ISRからFromISRなどのない関数を呼んでも動いてしまうことが多いので、気をつけて呼び出す必要があります。

通知などの排他制御も、受信タスクのコアや優先順位を考えて利用をする必要があります。特に無線を使っているときにはPRO_CPUで優先度23のタスクが動いていることを意識して設計をする必要があります。

続編

コメント