FreeRTOS 05 · TCB とコンテキストスイッチ — 「実行の途中」を畳んで保存する

Chapter 05

TCB とコンテキストスイッチ — 「実行の途中」を畳んで保存する

この章がなぜ必要なのか——ここが「OS の魔法」に見える部分の正体である.

「タスクが切り替わる」と言葉で言うのは簡単だが、 実際に何が起きているのかを説明できる人は少ない。

だが 01 章で見たとおり、「実行の途中」は有限のデータに落ちる。 データに落ちるなら、保存も復元もただのメモリ操作である。 魔法ではない。 この章でそれを最後まで確かめる。

この章で使う既出の用語(定義は各リンク先). コンテキスト(01 章 6 節)、汎用レジスタ(01 章 6 節)、TCB(02 章 2 節)、スタック(02 章 2 節)、タスク(02 章 2 節)、ビジーウェイト(02 章 3 節)、ハードウェア(03 章 2 節)、移植層(03 章 2 節)、Ready(04 章 1 節)、Suspended(04 章 1 節)、イベント待ち(04 章 4 節)

1. TCB — タスクの正体

FreeRTOS がタスクについて知っていることは、すべて TCB (Task Control Block) に入っている。

typedef struct tskTaskControlBlock
{
    volatile StackType_t *pxTopOfStack;   /* ★ 保存されたスタックポインタ */

    ListItem_t   xStateListItem;          /* Ready / 遅延 / Suspended リスト用 */
    ListItem_t   xEventListItem;          /* イベント待ちリスト用 */
    UBaseType_t  uxPriority;              /* 現在の優先度 */
    StackType_t *pxStack;                 /* スタック領域の先頭 */
    char         pcTaskName[ configMAX_TASK_NAME_LEN ];

    #if ( portSTACK_GROWTH > 0 )
        StackType_t *pxEndOfStack;
    #endif
    #if ( portCRITICAL_NESTING_IN_TCB == 1 )
        UBaseType_t uxCriticalNesting;
    #endif
    #if ( configUSE_MUTEXES == 1 )
        UBaseType_t uxBasePriority;       /* 優先度継承前の元の優先度([13 章](13_優先度逆転と優先度継承.md#uxbasepriority-元の優先度を覚えておく)) */
        UBaseType_t uxMutexesHeld;        /* 保持中のミューテックス数 */
    #endif
    #if ( configUSE_TASK_NOTIFICATIONS == 1 )
        volatile uint32_t ulNotifiedValue[ configTASK_NOTIFICATION_ARRAY_ENTRIES ];
        volatile uint8_t  ucNotifyState[  configTASK_NOTIFICATION_ARRAY_ENTRIES ];
    #endif
    ...
} tskTCB;

pxTopOfStack が構造体の先頭にある理由

これは偶然ではない。

コンテキストスイッチのアセンブラは、pxCurrentTCB をロードして、 そのアドレスが指す先頭 4 バイト(= pxTopOfStack)を読み書きする。

pxCurrentTCB ──→ ┌────────────────┐ ← オフセット 0
                 │ pxTopOfStack   │   アセンブラはここだけ触ればよい
                 ├────────────────┤
                 │ xStateListItem │
                 │ ...            │

先頭にあれば、アセンブラ側は構造体のレイアウトを一切知らなくてよい。 オフセット計算が不要になり、移植層のコードが数命令短くなる。

この「先頭に置く」というトリックは至るところで使われている. Linux の thread_info、Zephyr の _thread_arch—— アセンブラから触るフィールドは構造体の先頭、というのは OS 実装の常識である。

2. スタックの中身

タスクのスタックは、生成時にこう確保される。

pxStack = pvPortMalloc( usStackDepth * sizeof( StackType_t ) );

usStackDepth はワード数であることに注意。 32 bit CPU で usStackDepth = 128 なら、512 バイトである。

(スタックが下向きに伸びる CPU の場合)

pxStack ──→ ┌────────────────┐ 低位アドレス
            │ 0xA5A5A5A5     │  ← 未使用領域の埋め草([17 章](17_スタックとオーバーフロー.md)の HWM 検出用)
            │ 0xA5A5A5A5     │
            │      ⋮         │
            │ 0xA5A5A5A5     │
            ├────────────────┤ ← pxTopOfStack(保存されたスタックポインタ)
            │ 退避レジスタ    │  ← 切り替え時に積まれた R4-R11 など
            │ 復帰用の情報    │
            ├────────────────┤
            │ ローカル変数    │  ← タスクが実行中に積んだもの
            │ 戻りアドレス    │
            │      ⋮         │
            └────────────────┘ 高位アドレス(スタックの底)

