ESP32のFreeRTOS入門 その7 タイマー

概要

前回はセマフォとミューテックスでした。今見直したらイベントグループがあったのですが、次回以降にその他の機能としてまとめます。

今回はタイマーです。しなしながらFreeRTOSのタイマーはソフトウエアで、タスクを起動して時間まで待機みたいな処理しかしていません。

ESP32の場合にはハードウエアタイマーがありますので、FreeRTOSのタイマーではなく、ESP32のハードウエアタイマーをFreeRTOSとしてどのように利用するのかを説明したいと思います。

ESP32のタイマーについて

ESP32には4つのハードウエアタイマーが内蔵されています。

Tickerクラスを利用すると、あまり細かいことを考えなくても利用できますが、今回は低レベル関数のtimer関数群を利用します。

タイマーをセットしておくと、タイマー割り込みが発生し、ISRとしてタイマー処理が実装可能です。

最小構成

#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() {
}

タイマーを作成し、1秒間隔でLチカするサンプルです。細かいパラメーターについては、以下の記事で解説しているので今回は省略したいと思います。

リピートタイマー

// タイマーのストップボタン
#define BTN_STOP_ALARM    37

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() {
  // ミューテックスを利用して排他制御
  portENTER_CRITICAL_ISR(&timerMux);
  isrCounter++;
  lastIsrAt = millis();
  portEXIT_CRITICAL_ISR(&timerMux);

  // カウント更新用セマフォを許可
  xSemaphoreGiveFromISR(timerSemaphore, NULL);
}

void setup() {
  Serial.begin(115200);
  delay(50);

  pinMode(BTN_STOP_ALARM, INPUT);

  // カウント更新用セマフォ
  timerSemaphore = xSemaphoreCreateBinary();

  // 周波数設定(次のtimerBeginを固定値で呼んでいるとタイマーがずれます)
  setCpuFrequencyMhz(10);

  // 4つあるタイマーの1つめを利用
  // 1マイクロ秒ごとにカウント(どの周波数でも)
  // true:カウントアップ
  timer = timerBegin(0, getApbFrequency() / 1000000, true);
  //timer = timerBegin(0, 80, true); // CPU周波数が80以上でないとタイマーが遅くなる

  // タイマー割り込み設定
  timerAttachInterrupt(timer, &onTimer, true);

  // 1000000カウントにセット(=1000000マイクロ秒=1000ミリ秒=1秒)
  timerAlarmWrite(timer, 1000000, true);

  // タイマー開始
  timerAlarmEnable(timer);
}

void loop() {
  // カウント更新用セマフォ
  if (xSemaphoreTake(timerSemaphore, 0) == pdTRUE) {
    uint32_t isrCount = 0;
    uint32_t isrTime = 0;

    // 排他制御を利用してカウント取得
    portENTER_CRITICAL(&timerMux);
    isrCount = isrCounter;
    isrTime = lastIsrAt;
    portEXIT_CRITICAL(&timerMux);

    // 出力
    Serial.print("onTimer no. ");
    Serial.print(isrCount);
    Serial.print(" at ");
    Serial.print(isrTime);
    Serial.println(" ms");
  }

  // ストップボタン
  if (digitalRead(BTN_STOP_ALARM) == LOW) {
    if (timer) {
      Serial.println("Timer Stop");
      timerEnd(timer);
      timer = NULL;
    }
  }

  delay(1);
}

上記は、ESP32のスケッチ例にあるRepeatTimerをベースにちょっと修正したものです。

  // 周波数設定(次のtimerBeginを固定値で呼んでいるとタイマーがずれます)
  setCpuFrequencyMhz(10);

  // 4つあるタイマーの1つめを利用
  // 1マイクロ秒ごとにカウント(どの周波数でも)
  // true:カウントアップ
  timer = timerBegin(0, getApbFrequency() / 1000000, true);
  //timer = timerBegin(0, 80, true); // CPU周波数が80以上でないとタイマーが遅くなる

上記のタイマー作成のところがわかりにくく、ESP32のハードウエアタイマーはペリフェラルのクロック回数でカウントアップして、カウントが指定値になったらタイマー割り込みが走る仕様となっています。

ペリフェラル周波数は、80MHzが上限でCPU周波数とある程度連携しています。通常は80MHzで動いているので、80クロックでカウントすると1マイクロ秒になります。なので80を指定しているスケッチが多いですが、CPU周波数を40MHzに下げると、80クロックは0.5マイクロ秒になってしまいます。

そのため、ペリフェラル周波数を取得して、カウントが1マイクロ秒になるようなクロック数を計算して設定しています。

  // 1000000カウントにセット(=1000000マイクロ秒=1000ミリ秒=1秒)
  timerAlarmWrite(timer, 1000000, true);

