FreeRTOS 02 · タスクという抽象 — 「同時に動いている」ように見せる仕掛け

Chapter 02

タスクという抽象 — 「同時に動いている」ように見せる仕掛け

この章がなぜ必要なのか——タスクは「並列に走るもの」ではない.

タスクを「同時に動くもの」と説明されると、なんとなく分かった気になる。 だが単一コアの CPU は、どの瞬間も 1 つの命令しか実行していない。 では「同時」とは何なのか。

ここを曖昧にしたまま進むと、排他制御(19 章)で必ず躓く。 タスクとは何であって、何ではないのかをはっきりさせておく。

1. 並行 (Concurrency) と並列 (Parallelism)

この 2 つは日本語ではどちらも「同時」と訳されがちだが、まったく別のものである。

用語意味単一コアで可能か
並列 (Parallelism)物理的に同じ瞬間に複数の処理が進む不可能(コアが 1 つなので)
並行 (Concurrency)複数の処理が論理的に進行中である。実行は交互でよい可能

FreeRTOS が単一コアで提供するのは、並行性だけである。

具体的には、こういうことが起きている。

実際の CPU の動き(時間軸):
  [タスクA][タスクB][タスクA][タスクC][タスクB][タスクA] ...
  どの瞬間も走っているのは 1 つだけ

プログラマから見える世界:
  タスクA ━━━━━━━━━━━━━━━━━━━→  ずっと動いている
  タスクB ━━━━━━━━━━━━━━━━━━━→  ずっと動いている
  タスクC ━━━━━━━━━━━━━━━━━━━→  ずっと動いている

切り替えが十分に速ければ(1 ms 単位)、人間から見ても、 そして多くの場合プログラムから見ても、同時に動いているのと区別がつかない。

並行と並列の見え方

切り替え周期を変えると、「同時に見えるかどうか」がどう変わるかを確かめられる。

ここに罠がある.

「区別がつかない」のは多くの場合であって、常にではない。 共有変数を触るとき、この違いは牙をむく。

counter = counter + 1;     /* この 1 行の途中で切り替わりうる */

この行は機械語では「読む・足す・書く」の 3 命令になる。 「読む」と「書く」の間で切り替えが起きると、更新が消える。 並行であるだけで、並列でなくても、この問題は起きる。 19 章まで、この事実を頭の隅に置いておいてほしい。

2. タスクとは何か

FreeRTOS におけるタスクとは、次の 3 点セットである。

構成要素実体サイズの目安
関数無限ループを含む C 関数。タスクの「本体」—
スタックこのタスク専用のスタック領域数百バイト〜数 KB
TCBTask Control Block。カーネルが管理する制御構造体約 100 バイト

この 3 つが揃ったものが「タスク」である。 それ以上でも以下でもない。

void vBlinkTask(void *pvParameters)
{
    for (;;) {                       /* タスクは基本的に無限ループ */
        led_toggle();
        vTaskDelay(pdMS_TO_TICKS(500));   /* ← ここで「待てる」 */
    }
}

/* 生成 */
xTaskCreate(vBlinkTask,      /* 関数 */
            "Blink",          /* 名前(デバッグ用) */
            128,              /* スタックサイズ(ワード単位) */
            NULL,             /* 引数 */
            2,                /* 優先度 */
            NULL);            /* ハンドル受け取り先 */

vTaskDelay() の行が、01 章で「書けない」と言った「途中で待つ」である。 ここでタスクは実行を止め、500 ms 後にこの行の次から再開する。 その間、CPU は他のタスクが使う。

たったこれだけのことが、設計を根本から変える.

スーパーループなら、この 500 ms の待ちを状態変数とタイムスタンプ比較で書く必要があった。 タスクなら、上から下に読める素直なコードのまま書ける。

「上から下に読める」は美的な話ではない。バグの数が実際に減る。

3. タスク関数の 3 つの掟

掟 1: 戻ってはいけない

void vBadTask(void *pv)
{
    do_something();
    /* ← ここで関数が終わる。これは禁止 */
}

タスク関数から return すると、戻る先がない。 呼び出し元は存在せず、スタックの底に積んであるのはカーネルが仕込んだ番人だけである。 FreeRTOS ではこの場合 configASSERT で止まるか、未定義動作になる。

どうしても終わりたいなら、自分を削除する。

void vOneShotTask(void *pv)
{
    do_something();
    vTaskDelete(NULL);      /* NULL = 自分自身。これ以降は実行されない */
}

掟 2: 待たないループを書いてはいけない

void vBadTask(void *pv)
{
    for (;;) {
        if (flag) { handle(); }     /* ← 待ちがない。CPU を独占する */
    }
}

