FreeRTOS 03 · FreeRTOS の全体像 — カーネルの責務と、移植層との契約

Chapter 03

FreeRTOS の全体像 — カーネルの責務と、移植層との契約

この章がなぜ必要なのか——「どこまでが移植層の話か」を先に切り分ける.

FreeRTOS のソースを開くと、portmacro.h や portYIELD() といった 見慣れないマクロがそこかしこに出てくる。ここで詰まる人が多い。

だが実は、移植層がやることは 5 つしかない。 その 5 つを「契約」として先に押さえてしまえば、 中身がアセンブラだろうと何だろうと、カーネルのロジックは完全に読める。 この章でその線を引く。

この章で使う既出の用語(定義は各リンク先). コンテキスト(01 章 6 節)、TCB(02 章 2 節)、スタック(02 章 2 節)、タスク(02 章 2 節)

1. ソースの構成

FreeRTOS カーネルのリポジトリは、こうなっている。

FreeRTOS-Kernel/
├── tasks.c              ← カーネルの心臓。タスク管理とスケジューラ
├── queue.c              ← キュー・セマフォ・ミューテックス(全部これ 1 本)
├── list.c               ← 双方向リンクリスト。全データ構造の土台
├── timers.c             ← ソフトウェアタイマ
├── event_groups.c       ← イベントグループ
├── stream_buffer.c      ← ストリーム/メッセージバッファ
├── croutine.c           ← コルーチン(今は非推奨。無視してよい)
├── include/
│   ├── FreeRTOS.h       ← 設定の解決と既定値
│   ├── task.h  queue.h  semphr.h  list.h  ...
│   └── projdefs.h       ← pdTRUE / pdMS_TO_TICKS など
└── portable/
    ├── GCC/ARM_CM4F/    ← ★ 移植層(CPU 依存)
    │   ├── port.c
    │   └── portmacro.h
    ├── GCC/RISC-V/
    ├── ...
    └── MemMang/
        ├── heap_1.c ... heap_5.c   ← メモリ確保の実装(5 つから選ぶ)

行数の目安はこうである(v11 系、コメント込み)。

ファイル行数の目安役割の重み
tasks.c約 5,500★★★★★
queue.c約 3,000★★★★★
timers.c約 1,200★★
event_groups.c約 800★★
stream_buffer.c約 1,400★★
list.c約 250★★★(短いが全部の土台)
port.c(Cortex-M4F)約 800— 本シリーズでは扱わない

tasks.c と queue.c の 2 本で全体の 8 割. しかも queue.c は「キューを 1 つ理解すれば、セマフォもミューテックスも同じ実装」である。 実質的に読むべきものは、驚くほど少ない。

2. カーネルの地図

FreeRTOS が提供するものを、責務ごとに並べるとこうなる。

カーネル全体の地図

各層をクリックすると、その層が何をしていて、どの章で扱うかが表示される。

層提供するもの実装章
アプリケーションあなたのタスク関数——
同期・通信キュー・セマフォ・ミューテックス・通知・イベントグループqueue.c event_groups.c tasks.c11〜14
時間遅延・ソフトウェアタイマtasks.c timers.c08, 15
スケジューラ状態遷移・優先度判定・次タスク選択tasks.c04〜09
データ構造双方向リンクリストlist.c07
メモリヒープ確保・解放heap_N.c16
移植層コンテキストスイッチ・ティック割り込み・クリティカルセクションport.c扱わない
ハードウェアCPU・タイマ・割り込みコントローラ—扱わない

3. 移植層との契約 — これだけ知っていればよい

移植層が提供するのは、次の 5 つだけである。 これらの「何をするか」さえ分かれば、port.c の中身を一切見なくてもカーネルは読める。

契約 1: コンテキストスイッチを起こす

portYIELD();                          /* いますぐ切り替えろ */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);   /* ISR から要求する */

約束していること:

約束していないこと: いつ切り替わるか。 portYIELD() は「切り替えを要求する」だけで、 実際の切り替えは(多くの移植で)割り込み優先度の都合により少し後で起きる。

カーネルから見た portYIELD() の意味は、たった一行で表せる.

「pxCurrentTCB が指すタスクに、実行を移せ」

つまりカーネル側の仕事は、portYIELD() を呼ぶ前に pxCurrentTCB を正しく更新しておくことだけである。 レジスタの積み方は移植層の問題であって、カーネルの問題ではない。