タスクが走っているとき、CPU の SP レジスタがこのスタックを指している。 タスクが止まっているとき、その時点の SP が pxTopOfStack に保存されている。

これが「実行の途中の保存」の全体像である.

つまり切り替えの実体は、 「レジスタをスタックに積む → pxTopOfStack に SP を書く → 別のタスクの pxTopOfStack から SP を読む → レジスタを降ろす」 ——これだけである。

3. コンテキストスイッチの手順

移植層がやることを、CPU 非依存の擬似コードで書くとこうなる。

1. 現在のタスクの全レジスタを、現在の SP が指すスタックに積む
2. pxCurrentTCB->pxTopOfStack = 現在の SP        ← 保存完了

3. vTaskSwitchContext()  を呼ぶ                 ← カーネルの仕事
      ここで pxCurrentTCB が新しいタスクを指すようになる

4. 現在の SP = pxCurrentTCB->pxTopOfStack        ← 復元開始
5. スタックからレジスタを降ろす
6. 復帰する(PC が復元され、そのタスクの続きから走り出す)

ステップ 3 だけがカーネルの仕事で、残りは全部移植層である。 そして vTaskSwitchContext() の中身は、次章と 07 章で見るとおり、 「Ready リストから最高優先度のタスクを選んで pxCurrentTCB に代入する」だけである。

コンテキストスイッチをステップ実行する

各ステップで、レジスタ・SP・スタックの中身・pxCurrentTCB がどう変わるかを追える。

ステップ 6 の「復帰する」が最も面白い.

復元されるレジスタの中には PC(プログラムカウンタ) が含まれている。 PC を書き換えるということは、その瞬間に実行の流れが飛ぶということである。

つまり、

同じ関数に入って、違う場所から出てくる。 C 言語では書けない(setjmp/longjmp が近いが不十分)。 だからこの部分だけはアセンブラで書かざるを得ない。

4. 切り替えの 2 つのきっかけ

コンテキストスイッチが起きるきっかけは、大きく 2 つしかない。

種類きっかけ例
自発的 (voluntary)タスク自身が API を呼んだvTaskDelay、xQueueReceive、taskYIELD
強制的 (preemptive)割り込みの中で必要と判断されたティック割り込み、ISR がタスクを起こした

自発的な切り替え

void vTaskDelay( const TickType_t xTicksToDelay )
{
    if( xTicksToDelay > 0 ) {
        vTaskSuspendAll();
        {
            prvAddCurrentTaskToDelayedList( xTicksToDelay, pdFALSE );
        }
        xAlreadyYielded = xTaskResumeAll();
    }

    if( xAlreadyYielded == pdFALSE ) {
        portYIELD_WITHIN_API();     /* ← ここで切り替わる */
    }
}

自分で Ready リストを抜けて遅延リストに入り、それから portYIELD する。 この関数から戻ってくるのは、指定ティック後である。

強制的な切り替え

ティック割り込みの中で、xTaskIncrementTick() が 「起こすべきタスクがいて、それが現在のタスクより優先度が高い」と判断すると、 割り込みからの復帰時に切り替えが起きる。

void xPortSysTickHandler( void )     /* 移植層 */
{
    if( xTaskIncrementTick() != pdFALSE ) {
        /* 切り替えが必要 → PendSV を起こす(Cortex-M の場合) */
        portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
    }
}

重要な設計判断: 切り替えは「割り込みの一番外側」で行う.

割り込みハンドラの中で直接切り替えると、 割り込みが多重にネストしているときに破綻する。

だから多くの移植層は「切り替えたい」というフラグを立てるだけにして、 すべての割り込みを抜けた後の最低優先度の割り込み(Cortex-M なら PendSV)で 実際の切り替えを行う。

結果として、portYIELD() は即座には切り替わらない。 数命令〜数十命令の遅れがある。これは正常な動作である。

5. コンテキストスイッチのコスト

切り替えには時間がかかる。目安を持っておくことは重要である。

CPU1 回の切り替え備考
Cortex-M0+ @48 MHz約 3〜5 µs手動退避が多い
Cortex-M4 @168 MHz約 1〜2 µs一部ハードウェアが退避
Cortex-M4F @168 MHz約 2〜4 µsFPU レジスタ 32 個が追加される
RISC-V RV32 @100 MHz約 2〜4 µsレジスタ 31 本