上記がタイマーのカウント数の設定です。1カウントが1マイクロ秒なので、1000000カウントは1秒となります。

void IRAM_ATTR onTimer() {
  // ミューテックスを利用して排他制御
  portENTER_CRITICAL_ISR(&timerMux);
  isrCounter++;
  lastIsrAt = millis();
  portEXIT_CRITICAL_ISR(&timerMux);

  // カウント更新用セマフォを許可
  xSemaphoreGiveFromISR(timerSemaphore, NULL);
}

タイマーの割り込み関数です。ミューテックスを使って、カウントを保存したあとに、セマフォを使ってカウントが更新したことを通知しています。

このセマフォは同期処理で、割り込み関数の中で更新すると、意図しないタイミングで更新されてしまうので、明示的に更新する場所をしていするために利用しています。

  // カウント更新用セマフォ
  if (xSemaphoreTake(timerSemaphore, 0) == pdTRUE) {
    uint32_t isrCount = 0;
    uint32_t isrTime = 0;

    // 排他制御を利用してカウント取得
    portENTER_CRITICAL(&timerMux);
    isrCount = isrCounter;
    isrTime = lastIsrAt;
    portEXIT_CRITICAL(&timerMux);

    // 出力
    Serial.print("onTimer no. ");
    Serial.print(isrCount);
    Serial.print(" at ");
    Serial.print(isrTime);
    Serial.println(" ms");
  }

上記がメインのロジックです。セマフォを確認して、更新された場合のみ処理をしています。

割り込みで変更される可能性がある値を参照する場合には、ミューテックスを使って排他処理をしてから、値をコピーして利用します。

    Serial.println(isrCount);
    Serial.println(isrTime);

読み出しの場合のミューテックスは必須ではなく、上記のようにミューテックスなしで読み出すことも可能です。ただし、isrCountを出力したあとにタイマー割り込みが発生してしまうと、isrTimeが更新されてしまいます。

画面上に100ミリ秒ごとに表示するなどの場合で、多少不整合が起こっても構わない用途であれば、排他処理はなくても構わないと思いますが、基本的には排他処理を使ったほうが好ましいです。

Tickerクラス分析

class Ticker
{
public:
  Ticker();
  ~Ticker();
  typedef void (*callback_t)(void);
  typedef void (*callback_with_arg_t)(void*);

