ESP32×FreeRTOS:セマフォとは?ログ混在を直す排他制御とBinary/MUTEXの違い

FreeRTOS

ESP32でFreeRTOSを使い始めると、最初にぶつかりやすいのが「共有リソースの同時アクセス」です。
たとえば 複数タスクが同時にUART出力すると、ログが混ざって読めなくなります。

この記事では、FreeRTOSの Semaphore(セマフォ) を使って「混在→改善」を体験しながら、Binary SemaphoreとMutexの使い分けまで一気に理解できるようにまとめます。

この記事のコードは ESP-IDF + FreeRTOS でそのまま動作します。
タスクA/Bを同時に動かし、UARTログ混在 → セマフォ改善を再現できます。


この記事でわかること

  • セマフォ(Semaphore)とは何か
  • セマフォを使わないと何が起きるか(ログ混在の再現)
  • セマフォを使うメリット(排他制御)
  • Binary Semaphore と Mutex の違い(優先度逆転と優先度継承)

こんな方におすすめ

  • ESP32でFreeRTOSを使っていて、ログや通信の挙動が不安定
  • UART / I2C / SPI / SDカードなど「共有リソース」の扱いが不安
  • Binary SemaphoreとMutexの違いが曖昧で、選べない

FreeRTOS Semaphore(セマフォ)とは?役割と用途

FreeRTOSの Semaphore(セマフォ) は、複数タスクが 同じ共有リソースを同時に使わない ようにする仕組みです。

共有リソースの例:

  • UART(シリアル出力)
  • I2Cセンサー
  • SPIバス
  • SDカード書き込み
  • WiFi送信処理
  • 共有メモリ / 共有データ構造

これらは同時アクセスすると、データ破損処理の破綻につながります。


なぜ FreeRTOS でセマフォが必要か?(排他制御の重要性)

複数タスクが同時に printf() を実行すると、出力が混ざって読めなくなります。
ログが読めないだけならまだ軽症ですが、実際のIoTでは以下のような深刻な問題に繋がります。

  • 通信フレームが壊れて送受信不能
  • SDカード書き込みが壊れてファイル破損
  • センサー読み取りが壊れて異常値連発
  • タイムアウトやリトライが増えて不安定化

そこで、同時に使えるタスクを1つに制限する(排他制御)のが基本です。


セマフォ未使用の実装例(ログ混在を再現)

タスクAとタスクBが、それぞれ以下を出すとします。

  • タスクA:[A] START END
  • タスクB:[B] BEGIN DONE

まずは セマフォ無しで実装します。

#include <cstdio>
#include <random>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dist(500, 2000);

