Chapter 01
OS がない世界 — スーパーループはどこで破綻するのか
この章がなぜ必要なのか——「OS を使う理由」を先に体で分かっておく.
いきなり「タスクとは」「セマフォとは」と始めると、 覚えることばかりが増えて、なぜそんなものが必要なのかが分からなくなる。
だからまず、OS を使わずに書くとどうなるかを見る。 そして、それがどこで、どういう理由で破綻するかを具体的に確かめる。 OS が提供する機能は、すべてこの破綻に対する処方箋である。
1. スーパーループ — 最も単純なプログラムの形
組み込みのプログラムを OS なしで書くと、必ずこの形になる。
int main(void)
{
hardware_init();
for (;;) { /* この無限ループを「スーパーループ」と呼ぶ */
read_sensor();
update_display();
handle_buttons();
send_uart();
}
}これはばかにしたものではない。
- OS がないのでメモリを食わない(RAM 数百バイトのマイコンでも動く)
- 実行順序が完全に決まっているので動作が予測しやすい
- デバッグが簡単。1 本の流れを追えばよい
小さな機器なら、これで十分に完成する。実際、世の中の膨大な数の組み込み機器がこの形で動いている。
問題は、処理が増えたときに起きる。
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;
}
}これは確かに効く。ループ周期は短くなり、ボタンの応答は良くなる。
しかし代償がある。
- 処理の「途中の状態」を自分で変数に持たなければならない(上の
draw_step) - 処理が複雑になるほど、この状態変数が増える
- ループの中で書けば自然だったコードが、状態機械にバラされて読めなくなる
これは実質的に、「関数の実行途中」を手で保存しているということである。 そして——後で見るとおり——それこそが 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 つしかない。
- 実行の途中の状態を保存する場所 — CPU レジスタとスタックの内容
- 誰を次に動かすか決める仕組み — スケジューラ
この 2 つを備えたものを「マルチタスク OS」と呼ぶ。 FreeRTOS のカーネルは、要するにこの 2 つを実装しただけのものである。
保存すべきもの
「関数の実行途中」とは、具体的に何か。
| 保存対象 | 何が入っているか |
|---|---|
| プログラムカウンタ (PC) | 次に実行する命令のアドレス。「どこまで進んだか」 |
| 汎用レジスタ | 計算途中の値 |
| スタックポインタ (SP) | 「どの関数から呼ばれてきたか」の履歴の先端 |
| スタックの中身 | ローカル変数、戻りアドレス、退避したレジスタ |
| ステータスレジスタ | 条件フラグ、割り込み許可状態 |
これらを全部まとめて「コンテキスト」と呼ぶ。 コンテキストを保存して差し替えることが、コンテキストスイッチである。
そして重要なのは——スタックの中身は移動させなくてよいということである。 タスクごとに別々のスタック領域を用意しておけば、 切り替えのときに差し替えるのはスタックポインタという 1 つの値だけで済む。
ここが最初の「うまい」ところである. 「実行の途中」というつかみどころのないものが、 「レジスタ十数個+スタックポインタ 1 個」という有限のデータに落ちる。 データに落ちれば、保存も復元もただのメモリコピーである。
7. この章のまとめ
| ポイント | 内容 |
|---|---|
| スーパーループの問題 | 無関係な処理どうしが時間的に結合する |
| 手作業の対策 | 分割・割り込み・状態機械。効くが状態管理が破綻する |
| 書けない構文 | 「途中で待つ」。これが決定打 |
| OS の発明 | 実行の途中をコンテキストとして保存し、自動で切り替える |
| コンテキストの正体 | レジスタ群 + スタックポインタ。有限のデータ |
| OS を使わない判断 | 待ちを含む独立処理が 2 つ以下なら不要 |
次章では、この「コンテキストを持った実行の流れ」に タスク という名前を与え、 それがプログラマにとってどういう抽象なのかを整理する。