概要
前回はセマフォとミューテックスでした。今見直したらイベントグループがあったのですが、次回以降にその他の機能としてまとめます。
今回はタイマーです。しなしながら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を利用しているかを調べてみると楽しいと思います。
コメント
すばらしい内容です。ありがとうございます。
ESP32のタイマー割り込みを調べていてこちらの記事にたどり着きました。
ちなみに、私の方では最近次のような記事を書きました。
http://radiopench.blog96.fc2.com/blog-entry-1071.html
Arduino UNO で遊んでいた人間なので知らない話ばかりで、とても参考になります。関連記事も含め、これからじっくり読ませていただきます。すばらしい記事、ありがとうございます。
ありがとうございます
ブログも読ませていただいていました!
最新のesp32のパッケージだと動作しません。
https://aki-lab.hatenadiary.com/entry/2024/09/12/005011
ありがとうございます
たしかにいろいろ変わっているのでそのままだと動きませんね
v3対応は時間ができたところで調べてみたいと思います