ESP32の高精度タイマー割り込みを調べる

概要

Arduino環境ではTickerクラスを利用したタイマーと、より高精度の割り込みを利用したタイマー処理があり、割り込みを利用したタイマー処理を調べてみました。

タイマーの概要

ESP32には4つのタイマーがあり、自由に利用することができます。タイマーの原理としては、指定したクロック数が経過したらカウントして、特定カウントになったらタイマー割り込みをかけて、割り込み関数を呼び出すような動きになっています。

そのため、クロック数とカウント数の掛け算でタイマーの間隔を制御しています。あとの例では80クロック×1000000カウント=80Mクロックでタイマー割り込みが発生しています。タイマーは通常80MHzで動いていますので、1秒間隔での実行になります。

タイマーの動作周波数はCPU周波数に応じて変化します。CPU周波数と同一ではなく、ペリフェラル周波数と呼ばれる周辺機器の周波数です。

CPU周波数ペリフェラル周波数
240MHz
120MHz
80MHz
80MHz
40MHz40MHz
20MHz20MHz
10MHz10MHz

上記の対応になっていて、ペリフェラル周波数の上限は80MHzで、それ以下の場合にはCPU周波数と同一です。

つまり、1カウントの時間をわかりやすい1マイクロ秒に設定したい場合には、ペリフェラル周波数に合わせて変える必要があります。変更していない場合、80Mクロックで設定していたタイマーを、ペリフェラル周波数40MHzで動かすと2秒になります。

最小構成スケッチ

#define LED_PIN    10
hw_timer_t * timer = NULL;
void IRAM_ATTR onTimer() {
  digitalWrite(LED_PIN, !digitalRead(LED_PIN));
}
void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000000, true);
  timerAlarmEnable(timer);
}
void loop() {
}

最小構成のLチカです。このよう処理は実際にはTickerクラスを利用したほうが良いです。

タイマー作成

  timer = timerBegin(0, 80, true);

timerBegin()でタイマーを作成しています。一番最初の引数はタイマーのIDです。ESP32はタイマーが4つあり、0-3までのタイマーを利用できます。

2つ目が何クロックでカウントをするかの数値になります。80の場合、80クロックで1カウントします。getApbFrequency()/1000000で、どのCPU周波数でも1マイクロ秒に固定化することができます。

3つ目がカウンターをカウントアップする場合にはtrue。カウントダウンする場合にはfalseになります。通常はtrueのみしか使わないと思います。

タイマー割り込み関数登録

  timerAttachInterrupt(timer, &onTimer, true);

最初の引数は設定するタイマー。2つ目は割り込み時に呼ばれる割り込み関数。3つ目が割り込み検知方法です。