これを「ビジーウェイト」と呼ぶ。 このタスクより優先度の低いタスクは、永遠に実行されない(06 章)。 同じ優先度でもタイムスライスを食い潰し、消費電力も跳ね上がる。

タスクのループには、必ずブロックする API を入れる。

for (;;) {
    xQueueReceive(q, &item, portMAX_DELAY);   /* 来るまで寝る */
    handle(item);
}

掟 3: スタックを使いすぎてはいけない

void vBadTask(void *pv)
{
    uint8_t buffer[4096];      /* ← タスクのスタックに 4 KB 積む */
    ...
}

タスクのスタックは生成時に固定サイズで確保される。 超えると隣のメモリを踏む。これが 17 章で扱う最頻出のバグである。

4. タスクは「関数」ではなく「実行の流れ」である

ここが最も誤解されるところなので、はっきり書く。

同じ関数から、複数のタスクを作れる。

void vSensorTask(void *pv)
{
    int ch = (int)pv;                 /* 引数でチャネルを受け取る */
    for (;;) {
        int v = adc_read(ch);
        xQueueSend(q, &v, 0);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

xTaskCreate(vSensorTask, "S0", 128, (void*)0, 2, NULL);
xTaskCreate(vSensorTask, "S1", 128, (void*)1, 2, NULL);
xTaskCreate(vSensorTask, "S2", 128, (void*)2, 2, NULL);

この 3 つは同じコードを共有しているが、別のタスクである。 なぜなら、スタックが別々だからである。

タスクの同一性を決めるのはコードではなく、スタックと TCB である.

これを飲み込むと、次のことが自然に理解できる。

5. タスクに分ける基準

「どういう単位でタスクに分けるか」は設計の勘所である。原則はこうである。

「独立して待つもの」を 1 つのタスクにする。

良い分け方理由
UART 受信タスクUART の到着を待つ。他とは無関係に待てる
センサ取得タスク(周期)一定周期で寝る。周期が固有
表示更新タスク更新要求を待つ。遅くても他に迷惑をかけない
制御ループタスク厳密な周期で走る。優先度を高くしたい
悪い分け方理由
関数ごとに 1 タスクスタックと TCB でメモリが尽きる
「なんとなく機能が違うから」待ちが共通なら 1 タスクでよい。分けると排他制御が要る
ステップごとに 1 タスク順番に実行するだけなら 1 タスクの中で順に呼べばよい

タスク分割の効果

処理をタスクに分けたとき、応答時間がどう変わるかを 01 章のスーパーループと比較できる。

タスクを増やすコストは意外に大きい.

1 タスクあたり、TCB(約 100 バイト)+ スタック(最低でも 256 バイト、実用は 512〜2048 バイト)。 RAM 32 KB のマイコンなら、現実的に置けるのは 10〜15 個程度である。

「タスクを増やせば設計がきれいになる」は幻想である。 タスク間でデータをやり取りする仕組み(キュー、セマフォ)が必要になり、そこにバグが生まれる。 分けるべき理由がないなら分けない。

6. FreeRTOS のタスクと、他の OS の用語の対応

FreeRTOSLinux / POSIXZephyr意味
タスクスレッド (pthread)スレッドスタックを持った実行の流れ
(相当なし)プロセス(相当なし)独立したアドレス空間
TCBtask_structk_thread制御構造体
xTaskCreatepthread_createk_thread_create生成
vTaskDelayusleep / nanosleepk_sleep時間待ち

FreeRTOS に「プロセス」はない.

プロセスとは「独立したアドレス空間を持った実行単位」である。 これには MMU(メモリ管理ユニット)が要る。マイコンには普通ない。

つまり FreeRTOS では、全タスクが同じアドレス空間を共有する。 あるタスクのポインタバグが、別のタスクのスタックを壊せる。 保護がない。 これが Linux との最大の違いであり、 17 章と 19 章で神経を使う理由でもある。

(FreeRTOS-MPU という MPU を使う派生はあるが、本シリーズでは扱わない。)

7. この章のまとめ

ポイント内容
並行 ≠ 並列単一コアで得られるのは並行性のみ。それでも共有変数の問題は起きる
タスクの実体関数 + スタック + TCB の 3 点セット
掟戻らない・必ず待つ・スタックを溢れさせない
タスクの同一性コードではなくスタックと TCB が決める
分割の基準「独立して待つもの」を 1 タスクに
分割のコスト1 タスクあたり数百バイト〜数 KB。10〜15 個が現実的な上限
FreeRTOS の特徴プロセスがない = メモリ保護がない

次章では、FreeRTOS のカーネル全体を俯瞰し、 「カーネルが担う部分」と「移植層に押し込められた部分」の境界を引く。