FreeRTOS is the real-time operating system that ships on more devices than any other. It runs on hundreds of microcontroller architectures, is maintained by Amazon Web Services under the MIT licence, and underpins IoT products from consumer electronics to industrial control systems. When manufacturers describe an embedded product as “RTOS-based” without specifying which RTOS, there is a reasonable probability it is FreeRTOS.

Understanding FreeRTOS matters not because you need to memorise its internals but because it provides a set of primitives — tasks, queues, semaphores, timers — that are directly applicable to any concurrent embedded application. The same patterns work whether you’re programming an ESP32 for a prototype or an STM32 for a production device.

This guide covers the core concepts with practical code examples, explains how the FreeRTOS scheduler works at a level useful for real-world debugging, and covers the FreeRTOS Plus libraries for TLS, MQTT, and OTA that form the IoT connectivity stack.

Why a RTOS on a Microcontroller

A bare-metal embedded application typically runs as an infinite loop:

int main(void) {
    init_hardware();
    
    while (1) {
        read_sensors();
        process_data();
        update_outputs();
        delay_ms(100);
    }
}

This works for simple applications but breaks down when you need multiple activities with different timing requirements. Reading a sensor every 10ms while also listening for UART commands, maintaining a Wi-Fi connection, and blinking an LED status indicator all have different timing demands. Fitting them into a single loop becomes an exercise in carefully calculating execution times, and any blocking operation (waiting for a network response, waiting for sensor data) delays everything else.

An RTOS adds a scheduler that manages multiple tasks, each with its own stack and execution context, switching between them so rapidly that they appear to run concurrently. The core abstraction is the task — a function that runs as if it owns the processor.

FreeRTOS Core Concepts

Tasks

A FreeRTOS task is a C function with a specific signature:

void vTaskName(void *pvParameters) {
    // Task setup code runs once
    
    for (;;) {
        // Task main loop
        // Every task must either block or yield; never busy-wait
    }
    
    // Tasks should never return, but if they do:
    vTaskDelete(NULL);
}

Tasks are created with xTaskCreate():

#include "FreeRTOS.h"
#include "task.h"

#define STACK_SIZE_WORDS 256
#define TASK_PRIORITY    2

void vSensorTask(void *pvParameters);
void vCommunicationTask(void *pvParameters);

int main(void) {
    init_hardware();
    
    xTaskCreate(
        vSensorTask,         // Function
        "SensorTask",        // Debug name
        STACK_SIZE_WORDS,    // Stack size in words (not bytes)
        NULL,                // Parameters
        TASK_PRIORITY,       // Priority (higher = runs sooner)
        NULL                 // Handle (NULL if not needed)
    );
    
    xTaskCreate(
        vCommunicationTask,
        "CommTask",
        512,
        NULL,
        1,
        NULL
    );
    
    vTaskStartScheduler();  // Start the RTOS scheduler; never returns
    
    // Should never reach here
    for (;;);
}

The Scheduler

FreeRTOS uses a preemptive priority-based scheduler by default. The highest-priority ready task always runs. When multiple tasks have the same priority, they share the processor in a round-robin fashion.

A task’s state at any point is one of:

  • Running: Currently executing on the CPU
  • Ready: Able to run but waiting for a lower-priority task to yield
  • Blocked: Waiting for an event (time delay, queue message, semaphore)
  • Suspended: Explicitly suspended by vTaskSuspend()

The key design principle: tasks should spend most of their time in the Blocked state, waking only when they have work to do. A task that never blocks is a task that prevents lower-priority tasks from running, which is almost always a bug.

Task Delays

void vSensorTask(void *pvParameters) {
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xFrequency = pdMS_TO_TICKS(100);  // 100ms
    
    for (;;) {
        // Read sensor and process
        float temperature = read_temperature_sensor();
        process_temperature(temperature);
        
        // Block until 100ms has elapsed from xLastWakeTime
        // This handles execution time variations automatically
        vTaskDelayUntil(&xLastWakeTime, xFrequency);
    }
}

vTaskDelayUntil() is preferable to vTaskDelay() for periodic tasks because it compensates for the time the task itself takes to execute, maintaining precise timing across iterations.

Inter-Task Communication

Queues

Queues are the primary mechanism for passing data between tasks. A queue holds a fixed number of items of a fixed size:

#include "FreeRTOS.h"
#include "queue.h"

typedef struct {
    float temperature;
    float humidity;
    uint32_t timestamp;
} SensorReading_t;

QueueHandle_t xSensorQueue;

// In main(), before scheduler starts:
xSensorQueue = xQueueCreate(10, sizeof(SensorReading_t));