契約 2: 周期割り込みを起こす

移植層は、configTICK_RATE_HZ の周期で xTaskIncrementTick() を呼ぶ。

約束していること: 一定周期で呼ぶ。 約束していないこと: どのタイマを使うか(Cortex-M なら SysTick、RISC-V なら mtime など)。

契約 3: クリティカルセクションを作る

taskENTER_CRITICAL();
    /* ここは割り込みも他タスクも入ってこない */
taskEXIT_CRITICAL();

約束していること: この区間で、カーネル管理下の割り込みが入らない。 約束していないこと: どうやって実現するか(PRIMASK を立てる、BASEPRI を上げる、など)。

ネストが数えられていることが重要である. taskENTER_CRITICAL() は入れ子にできる。 移植層がネスト回数を数えていて、一番外側を抜けたときにだけ割り込みを許可する。 これがないと、内側の EXIT で割り込みが開いてしまう。

契約 4: 新しいタスクのスタックを「使用済みに見せかける」

StackType_t *pxPortInitialiseStack(StackType_t *pxTopOfStack,
                                   TaskFunction_t pxCode,
                                   void *pvParameters);

これが最も面白い契約である。

新しく作ったタスクは、まだ一度も実行されていない。 だからレジスタを「復元」しようにも、保存されたレジスタが存在しない。

そこで移植層は、「あたかも一度実行されて、直前に切り替えられたかのような偽のスタック」を組み立てる。

新タスクのスタック(Cortex-M の例。中身は移植層の都合)
高位アドレス ┌──────────────┐
             │ xPSR         │ ← 「Thumb 状態」のフラグを立てておく
             │ PC           │ ← タスク関数の先頭アドレス
             │ LR           │ ← 「タスクが return したら」の処理先
             │ R12, R3-R1   │
             │ R0           │ ← pvParameters(第 1 引数)
             │ R11-R4       │
低位アドレス └──────────────┘ ← ここを返す(初期スタックポインタ)

このスタックに対して普通の「復元」処理を行うと、 勝手にタスク関数の先頭にジャンプし、第 1 引数に pvParameters が入っている。

これが「タスクの起動」の正体である.

起動のための特別な処理は存在しない。 「復元」しかない世界で、復元されるべき偽の過去を作っておく。 だからカーネルは、新しいタスクと走り慣れたタスクをまったく同じに扱える。

このアイデアは FreeRTOS 固有ではなく、あらゆる OS がやっている。 Linux の copy_thread() も、やっていることは同じである。

契約 5: 最高優先度ビットを求める(任意)

portGET_HIGHEST_PRIORITY(uxTopPriority, uxReadyPriorities);

「立っているビットのうち最上位はどれか」を求める。 CPU に CLZ(先頭のゼロを数える)命令があれば 1 命令、なければ C のループで代替する。 あってもなくても動く(性能が変わるだけ)。詳しくは 07 章。

5 つの契約のまとめ

契約マクロ/関数カーネルから見た意味
1. 切り替えportYIELD()pxCurrentTCB のタスクに実行を移せ
2. 時間xTaskIncrementTick() を周期呼び出し一定周期で呼ばれる
3. 排他taskENTER/EXIT_CRITICAL()この区間は誰も入ってこない
4. 初期化pxPortInitialiseStack()偽の「切り替えられた直後」を作る
5. 最上位ビットportGET_HIGHEST_PRIORITY()立っている最上位ビットを返す(任意)

これで全部である。 以降の章では、この 5 つを「与えられたもの」として扱う。

4. 設定ファイル FreeRTOSConfig.h

FreeRTOS の挙動は、プリプロセッサマクロで決まる。 コンパイル時に確定するので、使わない機能はバイナリに入らない。

主要な設定を挙げる。

マクロ意味典型値章
configUSE_PREEMPTIONプリエンプションを使うか106
configUSE_TIME_SLICING同一優先度でタイムスライスするか108
configTICK_RATE_HZティック周波数1000(1 ms)08
configMAX_PRIORITIES優先度の段数5〜3206
configMINIMAL_STACK_SIZEアイドルタスクのスタック128 ワード09
configTOTAL_HEAP_SIZEヒープ総量数 KB〜数十 KB16
configUSE_MUTEXESミューテックスを使うか112
configUSE_TASK_NOTIFICATIONSタスク通知を使うか114
configCHECK_FOR_STACK_OVERFLOWスタック溢れ検出2(開発時)17
configASSERT表明。必ず定義すること下記21
configUSE_TRACE_FACILITYトレース情報を持つか開発時 121

