Chapter 4: The Preemptive Scheduler#
What this chapter answers: why do other apps keep running while one does heavy work?
On a PC, you can leave a heavy computation running and the other apps keep working. The OS forcibly switches the running program on every timer interrupt. In this chapter you build that same mechanism yourself.
The difference from a PC is that the UNO R4 has only one CPU core. A PC has several, so even if one heavy job uses up a core, the others are free. With one core, if nothing switches, everything really does stop. You can watch it stop in Break it at the end of this chapter.
Sample code#
Directory:
docs/os-on-arduino/code/04_scheduler
uv run pio run -d 04_scheduler -t upload
uv run pio device monitor -b 115200
Source code#
The sketch for this chapter. Click a file name to expand. The code is mirrored automatically from the manuscript repository, so what you see here is always current.
src/main.cpp — 135 lines
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 lines
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 lines
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}
On the board#
Count keeps appearing exactly once a second while Heavy is interrupted in between. It is printed by the Serial task (ID 3); Current task is that task’s own ID, so it is always 3. Recorded on a real Arduino UNO R4 WiFi.#
In the simulator#
Without the board, the same firmware running in the simulator looks like this.
L (LED1) toggles every 100 ms and the left half of the matrix (LED2) every 500 ms; the right half flips each time the busy Heavy task finishes a round. While Heavy keeps the CPU busy, L and the left half keep their rhythm. (Recorded in the simulator.)#
Break it#
What happens if you change just one place in working code? Predict the result before you change it, then open the answer.
Build the modified code and
drop the resulting firmware.elf into the browser version
to try it without a board. No flashing needed. To undo the change, run
git checkout -- 04_scheduler.
uv run pio run -d 04_scheduler
# drop 04_scheduler/.pio/build/uno_r4_wifi/firmware.elf into the browser version
Note
The outputs in the answers were captured by actually running the modified code in QEMU, the same emulator the browser version uses. They have not been checked on the real board yet; where it stops and the numbers in a dump may differ there.
1. Make the time slice one second#
Change one line in include/os_kernel.h. Tasks now switch every second
instead of every 10 ms.
-#define TIME_SLICE_MS 10
+#define TIME_SLICE_MS 1000
Predict: what happens to the L LED blinking and the Count line printed every second?
Answer
After Starting preemptive scheduler..., nothing more is printed.
The L LED does not blink either.
Tasks created:
ID 0: idle
ID 1: LED1
ID 2: LED2
ID 3: Serial
ID 4: Heavy
Starting preemptive scheduler...
The SysTick counter is 24 bits wide, so it can count at most 16,777,216 clocks
at a time. One second at 48 MHz is 48,000,000 clocks, which does not fit.
When SysTick_Config() is given a value that does not fit, it returns 1
without starting the timer
(SysTick_Config() in CMSIS core_cm4.h;
the limit is SysTick_LOAD_RELOAD_Msk = 0xFFFFFF).
os_start() does not check that return value, so no interrupt ever arrives,
and the idle task, chosen first, sleeps in __WFI() forever.
In QEMU, 349 ms still runs and 350 ms (16,800,000 clocks) stops.
At 100 ms it keeps running, but the Count timestamps move in 100 ms steps, like [3100ms].
To fix it: if SysTick_Config() returns anything other than 0, print an error and stop.
2. Take task switching out of SysTick#
In SysTick_Handler() in src/os_kernel.cpp, comment out the call to os_schedule().
Time still advances, but the timer interrupt no longer switches tasks.
g_system_ticks += TIME_SLICE_MS;
- os_schedule();
+ // os_schedule();
Predict: a task that calls os_sleep() still switches there. Which task keeps running?
Answer
Again, nothing is printed after Starting preemptive scheduler....
The first task to run is the idle task, ID 0. Idle never calls os_sleep();
it is a loop that only waits for interrupts in __WFI(), so it never gives up
the CPU on its own. Once os_sleep() is the only way to switch, idle keeps the
CPU and no other task ever runs.
The cooperative switching in Chapter 3 (Chapter 3: Implementing Context Switching) relied on each task
calling os_yield() itself. A single task that does not keep that promise
stops everything, and that is the problem preemption solves.
3. Stop saving registers R4–R11#
In PendSV_Handler(), replace the instructions that push and pop R4–R11
with ones that only move the stack pointer. The stack layout stays the same;
only the contents are no longer saved or restored.
- " STMDB R0!, {R4-R11} \n"
+ " SUB R0, R0, #32 \n"
...
- " LDMIA R0!, {R4-R11} \n"
+ " ADD R0, R0, #32 \n"
Predict: the CPU pushes R0–R3, PC and a few others automatically when it enters the exception. What happens if the remaining eight are not saved?
Answer
It prints the first Count line and then stops with a fault.
The dump below comes from the fault handler bundled with the Arduino core (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
(stack dump omitted)
=================== 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 are the registers a function promises to leave unchanged across a call. The compiler keeps loop values and addresses in them while calling other functions. If they are not saved, the task switched to picks up the previous task’s values as they are. Here a corrupted value was used as a jump target, and the CPU stopped trying to switch to an invalid state (the last line). Where it stops depends on which value gets corrupted and how.
Pitfalls#
(Collected from reader questions — ask via GitHub Issues.)