FreeRTOS 01 · OS がない世界 — スーパーループはどこで破綻するのか

Chapter 01

OS がない世界 — スーパーループはどこで破綻するのか

この章がなぜ必要なのか——「OS を使う理由」を先に体で分かっておく.

いきなり「タスクとは」「セマフォとは」と始めると、 覚えることばかりが増えて、なぜそんなものが必要なのかが分からなくなる。

だからまず、OS を使わずに書くとどうなるかを見る。 そして、それがどこで、どういう理由で破綻するかを具体的に確かめる。 OS が提供する機能は、すべてこの破綻に対する処方箋である。

1. スーパーループ — 最も単純なプログラムの形

組み込みのプログラムを OS なしで書くと、必ずこの形になる。

int main(void)
{
    hardware_init();

    for (;;) {          /* この無限ループを「スーパーループ」と呼ぶ */
        read_sensor();
        update_display();
        handle_buttons();
        send_uart();
    }
}

これはばかにしたものではない。

小さな機器なら、これで十分に完成する。実際、世の中の膨大な数の組み込み機器がこの形で動いている。

問題は、処理が増えたときに起きる。

2. 何が起きるか — 応答が「待たされる」

上のループで、update_display() が 50 ms かかるとしよう。 液晶に全画面を描き直すなら、それくらいはかかる。

このとき、ボタンを押してから handle_buttons() が呼ばれるまで、最悪 50 ms 以上待たされる。 なぜなら、ボタンを押した瞬間が update_display() の入口だった場合、 そこを抜けるまで誰もボタンを見ないからである。

処理を並べれば並べるほど、1 周にかかる時間(ループ周期)が延びる。 そして各処理の応答時間は、最悪でループ周期 1 周分になる。

ここが本質的な問題である.

スーパーループでは、ある処理の実行時間が、無関係な他の処理の応答時間を悪化させる。 液晶の描画が遅いせいでボタンが効かなくなる。この 2 つには論理的な関係がまったくないのに。

これを「処理どうしが時間的に結合している」という。 機能を足すたびに、既に動いていた部分の応答が劣化していく。これは設計としてまずい。

3. 応答遅延を実際に見る

スーパーループの応答時間

各処理の実行時間を変えると、他の処理の応答がどうなるかを確かめられる。 「ボタン応答」の欄に注目してほしい。

4. まず思いつく対策と、その限界

対策 A: 処理を細かく切る

長い処理を分割して、1 周で全部やらないようにする。

static int draw_step = 0;

void update_display_step(void)
{
    switch (draw_step) {
        case 0: draw_background(); draw_step = 1; break;
        case 1: draw_graph();      draw_step = 2; break;
        case 2: draw_text();       draw_step = 0; break;
    }
}

これは確かに効く。ループ周期は短くなり、ボタンの応答は良くなる。

しかし代償がある。

これは実質的に、「関数の実行途中」を手で保存しているということである。 そして——後で見るとおり——それこそが OS がやっていることそのものである。 OS は、この面倒な保存を自動で、しかも完全にやってくれる。

対策 B: 割り込みに追い出す

急ぎの処理を割り込みハンドラに移す。

void BUTTON_IRQHandler(void)
{
    button_pressed = 1;    /* フラグを立てるだけ */
}

割り込みはメインループを中断して即座に走るので、応答は劇的に良くなる。これは正しい対策である。

しかし、割り込みハンドラの中で長い処理をしてはいけない。

割り込みで解決できるのは「気づくのが速い」ところまで. 「気づいてから、実際に処理を終える」までの時間は、メインループの周期に縛られたままである。

対策 C: 状態機械にする

各処理を状態機械として書き、毎周期 1 ステップだけ進める。

これは対策 A の徹底版であり、OS なしで書ける最も強力な形である。 実際、安全性が要求される分野(自動車・医療機器の一部)では、 「OS を使わず、全体を 1 つの巨大な状態機械にする」設計がいまも現役である。 動作が完全に決定的になるからである。

