ESP32のFreeRTOS入門 その6 セマフォとミューテックス

概要

前回はキューを説明しました。今回が同期や排他制御の機能である、セマフォとミューテックスです。

一般的には最初にとりあげますが、通知や、キューを知ってからの方が説明しやすいと思い、後ろに持ってきました。というのは、建前でイマイチ勉強不足だったので、他の説明を書きつつ勉強しなおしていました。

セマフォとミューテックスとは?

ざっくり説明すると、セマフォは許可を与える機能です。同時アクセスの上限を設定し、上限に達していた場合には利用ができません。上限1の場合には、つねに一人しかそのリソースを利用できないことになります。

ミューテックスは、逆に使いますとの宣言をして、他の人の利用を制限します。上限1のセマフォと、ミューテックスは非常に似ていますので注意してください。

バイナリセマフォ

上限1のセマフォをバイナリセマフォとよびます。バイナリは0か1なので、上限1の場合はバイナリセマフォとよぶのだと思います。

バイナリセマフォの使い方としては、基本的には利用禁止で待機しているタスクに、割り込みなどで許可を出すような用途で使われます。

#include <M5StickC.h>

volatile SemaphoreHandle_t semaphore;

TaskHandle_t taskHandle;

void IRAM_ATTR onButton() {
  xSemaphoreGiveFromISR(semaphore, NULL);
}