// セマフォなし
void TaskA(void *pvParameters) {
    for (;;) {
        printf("[A] START ");
        vTaskDelay(pdMS_TO_TICKS(dist(gen))); // ランダムな時間待つ
        printf(" END\n");
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

void TaskB(void *pvParameters) {
    for (;;) {
        printf("[B] BEGIN ");
        vTaskDelay(pdMS_TO_TICKS(dist(gen))); // ランダムな時間待つ
        printf(" DONE\n");
        vTaskDelay(pdMS_TO_TICKS(300));
    }
}


extern "C" void app_main(void)
{
    xTaskCreate(TaskA, "TaskA", 2048, NULL, 1, NULL);
    xTaskCreate(TaskB, "TaskB", 2048, NULL, 1, NULL);
}

この状態では、出力タイミングが噛み合うと ログが混在します。
例:printf("[A] START ");printf(" END\n"); の間に他タスクが割り込む → 混ざる
実際にログが混在していることが、確認できます。

Binary Semaphoreで改善する(排他制御)

次に、どちらかのタスクがprintf出力している間は、もう片方は待つようにします。
これが 排他制御です。

#include <cstdio>
#include <random>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"

SemaphoreHandle_t uartLock;

std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dist(500, 2000);

// セマフォあり
void TaskA(void *pvParameters) {
    for (;;) {
        if (xSemaphoreTake(uartLock, portMAX_DELAY) == pdTRUE) {
            printf("[A] START ");
            vTaskDelay(pdMS_TO_TICKS(dist(gen))); // ランダムな時間待つ
            printf(" END\n");
            xSemaphoreGive(uartLock);
        }
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

void TaskB(void *pvParameters) {
    for (;;) {
        if (xSemaphoreTake(uartLock, portMAX_DELAY) == pdTRUE) {
            printf("[B] BEGIN ");
            vTaskDelay(pdMS_TO_TICKS(dist(gen))); // ランダムな時間待つ
            printf(" DONE\n");
            xSemaphoreGive(uartLock);
        }
        vTaskDelay(pdMS_TO_TICKS(300));
    }
}


extern "C" void app_main(void)
{
    uartLock = xSemaphoreCreateBinary();
    if (uartLock == NULL) {
        printf("xSemaphoreCreateBinary failed\n");
        return;
    }
    xSemaphoreGive(uartLock);
    xTaskCreate(TaskA, "TaskA", 2048, NULL, 1, NULL);
    xTaskCreate(TaskB, "TaskB", 2048, NULL, 1, NULL);
}

これでログは混ざりなくなり、「排他制御」が体感できます。

実際にログが混在せず正しく出力されることが確認できます。


主要なSemaphore関数(役割・引数・戻り値を最短で理解)

ここでは、この記事で使っている FreeRTOSのセマフォAPIを「何をする関数か」「引数は何か」だけに絞って整理します。
まず結論として、セマフォ操作で覚えるのは次の3つです。

  • 作るxSemaphoreCreateBinary() / xSemaphoreCreateMutex()
  • 取る(ロック)xSemaphoreTake()
  • 返す(アンロック)xSemaphoreGive()

xSemaphoreCreateBinary()(Binary Semaphoreを作る)

SemaphoreHandle_t sem = xSemaphoreCreateBinary();
  • 役割:Binary Semaphore(0/1の“合図”や簡易ロック)を作る
  • 戻り値
    • 成功:セマフォのハンドル(SemaphoreHandle_t
    • 失敗:NULL(メモリ不足など)

注意:Binary Semaphoreは作った直後は「空(取れない状態)」です。
排他ロックとして使うなら、最初に xSemaphoreGive() で“空き”にする必要があります。

uartLock = xSemaphoreCreateBinary();
xSemaphoreGive(uartLock);  // 最初に「空き」にする

xSemaphoreCreateMutex()(Mutexを作る)

SemaphoreHandle_t mtx = xSemaphoreCreateMutex();
  • 役割:Mutex(排他制御専用のロック)を作る
  • 戻り値
    • 成功:Mutexハンドル
    • 失敗:NULL

Mutexは 排他制御向きで、Binary Semaphoreと違い「優先度逆転」対策(優先度継承)が効くのが特徴です。
※「共有リソース保護」が目的なら、基本はMutexが第一候補です。


xSemaphoreTake()(取る/ロックする)

BaseType_t ok = xSemaphoreTake(uartLock, portMAX_DELAY);
  • 役割:セマフォ(またはMutex)を取得する=ロックする
  • 引数
    • 第1引数:対象のセマフォ/Mutexハンドル
    • 第2引数:待ち時間(Tick)
      • 0:待たない(今取れなければ失敗)
      • portMAX_DELAY:ずっと待つ(ブロック)
      • pdMS_TO_TICKS(100):最大100ms待つ、など
  • 戻り値
    • pdTRUE:取得成功(ロックできた)
    • pdFALSE:取得失敗(タイムアウト等)

コツ:デバッグ中は pdMS_TO_TICKS(1000) のように有限待ちにすると「詰まり」が見つけやすいです。
量産コードで“絶対に守るべき資源”なら portMAX_DELAY もよく使います。


xSemaphoreGive()(返す/アンロックする)

xSemaphoreGive(uartLock);
  • 役割:セマフォ/Mutexを解放する=アンロックする
  • 引数
    • 第1引数:対象のセマフォ/Mutexハンドル
  • 戻り値
    • 多くの場合 pdTRUE / pdFALSE(失敗するケースは少ないですが、ハンドル不正など)

重要:Takeしたら必ずGiveする
Giveし忘れると、他タスクが永久に待ち続ける(デッドロック)原因になります。


よくある実装パターン(テンプレ)

パターン1:ロックしてから処理、終わったら解放(基本形)

if (xSemaphoreTake(uartLock, portMAX_DELAY) == pdTRUE) {
  // ここから共有リソースを安全に使える
  printf("hello");
  // ここまで

  xSemaphoreGive(uartLock);
}

パターン2:短い処理は「待たない」判断もあり(取りこぼし許容)

if (xSemaphoreTake(uartLock, 0) == pdTRUE) {
  printf("log");
  xSemaphoreGive(uartLock);
} else {
  // 今は使えないので諦める(ログ捨てなど)
}

(補足)ISR(割り込み)から使う場合は別API

割り込みハンドラからセマフォを操作する場合、通常の xSemaphoreGive() は使わず、
xSemaphoreGiveFromISR() など FromISR系を使います。

この記事はまず「タスク間の排他制御」に集中するため、ISR用途は別記事で扱います。


FreeRTOS Binary Semaphore の限界(優先度逆転)

Binary Semaphoreは以下のメリットがあります。

  • 軽い
  • シンプル
  • ISR通知にも使える

ただし、ある問題を解決できません。
それが次の「優先度逆転」です。


優先度逆転(Priority Inversion)とは?

FreeRTOSはタスクごとに優先度を持ち、基本は高優先度が先に動きます。

本来の期待:

高優先度タスク(H)

中優先度タスク(M)

低優先度タスク(L)

しかし Binary Semaphore だけで排他すると、次の状況が起こり得ます。

低優先度タスク L がリソースを保持(ロック中)
↓
高優先度タスク H が待ちに入る(ロック待ち)
↓
その間に中優先度タスク M がCPUを使い続ける
↓
L が実行できずロックを解放できない
↓
結果として H がずっと待たされる

これが 優先度逆転(Priority Inversion) です。

リアルタイム性が重要なIoT(MQTT送信・ログ収集・センサー処理など)では、遅延が積み重なると
データ欠損通信タイムアウトの原因になります。


FreeRTOS Mutexが優先度逆転を解決する理由(優先度継承)

Mutex(ミューテックス)は、Binary Semaphoreで起きる優先度逆転を緩和する仕組みを持ちます。
それが 優先度継承(Priority Inheritance) です。

状況:

低優先度 L がロック保持中
高優先度 H がロック待ち

このときMutexは、L の優先度を一時的に引き上げます

  • LがMより優先される
  • Lが早く処理を終えてロック解放できる
  • Hがすぐ実行できる

つまり「ロックを持っている低優先度が、すぐ解放できるように押し上げる」ことで、
高優先度が不当に待たされる状況を減らします。


Binary Semaphore と Mutex を使い分ける基準

結論から言うと、“排他”目的ならMutexが第一候補で、
Binary Semaphoreは「通知/合図」用途でも強い、という捉え方が実務では分かりやすいです。

使い分け早見表

目的推奨代表例
排他制御(共有リソース保護)Mutex共有データ、SD、ネットワーク、長めのI/O
短時間の排他(優先度差が小さい)Binary Semaphoreでも可UARTの短い出力など
タスク間の「合図」「通知」Binary SemaphoreISR→タスク通知、イベントの発火

Mutex版の書き方(コード差分はほぼ同じ)

UART保護に「排他」目的で使うなら、Mutexは次のように作れます。

uartLock = xSemaphoreCreateMutex();

使い方(Take/Give)はほぼ同じです。


まとめ

  • セマフォは 共有リソースを守る排他制御の仕組み
    UART・I2C・SPI・SDカードなど同時アクセスで壊れるものを保護する
  • Binary Semaphoreは軽量で簡単
    短い排他や、ISR通知など「合図」にも使える
  • Mutexは 優先度逆転を緩和できる(優先度継承)
    優先度が絡む/長時間ロックする可能性がある処理はMutexが安全

次回はEventGroupについて記載します。

コメント

タイトルとURLをコピーしました