True preemptive multitasking on a bare ESP8266 — no RTOS, no FreeRTOS. Just the Arduino Non-OS SDK, one hardware timer, and a hand-written Xtensa context switch.
Full credit for the underlying implementation goes to the PDI framework.
Reference / inspiration: https://github.com/Suraj151/pdi-framework
A minimal preemptive, priority + aging task scheduler for the ESP8266 that forcibly time-slices tasks using the FRC1 hardware timer and a hand-coded Xtensa LX106 context switch — the same mechanism a real RTOS uses, distilled down to a few hundred lines you can read in one sitting.
The ESP8266 Arduino core is cooperative by nature: your loop() runs, and
nothing else of yours runs until loop() returns or you call yield()/delay().
If one piece of code hogs the CPU, everything else stalls.
A preemptive scheduler removes that limitation. A periodic hardware-timer interrupt stops whatever is running, saves its full CPU state, and resumes a different task — without the task's cooperation. The task never knows it was paused. This is exactly how an RTOS multitasks; this project shows how little it actually takes to do it yourself on the Non-OS SDK.
The headline demo: a task that busy-loops for 2 seconds straight and never sleeps. On a cooperative scheduler that hog would freeze the LED and the serial output. Here, the LED keeps blinking and the counters keep printing — because the timer ISR preempts the hog every millisecond and shares the CPU fairly.
Flash it, open the Serial Monitor at 115200 baud, and you get something like:
=== ESP8266 Preemptive Scheduler demo ===
Watch: LED blinks + counter prints EVEN while the hog runs.
[counter] tick #0 (heap=41200)
[loop ] main loop is alive at 512 ms
[counter] tick #1 (heap=41200)
[hog] finished a 2s busy burst (x=... ), now sleeping 3s
[loop ] main loop is alive at 2530 ms
[counter] tick #2 (heap=41200)
...
Meanwhile the on-board LED blinks steadily at ~4 Hz the entire time — including during the hog's 2-second busy burst. That steady blink is the proof of preemption.
| Item | Notes |
|---|---|
| Any ESP8266 board | NodeMCU, Wemos/LOLIN D1 mini, ESP-12E/F, etc. |
| USB cable | For flashing and Serial Monitor |
| On-board LED | Used by the blink task (no wiring needed) |
No external components required — the demo uses only LED_BUILTIN and the serial
port.
- Arduino IDE (or arduino-cli)
- ESP8266 Arduino core (esp8266 by ESP8266 Community) — board package
- Board setting: your ESP8266 board, Upload speed default, Flash size default
Four small building blocks:
Hand-written Xtensa LX106 assembly that saves and restores the entire CPU
state (registers a0–a15, PC, PS, SAR). xtensa_restore_context_isr() is the
clever bit: instead of a normal return, it rewrites the interrupted exception
frame so that when the timer ISR returns, the CPU resumes a different task.
This is the reverse-engineered trick that makes preemption possible on the
Arduino-ESP8266 exception/ISR model.
Programs the ESP8266 FRC1 (timer1) hardware timer to fire every ~1 ms. Its
naked ISR captures the interrupted exception frame and forwards it to a coroutine
hook (timer1_isr_coroutine) that the scheduler implements. The period is
self-tuning: the time spent inside the ISR is subtracted from the next reload so
the effective tick stays stable.
Keeps a ready queue and a sleeper list, both fixed-size (no heap use inside the ISR). On every tick it:
- wakes any sleepers whose deadline passed,
- requeues the interrupted task,
- picks the next task by priority + aging (
score = priority + wait_ticks), so a low-priority task can never starve, and - context-switches into it.
Tasks can also voluntarily give up the CPU early with sleep(ms) or yield().
The main Arduino loop() is captured on the first tick and scheduled as just
another task, so it too runs concurrently.
Because tasks are preempted at arbitrary points, two of them printing to
Serial at once would interleave characters. PreemptiveMutex fixes that: if
the lock is already held, the caller is parked (not busy-waiting) on a FIFO
waiter list and the CPU goes to someone else; unlock() hands ownership to the
next waiter and makes it ready again — exactly like a mutex in an RTOS. The demo
wraps every Serial print in it.
Registers three tasks (LED blink, heartbeat counter, CPU hog) at different
priorities, guards Serial with a PreemptiveMutex, and lets the scheduler run
them alongside loop().
FRC1 timer ──(every ~1ms)──▶ timer1_isr (naked)
│ captures exception frame
▼
timer1_isr_coroutine
│
▼
PreemptiveScheduler::run()
├─ wake due sleepers
├─ requeue current task
├─ pick_next_ready() (priority + aging)
└─ xtensa_restore_context_isr() ──▶ next task resumes
#include "PreemptiveScheduler.h"
void my_task(void* arg) {
while (true) {
// ... do work ...
preemptive_scheduler.sleep(500); // yield CPU for 500 ms
}
}
void setup() {
Serial.begin(115200);
// entry, arg, stack bytes, priority (higher = more eager)
preemptive_scheduler.addTask(my_task, nullptr, 1536, 2);
}
void loop() {
// still runs, preemptively, alongside your tasks
}API at a glance
| Call | Purpose |
|---|---|
addTask(fn, arg, stacksize, priority) |
Create a preemptive task |
sleep(ms) |
Park the current task for at least ms |
yield() |
Let others run, stay ready |
enable() / disable() |
Pause/resume preemptive switching at runtime |
PreemptiveMutex::lock()/unlock() |
Guard a shared resource between tasks |
PreemptiveMutex::try_lock() |
Non-blocking acquire |
Guarding a shared resource
#include "PreemptiveMutex.h"
PreemptiveMutex serial_mutex;
void a_task(void*) {
while (true) {
serial_mutex.lock();
Serial.println("only this task prints right now");
serial_mutex.unlock();
preemptive_scheduler.sleep(200);
}
}| File | Role |
|---|---|
ESP8266PreemptiveScheduler.ino |
Demo sketch (usage example) |
PreemptiveScheduler.h/.cpp |
The preemptive scheduler |
PreemptiveMutex.h/.cpp |
Blocking mutex for tasks (guards shared Serial) |
XtensaTimer.h/.cpp |
FRC1/timer1 tick + naked ISR |
XtensaContext.h/.c |
Xtensa context save/restore (assembly) |
- This is an experimental / educational implementation for the ESP8266 Non-OS SDK. It deliberately mirrors the low-level trick used in the PDI framework rather than hiding it.
- Give each task enough stack. Serial printing and floating point need headroom; 1024–1536 bytes per task is a reasonable starting point.
- Avoid doing SPI-flash writes (e.g. large
EEPROM/LittleFScommits) while the timer ISR path is active — the ISR runs code from flash, and flash operations temporarily disable the cache. - The main
loop()participates in scheduling only after the first timer tick.
PDI framework: https://github.com/Suraj151/pdi-framework
Free software — redistribute and/or modify without warranty, matching the PDI framework's terms.