Skip to content

authorizedengineer/ESP8266PreemptiveScheduler

Repository files navigation

Preemptive Task Scheduling on the ESP8266 (Arduino Non-OS SDK)

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


Summary (one-liner)

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 idea

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.


What you'll see

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.


Hardware

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.

Software

  • 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

How it works

Four small building blocks:

1. XtensaContext.c / .h — the context switch

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.

2. XtensaTimer.cpp / .h — the tick source

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.

3. PreemptiveScheduler.cpp / .h — the scheduler

Keeps a ready queue and a sleeper list, both fixed-size (no heap use inside the ISR). On every tick it:

  1. wakes any sleepers whose deadline passed,
  2. requeues the interrupted task,
  3. picks the next task by priority + aging (score = priority + wait_ticks), so a low-priority task can never starve, and
  4. 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.

4. PreemptiveMutex.cpp / .h — task synchronisation

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.

5. ESP8266PreemptiveScheduler.ino — the demo

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

Using the scheduler in your own sketch

#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);
    }
}

Files

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)

Notes & caveats

  • 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/LittleFS commits) 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.

Credits & reference

PDI framework: https://github.com/Suraj151/pdi-framework

License

Free software — redistribute and/or modify without warranty, matching the PDI framework's terms.

About

True preemptive multitasking on a bare ESP8266 — no RTOS — using one hardware timer and a well-written Xtensa context switch.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages