ESP32用ヘルパーライブラリ その4 GPIOとタイマー割り込み

概要

前回はキューでしたが、実際にキューを使うことが多い割り込み処理を今回は紹介したいと思います。

GPIO割り込み

GPIO割り込みはGPIOの状態が変化した時に割り込みがかかる機能になります。割り込みは最優先で動き、タスクよりも優先度が高いです。

そのため、そのコアで割り込みが発生するとタスクを停止して、割り込み処理が動作します。割り込み処理はできることが非常に限られていて、複雑な処理をするとハングアップしてしまいます。無線を使うのはもちろんGPIOの変更などもできません。浮動小数点演算などもだめな場合もあるようです。

そこで割り込み処理では最低限の処理だけを行い、優先度の高いタスクをキュー待ちで待機させておくことが多いです。

サンプル

#include "EspEasyGPIOInterrupt.h"

void gpioInt1Task() {
  Serial.println("gpioInt1");
}

EspEasyGPIOInterrupt gpioInt1;

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("GPIOInterrupt test");

  // Set Input Mode(INPUT or INPUT_PULLDOWN or INPUT_PULLUP)
  pinMode(GPIO_NUM_39, INPUT);

  // Int setting
  //   task : void function()
  //   gpio : 0 - 39(ESP32)
  //   mode : RISING or FALLING or CHANGE
  //   core : APP_CPU_NUM or PRO_CPU_NUM
  gpioInt1.begin(gpioInt1Task, GPIO_NUM_39, FALLING);
}

void loop() {
}

上記がサンプルです。最初にpinMode()でピンの状態を設定します。プルアップやプルダウンがあるのでライブラリの中で初期化はしていませんので注意してください。

begin()で、GPIO割り込みが発生した場合に呼び出されるタスク関数、GPIOのピン、状態を設定しています。

状態はLOWからHIGHになるRISING、HIGHからLOWになるFALLING、状態が変わるCHANGEがあります。あと、タスクが動くコアの指定も可能です。

内部実装

EspEasyUtils/EspEasyGPIOInterrupt.h at master · tanakamasayuki/EspEasyUtils
ESP32 Easy Utils. Contribute to tanakamasayuki/EspEasyUtils development by creating an account on GitHub.

上記が最新になります。

class EspEasyGPIOInterrupt {
public:
  QueueHandle_t _queue;
  TaskHandle_t _taskHandle;
  void (*_runTask)();

  static void ARDUINO_ISR_ATTR _isr(void *arg) {
    EspEasyGPIOInterrupt *gpioInt = (EspEasyGPIOInterrupt *)arg;
    xQueueSendFromISR(gpioInt->_queue, NULL, 0);
  };

  static void _waitTask(void *pvParameters) {
    EspEasyGPIOInterrupt *gpioInt = (EspEasyGPIOInterrupt *)pvParameters;
    while (1) {
      xQueueReceive(gpioInt->_queue, NULL, portMAX_DELAY);
      gpioInt->_runTask();
    }
  };

  void begin(void (*task)(), uint8_t pin, uint8_t mode, BaseType_t xCoreID = ESP_EASY_GPIO_INT_CPU_NUM) {
    _runTask = task;
    _queue = xQueueCreate(1, 0);

    xTaskCreateUniversal(
      _waitTask,
      "",
      8192,
      this,
      24,
      &_taskHandle,
      xCoreID);

      attachInterruptArg(pin, _isr, this, mode);
  };
};

非常に単純な処理で3つの関数しかありません。

タスク登録とGPIO割り込み設定 begin()

    _runTask = task;

実際に呼び出される関数の保存。

    _queue = xQueueCreate(1, 0);

xQueueCreate()で0バイトのデータを1つだけ保存できるキューを作っています。データサイズが0なのでデータ受け渡しがなく、通知と呼ばれる使い方になります。

    xTaskCreateUniversal(
      _waitTask,
      "",
      8192,
      this,
      24,
      &_taskHandle,
      xCoreID);

割り込みから通知を受けるタスクを動かします。

      attachInterruptArg(pin, _isr, this, mode);

実際にGPIO割り込み設定をしているのは上記になります。割り込みが発生すると_isr()関数が呼び出されます。

