Chapter 08
ティックとタイムスライス — 時間を刻む唯一の割り込み
この章がなぜ必要なのか——OS の「時間」は連続していない.
vTaskDelay(pdMS_TO_TICKS(10))と書くと 10 ms 待つように見える。 だが実際に待つ時間は 10 ms ちょうどではない。必ずずれる。なぜずれるのか、どれだけずれるのか、どうすれば減らせるのか。 これを知らないまま周期処理を書くと、気づかないうちに時間がずれていく。
この章で使う既出の用語(定義は各リンク先). TCB(02 章 2 節)、タスク(02 章 2 節)、スケジューラ(03 章 2 節)、移植層(03 章 2 節)、Ready(04 章 1 節)、Suspended(04 章 1 節)、イベント待ち(04 章 4 節)、ラウンドロビン(04 章 3 節)、FreeRTOS(06 章 4 節)
1. ティック — OS にとっての時間
FreeRTOS が知っている時間は、ティックカウンタ 1 個だけである。
static volatile TickType_t xTickCount = ( TickType_t ) 0;これは周期割り込みごとに 1 ずつ増える。それが時間のすべてである。
#define configTICK_RATE_HZ 1000 /* 1000 Hz = 1 ms 周期 */| ティック周波数 | 1 ティック | 用途 |
|---|---|---|
| 100 Hz | 10 ms | 低消費電力重視。応答は粗い |
| 1000 Hz | 1 ms | 最も一般的 |
| 10000 Hz | 100 µs | 高精度。オーバーヘッドが大きい |
ミリ秒とティックの変換
#define pdMS_TO_TICKS( xTimeInMs ) \
( ( TickType_t ) ( ( ( TickType_t ) ( xTimeInMs ) * ( TickType_t ) configTICK_RATE_HZ ) \
/ ( TickType_t ) 1000U ) )整数除算なので、切り捨てが起きる。
| ティック周波数 | pdMS_TO_TICKS(1) | pdMS_TO_TICKS(5) | pdMS_TO_TICKS(15) |
|---|---|---|---|
| 1000 Hz | 1 | 5 | 15 |
| 100 Hz | 0 ← 待たない! | 0 ← 待たない! | 1 |
configTICK_RATE_HZ = 100でvTaskDelay(pdMS_TO_TICKS(5))は「待たない」.
5 × 100 / 1000 = 0。vTaskDelay(0)は何もせず即座に戻る。 ビジーループになり、CPU を食い潰す。しかもコンパイルは通る。これは実際によくある事故である。 ティック周波数を下げるときは、全ての
pdMS_TO_TICKSを見直すこと。
2. xTaskIncrementTick() — 毎ティック何をしているか
BaseType_t xTaskIncrementTick( void )
{
BaseType_t xSwitchRequired = pdFALSE;
if( uxSchedulerSuspended == 0 ) {
const TickType_t xConstTickCount = xTickCount + 1;
xTickCount = xConstTickCount;
/* (1) ティックが 0 に戻った → 遅延リストを入れ替える([10 章](10_ブロックと遅延リスト.md)) */
if( xConstTickCount == 0 ) {
taskSWITCH_DELAYED_LISTS();
}
/* (2) 起こすべきタスクがいるか */
if( xConstTickCount >= xNextTaskUnblockTime ) {
for( ;; ) {
if( listLIST_IS_EMPTY( pxDelayedTaskList ) ) {
xNextTaskUnblockTime = portMAX_DELAY;
break;
}
pxTCB = listGET_OWNER_OF_HEAD_ENTRY( pxDelayedTaskList );
xItemValue = listGET_LIST_ITEM_VALUE( &( pxTCB->xStateListItem ) );
if( xConstTickCount < xItemValue ) {
/* まだ時間じゃない。先頭がこれなら以降も全部まだ */
xNextTaskUnblockTime = xItemValue;
break;
}
/* 起こす: 遅延リストから外す */
( void ) uxListRemove( &( pxTCB->xStateListItem ) );
/* イベント待ちでもあったなら、そちらからも外す */
if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL ) {
( void ) uxListRemove( &( pxTCB->xEventListItem ) );
}
prvAddTaskToReadyList( pxTCB );
/* 起きたタスクの方が優先度が高ければ切り替えが必要 */
#if ( configUSE_PREEMPTION == 1 )
if( pxTCB->uxPriority >= pxCurrentTCB->uxPriority ) {
xSwitchRequired = pdTRUE;
}
#endif
}
}
/* (3) タイムスライス: 同じ優先度に他にもいるなら交代 */
#if ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) )
if( listCURRENT_LIST_LENGTH(
&( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > 1 ) {
xSwitchRequired = pdTRUE;
}
#endif
/* (4) アプリケーションのティックフック */
#if ( configUSE_TICK_HOOK == 1 )
vApplicationTickHook();
#endif
}
else {
++xPendedTicks; /* スケジューラ停止中は溜めておく */
}
return xSwitchRequired;
}やっていることは 4 つだけである。
| # | 処理 | 章 |
|---|---|---|
| 1 | ティックの折り返し処理 | 10 |
| 2 | 時間が来たタスクを起こす | 10 |
| 3 | タイムスライスの判定 | 本章 |
| 4 | アプリケーションフックの呼び出し | 09 |
xNextTaskUnblockTime — 毎回リストを見ないための工夫
「起こすべきタスクがいるか」を毎ティック調べるのは無駄である。 そこで次に起こすべき時刻を変数に持っておく。
if( xConstTickCount >= xNextTaskUnblockTime ) { ... }この比較が偽なら、リストを一切触らずに終わる。 遅延リストは起床時刻の昇順にソートされている(07 章)ので、 先頭のタスクの起床時刻=次に起こすべき時刻である。
ティック割り込みの典型的な実行時間は 1〜3 µs である.
起こすタスクがいない大多数のティックでは、 「カウンタを増やして、1 回比較して、リスト長を 1 回見る」だけで終わる。
1 ms 周期でこれなら、オーバーヘッドは 0.1〜0.3 % である。 ティック周波数を 10 倍にすると 1〜3 % になる。ここが上げすぎの目安になる。
3. タイムスライス — 同一優先度のラウンドロビン
#define configUSE_TIME_SLICING 1 /* 既定値は 1 */同じ優先度の Ready タスクが複数いるとき、毎ティック交代する。
優先度 2 に TaskA, TaskB, TaskC がいる場合
ティック: 1 2 3 4 5 6 7 8
実行: [A] [B] [C] [A] [B] [C] [A] [B]タイムスライスの長さは常に 1 ティックである。可変長にはできない。
ティックとタイムスライスを見る
タスクの数・優先度・タイムスライスの有無を変えて、 毎ティックで何が起きるかを確かめられる。
タイムスライスを切ると何が起きるか
#define configUSE_TIME_SLICING 0同一優先度のタスクは、ブロックするか taskYIELD() を呼ぶまで走り続ける。
| 観点 | 有効 (1) | 無効 (0) |
|---|---|---|
| 公平性 | 同一優先度で均等 | 早い者勝ち |
| 切り替え回数 | 多い | 少ない |
| キャッシュ効率 | 悪い | 良い |
| 予測可能性 | やや低い | 高い |
タイムスライスは実はあまり有用でない、という意見がある.
リアルタイムシステムでは、 「同じ優先度のタスクが均等に CPU を分け合う」ことが望ましい場面は少ない。 むしろ「1 つずつ確実に片付ける」方が、応答時間の解析が楽になる。
それに——タイムスライスがあってもなくても、 ブロックする API を正しく使っていれば結果は同じになることが多い。 タイムスライスが効くのは「複数のタスクが同時に計算し続けている」状況だけであり、 組み込みではそれ自体が設計の見直し対象である。
タイムスライスの落とし穴
上の (3) の判定をよく見てほしい。
if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > 1 )現在のタスクも Ready リストに含まれているので、 「自分だけ」なら長さ 1 で切り替えは起きない。 「自分+他 1 つ以上」なら長さ 2 以上で切り替えが起きる。
問題は——ティックの途中で vTaskDelay(1) を呼んだ場合である。
vTaskDelay(1); /* 「1 ティック待つ」= 次のティックまで待つ */いま何 µs の位置にいるかによって、実際の待ち時間は 0 〜 1 ティックの範囲でばらつく。
ティック境界 → | | |
↑ ↑
呼び出し ここで起きる
(直後) → ほぼ 1 ティック待つ
ティック境界 → | |
↑ ↑
呼び出し ここで起きる
(直前) → ほとんど待たない
vTaskDelay(1)は「1 ティック待つ」ではなく「次のティックまで待つ」である.だから
vTaskDelay(n)の実際の待ち時間は \((n-1, n]\) ティックになる。 最低でもnを 2 以上にするか、次節のvTaskDelayUntilを使うこと。
4. vTaskDelay と vTaskDelayUntil — 累積誤差の話
vTaskDelay は誤差が累積する
for (;;) {
do_work(); /* 実行時間が毎回違う */
vTaskDelay(pdMS_TO_TICKS(100)); /* 「終わってから」100 ms */
}このループの周期は 100 ms ではない。
do_work() が 5 ms かかれば周期は 105 ms、10 ms かかれば 110 ms になる。 そしてこの誤差はずっと累積していく。
1 時間後には、想定より数十秒ずれている——ということが起きる。
vTaskDelayUntil は誤差が累積しない
TickType_t xLastWakeTime = xTaskGetTickCount();
for (;;) {
vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(100));
do_work();
}こちらは絶対時刻で管理する。
void vTaskDelayUntil( TickType_t * const pxPreviousWakeTime,
const TickType_t xTimeIncrement )
{
xTimeToWake = *pxPreviousWakeTime + xTimeIncrement; /* 絶対時刻 */
*pxPreviousWakeTime = xTimeToWake; /* 次回のために更新 */
...
prvAddCurrentTaskToDelayedList( xTimeToWake - xConstTickCount, pdFALSE );
}do_work() が何 ms かかろうと、起床時刻は 100 ms 刻みの格子の上に乗り続ける。
vTaskDelay: 実行時間が周期に加算されていく
|--work--|---100ms---|--work--|---100ms---|
0 5 105 112 212 ← ずれていく
vTaskDelayUntil: 起床時刻が格子に固定される
|--work--|-----------|--work--|-----------|
0 5 100 107 200 ← ずれない周期処理には必ず
vTaskDelayUntilを使うこと.ただし 2 つ注意がある。
xLastWakeTimeの初期化を忘れない。xTaskGetTickCount()で初期化する。do_work()が周期より長いと破綻する。 次の起床時刻が既に過ぎているので、vTaskDelayUntilは待たずに戻る。 結果、遅れを取り戻そうとして連続実行になる。 v10.4 以降はxTaskDelayUntil()(x始まり)が戻り値でこれを教えてくれる。if( xTaskDelayUntil( &xLastWakeTime, xPeriod ) == pdFALSE ) { /* 待たなかった = 周期に間に合っていない */ overrun_count++; }
5. ティックのオーバーフロー
TickType_t は 16 bit または 32 bit である。
#define configUSE_16_BIT_TICKS 0 /* 0 なら 32 bit */| 型 | 最大値 | 1 kHz で折り返すまで |
|---|---|---|
| 16 bit | 65,535 | 65.5 秒 |
| 32 bit | 4,294,967,295 | 約 49.7 日 |
16 bit は現代ではまず使わない。 65 秒で折り返すのは危険すぎる。 8 bit マイコン向けの遺産である。
32 bit でも 49.7 日で折り返す。この折り返しをカーネルが正しく扱う仕組みが、 次章で扱う遅延リストの二重化である。
ティック関連の API
| API | 意味 | ISR から |
|---|---|---|
xTaskGetTickCount() | 現在のティック値 | xTaskGetTickCountFromISR() |
pdMS_TO_TICKS(ms) | ms → ティック | マクロなのでどこでも |
pdTICKS_TO_MS(t) | ティック → ms(v10.5 以降) | 同上 |
経過時間の計算は必ず引き算で行うこと.
TickType_t start = xTaskGetTickCount(); ... TickType_t elapsed = xTaskGetTickCount() - start; /* ← 正しい */符号なし整数の引き算は、折り返しをまたいでも正しい値を返す。
start = 0xFFFFFFF0 now = 0x00000005 (折り返した) elapsed = 0x00000005 - 0xFFFFFFF0 = 0x00000015 = 21 ← 正しい逆に、大小比較は危険である。
if( xTaskGetTickCount() > deadline ) { ... } /* ← 折り返しで壊れる */ if( ( xTaskGetTickCount() - start ) > timeout ) { ... } /* ← 正しい */
6. ティックレス(低消費電力)
電池駆動の機器では、「何もしていない 1 ms ごとに割り込みで起きる」のは無駄である。
#define configUSE_TICKLESS_IDLE 1これを有効にすると、アイドル時にカーネルがこう振る舞う。
- 「次にタスクを起こすべき時刻」(
xNextTaskUnblockTime)を調べる - その時刻までティック割り込みを止める
- CPU を低消費電力モードに入れる
- 起きたら、寝ていた分のティックをまとめて加算する
vTaskStepTick( xExpectedIdleTime ); /* まとめて進める */ティックレスの前提条件.
- 長時間止まれるタイマが必要(Cortex-M の SysTick は 24 bit しかないので、 多くの移植では RTC などの別タイマを併用する)
- 割り込みで早く起きた場合、実際に経過した時間を正確に読める必要がある
- 周期の短いタスクがいると効果がない(すぐ起きてしまう)
ティックレスは移植層の作り込みが要る機能である。 ベンダ提供の SDK(Nordic、ESP-IDF、STM32 の一部)には 調整済みの移植層が入っているので、それを使うのが確実である。
7. この章のまとめ
| ポイント | 内容 |
|---|---|
| OS の時間 | ティックカウンタ 1 個だけ |
pdMS_TO_TICKS | 整数除算。低いティック周波数で 0 になる事故に注意 |
| ティック割り込み | やることは 4 つ。典型 1〜3 µs、オーバーヘッド 0.1〜0.3 % |
xNextTaskUnblockTime | 大多数のティックでリストを触らず済ませる工夫 |
| タイムスライス | 常に 1 ティック。切ってもよい(切る方が予測しやすい) |
vTaskDelay(n) | 実際の待ち時間は \((n-1, n]\) ティック。誤差が累積する |
vTaskDelayUntil | 絶対時刻で管理。周期処理には必ずこちら |
| オーバーフロー | 32 bit・1 kHz で 49.7 日。経過時間は必ず引き算で |
| ティックレス | 電池駆動なら必須。移植層の作り込みが要る |
次章では、時間の管理と密接に関わる—— 「何もしないタスク」がなぜ必要なのかを見る。