// Producer task (sensor reading)
void vSensorTask(void *pvParameters) {
    SensorReading_t reading;
    
    for (;;) {
        reading.temperature = read_temperature();
        reading.humidity = read_humidity();
        reading.timestamp = xTaskGetTickCount();
        
        // Send to queue; wait up to 10ms if queue is full
        if (xQueueSend(xSensorQueue, &reading, pdMS_TO_TICKS(10)) != pdPASS) {
            // Queue full — log error or increment counter
        }
        
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

// Consumer task (processing / transmission)
void vProcessingTask(void *pvParameters) {
    SensorReading_t reading;
    
    for (;;) {
        // Wait indefinitely for a reading
        if (xQueueReceive(xSensorQueue, &reading, portMAX_DELAY) == pdPASS) {
            transmit_to_cloud(reading);
        }
    }
}

Queues are thread-safe by design — the queue API handles the critical section internally. Never pass raw pointers to shared data between tasks without a queue or mutex protecting access.

Semaphores and Mutexes

A binary semaphore signals between tasks without passing data:

#include "semphr.h"

SemaphoreHandle_t xButtonSemaphore;

// In main():
xButtonSemaphore = xSemaphoreCreateBinary();

// GPIO interrupt handler (runs in ISR context)
void EXTI_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // Give semaphore from ISR — wake the button task
    xSemaphoreGiveFromISR(xButtonSemaphore, &xHigherPriorityTaskWoken);
    
    // Yield to the woken task if it has higher priority
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// Task that responds to button press
void vButtonTask(void *pvParameters) {
    for (;;) {
        // Block until semaphore is given (button pressed)
        if (xSemaphoreTake(xButtonSemaphore, portMAX_DELAY) == pdPASS) {
            handle_button_press();
        }
    }
}

For protecting shared resources (not just signalling), use a mutex instead:

SemaphoreHandle_t xSPIMutex;

// In main():
xSPIMutex = xSemaphoreCreateMutex();

// Task A accessing SPI bus
void vTaskA(void *pvParameters) {
    for (;;) {
        if (xSemaphoreTake(xSPIMutex, pdMS_TO_TICKS(100)) == pdPASS) {
            // Safe to access SPI bus
            spi_write(data, length);
            
            xSemaphoreGive(xSPIMutex);
        }
    }
}

FreeRTOS mutexes implement priority inheritance, which prevents the classic priority inversion problem where a low-priority task holding a mutex blocks a high-priority task indefinitely.

Software Timers

FreeRTOS software timers execute callbacks from the timer task, useful for one-shot or periodic actions that don’t warrant a full task:

#include "timers.h"

TimerHandle_t xLedTimer;

void vLedTimerCallback(TimerHandle_t xTimer) {
    toggle_led(LED_STATUS);
}

// In main():
xLedTimer = xTimerCreate(
    "LedTimer",
    pdMS_TO_TICKS(500),  // Period
    pdTRUE,              // Auto-reload (periodic)
    NULL,
    vLedTimerCallback
);
xTimerStart(xLedTimer, 0);

Software timers run in the context of the FreeRTOS timer task. Keep callbacks short and non-blocking. For operations that take time (network calls, flash writes), use the timer only to set a flag or post to a queue, then handle the work in a dedicated task.

FreeRTOS on ESP32

The ESP-IDF (Espressif IoT Development Framework) ships FreeRTOS as its base and adds ESP-specific extensions. Most FreeRTOS APIs work identically, with additions for the dual-core ESP32:

// Pin a task to a specific core on ESP32
xTaskCreatePinnedToCore(
    vNetworkTask,
    "NetworkTask",
    4096,
    NULL,
    5,
    NULL,
    0   // Core 0 (PRO_CPU); 1 = APP_CPU
);

// Standard xTaskCreate pins to the calling core by default
xTaskCreate(
    vSensorTask,
    "SensorTask", 
    2048,
    NULL,
    3,
    NULL
);

The Wi-Fi and Bluetooth stacks in ESP-IDF run on Core 0 by default. Pinning your time-sensitive tasks to Core 1 avoids interference from the network stack.

FreeRTOS Plus: Connectivity Libraries

FreeRTOS Plus is the suite of libraries for IoT connectivity. The main components relevant to edge devices:

coreMQTT

The FreeRTOS MQTT client, designed for resource-constrained devices. It’s transport-layer agnostic — you provide the socket implementation, coreMQTT handles the MQTT protocol:

#include "core_mqtt.h"

// Define network context (transport layer)
NetworkContext_t xNetworkContext = {
    .pTlsContext = &xTlsContext
};

// Transport interface
TransportInterface_t xTransport = {
    .pNetworkContext = &xNetworkContext,
    .send = TLS_FreeRTOS_send,
    .recv = TLS_FreeRTOS_recv
};

// MQTT context
MQTTContext_t xMQTTContext;
MQTTFixedBuffer_t xBuffer = {
    .pBuffer = ucBuffer,
    .size = sizeof(ucBuffer)
};

MQTT_Init(&xMQTTContext, &xTransport, prvGetTimeMs, prvEventCallback, &xBuffer);

// Connect
MQTTConnectInfo_t xConnectInfo = {
    .cleanSession = true,
    .pClientIdentifier = MQTT_CLIENT_ID,
    .clientIdentifierLength = strlen(MQTT_CLIENT_ID),
    .keepAliveSeconds = 60
};

bool sessionPresent;
MQTT_Connect(&xMQTTContext, &xConnectInfo, NULL, CONNACK_RECV_TIMEOUT_MS, &sessionPresent);

// Publish
MQTTPublishInfo_t xPublishInfo = {
    .qos = MQTTQoS1,
    .pTopicName = MQTT_TOPIC,
    .topicNameLength = strlen(MQTT_TOPIC),
    .pPayload = payload,
    .payloadLength = payloadLength
};

MQTT_Publish(&xMQTTContext, &xPublishInfo, MQTT_GetPacketId(&xMQTTContext));

coreHTTP

For devices that report to HTTP endpoints (REST APIs, AWS IoT Core data endpoint), coreHTTP provides a lightweight HTTP/1.1 client:

#include "core_http_client.h"

HTTPClient_InitSendHeaders(&requestInfo, requestHeaders.pBuffer, requestHeaders.bufferLen);
HTTPClient_AddHeader(&requestInfo, "Content-Type", strlen("Content-Type"),
                     "application/json", strlen("application/json"));

HTTPClient_Send(&transportInterface, &requestInfo,
                (const uint8_t *)payload, payloadLength, &response, 0);

AWS IoT Device Shadow

For devices integrated with AWS IoT Core, the Device Shadow library manages state synchronisation — the “desired” vs “reported” state model that lets cloud applications update device configuration without real-time connectivity:

#include "shadow.h"

// Publish reported state
Shadow_AssembleTopicString(ShadowTopicStringTypeUpdate, THING_NAME,
                           strlen(THING_NAME), topicBuffer, sizeof(topicBuffer),
                           &topicLength);

// The shadow document
const char *shadowDoc = "{\"state\":{\"reported\":{\"temperature\":23.5,\"connected\":true}}}";

MQTT_Publish(&xMQTTContext, &xPublishInfo, MQTT_GetPacketId(&xMQTTContext));

Memory Management

FreeRTOS provides five heap implementations (heap_1 through heap_5). For most IoT applications, heap_4 (coalescing free-block allocator) is the right choice — it supports both allocation and freeing, handles fragmentation better than heap_2, and is simpler than heap_5.

// FreeRTOSConfig.h
#define configTOTAL_HEAP_SIZE   (32 * 1024)  // 32KB heap

Monitor heap usage during development:

void vMemoryMonitorTask(void *pvParameters) {
    for (;;) {
        size_t freeHeap = xPortGetFreeHeapSize();
        size_t minEverFree = xPortGetMinimumEverFreeHeapSize();
        
        printf("Free heap: %u bytes, Min ever: %u bytes\n", 
               freeHeap, minEverFree);
        
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

A decreasing minimum-ever-free value over time indicates a memory leak. On FreeRTOS, this is almost always caused by creating tasks or queues in a loop without deleting them, or by malloc/free imbalances in task code.

Stack Overflow Detection

Enable stack overflow checking in FreeRTOSConfig.h:

#define configCHECK_FOR_STACK_OVERFLOW  2  // Method 2: more thorough check

Implement the hook:

void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
    printf("STACK OVERFLOW in task: %s\n", pcTaskName);
    // Halt or reset
    configASSERT(0);
}

If you’re seeing unexpected resets during development, a stack overflow is a common cause. Enable this hook early — it makes the problem immediately diagnosable rather than manifesting as mysterious crashes.

FreeRTOS vs. Zephyr in 2026

The two most actively maintained open-source RTOSes for microcontrollers are FreeRTOS and Zephyr. The practical distinction:

FreeRTOS: Smaller core, simpler API, broader microcontroller support, extremely well-documented, integrates cleanly with ESP-IDF and STM32 HAL. Lower learning curve. AWS support.

Zephyr: More complete POSIX-like environment, broader built-in peripheral driver support, better for complex applications with many subsystems, stronger community for safety-certified variants. Steeper initial setup.

For a new IoT project on ESP32, ESP-IDF with FreeRTOS is the path of least resistance and has the most examples, community support, and ready-made component libraries. For industrial applications where you need a safety case or a broad BSP ecosystem, Zephyr has advantages. For most prototypes and production IoT devices that aren’t safety-critical, FreeRTOS is the default that you’ll spend least time fighting.

Starting Points

  • FreeRTOS.org — Official documentation and API reference
  • ESP-IDF FreeRTOS documentation — ESP32-specific extensions
  • FreeRTOS GitHub — Source, examples, and coreMQTT/coreHTTP libraries
  • Mastering the FreeRTOS Real Time Kernel (Richard Barry) — Free PDF from FreeRTOS.org, the definitive reference

The FreeRTOS API takes a few hours to understand and several projects to internalize. The concepts — tasks, queues, semaphores — are the same concepts that appear in operating systems at every scale. Learning them on a microcontroller in C is one of the more transferable skills in embedded development.