  void attach(float seconds, callback_t callback)
  {
    _attach_ms(seconds * 1000, true, reinterpret_cast<callback_with_arg_t>(callback), 0);
  }

attachを呼び出すと、_attach_msをコールしています。

void Ticker::_attach_ms(uint32_t milliseconds, bool repeat, callback_with_arg_t callback, uint32_t arg) {
  esp_timer_create_args_t _timerConfig;
  _timerConfig.arg = reinterpret_cast<void*>(arg);
  _timerConfig.callback = callback;
  _timerConfig.dispatch_method = ESP_TIMER_TASK;
  _timerConfig.name = "Ticker";
  if (_timer) {
    esp_timer_stop(_timer);
    esp_timer_delete(_timer);
  }
  esp_timer_create(&_timerConfig, &_timer);
  if (repeat) {
    esp_timer_start_periodic(_timer, milliseconds * 1000ULL);
  } else {
    esp_timer_start_once(_timer, milliseconds * 1000ULL);
  }
}

さらに中身をみるとesp_timer_create()とesp_timer_start_periodic()かesp_timer_start_once()を呼び出しています。

esp_err_t esp_timer_create(const esp_timer_create_args_t* args,
                           esp_timer_handle_t* out_handle)
{
    if (!is_initialized()) {
        return ESP_ERR_INVALID_STATE;
    }
    if (args->callback == NULL) {
        return ESP_ERR_INVALID_ARG;
    }
    esp_timer_handle_t result = (esp_timer_handle_t) calloc(1, sizeof(*result));
    if (result == NULL) {
        return ESP_ERR_NO_MEM;
    }
    result->callback = args->callback;
    result->arg = args->arg;
#if WITH_PROFILING
    result->name = args->name;
    timer_insert_inactive(result);
#endif
    *out_handle = result;
    return ESP_OK;
}

最初に初期化を確認して、あとはコールバックの処理をリストに登録しています。

esp_err_t esp_timer_init(void)
{
    esp_err_t err;
    if (is_initialized()) {
        return ESP_ERR_INVALID_STATE;
    }

#if CONFIG_SPIRAM_USE_MALLOC
    memset(&s_timer_semaphore_memory, 0, sizeof(StaticQueue_t));
    s_timer_semaphore = xSemaphoreCreateCountingStatic(TIMER_EVENT_QUEUE_SIZE, 0, &s_timer_semaphore_memory);
#else
    s_timer_semaphore = xSemaphoreCreateCounting(TIMER_EVENT_QUEUE_SIZE, 0);
#endif
    if (!s_timer_semaphore) {
        err = ESP_ERR_NO_MEM;
        goto out;
    }

#if CONFIG_SPIRAM_USE_MALLOC
    memset(&s_timer_delete_mutex_memory, 0, sizeof(StaticQueue_t));
    s_timer_delete_mutex = xSemaphoreCreateRecursiveMutexStatic(&s_timer_delete_mutex_memory);
#else
    s_timer_delete_mutex = xSemaphoreCreateRecursiveMutex();
#endif
    if (!s_timer_delete_mutex) {
        err = ESP_ERR_NO_MEM;
        goto out;
    }


    int ret = xTaskCreatePinnedToCore(&timer_task, "esp_timer",
            ESP_TASK_TIMER_STACK, NULL, ESP_TASK_TIMER_PRIO, &s_timer_task, PRO_CPU_NUM);
    if (ret != pdPASS) {
        err = ESP_ERR_NO_MEM;
        goto out;
    }

    err = esp_timer_impl_init(&timer_alarm_handler);
    if (err != ESP_OK) {
        goto out;
    }

    return ESP_OK;

out:
    if (s_timer_task) {
        vTaskDelete(s_timer_task);
        s_timer_task = NULL;
    }
    if (s_timer_semaphore) {
        vSemaphoreDelete(s_timer_semaphore);
        s_timer_semaphore = NULL;
    }
    if (s_timer_delete_mutex) {
        vSemaphoreDelete(s_timer_delete_mutex);
        s_timer_delete_mutex = NULL;
    }
    return ESP_ERR_NO_MEM;
}

初期化部分をみてみると、ちょっと長いので全部は理解しなくてもいいのです。よく見るとエラー処理にgoto文を使っていますね。

以下がタスクを登録しているところです。

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

timer_taskを、PRO_CPU_NUMで優先度ESP_TASK_TIMER_PRIOで実行しています。

#define ESP_TASK_TIMER_PRIO           (ESP_TASK_PRIO_MAX - 3)
#define ESP_TASK_PRIO_MAX (configMAX_PRIORITIES)
#define configMAX_PRIORITIES			( 25 )

優先度は、上記で確認すると25-3の22で実行しています。

static void timer_task(void* arg)
{
    while (true){
        int res = xSemaphoreTake(s_timer_semaphore, portMAX_DELAY);
        assert(res == pdTRUE);
        timer_process_alarm(ESP_TIMER_TASK);
    }
}

タイマー用のセマフォが取得できるまで待機して、その後にタイマー用のコールバック関数を呼び出すtimer_process_alarm()関数をコールしていました。

さて、このタイマー用のセマフォを許可している場所を調べました。

static void IRAM_ATTR timer_alarm_handler(void* arg)
{
    int need_yield;
    if (xSemaphoreGiveFromISR(s_timer_semaphore, &need_yield) != pdPASS) {
        ESP_EARLY_LOGD(TAG, "timer queue overflow");
        return;
    }
    if (need_yield == pdTRUE) {
        portYIELD_FROM_ISR();
    }
}

IRAM_ATTRがついているのと、呼び出している関数がISRなので、タイマー割り込みで呼ばれる関数ですね。

esp_timer_init()の初期化時に登録されていました。

    err = esp_timer_impl_init(&timer_alarm_handler);

どうやって、タイマーが登録されているのかを調べてみます。

esp_err_t IRAM_ATTR esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period_us)
{
    if (!is_initialized() || timer_armed(timer)) {
        return ESP_ERR_INVALID_STATE;
    }
    period_us = MAX(period_us, esp_timer_impl_get_min_period_us());
    timer->alarm = esp_timer_get_time() + period_us;
    timer->period = period_us;
#if WITH_PROFILING
    timer->times_armed++;
#endif
    return timer_insert(timer);
}

登録時に呼ばれるesp_timer_start_periodic()関数をみたところ、timer_insert()関数でタイマーを追加しています。

