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 |
| TCB | Task 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 つは同じコードを共有しているが、別のタスクである。 なぜなら、スタックが別々だからである。
chはそれぞれのスタックにある → 別の値を持てる- 「いまどこまで実行したか」もそれぞれのスタックにある → 別々の場所を実行できる
タスクの同一性を決めるのはコードではなく、スタックと TCB である.
これを飲み込むと、次のことが自然に理解できる。
static変数は全タスクで共有される(スタックにないから)- グローバル変数も共有される(同上)
- ローカル変数だけがタスク固有である
- だから再入可能 (reentrant) な関数とは「static とグローバルを書かない関数」のことである
5. タスクに分ける基準
「どういう単位でタスクに分けるか」は設計の勘所である。原則はこうである。
「独立して待つもの」を 1 つのタスクにする。
| 良い分け方 | 理由 |
|---|---|
| UART 受信タスク | UART の到着を待つ。他とは無関係に待てる |
| センサ取得タスク(周期) | 一定周期で寝る。周期が固有 |
| 表示更新タスク | 更新要求を待つ。遅くても他に迷惑をかけない |
| 制御ループタスク | 厳密な周期で走る。優先度を高くしたい |
| 悪い分け方 | 理由 |
|---|---|
| 関数ごとに 1 タスク | スタックと TCB でメモリが尽きる |
| 「なんとなく機能が違うから」 | 待ちが共通なら 1 タスクでよい。分けると排他制御が要る |
| ステップごとに 1 タスク | 順番に実行するだけなら 1 タスクの中で順に呼べばよい |
タスク分割の効果
処理をタスクに分けたとき、応答時間がどう変わるかを 01 章のスーパーループと比較できる。
タスクを増やすコストは意外に大きい.
1 タスクあたり、TCB(約 100 バイト)+ スタック(最低でも 256 バイト、実用は 512〜2048 バイト)。 RAM 32 KB のマイコンなら、現実的に置けるのは 10〜15 個程度である。
「タスクを増やせば設計がきれいになる」は幻想である。 タスク間でデータをやり取りする仕組み(キュー、セマフォ)が必要になり、そこにバグが生まれる。 分けるべき理由がないなら分けない。
6. FreeRTOS のタスクと、他の OS の用語の対応
| FreeRTOS | Linux / POSIX | Zephyr | 意味 |
|---|---|---|---|
| タスク | スレッド (pthread) | スレッド | スタックを持った実行の流れ |
| (相当なし) | プロセス | (相当なし) | 独立したアドレス空間 |
| TCB | task_struct | k_thread | 制御構造体 |
xTaskCreate | pthread_create | k_thread_create | 生成 |
vTaskDelay | usleep / nanosleep | k_sleep | 時間待ち |
FreeRTOS に「プロセス」はない.
プロセスとは「独立したアドレス空間を持った実行単位」である。 これには MMU(メモリ管理ユニット)が要る。マイコンには普通ない。
つまり FreeRTOS では、全タスクが同じアドレス空間を共有する。 あるタスクのポインタバグが、別のタスクのスタックを壊せる。 保護がない。 これが Linux との最大の違いであり、 17 章と 19 章で神経を使う理由でもある。
(FreeRTOS-MPU という MPU を使う派生はあるが、本シリーズでは扱わない。)
7. この章のまとめ
| ポイント | 内容 |
|---|---|
| 並行 ≠ 並列 | 単一コアで得られるのは並行性のみ。それでも共有変数の問題は起きる |
| タスクの実体 | 関数 + スタック + TCB の 3 点セット |
| 掟 | 戻らない・必ず待つ・スタックを溢れさせない |
| タスクの同一性 | コードではなくスタックと TCB が決める |
| 分割の基準 | 「独立して待つもの」を 1 タスクに |
| 分割のコスト | 1 タスクあたり数百バイト〜数 KB。10〜15 個が現実的な上限 |
| FreeRTOS の特徴 | プロセスがない = メモリ保護がない |
次章では、FreeRTOS のカーネル全体を俯瞰し、 「カーネルが担う部分」と「移植層に押し込められた部分」の境界を引く。