DEV Community

Rajvardhan Patil
Rajvardhan Patil

Posted on Edited on

FreeRTOS vs Zephyr: Which RTOS Should You Use for Your IoT Project?

If you've studied operating systems, you know the classic ideas: processes, scheduling, context switches, semaphores, deadlocks. They usually live in textbooks about Linux and Windows. But there's a whole other world where those same ideas run on a chip with a few hundred kilobytes of RAM, controlling a drone, a heart-rate monitor or a smart light bulb.

That world runs on a real-time operating system (RTOS), and the two names you'll meet most often are FreeRTOS and Zephyr. This post compares them the way an OS course would: how they schedule tasks, manage memory, handle communication and deal with the problems that come with concurrency. At the end there's a decision guide and a small quiz.

An IoT OS is a different kind of OS

A desktop OS like Linux or Windows tries to be fast on average and fair to every program. An RTOS has a different job: predictability. When a sensor interrupt fires, the response must happen within a known time limit, every time.

There are two flavors of "real-time":

  • Hard real-time: missing a deadline is a failure (airbag controller, motor control).
  • Soft real-time: missing a deadline hurts quality but isn't catastrophic (audio streaming, a sensor dashboard). The environment is also very different from a PC:
General-purpose OS (Linux, Windows) RTOS (FreeRTOS, Zephyr)
Goal Throughput and fairness Determinism and low latency
Memory Virtual memory, MMU, gigabytes Usually no virtual memory, kilobytes
Address space Separate for each process Usually one shared space for kernel and app
Scheduler Complex, tuned for fairness Simple, priority-based, predictable
Boot and footprint Typically seconds, large Often milliseconds, small

That "one shared address space" row matters. On most microcontrollers there's no MMU doing per-process protection, so the kernel and your application are compiled into a single binary. A bug in one task can overwrite another task's memory. Keep that in mind, because it shapes everything below.

The core ideas inside an RTOS

Tasks (threads) and their states

In RTOS language, the unit of scheduling is a task (FreeRTOS) or a thread (Zephyr). Each one gets its own stack and a small control block that stores its state: the TCB (Task Control Block) in FreeRTOS, a k_thread structure in Zephyr.

A task is always in one of a few states:

          scheduler picks
 READY ─────────────────────► RUNNING
   ▲  ◄───────────────────────   │
   │      preempted / yields     │
   │                             │ vTaskDelay(), waits on queue/semaphore
   │ delay over / data arrives   ▼
   └───────────────────────── BLOCKED
Enter fullscreen mode Exit fullscreen mode

FreeRTOS also has a Suspended state for tasks you pause manually. Blocked tasks use no CPU time, and that is how an RTOS stays efficient.

Scheduling

Both kernels use priority-based preemptive scheduling. With preemption enabled, the rule is simple: the highest-priority READY task runs, and if a more important task becomes ready, the current one is preempted immediately.

Two assumptions are worth stating, because both kernels let you change them:

  • FreeRTOS preemption is controlled by configUSE_PREEMPTION. With it turned off, the kernel switches tasks only when a task blocks or yields. ESP-IDF runs with preemption on.
  • Zephyr cooperative threads (negative priority numbers) are never preempted by other threads. They only give up the CPU when they block or yield. They can still be interrupted by ISRs.
    What happens between tasks of the same priority differs a bit:

  • FreeRTOS uses round-robin time slicing (driven by the tick interrupt) when configUSE_TIME_SLICING is enabled.

  • Zephyr offers optional time slicing (CONFIG_TIMESLICING and CONFIG_TIMESLICE_SIZE). It can also schedule by earliest deadline first (EDF) among equal-priority threads when CONFIG_SCHED_DEADLINE is enabled.
    Here's a classic gotcha: the priority numbers point in opposite directions.

  • FreeRTOS: a higher number means higher priority.

  • Zephyr: a lower number means higher priority. Negative numbers are cooperative threads.
    Mixing this up is a very easy way to build a system that works backwards.