割り込み処理 _isr()

  static void ARDUINO_ISR_ATTR _isr(void *arg) {
    EspEasyGPIOInterrupt *gpioInt = (EspEasyGPIOInterrupt *)arg;
    xQueueSendFromISR(gpioInt->_queue, NULL, 0);
  };

こちらが実際の割り込みが発生したときに呼び出される関数です。割り込みで呼び出される関数はARDUINO_ISR_ATTR属性が必要になります。この属性がついていると高速動作するメモリに関数が配置されます。

また通常のクラス関数は割り込みで呼び出せないのでstaticにする必要があります。そうするとクラスの情報が取れないので、argにthisを渡して復元しています。

処理内容は通知を飛ばしているだけになります。割り込み内部からのキュー操作はFromISRがついている関数を呼び出す必要があるので注意してください。

通知待ちタスク _waitTask()

  static void _waitTask(void *pvParameters) {
    EspEasyGPIOInterrupt *gpioInt = (EspEasyGPIOInterrupt *)pvParameters;
    while (1) {
      xQueueReceive(gpioInt->_queue, NULL, portMAX_DELAY);
      gpioInt->_runTask();
    }
  };

こちらも通常のクラス関数をタスク登録することができないため、staticになっています。呼び出し時にthisを渡しているので、復元してから無限ループをしています。

内容的にはportMAX_DELAYで通知を受信するまで待機しており、通知を受け取ったらbegin()で設定した_runTask()関数を呼び出しているだけになります。

この処理は割り込みではなく、単に優先順位が高いタスクですので比較的どんな処理を行うことも可能です。

タイマー処理

タスクを作ってdelay()ループを実行していると、実際の処理時間+delay()時間になるのでどんどん時間がずれていきます。そこでタイマー割り込みを使うことで、精度の高い繰り返し処理を実現可能になっています。

利用例

#include "EspEasyTimer.h"

void timer1Task() {
  Serial.println("timer1");
}
void timer2Task() {
  Serial.println("timer2");
}
void timer3Task() {
  Serial.println("timer3");
}

// Max Timer ESP32=4, ESP32-S3=4, ESP32-C2=1, ESP32-C3=2, ESP32-C6=2 
EspEasyTimer timer1(TIMER_GROUP_0, TIMER_0);              // Dual core=APP_CPU_NUM(core1), Single Core=PRO_CPU_NUM(core0)
EspEasyTimer timer2(TIMER_GROUP_0, TIMER_1, APP_CPU_NUM); // APP_CPU_NUM(core1)
EspEasyTimer timer3(TIMER_GROUP_1, TIMER_0, PRO_CPU_NUM); // PRO_CPU_NUM(core0)

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("Timer test");

  // Timer begin
  //  timerTask  : task function
  //  ms         : millisecond interval
  timer1.begin(timer1Task, 1000);
  timer2.begin(timer2Task, 2000);
  timer3.begin(timer3Task, 3000);
}

void loop() {
  delay(1);
}

非常にシンプルになっています。

EspEasyTimer timer1(TIMER_GROUP_0, TIMER_0);

上記でタイマーを設定しています。ちょっとわかりにくいのですがESP32には4つのタイマーがあります。内訳としてはグループが0と1の2つで、その中にタイマーが0と1の2つになります。

EspEasyTimer timer1(TIMER_GROUP_0, TIMER_0);
EspEasyTimer timer2(TIMER_GROUP_0, TIMER_1);
EspEasyTimer timer3(TIMER_GROUP_1, TIMER_0);
EspEasyTimer timer4(TIMER_GROUP_1, TIMER_1);

つまり上記の組み合わせで呼び出しが可能です。ESP32-C2とかだと呼び出せるタイマーが少ないので注意してください。たぶんTIMER_0とか使っているので、使えないやつはエラーがでるかも?

  timer1.begin(timer1Task, 1000);

上記で1000ms(1秒)間隔でtimer1Task()関数が呼び出されます。

void timer1Task() {
  Serial.println("timer1");
}

呼び出される関数です。こちらは割り込み関数ではなく、通常の優先順位の高いタスクになります。優先順位を最高に設定しているのであまり長い処理はしないようにしてださい。

仕組み

EspEasyUtils/EspEasyTimer.h at master · tanakamasayuki/EspEasyUtils
ESP32 Easy Utils. Contribute to tanakamasayuki/EspEasyUtils development by creating an account on GitHub.

上記が最新版です。

