第4章: プリエンプティブスケジューラ#
SysTick を心臓にして、タスクを強制的に切り替えるスケジューラを作ります。
この章で分かること: 重い処理があっても、ほかのアプリが止まらないのはなぜ?
PC で重い計算を回しっぱなしにしても、ほかのアプリは動き続けます。 OS がタイマ割り込みのたびに、走っているプログラムを強制的に切り替えているからです。 この章では、それと同じ仕組みを自分で作ります。
PC と違うのは、UNO R4 の CPU コアが 1 つしかないことです。 PC ならコアが複数あり、重い処理が 1 つのコアを使い切っても、ほかのコアは空いています。 コアが 1 つだと、切り替えなければ本当にすべてが止まります。 実際に止まるところは、この章の最後の 壊してみる で見られます。
サンプルコード#
ディレクトリ:
docs/os-on-arduino/code/04_schedulerビルドと書き込み:
uv run pio run -d 04_scheduler -t upload
uv run pio device monitor -b 115200
ソースコード#
この章のスケッチです。ファイル名をクリックすると開きます。コードは原稿リポジトリから自動で同期されているので、ここに出ているものが常に最新です。
src/main.cpp — 135 行
1/**
2 * Chapter 4: Preemptive Scheduler Demo
3 *
4 * Tasks are automatically switched by SysTick timer interrupt.
5 * No need to call os_yield() - the scheduler runs every TIME_SLICE_MS.
6 */
7
8#include <Arduino.h>
9#include "os_kernel.h"
10#include "Arduino_LED_Matrix.h"
11
12// Arduino UNO R4 WiFi は基板上に "L" と印字された LED を持っています。
13// 追加部品なしで動かせるよう、これを主 LED として使います。
14#define LED1_PIN LED_BUILTIN
15
16// 2個目以降の LED は外付けせず、基板内蔵の 12×8 LED マトリクスで代用します。
17// 左半分を LED2、右半分を LED3 として丸ごと点灯・消灯します。
18// マトリクス制御の仕組みは第7章で学びます。ここではライブラリに任せます。
19static ArduinoLEDMatrix matrix;
20static uint8_t matrix_frame[8][12];
21
22static void matrix_led(int led, bool on)
23{
24 int col_start = (led == 3) ? 6 : 0;
25 for (int row = 0; row < 8; row++) {
26 for (int col = col_start; col < col_start + 6; col++) {
27 matrix_frame[row][col] = on ? 1 : 0;
28 }
29 }
30 matrix.renderBitmap(matrix_frame, 8, 12);
31}
32
33// Task 1: Fast LED blink
34void task_led1(void)
35{
36 pinMode(LED1_PIN, OUTPUT);
37 while (1) {
38 digitalWrite(LED1_PIN, HIGH);
39 os_sleep(100); // Sleep 100ms
40 digitalWrite(LED1_PIN, LOW);
41 os_sleep(100);
42 }
43}
44
45// Task 2: Slow blink on LED2 (matrix left half)
46void task_led2(void)
47{
48 while (1) {
49 matrix_led(2, true);
50 os_sleep(500); // Sleep 500ms
51 matrix_led(2, false);
52 os_sleep(500);
53 }
54}
55
56// Task 3: Serial output with system info
57void task_serial(void)
58{
59 int count = 0;
60 while (1) {
61 Serial.print("[");
62 Serial.print(os_get_tick());
63 Serial.print("ms] Count: ");
64 Serial.print(count++);
65 Serial.print(" | Current task: ");
66 Serial.println(os_get_current_task());
67
68 os_sleep(1000); // Sleep 1 second
69 }
70}
71
72// Task 4: Heavy computation (demonstrates preemption)
73void task_heavy(void)
74{
75 volatile uint32_t sum = 0;
76 while (1) {
77 // Heavy computation - will be preempted
78 for (volatile int i = 0; i < 1000000; i++) {
79 sum += i;
80 }
81 Serial.print("Heavy task completed iteration, sum=");
82 Serial.println(sum);
83 sum = 0;
84
85 // 計算完了をマトリクス右半分(LED3 相当)のトグルで示す
86 static bool led3_on = false;
87 led3_on = !led3_on;
88 matrix_led(3, led3_on);
89 }
90}
91
92void setup()
93{
94 Serial.begin(115200);
95 while (!Serial) { }
96
97 // LEDマトリクスを初期化(LED2 / LED3 の代用)
98 matrix.begin();
99
100 Serial.println("=== Preemptive Scheduler Demo ===");
101 Serial.println();
102 Serial.println("Tasks are automatically switched every 10ms by SysTick.");
103 Serial.println("The heavy_task demonstrates that even long-running");
104 Serial.println("computations don't block other tasks.");
105 Serial.println();
106
107 // Initialize OS
108 os_init();
109
110 // Create tasks
111 os_create_task(task_led1, "LED1", 512);
112 os_create_task(task_led2, "LED2", 512);
113 os_create_task(task_serial, "Serial", 1024);
114 os_create_task(task_heavy, "Heavy", 512);
115
116 Serial.println("Tasks created:");
117 for (int i = 0; i < os_get_task_count(); i++) {
118 Serial.print(" ID ");
119 Serial.print(i);
120 Serial.print(": ");
121 Serial.println(os_get_task_name(i));
122 }
123 Serial.println();
124 Serial.println("Starting preemptive scheduler...");
125 Serial.flush();
126 delay(100);
127
128 // Start OS
129 os_start();
130}
131
132void loop()
133{
134 // Never reached
135}
include/os_kernel.h — 79 行
1/**
2 * os_kernel.h - Mini OS Kernel Header
3 * Chapter 4: Preemptive Scheduler with SysTick
4 */
5
6#ifndef OS_KERNEL_H
7#define OS_KERNEL_H
8
9#include <stdint.h>
10
11#ifdef __cplusplus
12extern "C" {
13#endif
14
15// Task states
16typedef enum {
17 TASK_READY,
18 TASK_RUNNING,
19 TASK_BLOCKED,
20 TASK_SUSPENDED,
21 TASK_TERMINATED
22} TaskState;
23
24// Task Control Block (TCB)
25typedef struct {
26 uint32_t *sp;
27 uint32_t *stack_base;
28 uint32_t stack_size;
29 TaskState state;
30 uint8_t id;
31 uint8_t priority;
32 char name[16];
33 void (*entry)(void);
34 uint32_t wake_tick; // For os_sleep()
35 uint32_t cpu_ticks; // CPU usage counter
36} TCB;
37
38// System configuration
39#define MAX_TASKS 8
40#define DEFAULT_STACK_SIZE 1024
41// タスク用スタックをまとめて置く領域。os_create_task() は
42// ここから要求されたぶんだけ切り出す。
43// 既定より大きなスタックを要求するタスク(シェルなど)があるため、
44// MAX_TASKS * DEFAULT_STACK_SIZE では足りない。余裕を持たせる。
45#define STACK_POOL_SIZE (MAX_TASKS * DEFAULT_STACK_SIZE + 2048)
46#define IDLE_STACK_SIZE 256
47#define TIME_SLICE_MS 10
48
49// Kernel API
50void os_init(void);
51int os_create_task(void (*entry)(void), const char *name, uint32_t stack_size);
52void os_start(void);
53void os_yield(void);
54void os_sleep(uint32_t ms);
55
56// Task info API
57int os_get_task_count(void);
58const char* os_get_task_name(int id);
59TaskState os_get_task_state(int id);
60int os_get_current_task(void);
61int os_get_cpu_usage(int task_id);
62
63// Internal
64void os_schedule(void);
65uint32_t os_get_tick(void);
66
67// Global variables
68extern TCB g_tasks[MAX_TASKS];
69extern volatile int g_task_count;
70extern volatile int g_current_task;
71extern volatile int g_next_task;
72extern volatile uint32_t g_system_ticks;
73extern const uint32_t g_tcb_size;
74
75#ifdef __cplusplus
76}
77#endif
78
79#endif // OS_KERNEL_H
src/os_kernel.cpp — 287 行
1/**
2 * os_kernel.cpp - Mini OS Kernel Implementation
3 * Chapter 4: Preemptive Scheduler with SysTick
4 */
5
6#include "os_kernel.h"
7#include <Arduino.h>
8#include <string.h>
9
10// Global variables
11TCB g_tasks[MAX_TASKS];
12volatile int g_task_count = 0;
13volatile int g_current_task = -1;
14volatile int g_next_task = -1;
15volatile uint32_t g_system_ticks = 0;
16static volatile uint32_t g_tick_count = 0;
17
18const uint32_t g_tcb_size = sizeof(TCB);
19
20// Stack areas
21static uint32_t g_stack_pool[STACK_POOL_SIZE / 4];
22static uint32_t g_stack_used; // 切り出し済みのワード数
23
24// Idle task
25static void idle_task(void)
26{
27 while (1) {
28 __WFI();
29 }
30}
31
32// Initialize stack for a task
33// タスク関数が return したときの受け皿。例外フレームの LR スロットは
34// ここを指す (第3章 3.4)。EXC_RETURN を置く場所ではない。
35static void task_exit(void)
36{
37 g_tasks[g_current_task].state = TASK_TERMINATED;
38 os_yield();
39 while (1) { } // ここには到達しない
40}
41
42static uint32_t* init_task_stack(uint32_t *stack_top, void (*entry)(void))
43{
44 uint32_t *sp = stack_top;
45
46 // Hardware auto-saved registers
47 *(--sp) = 0x01000000; // xPSR: Thumb bit
48 *(--sp) = (uint32_t)entry; // PC
49 *(--sp) = (uint32_t)task_exit; // LR: タスクが return したときの戻り先
50 *(--sp) = 0; // R12
51 *(--sp) = 0; // R3
52 *(--sp) = 0; // R2
53 *(--sp) = 0; // R1
54 *(--sp) = 0; // R0
55
56 // Software saved registers
57 *(--sp) = 0; // R11
58 *(--sp) = 0; // R10
59 *(--sp) = 0; // R9
60 *(--sp) = 0; // R8
61 *(--sp) = 0; // R7
62 *(--sp) = 0; // R6
63 *(--sp) = 0; // R5
64 *(--sp) = 0; // R4
65
66 return sp;
67}
68
69void os_init(void)
70{
71 memset(g_tasks, 0, sizeof(g_tasks));
72 g_task_count = 0;
73 g_current_task = -1;
74 g_next_task = -1;
75 g_system_ticks = 0;
76
77 // Create idle task (ID=0)
78 os_create_task(idle_task, "idle", IDLE_STACK_SIZE);
79}
80
81int os_create_task(void (*entry)(void), const char *name, uint32_t stack_size)
82{
83 if (g_task_count >= MAX_TASKS) {
84 return -1;
85 }
86
87 int id = g_task_count;
88 TCB *task = &g_tasks[id];
89
90 task->id = id;
91 task->state = TASK_READY;
92 task->priority = id;
93 task->entry = entry;
94 task->wake_tick = 0;
95 task->cpu_ticks = 0;
96 strncpy(task->name, name, sizeof(task->name) - 1);
97
98 if (stack_size == 0) {
99 stack_size = DEFAULT_STACK_SIZE;
100 }
101 uint32_t words = (stack_size + 3) / 4;
102 if (g_stack_used + words > STACK_POOL_SIZE / 4) {
103 return -1; // プールが尽きた。黙って縮めず、失敗として返す
104 }
105 g_stack_used += words;
106 task->stack_size = stack_size;
107 task->stack_base = &g_stack_pool[g_stack_used]; // スタックは上端から下へ伸びる
108 task->sp = init_task_stack(task->stack_base, entry);
109
110 g_task_count++;
111 return id;
112}
113
114void os_schedule(void)
115{
116 // Guard: don't schedule before OS is initialized
117 if (g_task_count == 0) return;
118
119 // Wake up blocked tasks
120 for (int i = 0; i < g_task_count; i++) {
121 if (g_tasks[i].state == TASK_BLOCKED) {
122 if (g_system_ticks >= g_tasks[i].wake_tick) {
123 g_tasks[i].state = TASK_READY;
124 }
125 }
126 }
127
128 // Round-robin scheduling
129 int next = g_current_task;
130 do {
131 next = (next + 1) % g_task_count;
132 if (g_tasks[next].state == TASK_READY ||
133 g_tasks[next].state == TASK_RUNNING) {
134 break;
135 }
136 } while (next != g_current_task);
137
138 if (next != g_current_task || g_current_task < 0) {
139 if (g_current_task >= 0) {
140 if (g_tasks[g_current_task].state == TASK_RUNNING) {
141 g_tasks[g_current_task].state = TASK_READY;
142 }
143 }
144 g_tasks[next].state = TASK_RUNNING;
145 g_next_task = next;
146
147 // Trigger PendSV
148 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
149 }
150}
151
152void os_yield(void)
153{
154 os_schedule();
155}
156
157void os_sleep(uint32_t ms)
158{
159 if (g_current_task > 0) { // Don't block idle task
160 g_tasks[g_current_task].state = TASK_BLOCKED;
161 g_tasks[g_current_task].wake_tick = g_system_ticks + ms;
162 os_schedule();
163 }
164}
165
166uint32_t os_get_tick(void)
167{
168 return g_system_ticks;
169}
170
171int os_get_task_count(void)
172{
173 return g_task_count;
174}
175
176const char* os_get_task_name(int id)
177{
178 if (id < 0 || id >= g_task_count) return "";
179 return g_tasks[id].name;
180}
181
182TaskState os_get_task_state(int id)
183{
184 if (id < 0 || id >= g_task_count) return TASK_TERMINATED;
185 return g_tasks[id].state;
186}
187
188int os_get_current_task(void)
189{
190 return g_current_task;
191}
192
193void os_start(void)
194{
195 // Set PendSV to lowest priority
196 NVIC_SetPriority(PendSV_IRQn, 0xFF);
197
198 // Configure SysTick for TIME_SLICE_MS interval
199 SysTick_Config(SystemCoreClock / 1000 * TIME_SLICE_MS);
200
201 g_current_task = -1;
202 g_next_task = 0;
203
204 // Do NOT switch CONTROL to PSP here — PSP is not yet initialized.
205 // PendSV returns with EXC_RETURN=0xFFFFFFFD, which automatically
206 // switches thread mode to PSP after loading the first task's context.
207 SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
208
209 while (1) { }
210}
211
212// SysTick Handler - called every TIME_SLICE_MS
213extern "C" void SysTick_Handler(void)
214{
215 // 実際に経過した時間を、いま走っていたタスクの取り分として計上する。
216 // os_schedule() 側で数えると os_yield() の回数まで混ざってしまう。
217 if (g_current_task >= 0) {
218 g_tasks[g_current_task].cpu_ticks += TIME_SLICE_MS;
219 }
220 g_system_ticks += TIME_SLICE_MS;
221 os_schedule();
222}
223
224// PendSV Handler - Context Switch
225extern "C" __attribute__((naked)) void PendSV_Handler(void)
226{
227 __asm volatile (
228 " MRS R0, PSP \n"
229 " LDR R1, =g_current_task \n"
230 " LDR R2, [R1] \n"
231 " CMP R2, #0 \n"
232 " BLT _skip_save \n"
233 // Save R4-R11 only when there is a valid current task
234 " STMDB R0!, {R4-R11} \n"
235 " LDR R3, =g_tasks \n"
236 " LDR R4, =g_tcb_size \n"
237 " LDR R4, [R4] \n"
238 " MUL R2, R2, R4 \n"
239 " ADD R3, R3, R2 \n"
240 " STR R0, [R3] \n"
241 "_skip_save: \n"
242 " LDR R1, =g_next_task \n"
243 " LDR R2, [R1] \n"
244 " LDR R1, =g_current_task \n"
245 " STR R2, [R1] \n"
246 " \n"
247 " LDR R3, =g_tasks \n"
248 " LDR R4, =g_tcb_size \n"
249 " LDR R4, [R4] \n"
250 " MUL R2, R2, R4 \n"
251 " ADD R3, R3, R2 \n"
252 " LDR R0, [R3] \n"
253 " \n"
254 " LDMIA R0!, {R4-R11} \n"
255 " MSR PSP, R0 \n"
256 " \n"
257 " LDR LR, =0xFFFFFFFD \n"
258 " BX LR \n"
259 );
260}
261
262// CPU使用率を取得(パーセント)
263// 直前にこの関数を呼んだ時点からの差分で計算する。
264// ps のようにタスクごとにループで呼ぶため、基準時刻もタスクごとに持つ。
265int os_get_cpu_usage(int task_id)
266{
267 if (task_id < 0 || task_id >= g_task_count) {
268 return -1;
269 }
270
271 static uint32_t last_total[MAX_TASKS] = {0};
272 static uint32_t last_task_ticks[MAX_TASKS] = {0};
273
274 uint32_t total = g_system_ticks;
275 uint32_t delta_total = total - last_total[task_id];
276
277 if (delta_total == 0) {
278 return 0;
279 }
280
281 uint32_t delta_task = g_tasks[task_id].cpu_ticks - last_task_ticks[task_id];
282
283 last_total[task_id] = total;
284 last_task_ticks[task_id] = g_tasks[task_id].cpu_ticks;
285
286 return (delta_task * 100) / delta_total;
287}
実機での出力#
Arduino UNO R4 WiFi の実機で実際に取得した出力です(macOS / Windows の 両方で同じ結果を確認しています)。手元の出力と見比べてください。
Heavy が重い計算を回している最中も、1 秒ごとに Count を印字する Serial タスク(ID 3)の
周期が乱れません。これがプリエンプションの効果です。Current task は印字している
Serial タスク自身の ID なので、いつも 3 です。
Count がきっちり 1 秒ごとに出続け、その合間に Heavy が割り込まれている
ところ。実機の Arduino UNO R4 WiFi で録ったものです#
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
...
実機ではこう光ります。
シミュレータで見る#
実機が無くても、同じファームウェアを シミュレータ で動かすとこう見えます。
L(LED1)が 100ms ごと、マトリクス左半分(LED2)が 500ms ごとに点滅し、右半分は重い計算の Heavy タスクが 1 周するたびに切り替わります。Heavy が CPU を回し続けている最中も、L と左半分の点滅は乱れません。(シミュレータで撮影)#
壊してみる#
動いているコードを 1 か所だけ変えると、何が起きるでしょう。 変える前に結果を予想してから、答えを開いてください。
改造したコードは、ビルドしてできた firmware.elf を
ブラウザ版にドロップ すれば、基板が無くても試せます。
書き込みは要りません。元に戻すときは git checkout -- 04_scheduler です。
uv run pio run -d 04_scheduler
# できた 04_scheduler/.pio/build/uno_r4_wifi/firmware.elf をブラウザ版にドロップ
注釈
答えに載せた出力は、ブラウザ版と同じ QEMU で実際に動かして取ったものです。 実機ではまだ確かめていません。止まる場所やダンプの数字は、実機では違うことがあります。
1. タイムスライスを 1 秒にする#
include/os_kernel.h の 1 行を変えます。タスクを切り替える間隔が 10 ms から 1 秒になります。
-#define TIME_SLICE_MS 10
+#define TIME_SLICE_MS 1000
予想: L LED の点滅と、1 秒ごとの Count の印字はどうなるでしょう。
答え
Starting preemptive scheduler... のあと、何も出なくなります。
L LED も点滅しません。
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()、
上限は SysTick_LOAD_RELOAD_Msk = 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() の呼び出しをコメントにします。
時間は進みますが、タイマ割り込みでタスクを切り替えなくなります。
g_system_ticks += TIME_SLICE_MS;
- os_schedule();
+ // os_schedule();
予想: os_sleep() を呼ぶタスクはそこで切り替わります。どのタスクが動き続けるでしょう。
答え
これも Starting preemptive scheduler... のあと、何も出ません。
最初に走るのは ID 0 の idle タスクです。idle は os_sleep() を呼ばず、
__WFI() で割り込みを待つだけのループなので、自分から CPU を手放しません。
切り替えのきっかけが os_sleep() だけになると、idle が CPU を持ったまま、
ほかのタスクは一度も走りません。
第3章の協調的な切り替え(第3章: コンテキストスイッチの実装)では、タスクが自分で os_yield() を呼ぶ
約束でした。約束を守らないタスクが 1 つあるだけで全体が止まる、というのが
プリエンプションで解いている問題です。
3. レジスタ R4〜R11 を保存しない#
PendSV_Handler() の、R4〜R11 を積む命令と戻す命令を、スタックポインタを
動かすだけの命令に置き換えます。スタックの形は変わらないので、中身だけが
保存も復元もされなくなります。
- " 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 本を保存しないと、何が起きるでしょう。
答え
最初の Count を 1 行出したところで、フォールトで止まります。
下のダンプは Arduino コアに入っているフォールトハンドラ(CmBacktrace)が出したものです。
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 へどうぞ)