Context switching

When the scheduler changes tasks, the kernel saves the CPU registers and stack pointer of the old task into its control block, then restores the new task's saved context. This is the context switch, and it's the main overhead of multitasking. RTOS kernels make it extremely fast (on Cortex-M chips it often happens in a low-priority interrupt like PendSV), because jitter in switch time means jitter in your deadlines.

The tick interrupt, a periodic timer, is what lets the kernel wake delayed tasks and do time slicing. A heads-up for ESP32 users: ESP-IDF's default tick rate is 100 Hz (10 ms per tick), so very short delays like pdMS_TO_TICKS(5) round down to zero ticks. You can raise the rate with CONFIG_FREERTOS_HZ (for example to 1000) if you need 1 ms granularity.

FreeRTOS in a nutshell

FreeRTOS was created by Richard Barry in the early 2000s. Amazon took over stewardship in 2017, and it's released under the permissive MIT license.

Think of it as a small, focused kernel: tasks, queues, semaphores, mutexes and software timers, and not much more. Networking, file systems and cloud connectors are add-on libraries or part of your chip vendor's SDK.

Strengths: tiny and easy to read, runs on a huge range of chips, huge community. And on the ESP32 it's already there, because Espressif's ESP-IDF is built on FreeRTOS (with SMP support for the ESP32's two cores).

Weaknesses: you assemble a lot yourself, and since every vendor wraps it differently, code isn't always portable across chip families.

Zephyr in a nutshell

Zephyr started at Wind River and is now a Linux Foundation project under the Apache 2.0 license. It's closer to a complete platform: kernel, drivers, networking (Bluetooth LE, Thread, MQTT, CoAP), the MCUboot bootloader, power management and a unified build system.

It borrows ideas from the Linux world. Kconfig switches features on and off, devicetree describes the hardware, and you build with CMake and a tool called west.

Strengths: one consistent programming model across hundreds of boards, lots built in, strong backing from chip vendors.

Weaknesses: a steep learning curve (devicetree and Kconfig take time) and heavier tooling. On the ESP32, Espressif contributes to Zephyr directly and Wi-Fi is supported, but ESP-IDF is still the path with the most complete feature coverage, so check that the peripherals you need are supported before committing.

The OS concepts, head to head

A fair-comparison note: FreeRTOS and Zephyr aren't the same kind of product. FreeRTOS is a small kernel plus optional libraries and vendor integrations. Zephyr is an integrated embedded OS with a driver model, configuration system and networking stacks. Comparing them feature for feature, or by kernel size alone, isn't apples-to-apples.

Memory management

No virtual memory means no paging and no page faults, which is a good thing here because page faults add unpredictable delays. Instead, memory is managed more directly:

  • Static allocation: everything is fixed at compile time. FreeRTOS offers xTaskCreateStatic, and Zephyr's K_THREAD_DEFINE reserves a thread's stack at build time. It's the most predictable approach and avoids fragmentation.
  • Dynamic allocation: FreeRTOS ships several heap schemes (heap_1 to heap_5), from a simple allocate-only heap to heap_4, which merges freed blocks to reduce fragmentation. Zephyr provides k_heap and memory slabs, which hand out fixed-size blocks and so can't fragment.
  • Memory protection: optional in both. FreeRTOS has MPU-enabled ports for some Cortex-M chips, and Zephyr has a userspace feature that uses an MPU or MMU to isolate threads. Neither gives you Linux-style process isolation by default. ### Communication between tasks (IPC)

Tasks need to pass data safely. Both kernels give you the classics:

  • FreeRTOS: queues, semaphores, mutexes, event groups and lightweight task notifications.
  • Zephyr: message queues, FIFOs/LIFOs, pipes, mailboxes, events, semaphores and mutexes. Under the hood this is the textbook producer-consumer problem, and a queue solves it neatly: the consumer sleeps in the BLOCKED state until data arrives, and no CPU is wasted polling.

Synchronization and priority inversion

Anytime two tasks share data, you need protection against race conditions, usually a mutex. But real-time systems add a nasty twist: priority inversion.

Picture three tasks. A low-priority task takes a mutex. A high-priority task then needs that mutex and blocks. Now a medium-priority task becomes ready and preempts the low one, so the low-priority task can't finish and release the mutex, and the high-priority task is stuck behind a medium one. This exact bug caused repeated system resets on NASA's Mars Pathfinder in 1997, and engineers fixed it by enabling priority inheritance (the low-priority task temporarily inherits the high priority while it holds the lock).

Both kernels' mutexes support priority inheritance. In FreeRTOS, use a mutex and not a binary semaphore for protecting shared resources, because binary semaphores don't do inheritance.

Interrupts

Hardware interrupts always beat tasks. The usual pattern mirrors a "top half / bottom half" design: keep the interrupt handler (ISR) tiny, then signal a task to do the heavy work. FreeRTOS has special ...FromISR() functions that are safe to call inside an ISR, and Zephyr has ISR-safe APIs plus work queues for deferring work.

Side-by-side comparison

FreeRTOS Zephyr
What it is Small real-time kernel plus optional libraries Full RTOS platform (kernel, drivers, stacks, tooling)
License MIT Apache 2.0
Governance Maintained under Amazon Linux Foundation project
Scheduler Preemptive, fixed priority, optional time slicing Preemptive and cooperative threads, optional time slicing and EDF
Priority direction Higher number = higher priority Lower number = higher priority
Memory model Static or heap_1–heap_5, optional MPU Static, k_heap, memory slabs, optional userspace
IPC Queues, semaphores, event groups, notifications Message queues, FIFOs, pipes, mailboxes, events
Priority inheritance Yes, on mutexes Yes, on mutexes
Built-in networking and BLE Via add-on libraries and vendor SDKs Built into the project
Learning curve Gentle Steep at the start
Hardware portability Depends on the vendor SDK Strong, through devicetree and a shared driver model
Build system Your SDK's (ESP-IDF, STM32Cube, etc.) CMake + west + Kconfig
ESP32 experience Native, through ESP-IDF Supported; ESP-IDF still has the most complete feature coverage

Let's write some code on an ESP32

A few things to keep in mind: the exact board target, the led0 alias and the stack sizes below depend on your hardware, your Zephyr version and your ESP-IDF version. Treat the examples as templates and adjust them for your setup.

Demo 1: Two tasks (scheduling)

Two tasks: one blinks an LED every half second, the other prints a message every two seconds.

FreeRTOS (ESP-IDF):

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define LED_PIN GPIO_NUM_2   // Onboard LED on many ESP32 dev boards
static const char *TAG = "demo";

void blink_task(void *pvParameters)
{
    gpio_reset_pin(LED_PIN);
    gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT);

    while (1) {
        gpio_set_level(LED_PIN, 1);
        vTaskDelay(pdMS_TO_TICKS(500));
        gpio_set_level(LED_PIN, 0);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void message_task(void *pvParameters)
{
    int count = 0;
    while (1) {
        ESP_LOGI(TAG, "Hello from task #2, count = %d", count++);
        vTaskDelay(pdMS_TO_TICKS(2000));
    }
}

void app_main(void)
{
    // In ESP-IDF the stack size is in BYTES (in vanilla FreeRTOS it is in words).
    // Tasks that log need more stack than tasks that only toggle a pin.
    BaseType_t ok1 = xTaskCreate(blink_task,   "blink",   2048, NULL, 5, NULL);
    BaseType_t ok2 = xTaskCreate(message_task, "message", 3072, NULL, 5, NULL);

    if (ok1 != pdPASS || ok2 != pdPASS) {
        ESP_LOGE(TAG, "Task creation failed (out of memory?)");
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that vTaskDelay moves the task to the BLOCKED state, so the scheduler can run someone else. Compare that with a bare-metal Arduino sketch on a classic board like the Uno, where delay() busy-waits and nothing else can run. (On the ESP32, the Arduino core sits on top of FreeRTOS and delay() ends up blocking only the calling task, so the difference depends on the platform.) Build and flash with idf.py build flash monitor.

Zephyr:

#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/sys/printk.h>

#define STACK_SIZE 2048
#define PRIORITY   5

static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

void blink_thread(void *p1, void *p2, void *p3)
{
    if (!gpio_is_ready_dt(&led)) {
        return;
    }
    if (gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE) < 0) {
        return;
    }

    while (1) {
        gpio_pin_toggle_dt(&led);
        k_msleep(500);
    }
}

void message_thread(void *p1, void *p2, void *p3)
{
    int count = 0;
    while (1) {
        printk("Hello from thread #2, count = %d\n", count++);
        k_msleep(2000);
    }
}

K_THREAD_DEFINE(blink_id,   STACK_SIZE, blink_thread,   NULL, NULL, NULL, PRIORITY, 0, 0);
K_THREAD_DEFINE(message_id, STACK_SIZE, message_thread, NULL, NULL, NULL, PRIORITY, 0, 0);
Enter fullscreen mode Exit fullscreen mode

prj.conf needs CONFIG_GPIO=y. Then:

west build -b esp32_devkitc_wroom/esp32/procpu .
west flash
Enter fullscreen mode Exit fullscreen mode

A few honest notes:

  • Board names change between Zephyr versions. Zephyr's board qualifier has the form board/soc/core, which is why the ESP32 target includes esp32 before procpu. Newer releases also list an esp32_devkitc/esp32/procpu target. Run west boards | grep esp32 and check the board page in the Zephyr docs for your version.
  • If your board's devicetree has no led0 alias, you'll need a small devicetree overlay pointing it at the right pin.
  • The stack size here is a starting point. Required stack depends on your hardware and what your threads call.
  • K_THREAD_DEFINE reserves the thread's stack at compile time, which is the static allocation idea from earlier. ### Demo 2: Producer and consumer (IPC)

Now the classic OS problem. A producer task generates fake sensor readings and sends them through a queue. A consumer task sleeps until a reading arrives.

FreeRTOS:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"

static const char *TAG = "queue-demo";
static QueueHandle_t sensor_q;

void producer_task(void *arg)
{
    int reading = 0;
    while (1) {
        reading = (reading + 7) % 100;            // fake sensor value
        xQueueSend(sensor_q, &reading, portMAX_DELAY);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void consumer_task(void *arg)
{
    int value;
    while (1) {
        // Blocks (uses no CPU) until data arrives
        if (xQueueReceive(sensor_q, &value, portMAX_DELAY) == pdTRUE) {
            ESP_LOGI(TAG, "Got reading: %d", value);
        }
    }
}

void app_main(void)
{
    sensor_q = xQueueCreate(8, sizeof(int));
    if (sensor_q == NULL) {
        ESP_LOGE(TAG, "Queue creation failed");
        return;
    }

    BaseType_t ok1 = xTaskCreate(producer_task, "producer", 2048, NULL, 5, NULL);
    BaseType_t ok2 = xTaskCreate(consumer_task, "consumer", 3072, NULL, 5, NULL);
    if (ok1 != pdPASS || ok2 != pdPASS) {
        ESP_LOGE(TAG, "Task creation failed (out of memory?)");
    }
}
Enter fullscreen mode Exit fullscreen mode

Zephyr:

#include <zephyr/kernel.h>
#include <zephyr/sys/printk.h>

K_MSGQ_DEFINE(sensor_q, sizeof(int), 8, 4);   // item size, max items, alignment

void producer(void *p1, void *p2, void *p3)
{
    int reading = 0;
    while (1) {
        reading = (reading + 7) % 100;
        k_msgq_put(&sensor_q, &reading, K_FOREVER);
        k_msleep(1000);
    }
}

void consumer(void *p1, void *p2, void *p3)
{
    int value;
    while (1) {
        k_msgq_get(&sensor_q, &value, K_FOREVER);   // sleeps until data arrives
        printk("Got reading: %d\n", value);
    }
}

K_THREAD_DEFINE(prod_id, 2048, producer, NULL, NULL, NULL, 5, 0, 0);
K_THREAD_DEFINE(cons_id, 2048, consumer, NULL, NULL, NULL, 5, 0, 0);
Enter fullscreen mode Exit fullscreen mode

Same idea, different names. In both, the consumer spends almost all its life in the BLOCKED state, and the kernel handles the synchronization for you, so there's no hand-written locking and no race condition on the data. Notice the FreeRTOS version checks that the queue and tasks were actually created. On a tiny chip, running out of memory is a real failure mode, not a theoretical one. (Zephyr's K_MSGQ_DEFINE and K_THREAD_DEFINE allocate at build time, so those failures show up as linker errors instead of runtime ones.)

Classic OS problems you'll hit on a tiny chip

  • Stack overflow: every task has its own small stack. If a task randomly crashes or resets the board, increase its stack size first. With no memory protection, overflows can corrupt other tasks silently.
  • Starvation: a high-priority task with a while(1) loop and no delay or blocking call never lets lower-priority tasks run. Always block, sleep or wait on something.
  • Race conditions: two tasks touching the same variable can corrupt it. Use a mutex or pass data through a queue.
  • Priority inversion: covered above. Use mutexes with priority inheritance.
  • Deadlock: task A holds lock 1 and waits for lock 2, while task B holds lock 2 and waits for lock 1. Fix it by always taking locks in the same order and by using timeouts instead of waiting forever. ## So which one should you pick?

Pick FreeRTOS if:

  • You're a beginner or building your first RTOS project
  • You're on an ESP32 and happy with ESP-IDF
  • You want something small and simple, with the most tutorials
  • You're integrating with AWS IoT or a vendor SDK that already assumes FreeRTOS
    Pick Zephyr if:

  • You want one codebase that moves across many boards and vendors

  • Your product needs Bluetooth LE, Thread, secure updates and power management working together

  • You're building something long-lived and want strong governance and a clear roadmap

  • You don't mind investing time upfront in the tooling
    Rule of thumb: learning or prototyping on an ESP32? Start with FreeRTOS. Building a multi-board product that has to last for years? Look hard at Zephyr.

Other options exist: RIOT OS targets very constrained devices, Contiki-NG is popular in low-power network research, and Embedded Linux is the right pick when your device is more like a small computer (a Raspberry Pi) than a microcontroller.

Test yourself

  1. A task at the highest priority runs while(1) {} with no delay or blocking call. What happens to the lower-priority tasks?
  2. A low-priority task holds a mutex, a high-priority task is waiting for it, and a medium-priority task keeps running. What is this problem called, and what's the standard fix?
  3. Why do most microcontroller RTOS kernels skip virtual memory?

    Answers

  4. They starve and never get CPU time (on the ESP32 this usually also triggers the watchdog).

  5. Priority inversion, fixed with priority inheritance.

  6. There's usually no MMU, and page faults would add unpredictable delays, which breaks the determinism an RTOS exists to provide.

    Wrapping up

FreeRTOS and Zephyr are built on the same OS fundamentals you study in class: tasks, scheduling, context switches, IPC, synchronization. They just apply them under tight limits on memory and time. FreeRTOS wins on simplicity and community. Zephyr wins on portability and its all-in-one platform.

The best way to decide is to run the demos above and see which workflow feels better to you. If you've used either one on a real project, tell me in the comments which you picked and what tripped you up. And if this helped, share it with a friend who's about to start embedded systems.

Happy hacking! 🚀

References

Top comments (0)