DEV Community

Rajvardhan Patil
Rajvardhan Patil

Posted 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 One shared space for kernel and app
Scheduler Complex, tuned for fairness Simple, priority-based, predictable
Boot and footprint Seconds, huge Milliseconds, tiny

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. The rule is simple: the highest-priority READY task always runs. If a more important task becomes ready, the current one is preempted immediately.

What happens between tasks of the same priority differs a bit:

  • FreeRTOS uses round-robin time slicing (driven by the tick interrupt), if enabled.
  • Zephyr offers optional time slicing too, and also supports earliest deadline first (EDF) scheduling among equal-priority threads, plus cooperative threads that are never preempted by other threads.
    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) can round down to zero ticks.

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), heavier tooling, and on some boards including the ESP32, the vendor's own SDK still has more complete Wi-Fi and peripheral support.

The OS concepts, head to head

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, still catching up on some features

Let's write some code on an ESP32

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)
{
    xTaskCreate(blink_task,   "blink",   2048, NULL, 5, NULL);
    xTaskCreate(message_task, "message", 2048, NULL, 5, NULL);
}
Enter fullscreen mode Exit fullscreen mode

Note that vTaskDelay moves the task to the BLOCKED state, so the scheduler can run someone else. That's the opposite of Arduino's delay(), which freezes everything. 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 1024
#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;
    }
    gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);

    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/procpu .
west flash
Enter fullscreen mode Exit fullscreen mode

Two honest notes: board names change between Zephyr versions (run west boards | grep esp32 to check yours), and if your board's devicetree has no led0 alias, you'll need a small devicetree overlay pointing it at the right pin. Notice also that 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));
    xTaskCreate(producer_task, "producer", 2048, NULL, 5, NULL);
    xTaskCreate(consumer_task, "consumer", 2048, NULL, 5, NULL);
}
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, 1024, producer, NULL, NULL, NULL, 5, 0, 0);
K_THREAD_DEFINE(cons_id, 1024, 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.

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! 🚀


Top comments (0)