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.c | 11〜14 |
| 時間 | 遅延・ソフトウェアタイマ | tasks.c timers.c | 08, 15 |
| スケジューラ | 状態遷移・優先度判定・次タスク選択 | tasks.c | 04〜09 |
| データ構造 | 双方向リンクリスト | list.c | 07 |
| メモリ | ヒープ確保・解放 | heap_N.c | 16 |
| 移植層 | コンテキストスイッチ・ティック割り込み・クリティカルセクション | port.c | 扱わない |
| ハードウェア | CPU・タイマ・割り込みコントローラ | — | 扱わない |
3. 移植層との契約 — これだけ知っていればよい
移植層が提供するのは、次の 5 つだけである。 これらの「何をするか」さえ分かれば、port.c の中身を一切見なくてもカーネルは読める。
契約 1: コンテキストスイッチを起こす
portYIELD(); /* いますぐ切り替えろ */
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); /* ISR から要求する */約束していること:
- 現在のタスクの全レジスタを、そのタスクのスタックに積む
pxCurrentTCBが指すタスクのスタックポインタから、レジスタを復元する- そのタスクの続きから実行を再開する
約束していないこと: いつ切り替わるか。 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 | プリエンプションを使うか | 1 | 06 |
configUSE_TIME_SLICING | 同一優先度でタイムスライスするか | 1 | 08 |
configTICK_RATE_HZ | ティック周波数 | 1000(1 ms) | 08 |
configMAX_PRIORITIES | 優先度の段数 | 5〜32 | 06 |
configMINIMAL_STACK_SIZE | アイドルタスクのスタック | 128 ワード | 09 |
configTOTAL_HEAP_SIZE | ヒープ総量 | 数 KB〜数十 KB | 16 |
configUSE_MUTEXES | ミューテックスを使うか | 1 | 12 |
configUSE_TASK_NOTIFICATIONS | タスク通知を使うか | 1 | 14 |
configCHECK_FOR_STACK_OVERFLOW | スタック溢れ検出 | 2(開発時) | 17 |
configASSERT | 表明。必ず定義すること | 下記 | 21 |
configUSE_TRACE_FACILITY | トレース情報を持つか | 開発時 1 | 21 |
configASSERTは必ず定義すること.#define configASSERT(x) if((x)==0){ taskDISABLE_INTERRUPTS(); for(;;); }FreeRTOS のソースには 300 か所以上の
configASSERTが仕込まれている。 割り込み優先度の設定ミス、ISR から非 ISR 版 API を呼ぶミス、 ハンドルが NULL——ほとんどの初歩的なミスはこれで即座に捕まる。定義しないと、これらがすべて無言で通過して、後で謎の暴走になる。 開発中に
configASSERTを切る理由は 1 つもない。
5. カーネルが持っている状態
カーネルの「頭の中身」は、tasks.c の中の静的変数である。 名前を先に覚えておくと、以降の章が読みやすい。
| 変数 | 型 | 意味 | 章 |
|---|---|---|---|
pxCurrentTCB | TCB_t * | いま走っているタスク。カーネルで最も重要な 1 個 | 05 |
pxReadyTasksLists[] | List_t[] | 優先度ごとの Ready リスト | 07 |
uxTopReadyPriority | UBaseType_t | Ready なタスクがいる最高優先度(またはビットマップ) | 07 |
xDelayedTaskList1/2 | List_t | 時間待ちのタスク(2 本を交換して使う) | 10 |
pxDelayedTaskList | List_t * | 現在有効な遅延リスト | 10 |
pxOverflowDelayedTaskList | List_t * | ティックが折り返した後の遅延リスト | 10 |
xPendingReadyList | List_t | スケジューラ停止中に Ready になったタスク | 10 |
xSuspendedTaskList | List_t | 中断中・無期限待ちのタスク | 04 |
xTasksWaitingTermination | List_t | 削除されたが後片付け待ちのタスク | 09 |
xTickCount | TickType_t | ティックの累積カウント | 08 |
uxSchedulerSuspended | UBaseType_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 | 割り込みを禁止 | — |
| 4 | xTickCount = 0、xSchedulerRunning = pdTRUE | 08 |
| 5 | xPortStartScheduler() を呼ぶ(移植層) | — |
| 6 | 移植層がティックタイマを起動し、最高優先度タスクに飛び込む | — |
ステップ 6 の「飛び込む」が面白い。 移植層は、最高優先度タスクのコンテキストを「復元」する。 契約 4 で作った偽のスタックがここで効いてくる。 復元した結果、タスク関数の先頭から実行が始まる。 main() に戻る道はもう存在しない。
vTaskStartScheduler()から戻ってきたら、原因はほぼ 1 つ. ヒープ不足である。アイドルタスクかタイマタスクのスタックが確保できなかった。configTOTAL_HEAP_SIZEを増やすか、静的確保に切り替える(16 章)。
7. この章のまとめ
| ポイント | 内容 |
|---|---|
| 実質的なソース | tasks.c と queue.c の 2 本で 8 割 |
| 移植層の契約 | 5 つだけ。切替・ティック・排他・スタック初期化・最上位ビット |
portYIELD() の意味 | 「pxCurrentTCB のタスクに実行を移せ」 |
| 新タスクの起動 | 偽の「切り替えられた直後」のスタックを作る。起動処理は存在しない |
| 設定 | コンパイル時マクロ。configASSERT は必ず定義する |
| カーネルの状態 | ほとんどが List_t。タスクをリスト間で動かす機械 |
| 起動 | vTaskStartScheduler() は戻らない。戻ったらヒープ不足 |
ここまでで準備は終わりである。 次章から、カーネルの心臓部——タスクの状態機械に入る。