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
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_SLICINGis enabled.Zephyr offers optional time slicing (
CONFIG_TIMESLICINGandCONFIG_TIMESLICE_SIZE). It can also schedule by earliest deadline first (EDF) among equal-priority threads whenCONFIG_SCHED_DEADLINEis 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'sK_THREAD_DEFINEreserves a thread's stack at build time. It's the most predictable approach and avoids fragmentation. -
Dynamic allocation: FreeRTOS ships several heap schemes (
heap_1toheap_5), from a simple allocate-only heap toheap_4, which merges freed blocks to reduce fragmentation. Zephyr providesk_heapand 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?)");
}
}
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);
prj.conf needs CONFIG_GPIO=y. Then:
west build -b esp32_devkitc_wroom/esp32/procpu .
west flash
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 includesesp32beforeprocpu. Newer releases also list anesp32_devkitc/esp32/procputarget. Runwest boards | grep esp32and check the board page in the Zephyr docs for your version. - If your board's devicetree has no
led0alias, 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_DEFINEreserves 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?)");
}
}
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);
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
- A task at the highest priority runs
while(1) {}with no delay or blocking call. What happens to the lower-priority tasks? - 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?
-
Why do most microcontroller RTOS kernels skip virtual memory?
Answers
They starve and never get CPU time (on the ESP32 this usually also triggers the watchdog).
Priority inversion, fixed with priority inheritance.
-
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)