void IRAM_ATTR onTimer() {

割り込み関数は上記のようにIRAM_ATTRが追加されています。

このオプションは関数を高速なIRAM上に読み込みことを保証させるもののようです。おのオプションが無いと、フラッシュ上に配置される可能性があり、その場合には低速動作になり、結果的にクラッシュする可能性があるみたいです。

単純なプログラムの場合にはおそらく指定しなくても動きますが、基本的には割り込み関数は高速である必要があり、IRAM_ATTRをつけてください。

3つ目の割り込み検知方法は、trueの場合にはエッジトリガー、falseの場合にはレベルトリガーになります。タイマーの場合にはカウントアップしていって、指定カウントに変わったところを検知するので、エッジトリガーのtrueを指定します。

タイマー割り込み判定方法設定

  timerAlarmWrite(timer, 1000000, true);

割り込みが発生したときの、トリガー条件を設定します。最初の引数は設定するタイマー。2つ目はカウント数。3つ目がautoreloadで、trueの場合には定期実行、falseの場合には1ショットの実行になります。

タイマー有効化

  timerAlarmEnable(timer);

上記でタイマーを有効化しています。timerAlarmDisable()という関数で停止させることもできます。

公式スケッチ例

ESP32\Timer\RepeatTimer

/*
 Repeat timer example
 This example shows how to use hardware timer in ESP32. The timer calls onTimer
 function every second. The timer can be stopped with button attached to PIN 0
 (IO0).
 This example code is in the public domain.
 */
// Stop button is attached to PIN 0 (IO0)
#define BTN_STOP_ALARM    0
hw_timer_t * timer = NULL;
volatile SemaphoreHandle_t timerSemaphore;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
volatile uint32_t isrCounter = 0;
volatile uint32_t lastIsrAt = 0;
void IRAM_ATTR onTimer(){
  // Increment the counter and set the time of ISR
  portENTER_CRITICAL_ISR(&timerMux);
  isrCounter++;
  lastIsrAt = millis();
  portEXIT_CRITICAL_ISR(&timerMux);
  // Give a semaphore that we can check in the loop
  xSemaphoreGiveFromISR(timerSemaphore, NULL);
  // It is safe to use digitalRead/Write here if you want to toggle an output
}
void setup() {
  Serial.begin(115200);
  // Set BTN_STOP_ALARM to input mode
  pinMode(BTN_STOP_ALARM, INPUT);
  // Create semaphore to inform us when the timer has fired
  timerSemaphore = xSemaphoreCreateBinary();
  // Use 1st timer of 4 (counted from zero).
  // Set 80 divider for prescaler (see ESP32 Technical Reference Manual for more
  // info).
  timer = timerBegin(0, 80, true);
  // Attach onTimer function to our timer.
  timerAttachInterrupt(timer, &onTimer, true);
  // Set alarm to call onTimer function every second (value in microseconds).
  // Repeat the alarm (third parameter)
  timerAlarmWrite(timer, 1000000, true);
  // Start an alarm
  timerAlarmEnable(timer);
}
void loop() {
  // If Timer has fired
  if (xSemaphoreTake(timerSemaphore, 0) == pdTRUE){
    uint32_t isrCount = 0, isrTime = 0;
    // Read the interrupt count and time
    portENTER_CRITICAL(&timerMux);
    isrCount = isrCounter;
    isrTime = lastIsrAt;
    portEXIT_CRITICAL(&timerMux);
    // Print it
    Serial.print("onTimer no. ");
    Serial.print(isrCount);
    Serial.print(" at ");
    Serial.print(isrTime);
    Serial.println(" ms");
  }
  // If button is pressed
  if (digitalRead(BTN_STOP_ALARM) == LOW) {
    // If timer is still running
    if (timer) {
      // Stop and free timer
      timerEnd(timer);
      timer = NULL;
    }
  }
}

実際のタイマー利用例です。マルチタスクや割り込みを使う場合には、通常のコールバックと違い意図しないタイミングで変数を更新される可能性などがあり、排他処理を行う必要があります。

ざっくり説明すると、他の人が使っていないのを確認してから、利用を宣言して、使い終わっていたら、開放する処理です。

上記に詳しく書いてありますが、スケッチ例ではバイナリセマフォを利用しています。記事には書いていませんが、キューに似ているが、1つしか保存しない通知機能もあるのでどこかでマルチタスクでの注意点をまとめたいと思っています。

このスケッチでは、1秒間隔でシリアルに時間を出力し、GPIOがLOWになるとタイマーを停止する動きになります。M5StickCの場合にはGPIO37がHOMEボタンなので、書き換えてから実行してみてください。

この動き自体はGPIOにLOWになったら割り込み関数を実行したほうが適して、あまり良くないサンプルに見えます。

バイナリセマフォ作成

  timerSemaphore = xSemaphoreCreateBinary();

バイナリセマフォとは、一人だけセマフォを取得できる排他制御です。ただし、デフォルトは誰もセマフォを取得できず、誰かが許可した場合にだけ取得可能です。

  timer = timerBegin(0, 80, true);
  timerAttachInterrupt(timer, &onTimer, true);
  timerAlarmWrite(timer, 1000000, true);
  timerAlarmEnable(timer);

1マイクロ秒単位のタイマーを作成して、1000000カウント(1秒)したらonTimer()関数を割り込みで呼び出すタイマーを実行しています。

ここは最小構成スケッチと同じです。

セマフォ取得判定

  if (xSemaphoreTake(timerSemaphore, 0) == pdTRUE){

ここはセマフォの処理になります。0はタイムアウトの時間を表し、この場合にはセマフォを取得できなかった場合には即時終了します。portMAX_DELAYを指定すると、セマフォが取得できるまで、ブロッキングして待ちます。

タイマー値取得

    // Read the interrupt count and time
    portENTER_CRITICAL(&timerMux);
    isrCount = isrCounter;
    isrTime = lastIsrAt;
    portEXIT_CRITICAL(&timerMux);

セマフォが取得できた場合には、タイマー値を取得してからシリアルに出力しています。portENTER_CRITICAL()とportEXIT_CRITICAL()は割り込み禁止で、この関数に囲まれているクリティカルセクションと呼ばれる処理を実行中には、タイマー割り込みなどが発生しません。

取得しようとしている変数が、タイマー割り込みで変更される可能性があるものなので、取得途中で変更されて困るものは、割り込み禁止を設定します。

タイマー停止

  if (digitalRead(BTN_STOP_ALARM) == LOW) {
    // If timer is still running
    if (timer) {
      // Stop and free timer
      timerEnd(timer);
      timer = NULL;
    }
  }

ここは単純にGPIOがLOWの場合にはタイマーを終了しています。

セマフォ宣言

volatile SemaphoreHandle_t timerSemaphore;

volatile宣言がついています。割り込みで利用する変数にはすべてこの宣言を利用する必要があります。

この宣言があると、変数の置き場所がいろいろな場所から変更されても大丈夫な領域に確保されます。

割り込み禁止用Mux宣言

portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;

割り込み禁止も、ミューテックスと呼ばれる排他制御です。初期値は常にportMUX_INITIALIZER_UNLOCKEDで排他制御されていない状態です。

タイマー用変数宣言

volatile uint32_t isrCounter = 0;
volatile uint32_t lastIsrAt = 0;

isrCounterが割り込み発生回数、lastIsrAtが最後に割り込みが発生したときの経過時間が入っている変数です。

両方とも割り込みから変更される可能性があるのでvolatile宣言が付いています。

割り込み関数

void IRAM_ATTR onTimer(){
  // Increment the counter and set the time of ISR
  portENTER_CRITICAL_ISR(&timerMux);
  isrCounter++;
  lastIsrAt = millis();
  portEXIT_CRITICAL_ISR(&timerMux);
  // Give a semaphore that we can check in the loop
  xSemaphoreGiveFromISR(timerSemaphore, NULL);
  // It is safe to use digitalRead/Write here if you want to toggle an output
}

割り込み関数では、通常と使える関数群が違いますので注意しましょう。_ISRが最後につく関数群がある場合には、そちらを利用しないと正しく動きません。ですが、単純な処理の場合には_ISRの割り込み中用の関数群ではなくても動いてしまうので、注意してください。複雑な割り込みが発生した場合など、意図しないタイミングでハングアップする可能性があります。

portENTER_CRITICAL_ISR()とportEXIT_CRITICAL_ISR()で囲んだ間で、タイマー変数の更新を行っています。この処理であれば、おそらく割り込み禁止をしなくても大丈夫そうですが、割込み禁止にしているようです。

xSemaphoreGiveFromISR(timerSemaphore, NULL)で、タイマー用のバイナリセマフォに許可を与えています。NULLに指定しているところには、本来以下のように値をいれた変数にする必要があります。

static signed portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR( xSemaphore, &xHigherPriorityTaskWoken );

この関数を実行後にxHigherPriorityTaskWokenの中身を確認し、pdTRUEに変更された場合には、セマフォ取得待ちでブロックだった優先度の高いタスクが実行されたのがわかります。

ブロックしていると、かなりテクニカルな処理が必要になるので、なるべくノンブロックで動かすことをおすすめします。

まとめ

割り込みは非常に難しいです。今回の例ではありませんでしたが基本的にはマルチタスクを意識したコーディングが必要になります。

使う関数群なども違いますので注意が必要です。このへんの処理はArduinoやESP32ではなく、内部で利用しているFreeRTOSの知識が必要になります。ただし日本語の情報も少なく、体系的に勉強するのは難易度が高そうです。

コメント