FreeRTOS 08 · ティックとタイムスライス — 時間を刻む唯一の割り込み

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 Hz10 ms低消費電力重視。応答は粗い
1000 Hz1 ms最も一般的
10000 Hz100 µ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 Hz1515
100 Hz0 ← 待たない!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 ではない。

\[ T_{\text{周期}} = T_{\text{work}} + 100\ \text{ms} + (\text{切り替え遅延}) \]

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 つ注意がある。

  1. xLastWakeTime の初期化を忘れない。xTaskGetTickCount() で初期化する。
  2. 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 bit65,53565.5 秒
32 bit4,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

これを有効にすると、アイドル時にカーネルがこう振る舞う。

  1. 「次にタスクを起こすべき時刻」(xNextTaskUnblockTime)を調べる
  2. その時刻までティック割り込みを止める
  3. CPU を低消費電力モードに入れる
  4. 起きたら、寝ていた分のティックをまとめて加算する
vTaskStepTick( xExpectedIdleTime );      /* まとめて進める */

ティックレスの前提条件.

ティックレスは移植層の作り込みが要る機能である。 ベンダ提供の 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 日。経過時間は必ず引き算で
ティックレス電池駆動なら必須。移植層の作り込みが要る

次章では、時間の管理と密接に関わる—— 「何もしないタスク」がなぜ必要なのかを見る。