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 に保存されている。
これが「実行の途中の保存」の全体像である.
- スタックの中身は動かさない。そこにあり続ける
- 保存するのはスタックポインタ 1 個だけ
- レジスタは、そのスタックの上に積んでおく
つまり切り替えの実体は、 「レジスタをスタックに積む →
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 を書き換えるということは、その瞬間に実行の流れが飛ぶということである。
つまり、
- 入ったのは タスク A のコンテキスト
- 出ていくのは タスク B のコンテキスト
同じ関数に入って、違う場所から出てくる。 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. コンテキストスイッチのコスト
切り替えには時間がかかる。目安を持っておくことは重要である。
| CPU | 1 回の切り替え | 備考 |
|---|---|---|
| Cortex-M0+ @48 MHz | 約 3〜5 µs | 手動退避が多い |
| Cortex-M4 @168 MHz | 約 1〜2 µs | 一部ハードウェアが退避 |
| Cortex-M4F @168 MHz | 約 2〜4 µs | FPU レジスタ 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\) とすると、 オーバーヘッド率は次のようになる。
たとえば \(T_{tick} = 1\) ms、\(C_{sw} = 2\) µs、\(n = 4\) なら
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() の中身—— 「誰を次に走らせるか」を決める方針を見る。