static IRAM_ATTR esp_err_t timer_insert(esp_timer_handle_t timer)
{
    timer_list_lock();
#if WITH_PROFILING
    timer_remove_inactive(timer);
#endif
    esp_timer_handle_t it, last = NULL;
    if (LIST_FIRST(&s_timers) == NULL) {
        LIST_INSERT_HEAD(&s_timers, timer, list_entry);
    } else {
        LIST_FOREACH(it, &s_timers, list_entry) {
            if (timer->alarm < it->alarm) {
                LIST_INSERT_BEFORE(it, timer, list_entry);
                break;
            }
            last = it;
        }
        if (it == NULL) {
            assert(last);
            LIST_INSERT_AFTER(last, timer, list_entry);
        }
    }
    if (timer == LIST_FIRST(&s_timers)) {
        esp_timer_impl_set_alarm(timer->alarm);
    }
    timer_list_unlock();
    return ESP_OK;
}

リストへの登録をしていて、最初のタイマーの場合はesp_timer_impl_set_alarm()関数を呼んでいますね。

void IRAM_ATTR esp_timer_impl_set_alarm(uint64_t timestamp)
{
    portENTER_CRITICAL(&s_time_update_lock);
    // Alarm time relative to the moment when counter was 0
    uint64_t time_after_timebase_us = timestamp - s_time_base_us;
    // Adjust current time if overflow has happened
    bool overflow = timer_overflow_happened();
    uint64_t cur_count = REG_READ(FRC_TIMER_COUNT_REG(1));

    if (overflow) {
        assert(time_after_timebase_us > s_timer_us_per_overflow);
        time_after_timebase_us -= s_timer_us_per_overflow;
        s_overflow_happened = true;
    }
    // Calculate desired timer compare value (may exceed 2^32-1)
    uint64_t compare_val = time_after_timebase_us * s_timer_ticks_per_us;
    uint32_t alarm_reg_val = ALARM_OVERFLOW_VAL;
    // Use calculated alarm value if it is less than ALARM_OVERFLOW_VAL.
    // Note that if by the time we update ALARM_REG, COUNT_REG value is higher,
    // interrupt will not happen for another ALARM_OVERFLOW_VAL timer ticks,
    // so need to check if alarm value is too close in the future (e.g. <2 us away).
    const uint32_t offset = s_timer_ticks_per_us * 2;
    if (compare_val < ALARM_OVERFLOW_VAL) {
        if (compare_val < cur_count + offset) {
            compare_val = cur_count + offset;
            if (compare_val > ALARM_OVERFLOW_VAL) {
                compare_val = ALARM_OVERFLOW_VAL;
            }
        }
        alarm_reg_val = (uint32_t) compare_val;
    }
    REG_WRITE(FRC_TIMER_ALARM_REG(1), alarm_reg_val);
    portEXIT_CRITICAL(&s_time_update_lock);
}

いろいろ処理をしていますが、最後にREG_WRITE()関数で、FRC_TIMER_ALARM_REGという、タイマー用のレジスタに書き込んでいました。

Tickerクラスまとめ

Tickerクラスはタイマーレジスタに直接タイマーを登録しています。タイマー割り込みが発生すると、セマフォを使って処理用のタイマータスクを同期させます。

タイマータスクはCore0のPRO CPUに優先度22で動いています。タイマータスクはセマフォにて同期されると、タイマーの中身を調べてから登録されたコールバック関数を呼び出します。

直接ISRから登録したコールバック関数を呼び出さないのは、どんなコールバック関数が呼び出されるかわからないからだと思います。たとえばI2Cなどの呼び出しをするコールバック関数の場合にはハングアップしてしまいます。

そのため、割り込みではなくタイマータスクからの呼び出しになっていると推測できます。ここで注意しなければいけないのは、タイマータスクがCore0のPRO CPUに優先度22で動いていることです。

Core0のPRO CPUは、無線系のタスクも動いており、優先度が23です。つまり無線系のタスクが動いているときには、タイマー割り込みは発生しますが、タイマータスクより、無線系のタスクの方が優先順位が高いので、タイマータスクのコールバック呼び出しが遅延する場合があります。

タイマー精度が重要なものに関してはTickerクラスを利用することができません。自分でタイマー割り込みを処理し、Tickerクラス相当の処理を作る必要があります。

特にI2Cなど割り込み関数の中で呼び出せない処理をする場合には、無線系のタスクよりも優先順位の高いタスクにするか、Core1のAPP CPUで動かす必要があります。

まとめ

タイマー処理自体はFreeRTOSに関係ないのですが、適切に動かすためにはFreeRTOSの排他制御などの知識が必要になります。

既存クラスでどのようにFreeRTOSを利用しているかを調べてみると楽しいと思います。

1件のコメント

コメントする

メールアドレスが公開されることはありません。

管理者承認後にページに追加されます。公開されたくない相談はその旨本文に記載するかTwitterなどでDM投げてください。またスパム対策として、日本語が含まれない投稿は無視されますのでご注意ください。