だが、機能が 10 個、20 個と増えると、状態の組み合わせが爆発する。 そして何より——

/* こう書きたい */
send_command();
wait_ms(100);            /* ← ここで 100 ms 待ちたい */
if (read_response() != OK) retry();

この「途中で待つ」が書けない。 wait_ms() の中で回して待つと、その間ループ全体が止まってしまう。 状態機械に分解すると、上の 3 行が 3 つの状態と 2 つの変数に化ける。

「途中で待てる」——これが OS の最大の価値である。

5. 破綻する 4 つのポイント

スーパーループは、次の 4 つのどれかに当たった時点で限界を迎える。

破綻の形具体的にどうなるかOS での解決
応答時間が守れない機能を足すたびに他が遅くなる。最悪応答時間が読めない優先度(06 章)
待てない「100 ms 待つ」「データが来るまで待つ」が書けないブロック(10 章)
状態爆発処理を分割した結果、状態変数だらけで保守できないタスクごとのスタック(05 章)
優先度が付けられない「モータ制御は液晶より絶対に優先」を表現する手段がないプリエンプション(06 章)

逆に言えば、この 4 つに当たらないならスーパーループで十分である. OS は無料ではない。RAM を数 KB 食い、コンテキストスイッチのオーバーヘッドがあり、 排他制御という新しいバグの種を持ち込む。必要ないなら使わないのが正しい。

判断の目安は「待ちを含む独立した処理が 3 つ以上あるか」である。

6. OS が与える 1 つの発明

ここまでの問題は、突き詰めると 1 つの欲求に集約される。

「この関数の実行を、途中で一時停止して、あとから続きから再開したい」

対策 A・C で手作業でやっていたのは、まさにこれだった。 そして OS は、これを完全に自動化する。

そのために必要なものは、実は 2 つしかない。

  1. 実行の途中の状態を保存する場所 — CPU レジスタとスタックの内容
  2. 誰を次に動かすか決める仕組み — スケジューラ

この 2 つを備えたものを「マルチタスク OS」と呼ぶ。 FreeRTOS のカーネルは、要するにこの 2 つを実装しただけのものである。

保存すべきもの

「関数の実行途中」とは、具体的に何か。

保存対象何が入っているか
プログラムカウンタ (PC)次に実行する命令のアドレス。「どこまで進んだか」
汎用レジスタ計算途中の値
スタックポインタ (SP)「どの関数から呼ばれてきたか」の履歴の先端
スタックの中身ローカル変数、戻りアドレス、退避したレジスタ
ステータスレジスタ条件フラグ、割り込み許可状態

これらを全部まとめて「コンテキスト」と呼ぶ。 コンテキストを保存して差し替えることが、コンテキストスイッチである。

そして重要なのは——スタックの中身は移動させなくてよいということである。 タスクごとに別々のスタック領域を用意しておけば、 切り替えのときに差し替えるのはスタックポインタという 1 つの値だけで済む。

ここが最初の「うまい」ところである. 「実行の途中」というつかみどころのないものが、 「レジスタ十数個+スタックポインタ 1 個」という有限のデータに落ちる。 データに落ちれば、保存も復元もただのメモリコピーである。

7. この章のまとめ

ポイント内容
スーパーループの問題無関係な処理どうしが時間的に結合する
手作業の対策分割・割り込み・状態機械。効くが状態管理が破綻する
書けない構文「途中で待つ」。これが決定打
OS の発明実行の途中をコンテキストとして保存し、自動で切り替える
コンテキストの正体レジスタ群 + スタックポインタ。有限のデータ
OS を使わない判断待ちを含む独立処理が 2 つ以下なら不要

次章では、この「コンテキストを持った実行の流れ」に タスク という名前を与え、 それがプログラマにとってどういう抽象なのかを整理する。