configASSERT は必ず定義すること.

#define configASSERT(x)  if((x)==0){ taskDISABLE_INTERRUPTS(); for(;;); }

FreeRTOS のソースには 300 か所以上の configASSERT が仕込まれている。 割り込み優先度の設定ミス、ISR から非 ISR 版 API を呼ぶミス、 ハンドルが NULL——ほとんどの初歩的なミスはこれで即座に捕まる。

定義しないと、これらがすべて無言で通過して、後で謎の暴走になる。 開発中に configASSERT を切る理由は 1 つもない。

5. カーネルが持っている状態

カーネルの「頭の中身」は、tasks.c の中の静的変数である。 名前を先に覚えておくと、以降の章が読みやすい。

変数型意味章
pxCurrentTCBTCB_t *いま走っているタスク。カーネルで最も重要な 1 個05
pxReadyTasksLists[]List_t[]優先度ごとの Ready リスト07
uxTopReadyPriorityUBaseType_tReady なタスクがいる最高優先度(またはビットマップ)07
xDelayedTaskList1/2List_t時間待ちのタスク(2 本を交換して使う)10
pxDelayedTaskListList_t *現在有効な遅延リスト10
pxOverflowDelayedTaskListList_t *ティックが折り返した後の遅延リスト10
xPendingReadyListList_tスケジューラ停止中に Ready になったタスク10
xSuspendedTaskListList_t中断中・無期限待ちのタスク04
xTasksWaitingTerminationList_t削除されたが後片付け待ちのタスク09
xTickCountTickType_tティックの累積カウント08
uxSchedulerSuspendedUBaseType_tスケジューラ停止のネスト数10

注目してほしい: ほとんどが List_t(双方向リンクリスト)である。 FreeRTOS のカーネルは、要するに「タスクをリストからリストへ動かす機械」である。 04 章から 10 章までは、ずっとこの話をする。

6. 起動の流れ

int main(void)
{
    hardware_init();

    xTaskCreate(vTaskA, "A", 256, NULL, 2, NULL);
    xTaskCreate(vTaskB, "B", 256, NULL, 1, NULL);

    vTaskStartScheduler();     /* ← ここから戻ってこない */

    /* ここに来たらヒープ不足 */
    for (;;);
}

vTaskStartScheduler() の中では、こういうことが起きる。

順処理章
1アイドルタスクを生成(優先度 0)09
2ソフトウェアタイマを使うならタイマタスクを生成15
3割り込みを禁止—
4xTickCount = 0、xSchedulerRunning = pdTRUE08
5xPortStartScheduler() を呼ぶ(移植層)—
6移植層がティックタイマを起動し、最高優先度タスクに飛び込む—

ステップ 6 の「飛び込む」が面白い。 移植層は、最高優先度タスクのコンテキストを「復元」する。 契約 4 で作った偽のスタックがここで効いてくる。 復元した結果、タスク関数の先頭から実行が始まる。 main() に戻る道はもう存在しない。

vTaskStartScheduler() から戻ってきたら、原因はほぼ 1 つ. ヒープ不足である。アイドルタスクかタイマタスクのスタックが確保できなかった。 configTOTAL_HEAP_SIZE を増やすか、静的確保に切り替える(16 章)。

7. この章のまとめ

ポイント内容
実質的なソースtasks.c と queue.c の 2 本で 8 割
移植層の契約5 つだけ。切替・ティック・排他・スタック初期化・最上位ビット
portYIELD() の意味「pxCurrentTCB のタスクに実行を移せ」
新タスクの起動偽の「切り替えられた直後」のスタックを作る。起動処理は存在しない
設定コンパイル時マクロ。configASSERT は必ず定義する
カーネルの状態ほとんどが List_t。タスクをリスト間で動かす機械
起動vTaskStartScheduler() は戻らない。戻ったらヒープ不足

ここまでで準備は終わりである。 次章から、カーネルの心臓部——タスクの状態機械に入る。