03 · The Scheduler¶
The scheduler is the heart of the kernel: the piece of code that decides, at every moment, which task owns the CPU. Its rule is short enough to memorize — the highest-priority Ready task runs, always — but the consequences (preemption, time slicing, starvation, the idle task) are where real firmware behavior is decided. This module makes the rule concrete, shows what happens between equal-priority tasks, and covers the ESP32's dual-core twist.
The one rule, and preemption¶
FreeRTOS priorities run from 0 (lowest) to configMAX_PRIORITIES - 1
(24 on ESP32 Arduino). At every scheduling decision, the kernel runs the
highest-priority task that is Ready. If a higher-priority task becomes
Ready — its delay expires, its queue receives data, an ISR signals it — the
running task is preempted immediately, mid-statement, and the
higher-priority task runs. No politeness, no waiting for a yield.
Watch it happen:
void lowTask(void *pv) {
for (;;) {
Serial.println("low: starting a long computation...");
uint32_t t0 = millis();
while (millis() - t0 < 300) { } // 300 ms of BUSY work (no blocking)
Serial.println("low: ...finished");
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void highTask(void *pv) {
for (;;) {
vTaskDelay(pdMS_TO_TICKS(100)); // wakes every 100 ms
Serial.printf(" HIGH ran at %lu ms\n", millis());
}
}
void setup() {
Serial.begin(115200);
xTaskCreate(lowTask, "low", 2048, NULL, 1, NULL);
xTaskCreate(highTask, "high", 2048, NULL, 3, NULL); // higher priority
}
void loop() { vTaskDelay(portMAX_DELAY); }
The output shows HIGH ran at ... lines appearing every 100 ms right
through the middle of low's 300 ms computation. The low task never
volunteered — the tick interrupt noticed high's delay had expired, and the
scheduler swapped tasks in a few microseconds. That context switch —
saving low's registers, restoring high's — is the mechanism behind
everything an RTOS promises.
Assigning priorities sensibly¶
Priorities encode urgency (deadline tightness), not importance:
| Priority | Typical residents |
|---|---|
| High (5+) | Tight-deadline work triggered by ISRs; motor/control loops |
| Medium (2-4) | Sensor sampling, protocol handling, application logic |
| Low (1) | Logging, display refresh, loop() (Arduino puts it at 1) |
| 0 | Idle task lives here — avoid putting your tasks at 0 |
Rule of thumb (rate-monotonic): the shorter a task's period/deadline, the higher its priority. And keep high-priority tasks short — they run at the expense of everyone below.
The tick, and time slicing between equals¶
A hardware timer fires the tick interrupt at configTICK_RATE_HZ —
1000 Hz on ESP32 Arduino, so one tick = 1 ms (this is why
pdMS_TO_TICKS exists: on a 100 Hz build, 1 tick = 10 ms and delays are
rounded accordingly). The tick wakes expired delays and drives scheduling
decisions.
When several tasks share the same priority and are all Ready, FreeRTOS
time-slices them: each tick, the scheduler rotates to the next
equal-priority Ready task (round-robin). They appear to run "in parallel,"
interleaved at 1 ms granularity. Tasks can also hand over early:
taskYIELD() says "I'm done for now — if another task of my priority is
Ready, switch to it" (it never gives the CPU to lower priority tasks).
The idle task, and starvation¶
When no application task is Ready — everyone's Blocked — the scheduler runs the idle task, a priority-0 task the kernel creates automatically. It cleans up deleted tasks' memory and, when configured, puts the CPU into a low-power state. The ESP32 also uses idle time to feed a watchdog.
Starvation is the classic scheduler bug: a task that never blocks starves everything below it — forever, not just "slows it down."
At priority 2, greedy permanently freezes every priority-1 and 0 task: the
loop() task, your logger, and the idle task. On ESP32 the symptom is
dramatic — the idle task watchdog fires and prints
Task watchdog got triggered because idle never got to feed it. The fix is
never taskYIELD() (that only helps equal priorities) — the fix is making
the task block: on a delay, a queue, or a notification. In a preemptive
RTOS, blocking is not lost time; it is how you donate time downward.
ESP32: two cores¶
The ESP32 has two cores (0 and 1), and its FreeRTOS schedules across both —
the rule becomes "on each core, run the highest-priority Ready task allowed
to run there." Arduino's setup()/loop() run on core 1; WiFi and
Bluetooth stacks run mostly on core 0. xTaskCreate lets the task run
on either core; to control placement use:
xTaskCreatePinnedToCore(
taskFn, "ctrl", 2048, NULL, 5, NULL,
1 // core id: 0, 1, or tskNO_AFFINITY (either core)
);
Pinning matters when you need cache locality, when a library demands a specific core, or when you want your control loop unaffected by WiFi bursts (pin it to core 1). It also means two tasks really can run simultaneously — a mental-model upgrade that makes the data-sharing tools of the next two modules non-optional, not just polite. Everything else in this level works identically with or without pinning; Level 2's ESP-IDF module digs into the dual-core details.
How It Actually Works¶
Under the hood, every task is a Task Control Block (TCB) — a C struct
holding the task's name, priority, current state, stack pointer, and a
pxTopOfStack value that points into that task's own private stack region.
When the scheduler decides task A must stop and task B must run, it does not
"transfer control" in any abstract sense — it performs a context switch:
the current CPU registers (program counter, general-purpose registers, stack
pointer) are pushed onto A's stack, A's TCB is updated to record where its
stack pointer now sits, then B's TCB is read back to restore B's saved
registers from B's stack, and execution resumes at exactly the instruction B
was at when it was last preempted. This is why each task needs its own
stack — the switch is really just "save state to this stack, load state from
that stack."
The tick interrupt is what makes this happen without cooperation. On every
tick, the interrupt handler decrements the delay counters of any Blocked
tasks; when a counter reaches zero, that task's TCB moves from a delayed
list to the Ready list for its priority. The handler then asks the
scheduler to re-evaluate: is the highest-priority Ready task the one
currently running? If not, it sets a flag requesting a context switch, which
happens either immediately (on return from the ISR) or, on the ESP32's dual
FreeRTOS ports, via a similar mechanism per core. FreeRTOS keeps a small
array of Ready lists indexed by priority (pxReadyTasksLists[priority]), so
finding "the highest-priority Ready task" is just scanning from the top of
that array down to the first non-empty list — O(1) in practice via a
bitmap of occupied priorities on most ports.
Queues and semaphores extend this same TCB machinery with a blocked-on
this object list. When a task calls xQueueReceive() on an empty queue,
its TCB is removed from the Ready list and appended to that queue's own
list of waiting tasks (ordered by priority), and the scheduler immediately
runs something else — the blocked task consumes zero CPU while it waits.
When another task or ISR calls xQueueSend(), the kernel checks that
waiting list: if a task is there, its TCB is moved straight back to Ready
(and, if it outranks the currently running task, a context switch is
requested on the spot) — the data reaches it without ever passing through
the general Ready-list scan.
This blocked-list design is also the root of priority inversion and its fix. If a low-priority task holds a mutex a high-priority task needs, the high-priority task's TCB sits on that mutex's blocked list — invisible to the scheduler as "urgent" until the mutex is released. FreeRTOS's mutex implementation (unlike a plain binary semaphore) checks this case: when a higher-priority task blocks on a mutex, the kernel temporarily raises the holder's priority to match, via priority inheritance, so the holder gets scheduled, finishes, and releases the mutex sooner — after which its priority drops back to normal. This is purely bookkeeping in the TCB's priority field plus a re-sort of Ready-list membership; no separate "inheritance task" exists.
Cheat sheet¶
| Concept / API | Meaning |
|---|---|
| Scheduling rule | Highest-priority Ready task runs — immediately, via preemption |
| Priorities | 0 = lowest (idle); ESP32 Arduino: up to 24; loop() runs at 1 |
| Priority heuristic | Shorter deadline → higher priority; keep high-prio tasks short |
| Tick | 1000 Hz on ESP32 Arduino (1 tick = 1 ms); drives delays & time slicing |
| Time slicing | Equal-priority Ready tasks round-robin each tick |
taskYIELD() |
Offer CPU to equal-priority tasks now (never to lower) |
| Idle task | Priority-0 kernel task; runs when nothing else is Ready; feeds ESP32 idle watchdog |
| Starvation | A never-blocking task freezes all lower priorities — fix by blocking, not yielding |
xTaskCreatePinnedToCore(..., core) |
ESP32: pin a task to core 0 or 1 (tskNO_AFFINITY = either) |
Exercise¶
- Run the preemption demo above and confirm
HIGHlines appear during low's busy-wait. Then set both tasks to priority 1 — explain what you now see (hint: with a 1000 Hz tick, does time slicing lethighstill run during the busy loop? Why is it now late?). - Create the
greedytask at priority 2 alongside a priority-1 blinker. Observe the blinker freeze and the watchdog complaint in the serial monitor. Fix it with a singlevTaskDelay(1)and confirm recovery. - Pin two busy-ish tasks (each printing its core via
xPortGetCoreID()) to core 0 and core 1 respectively, then to the same core, and compare the interleaving of their output.