切り替えコストの影響

ティック周期と切り替え回数を変えると、オーバーヘッドが何 % になるかを確かめられる。

FPU を使うタスクは切り替えが重い.

Cortex-M4F の FPU レジスタは S0〜S31 の 32 本=128 バイト。 汎用レジスタと合わせると退避量が 2 倍以上になる。

対策として、多くの移植層は遅延スタッキング (lazy stacking) を使う。 「FPU を実際に使ったタスクだけ FPU レジスタを退避する」という仕組みである。 ハードウェアが自動でやってくれるので、FPU を使わないタスクは軽いままである。

だから「割り込みハンドラの中で float を使わない」は今も有効な指針である。 使った瞬間に、そのハンドラの退避量が跳ね上がる。

コストを見積もる式

ティック周期を \(T_{tick}\)、1 回の切り替えコストを \(C_{sw}\)、 1 ティックあたりの平均切り替え回数を \(n\) とすると、 オーバーヘッド率は次のようになる。

\[ \eta = \frac{n \cdot C_{sw}}{T_{tick}} \]

たとえば \(T_{tick} = 1\) ms、\(C_{sw} = 2\) µs、\(n = 4\) なら

\[ \eta = \frac{4 \times 2\ \mu s}{1000\ \mu s} = 0.8\% \]

1 % 未満なら、まず問題にならない。 逆に \(n\) が 50 を超えるようなら(= 1 ティックに 50 回切り替わる)、 設計を疑うべきである。たいていはビジーウェイトか、細かすぎるタスク分割が原因である。

6. 切り替えで壊れるもの・壊れないもの

対象切り替えで保存されるか理由
ローカル変数されるタスクのスタックにある
関数の引数される同上
CPU レジスタされるスタックに積まれる
グローバル変数されない(共有される)どのタスクからも同じものが見える
static 変数されない(共有される)同上
errno設定次第configUSE_NEWLIB_REENTRANT で TCB に持てる
ペリフェラルのレジスタされないハードウェアはタスクを知らない

最後の行が実務で効いてくる.

/* タスク A */
SPI->CR = SPI_MODE_0;    /* SPI をモード 0 に設定 */
/* ← ここで切り替わる */
spi_transfer(...);        /* 実際はモード 3 で転送されるかもしれない */

/* タスク B */
SPI->CR = SPI_MODE_3;    /* SPI をモード 3 に設定 */

ペリフェラルは 1 セットしかないので、タスクごとの設定を持てない。 だから共有ペリフェラルは必ずミューテックスで守る(12 章)。 これはメモリの排他制御と並んで、RTOS で最も多いバグの原因である。

7. スタックサイズをどう決めるか

これは 17 章で詳しくやるが、原理だけ先に述べる。

タスクのスタックに必要な量は、次の合計である。

項目量
コンテキスト退避領域移植層が決める(Cortex-M4F で約 100 バイト)
最も深い呼び出し経路のローカル変数関数を追って合計する
その経路上の戻りアドレスと退避レジスタ関数 1 段あたり 20〜40 バイト
割り込みのネスト分移植による(Cortex-M は割り込み専用スタックがないので加算が必要)
安全マージン上記合計の 30〜50 %

printf は要注意.

標準ライブラリの printf は、実装によっては1〜2 KB のスタックを使う。 %f を使うと更に増える。

「タスクにデバッグ用の printf を足したら動かなくなった」は定番の事故である。 組み込みでは軽量な printf 実装に差し替えるか、 ログ専用タスクを 1 つ作って、そこだけ大きなスタックを与えるのが定石である。

8. この章のまとめ

ポイント内容
TCBタスクの全情報。pxTopOfStack が構造体の先頭にあるのは意図的
保存されるものスタックポインタ 1 個。スタックの中身は動かさない
切り替えの手順積む → SP を保存 → vTaskSwitchContext() → SP を復元 → 降ろす
カーネルの仕事pxCurrentTCB を書き換えるだけ。残りは移植層
出口が違う入るのは A、出るのは B。C 言語では書けない
切り替えのタイミング即座ではない。割り込みの一番外側で行う移植が多い
コスト1〜5 µs。オーバーヘッド率 1 % 未満が目安
保存されないものグローバル・static・ペリフェラルレジスタ

次章では、vTaskSwitchContext() の中身—— 「誰を次に走らせるか」を決める方針を見る。