class EspEasyTimer {
public:
  timer_group_t _timerGroup;
  timer_idx_t _timerIdx;
  uint8_t _timerId;
  void (*_timerTask)();
  TaskHandle_t _taskHandle;
  QueueHandle_t _queue;
  hw_timer_t *_timer;

  EspEasyTimer(timer_group_t timerGroup, timer_idx_t timerIdx, BaseType_t xCoreID = ESP_EASY_TIMER_CPU_NUM) {
    _timerGroup = timerGroup;
    _timerIdx = timerIdx;
    _timerId = _timerGroup + _timerIdx * 2;

    _queue = xQueueCreate(1, 0);

    xTaskCreateUniversal(
      _waitTask,
      "",
      8192,
      this,
      24,
      &_taskHandle,
      xCoreID);

    _timer = timerBegin(_timerId, getApbFrequency() / 1000000, true);
    timer_isr_callback_add(_timerGroup, _timerIdx, _onTimer, this, false);
  }

  static void _waitTask(void *pvParameters) {
    EspEasyTimer *timer = (EspEasyTimer *)pvParameters;
    while (1) {
      xQueueReceive(timer->_queue, NULL, portMAX_DELAY);
      timer->_timerTask();
    }
  };

  void begin(void (*timerTask)(), uint32_t ms) {
    _timerTask = timerTask;

    timerAlarmWrite(_timer, ms * 1000, true);
    timerAlarmEnable(_timer);
  };

  static bool ARDUINO_ISR_ATTR _onTimer(void *arg) {
    EspEasyTimer *timer = (EspEasyTimer *)arg;
    xQueueSendFromISR(timer->_queue, NULL, 0);
    return false;
  };
};

GPIO割り込みと同じような構成になっています。ただしタスクは最初に作っています。GPIO割り込みの最初に作ったほうがいい気がしてきました。

初期化部分

  EspEasyTimer(timer_group_t timerGroup, timer_idx_t timerIdx, BaseType_t xCoreID = ESP_EASY_TIMER_CPU_NUM) {
    _timerGroup = timerGroup;
    _timerIdx = timerIdx;
    _timerId = _timerGroup + _timerIdx * 2;

    _queue = xQueueCreate(1, 0);

    xTaskCreateUniversal(
      _waitTask,
      "",
      8192,
      this,
      24,
      &_taskHandle,
      xCoreID);

    _timer = timerBegin(_timerId, getApbFrequency() / 1000000, true);
    timer_isr_callback_add(_timerGroup, _timerIdx, _onTimer, this, false);
  }

コンストラクタでキュー、待ち受けようタスク、タイマーの最低限の初期化を行っています。

ここ実はtimer_isr_callback_add()だけESP-IDFの関数で、それ以外のタイマー系はArduinoの関数になります。どうしてもthisを引き渡す割り込み関数の設定ができなかったので、内部関数を呼び出しました。

本当はArduinoではなくて、ESP-IDF関数のみを使ったほうが良かったかもしれません。

タイマー設定

  void begin(void (*timerTask)(), uint32_t ms) {
    _timerTask = timerTask;

    timerAlarmWrite(_timer, ms * 1000, true);
    timerAlarmEnable(_timer);
  };

呼び出し関数を保存して、タイマーの時間を設定してから有効化しているだけです。

タイマー割り込み関数

  static bool ARDUINO_ISR_ATTR _onTimer(void *arg) {
    EspEasyTimer *timer = (EspEasyTimer *)arg;
    xQueueSendFromISR(timer->_queue, NULL, 0);
    return false;
  };

GPIO割り込みと同じく、thisを復元してから通知を送信しているだけです。タイマーの場合にはfalseを返却する必要があります。

待ち受けタスク

  static void _waitTask(void *pvParameters) {
    EspEasyTimer *timer = (EspEasyTimer *)pvParameters;
    while (1) {
      xQueueReceive(timer->_queue, NULL, portMAX_DELAY);
      timer->_timerTask();
    }
  };

GPIO割り込みと同じ処理になります。

まとめ

タスクとキュー、割り込みの関係性がわかったでしょうか?

分解してみると比較的単純なのですが、個別に調べると結構わかりにくいと思います。ヘルパークラスをそのまま使ってもよいのですが、自分でカスタマイズして使えるようになると便利だと思います。

コメント