# 第4章: プリエンプティブスケジューラ SysTick を心臓にして、タスクを強制的に切り替えるスケジューラを作ります。 :::{admonition} この章で分かること: 重い処理があっても、ほかのアプリが止まらないのはなぜ? :class: tip PC で重い計算を回しっぱなしにしても、ほかのアプリは動き続けます。 OS がタイマ割り込みのたびに、走っているプログラムを強制的に切り替えているからです。 この章では、それと同じ仕組みを自分で作ります。 PC と違うのは、UNO R4 の CPU コアが 1 つしかないことです。 PC ならコアが複数あり、重い処理が 1 つのコアを使い切っても、ほかのコアは空いています。 コアが 1 つだと、切り替えなければ本当にすべてが止まります。 実際に止まるところは、この章の最後の {ref}`ch04-break` で見られます。 ::: ## サンプルコード - ディレクトリ: `docs/os-on-arduino/code/04_scheduler` - ビルドと書き込み: ```bash uv run pio run -d 04_scheduler -t upload uv run pio device monitor -b 115200 ``` コードを見る: [docs/os-on-arduino/code/04_scheduler](https://github.com/iory/learning-os-from-arduino/tree/main/docs/os-on-arduino/code/04_scheduler) ## ソースコード この章のスケッチです。ファイル名をクリックすると開きます。コードは原稿リポジトリから自動で同期されているので、ここに出ているものが常に最新です。 :::{dropdown} `src/main.cpp` — 135 行 :icon: code [GitHub で開く](https://github.com/iory/learning-os-from-arduino/blob/main/docs/os-on-arduino/code/04_scheduler/src/main.cpp) ```{literalinclude} ../../../../code/04_scheduler/src/main.cpp :language: cpp :linenos: ``` ::: :::{dropdown} `include/os_kernel.h` — 79 行 :icon: code [GitHub で開く](https://github.com/iory/learning-os-from-arduino/blob/main/docs/os-on-arduino/code/04_scheduler/include/os_kernel.h) ```{literalinclude} ../../../../code/04_scheduler/include/os_kernel.h :language: cpp :linenos: ``` ::: :::{dropdown} `src/os_kernel.cpp` — 287 行 :icon: code [GitHub で開く](https://github.com/iory/learning-os-from-arduino/blob/main/docs/os-on-arduino/code/04_scheduler/src/os_kernel.cpp) ```{literalinclude} ../../../../code/04_scheduler/src/os_kernel.cpp :language: cpp :linenos: ``` ::: ## 実機での出力 Arduino UNO R4 WiFi の実機で実際に取得した出力です(macOS / Windows の 両方で同じ結果を確認しています)。手元の出力と見比べてください。 `Heavy` が重い計算を回している最中も、1 秒ごとに `Count` を印字する `Serial` タスク(ID 3)の 周期が乱れません。これがプリエンプションの効果です。`Current task` は印字している `Serial` タスク自身の ID なので、いつも 3 です。 ```{figure} ../_static/ch04_preemption.gif :name: fig-ch04-preemption :width: 100% `Count` がきっちり 1 秒ごとに出続け、その合間に `Heavy` が割り込まれている ところ。実機の Arduino UNO R4 WiFi で録ったものです ``` ```text Heavy task completed iteration, sum=1783293664 [3010ms] Count: 3 | Current task: 3 Heavy task completed iteration, sum=1783293664 [4010ms] Count: 4 | Current task: 3 Heavy task completed iteration, sum=1783293664 Heavy task completed iteration, sum=1783293664 [5010ms] Count: 5 | Current task: 3 Heavy task completed iteration, sum=1783293664 [6010ms] Count: 6 | Current task: 3 Heavy task completed iteration, sum=1783293664 Heavy task completed iteration, sum=1783293664 [7010ms] Count: 7 | Current task: 3 ... ``` 実機ではこう光ります。 ```{raw} html
L LED(LED1、0.1 秒ごと)とマトリクス左半分(LED2、0.5 秒ごと)は、重い計算を回し続ける Heavy タスクがいても止まらずに点滅します。右半分は Heavy が計算を 1 回終えるたびに切り替わります。(UNO R4 WiFi を Web カメラで 30 fps で撮影)
``` ### シミュレータで見る 実機が無くても、同じファームウェアを {doc}`シミュレータ <../getting-started/simulator>` で動かすとこう見えます。 ```{figure} ../_static/sim_ch04_blink.gif :name: fig-ch04-sim :width: 80% L(LED1)が 100ms ごと、マトリクス左半分(LED2)が 500ms ごとに点滅し、右半分は重い計算の `Heavy` タスクが 1 周するたびに切り替わります。`Heavy` が CPU を回し続けている最中も、L と左半分の点滅は乱れません。(シミュレータで撮影) ``` (ch04-break)= ## 壊してみる 動いているコードを 1 か所だけ変えると、何が起きるでしょう。 **変える前に結果を予想してから**、答えを開いてください。 改造したコードは、ビルドしてできた `firmware.elf` を {ref}`ブラウザ版にドロップ ` すれば、基板が無くても試せます。 書き込みは要りません。元に戻すときは `git checkout -- 04_scheduler` です。 ```bash uv run pio run -d 04_scheduler # できた 04_scheduler/.pio/build/uno_r4_wifi/firmware.elf をブラウザ版にドロップ ``` :::{note} 答えに載せた出力は、ブラウザ版と同じ QEMU で実際に動かして取ったものです。 実機ではまだ確かめていません。止まる場所やダンプの数字は、実機では違うことがあります。 ::: ### 1. タイムスライスを 1 秒にする `include/os_kernel.h` の 1 行を変えます。タスクを切り替える間隔が 10 ms から 1 秒になります。 ```diff -#define TIME_SLICE_MS 10 +#define TIME_SLICE_MS 1000 ``` **予想:** L LED の点滅と、1 秒ごとの `Count` の印字はどうなるでしょう。 :::{dropdown} 答え `Starting preemptive scheduler...` のあと、**何も出なくなります。** L LED も点滅しません。 ```text Tasks created: ID 0: idle ID 1: LED1 ID 2: LED2 ID 3: Serial ID 4: Heavy Starting preemptive scheduler... ``` SysTick のカウンタは 24 ビットで、1 回に数えられるのは 16,777,216 クロックまでです。 48 MHz で 1 秒ぶんは 48,000,000 クロックなので、入りきりません。 `SysTick_Config()` は、入りきらない値を渡されるとタイマを動かさずに 1 を返します ([CMSIS `core_cm4.h` の `SysTick_Config()`](https://github.com/ARM-software/CMSIS_5/blob/5.9.0/CMSIS/Core/Include/core_cm4.h#L2022-L2037)、 上限は [`SysTick_LOAD_RELOAD_Msk`](https://github.com/ARM-software/CMSIS_5/blob/5.9.0/CMSIS/Core/Include/core_cm4.h#L787) = `0xFFFFFF`)。 `os_start()` はこの戻り値を見ていないので、割り込みが一度も来ないまま、 最初に選ばれた idle タスクが `__WFI()` で眠り続けます。 QEMU で確かめたところ、349 ms までは動き、350 ms(16,800,000 クロック)で止まりました。 100 ms にすると動き続けますが、`Count` の時刻が `[3100ms]` のように 100 ms 刻みになります。 **直すなら:** `SysTick_Config()` の戻り値が 0 でなければ、エラーを出して止まるようにします。 ::: ### 2. SysTick から切り替えを外す `src/os_kernel.cpp` の `SysTick_Handler()` で、`os_schedule()` の呼び出しをコメントにします。 時間は進みますが、タイマ割り込みでタスクを切り替えなくなります。 ```diff g_system_ticks += TIME_SLICE_MS; - os_schedule(); + // os_schedule(); ``` **予想:** `os_sleep()` を呼ぶタスクはそこで切り替わります。どのタスクが動き続けるでしょう。 :::{dropdown} 答え これも `Starting preemptive scheduler...` のあと、**何も出ません。** 最初に走るのは ID 0 の idle タスクです。idle は `os_sleep()` を呼ばず、 `__WFI()` で割り込みを待つだけのループなので、自分から CPU を手放しません。 切り替えのきっかけが `os_sleep()` だけになると、idle が CPU を持ったまま、 ほかのタスクは一度も走りません。 第3章の協調的な切り替え({doc}`ch03`)では、タスクが自分で `os_yield()` を呼ぶ 約束でした。約束を守らないタスクが 1 つあるだけで全体が止まる、というのが プリエンプションで解いている問題です。 ::: ### 3. レジスタ R4〜R11 を保存しない `PendSV_Handler()` の、R4〜R11 を積む命令と戻す命令を、スタックポインタを 動かすだけの命令に置き換えます。スタックの形は変わらないので、中身だけが 保存も復元もされなくなります。 ```diff - " STMDB R0!, {R4-R11} \n" + " SUB R0, R0, #32 \n" ... - " LDMIA R0!, {R4-R11} \n" + " ADD R0, R0, #32 \n" ``` **予想:** R0〜R3 や PC などは、例外に入るときに CPU が自動で積んでいます。 残りの 8 本を保存しないと、何が起きるでしょう。 :::{dropdown} 答え 最初の `Count` を 1 行出したところで、**フォールトで止まります。** 下のダンプは Arduino コアに入っているフォールトハンドラ(CmBacktrace)が出したものです。 ```text Starting preemptive scheduler... [10ms] Count: 0 | Current task: 3 Firmware name: PROJECT_NAME, compiled on: Sep 28 2026 Fault on interrupt or bare metal(no OS) environment (スタックのダンプは省略) =================== Registers information ==================== R0 : 000000f0 R1 : e000ed00 R2 : e000ed00 R3 : 10000800 R12: 00000001 LR : 00006981 PC : 00004860 PSR: 41000000 ============================================================== Usage fault is caused by attempts to switch to an invalid state (e.g., ARM) ``` R4〜R11 は、関数を呼んでも中身が変わらない約束のレジスタです。コンパイラは、 ループの途中の値やアドレスをここに置いたまま、ほかの関数を呼びます。 保存しないと、切り替わった先のタスクが前のタスクの値をそのまま使ってしまいます。 今回は、化けた値を飛び先のアドレスとして使い、不正な状態に切り替えようとして止まりました (最後の行)。どこで止まるかは、どの値がどう化けるかで変わります。 ::: ## つまずきやすいポイント (読者からの質問に応じて随時追記します。質問は [GitHub Issues](https://github.com/iory/build-your-own-arduino-rtos/issues) へどうぞ)