概要
前回はセマフォとミューテックスでした。今見直したらイベントグループがあったのですが、次回以降にその他の機能としてまとめます。
今回はタイマーです。しなしながら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対応は時間ができたところで調べてみたいと思います