void task1(void *pvParameters) {
  while (1) {
    xSemaphoreTake(semaphore, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

void setup() {
  M5.begin();

  // バイナリセマフォ作成
  semaphore = xSemaphoreCreateBinary();

  // Core1の優先度1で通知受信用タスク起動
  xTaskCreateUniversal(
    task1,
    "task1",
    8192,
    NULL,
    1,
    &taskHandle,
    APP_CPU_NUM
  );

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}

void loop() {
  delay(1);
}

上記が、かんたんなバイナリセマフォの例です。

void task1(void *pvParameters) {
  while (1) {
    xSemaphoreTake(semaphore, portMAX_DELAY);
    Serial.println(M5.Axp.GetBatVoltage());
    delay(1);
  }
}

上記のタスクでバイナリセマフォの処理待ちをしています。タスクを利用しないでloop()の中で待ち受けることもできます。

void loop() {
  if (xSemaphoreTake(semaphore, 0) == pdTRUE) {
    Serial.println(M5.Axp.GetBatVoltage());
  }
  delay(1);
}

上記のように待ち時間0で条件待ちをすることも可能です。ただし、基本的にはloop()関数はいつ呼ばれるのか不安定なので、優先度の高いタスクを作成して、portMAX_DELAYを利用して即時実行するような用途で使われることが多いです。

void IRAM_ATTR onButton() {
  xSemaphoreGiveFromISR(semaphore, NULL);
}

割り込みの中ではセマフォに許可を与えています。この動作を割り込みとタスクの動作が同じタイミングで実行するということで、同期と呼びます。

ここまで見てもらって、通知と何が違うのかというと、あまり変わりません。より原始的なのがバイナリセマフォであり、他の環境では通知の機能はなかったりします。内部的には長さ1のキューとバイナリセマフォは同じような実装のようです。

カウンティングセマフォ

バイナリセマフォの内部実装は長さ1のキューと同様でしたが、カウンティングセマフォは長さ2以上のキューと同様の実装です。内部的にはキューと同様なのですが、データの中身はあまり意味がなく、キューの残数で管理をしています。

カウンティングセマフォを利用する時には、キューの送信と同じ処理になります。キューに空きがない場合にはportMAX_DELAYで送信待ちをする処理です。

ESP32で適切なサンプルを作るのは難しいのですが、同時実行の制限などで使います。処理の開始前にカウンティングセマフォを利用して、キューの残数を減らします。処理が終わったらキューを受信して、キューの残数を戻すことで同時に実行できる数を制限することができます。

ミューテックス

ミューテックスは利用宣言をして、他の処理で使わないように排他制御する仕組みです。宣言をしたときに、他の処理が使っていた場合には使えるようになるまで待機します。

#include <M5StickC.h>

portMUX_TYPE mutex = portMUX_INITIALIZER_UNLOCKED;
volatile uint32_t isrTime = 0;
volatile uint32_t isrCount = 0;

void IRAM_ATTR onButton() {
  portENTER_CRITICAL_ISR(&mutex);
  isrTime = millis();
  isrCount++;
  portEXIT_CRITICAL_ISR(&mutex);
}

void setup() {
  M5.begin();

  pinMode(GPIO_NUM_37, INPUT);
  attachInterrupt(GPIO_NUM_37, onButton, FALLING);
}

void loop() {
  M5.update();

  if (M5.BtnB.wasPressed()) {
    portENTER_CRITICAL(&mutex);
    isrTime = 0;
    isrCount = 0;
    portEXIT_CRITICAL(&mutex);
  }

  M5.Lcd.setCursor(0, 0);
  M5.Lcd.println(isrTime);
  M5.Lcd.println(isrCount);

  delay(1);
}

Aボタンを押すと、割り込みが発生してカウントアップと、起動経過時間を更新する処理です。loop()関数の中でその数値を表示しているのと、Bボタンをしたら数値をクリアしています。

void IRAM_ATTR onButton() {
  portENTER_CRITICAL_ISR(&mutex);
  isrTime = millis();
  isrCount++;
  portEXIT_CRITICAL_ISR(&mutex);
}

上記のportENTER_CRITICAL_ISR()で排他制御の利用開始宣言をし、portEXIT_CRITICAL_ISR()で排他制御の利用終了宣言をしています。利用終了を呼び忘れると、今後だれもそのミューテックスが使えなくなるので注意してください。

またミューテックスを使う場合には、なくべく最低限の処理をするようにして、後で計算すればよい処理などは、portEXIT_CRITICAL_ISR()関数のあとにしてください。

void loop() {
  M5.update();

  if (M5.BtnB.wasPressed()) {
    portENTER_CRITICAL(&mutex);
    isrTime = 0;
    isrCount = 0;
    portEXIT_CRITICAL(&mutex);
  }

上記でも排他制御を行っています。portENTER_CRITICAL()関数、もしくはportENTER_CRITICAL_ISR()関数を実行したときに、該当ミューテックスが利用中の場合には、開放されるまで待機します。

ミューテックスは複数利用することができますので、引数のミューテックスごとに排他制御を管理しています。

ミューテックスを利用する理由ですが、複数の変数が同時に更新される場合に、確実の両方同時に更新されたことを担保することができます。

void loop() {
  M5.update();

  if (M5.BtnB.wasPressed()) {
    isrTime = 0;
    // ここで割り込みが発生!
    isrCount = 0;
  }

ミューテックスを利用していない場合に、isrCountを初期化したあとに、割り込みが発生した場合にはisrTimeとisrCountが割り込みで更新されますが、その後にisrCountだけ0に初期化されます。

そのためisrCountが0なのに、isrTimeが代入されている状況が発生してしまいます。

portMUX_TYPE mutex = portMUX_INITIALIZER_UNLOCKED;

ミューテックスの宣言部ですが、ミューテックスはアンロックドですので利用可能の状態で作成します。バイナリセマフォは初期値が利用不可ですので、そこが差になります。

volatile uint32_t isrTime = 0;
volatile uint32_t isrCount = 0;

ちなみに割り込みで操作する変数にはvolatileをつけるというルールがあるので、必ずつけてください。portMUX_TYPEはサンプルをみたところつけなくても大丈夫のようです。

  M5.Lcd.println(isrTime);
  M5.Lcd.println(isrCount);

ちなみに、上記の表示でもisrTimeを表示したあとに、割り込みが発生するとisrCountだけ更新された値が表示されてしまいます。ここにも何らかの排他制御が必要になります。表示のたびに排他制御を行うのが楽なのですが、そうすると排他制御の量が増えてしまい重い処理になってしまいます。

その場合には、割り込みが発生した場合に値を更新して、その後に通知などを使って表示用の変数を、表示と同じタスクの中で更新することで排他制御を実施することも可能です。

再帰的ミューテックス

カウンティングセマフォと同じように、再帰的ミューテックスも説明が難しいです。ESP32のライブラリを検索したところ、I2Cのライブラリで利用されていました。

再帰的というのは、ミューテックスを取得したスレッドが呼び出した関数の中でも、再度同じミューテックスを取得できる機能です。I2CなどがGPIOを制御する場合には、ミューテックスを利用して他の処理が利用しないように制限をする必要があります。

ただし、I2Cクラスが呼び出したGPIO制御関数がさらにミューテックスを取得しようとした場合には、通常のミューテックスではすでに取得されているので、いつまでたっても取得できません。そこで再帰的ミューテックスが利用されます。

呼び出し元クラスが取得済みのミューテックスの場合には、呼び出し先の関数でも再度、同じミューテックスが取得できます。関数の中でミューテックスを解放して、さらに呼び出し元クラスで解放すると、他のデバイスがそのミューテックスを利用できるようになります。

このように再帰的ミューテックスは、同じ処理の中では複数回ミューテックスが取得できますが、解放も同じ数だけ呼び出す必要があります。

まとめ

セマフォとミューテックスの違いが理解できましたでしょうか?

セマフォとミューテックスは、概要を理解してもらえたら大丈夫です。次回以降で実際の使い方を含めて説明をしていきたいと思います。

続編

コメントする

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

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