Introduction
When developing embedded systems, managing tasks, timing, and resources efficiently becomes a challenge as the complexity of the application grows. This is where Real-Time Operating Systems (RTOS) come in.
FreeRTOS is one of the most popular open-source real-time operating systems for microcontrollers. It is small, fast, and easy to integrate into resource-constrained devices like the ESP32, making it ideal for IoT, automation, and robotics projects.
In this blog topic, we will cover:
- What FreeRTOS is
- Key features of FreeRTOS
- Why FreeRTOS is a good choice for ESP32 projects
- A hands-on example using ESP32
What is FreeRTOS?
FreeRTOS is a lightweight, real-time operating system kernel for embedded devices. It provides multitasking capabilities, letting you split your application into independent tasks (threads) that run seemingly in parallel.
For example, on ESP32, you can have:
- One task reading sensors
- Another handling Wi-Fi communication
- A third controlling LEDs
All running at the same time without interfering with each other.
Key Features of FreeRTOS
1. Multitasking with Priorities
FreeRTOS allows multiple tasks to run with different priorities. The scheduler ensures high-priority tasks get CPU time first, making it suitable for real-time applications.
2. Lightweight and Portable
The kernel is very small (a few KBs), making it ideal for microcontrollers like ESP32 with limited resources.
3. Preemptive and Cooperative Scheduling
- Preemptive: Higher priority tasks can interrupt lower ones.
- Cooperative: Tasks voluntarily give up CPU control.
This provides flexibility depending on your project needs.
4. Task Synchronization
Features like semaphores, mutexes, and queues help coordinate tasks and prevent resource conflicts.
5. Software Timers
Timers allow tasks to be triggered at regular intervals without blocking the main code.
6. Memory Management
Multiple memory allocation schemes let you optimize for speed or minimal memory fragmentation.
7. Extensive Hardware Support
FreeRTOS runs on 40+ architectures, including ARM Cortex-M, AVR, RISC-V, and of course, ESP32 (via the ESP-IDF framework).
Why Use FreeRTOS on ESP32?
The ESP32 has:
- Dual-core processor
- Wi-Fi + Bluetooth
- Plenty of GPIOs
With FreeRTOS, you can use these resources efficiently:
- Run Wi-Fi tasks on Core 0
- Handle sensor data on Core 1
- Keep the system responsive and organized
Example: Blinking LED Using FreeRTOS on ESP32
Below is a simple FreeRTOS example using ESP-IDF or Arduino IDE with the ESP32.
Code Example
#include <Arduino.h>
// Task Handles
TaskHandle_t Task1;
TaskHandle_t Task2;
// Task 1: Blink LED every 1 second
void TaskBlink1(void *pvParameters) {
pinMode(2, OUTPUT); // Onboard LED
while (1) {
digitalWrite(2, HIGH);
vTaskDelay(1000 / portTICK_PERIOD_MS); // 1 second delay
digitalWrite(2, LOW);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
// Task 2: Print message every 2 seconds
void TaskPrint(void *pvParameters) {
while (1) {
Serial.println("Task 2 is running!");
vTaskDelay(2000 / portTICK_PERIOD_MS);
}
}
void setup() {
Serial.begin(115200);
// Create two FreeRTOS tasks
xTaskCreate(TaskBlink1, "Blink Task", 1000, NULL, 1, &Task1);
xTaskCreate(TaskPrint, "Print Task", 1000, NULL, 1, &Task2);
}
void loop() {
// Nothing here - tasks handle everything
}
How the Code Works
xTaskCreate
: Creates a FreeRTOS task. Each task runs independently.vTaskDelay
: Delays a task without blocking others.- Two tasks:
- Task 1 blinks the LED every second.
- Task 2 prints a message every two seconds.
Both tasks run in parallel on the ESP32.
In Diagramatically shown below:
The above diagram represents;
- Groups tasks clearly by Core 0 (Network/IO) and Core 1 (Control/Timing).
- Places shared Queue/Event Group in the center.
- Shows ISR → Queue → Tasks data flow with minimal arrows for clarity.
1) Pin Tasks to Cores + Precise Periodic Scheduling
Use xTaskCreatePinnedToCore
to control where tasks run and vTaskDelayUntil
for jitter-free loops.
#include <Arduino.h>
TaskHandle_t sensorTaskHandle, wifiTaskHandle;
void sensorTask(void *pv) {
const TickType_t period = pdMS_TO_TICKS(10); // 100 Hz
TickType_t last = xTaskGetTickCount();
for (;;) {
// read sensor here
// ...
vTaskDelayUntil(&last, period);
}
}
void wifiTask(void *pv) {
for (;;) {
// handle WiFi / MQTT here
vTaskDelay(pdMS_TO_TICKS(50));
}
}
void setup() {
Serial.begin(115200);
// Run time-critical sensor task on Core 1, comms on Core 0
xTaskCreatePinnedToCore(sensorTask, "sensor", 2048, NULL, 3, &sensorTaskHandle, 1);
xTaskCreatePinnedToCore(wifiTask, "wifi", 4096, NULL, 2, &wifiTaskHandle, 0);
}
void loop() {}
Why it’s useful: keep deterministic work (sensors/control) isolated from network stacks.
2) Queues: From ISR to Task (Button → LED)
Move edge events out of the ISR using queues and process them safely in a task.
#include <Arduino.h>
static QueueHandle_t buttonQueue;
const int BTN_PIN = 0; // adjust for your board
const int LED_PIN = 2;
void IRAM_ATTR onButtonISR() {
uint32_t tick = millis();
BaseType_t hpTaskWoken = pdFALSE;
xQueueSendFromISR(buttonQueue, &tick, &hpTaskWoken);
if (hpTaskWoken) portYIELD_FROM_ISR();
}
void ledTask(void *pv) {
pinMode(LED_PIN, OUTPUT);
uint32_t eventTime;
for (;;) {
if (xQueueReceive(buttonQueue, &eventTime, portMAX_DELAY) == pdPASS) {
// simple action: blink LED on each press
digitalWrite(LED_PIN, !digitalRead(LED_PIN));
Serial.printf("Button @ %lu ms\n", eventTime);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(BTN_PIN, INPUT_PULLUP);
buttonQueue = xQueueCreate(8, sizeof(uint32_t));
attachInterrupt(digitalPinToInterrupt(BTN_PIN), onButtonISR, FALLING);
xTaskCreate(ledTask, "ledTask", 2048, NULL, 2, NULL);
}
void loop() {}
Tip: keep ISRs tiny; send data to tasks via queues.
3) Mutex: Protect Shared Resources (Serial / I²C / SPI)
Avoid interleaved prints or bus collisions with a mutex.
#include <Arduino.h>
SemaphoreHandle_t ioMutex;
void chatterTask(void *pv) {
const char *name = (const char*)pv;
for (;;) {
if (xSemaphoreTake(ioMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
Serial.printf("[%s] hello\n", name);
xSemaphoreGive(ioMutex);
}
vTaskDelay(pdMS_TO_TICKS(200));
}
}
void setup() {
Serial.begin(115200);
ioMutex = xSemaphoreCreateMutex();
xTaskCreate(chatterTask, "chat1", 2048, (void*)"T1", 1, NULL);
xTaskCreate(chatterTask, "chat2", 2048, (void*)"T2", 1, NULL);
}
void loop() {}
Why it’s useful: prevents priority inversion and corrupted I/O.
4) Binary Semaphore: Signal Readiness (Wi-Fi Connected → Start Task)
Use a binary semaphore to gate a task until some condition is met.
#include <Arduino.h>
SemaphoreHandle_t wifiReady;
void workerTask(void *pv) {
// wait until Wi-Fi is ready
xSemaphoreTake(wifiReady, portMAX_DELAY);
Serial.println("WiFi ready, starting cloud sync…");
for (;;) {
// do cloud work
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void setup() {
Serial.begin(115200);
wifiReady = xSemaphoreCreateBinary();
// simulate Wi-Fi connect on another task/timer
xTaskCreate([](void*){
vTaskDelay(pdMS_TO_TICKS(2000)); // pretend connect delay
xSemaphoreGive(wifiReady);
vTaskDelete(NULL);
}, "wifiSim", 2048, NULL, 2, NULL);
xTaskCreate(workerTask, "worker", 4096, NULL, 2, NULL);
}
void loop() {}
5) Event Groups: Wait for Multiple Conditions
Synchronize on multiple bits (e.g., Wi-Fi + Sensor) before proceeding.
#include <Arduino.h>
#include "freertos/event_groups.h"
EventGroupHandle_t appEvents;
const int WIFI_READY_BIT = BIT0;
const int SENSOR_READY_BIT= BIT1;
void setup() {
Serial.begin(115200);
appEvents = xEventGroupCreate();
// Simulate async readiness
xTaskCreate([](void*){
vTaskDelay(pdMS_TO_TICKS(1500));
xEventGroupSetBits(appEvents, WIFI_READY_BIT);
vTaskDelete(NULL);
}, "wifi", 2048, NULL, 2, NULL);
xTaskCreate([](void*){
vTaskDelay(pdMS_TO_TICKS(800));
xEventGroupSetBits(appEvents, SENSOR_READY_BIT);
vTaskDelete(NULL);
}, "sensor", 2048, NULL, 2, NULL);
// Wait for both bits
xTaskCreate([](void*){
EventBits_t bits = xEventGroupWaitBits(
appEvents, WIFI_READY_BIT | SENSOR_READY_BIT,
pdFALSE, /* don't clear */
pdTRUE, /* wait for all */
portMAX_DELAY
);
Serial.printf("Ready! bits=0x%02x\n", bits);
vTaskDelete(NULL);
}, "gate", 2048, NULL, 3, NULL);
}
void loop() {}
6) Software Timers: Non-Blocking Periodic Work
Use xTimerCreate
for periodic or one-shot jobs without dedicating a full task.
#include <Arduino.h>
TimerHandle_t blinkTimer;
const int LED = 2;
void blinkCb(TimerHandle_t) {
digitalWrite(LED, !digitalRead(LED));
}
void setup() {
pinMode(LED, OUTPUT);
blinkTimer = xTimerCreate("blink", pdMS_TO_TICKS(250), pdTRUE, NULL, blinkCb);
xTimerStart(blinkTimer, 0);
}
void loop() {}
Why it’s useful: frees CPU and stack compared to a dedicated blink task.
7) Task Notifications: Fast 1-to-1 Signal (Lighter than Queues)
Direct-to-task notifications are like super-light binary semaphores.
#include <Arduino.h>
TaskHandle_t workTaskHandle;
void IRAM_ATTR quickISR() {
BaseType_t xHigher = pdFALSE;
vTaskNotifyGiveFromISR(workTaskHandle, &xHigher);
if (xHigher) portYIELD_FROM_ISR();
}
void workTask(void *pv) {
for (;;) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // waits, clears on take
// handle event fast
Serial.println("Notified!");
}
}
void setup() {
Serial.begin(115200);
xTaskCreate(workTask, "work", 2048, NULL, 3, &workTaskHandle);
// simulate an interrupt source using a timer
hw_timer_t *timer = timerBegin(0, 80, true); // 1 us tick
timerAttachInterrupt(timer, &quickISR, true);
timerAlarmWrite(timer, 500000, true); // 500ms
timerAlarmEnable(timer);
}
void loop() {}
8) Producer–Consumer with Queue + Backpressure
Avoid overruns by letting the queue throttle the producer.
#include <Arduino.h>
QueueHandle_t dataQ;
void producer(void *pv) {
uint16_t sample = 0;
for (;;) {
sample++;
if (xQueueSend(dataQ, &sample, pdMS_TO_TICKS(10)) != pdPASS) {
// queue full -> dropped (or handle differently)
}
vTaskDelay(pdMS_TO_TICKS(5)); // 200 Hz
}
}
void consumer(void *pv) {
uint16_t s;
for (;;) {
if (xQueueReceive(dataQ, &s, portMAX_DELAY) == pdPASS) {
// heavy processing
vTaskDelay(pdMS_TO_TICKS(20)); // slower than producer
Serial.printf("Processed %u\n", s);
}
}
}
void setup() {
Serial.begin(115200);
dataQ = xQueueCreate(16, sizeof(uint16_t));
xTaskCreatePinnedToCore(producer, "prod", 2048, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(consumer, "cons", 4096, NULL, 2, NULL, 0);
}
void loop() {}
9) Watchdog-Friendly Yields in Busy Tasks
Long loops should yield to avoid soft WDT resets and keep the system responsive.
#include <Arduino.h>
void heavyTask(void *pv) {
for (;;) {
// do chunks of work…
// ...
vTaskDelay(1); // yield to scheduler (~1 tick)
}
}
void setup() {
xTaskCreate(heavyTask, "heavy", 4096, NULL, 1, NULL);
}
void loop() {}
10) Minimal ESP-IDF Style (for reference)
If you’re on ESP-IDF directly:
// C (ESP-IDF)
void app_main(void) {
xTaskCreatePinnedToCore(taskA, "taskA", 2048, NULL, 3, NULL, 1);
xTaskCreatePinnedToCore(taskB, "taskB", 4096, NULL, 2, NULL, 0);
}
APIs are the same FreeRTOS ones; you’ll use ESP-IDF drivers (I2C, ADC, Wi-Fi) instead of Arduino wrappers.
Practical Stack/Perf Tips
- Start with 2 ~ 4 KB stack per task; raise if you see resets. Use
uxTaskGetStackHighWaterMark(NULL)
to check headroom.
- Prefer task notifications over queues for single-bit triggers; they’re faster and lighter.
- Keep ISRs tiny; do work in tasks.
- Use
vTaskDelayUntil
for fixed-rate loops (control systems).
- Group readiness with Event Groups; single readiness with binary semaphores.
Real-World Use Cases on ESP32
- Home Automation: Sensor monitoring + Wi-Fi communication + relay control.
- Industrial IoT: Data acquisition + edge processing + cloud integration.
- Wearables: Health data collection + Bluetooth communication.
FreeRTOS turns your ESP32 into a powerful multitasking device capable of handling complex, real-time applications. Its lightweight nature, multitasking support, and rich feature set make it perfect for IoT, robotics, and industrial projects.
By starting with simple tasks like LED blinking, you can gradually build more complex systems involving sensors, communication, and user interfaces; all running smoothly on FreeRTOS.
Bibliography
- FreeRTOS Official Documentation– Reference for API usage and concepts.
- Espressif ESP-IDF Programming Guide– For ESP32-specific FreeRTOS features.
- Arduino-ESP32 Core– Arduino core for ESP32 with FreeRTOS integration.
- Richard Barry, Mastering the FreeRTOS Real-Time Kernel, Real Time Engineers Ltd.