RTOS Fundamentals: FreeRTOS vs Zephyr for Embedded Projects

Choosing an RTOS is one of those decisions that sticks with you for the entire life of a product. I've ported both FreeRTOS and Zephyr to custom boards over the last eight years, from Cortex-M0 sensors that run on a coin cell to Cortex-M7 gateways bridging CAN and LTE, and the trade-offs between these two kernels are practical, not theoretical. This article breaks down how each RTOS works under the hood, what development really feels like day-to-day, and how to make a defensible choice for your next embedded project. If you are still weighing whether you need an RTOS at all, start with Bare Metal vs RTOS: When Each Approach Makes Sense before you commit to a kernel.

Why Your Embedded Project Actually Needs an RTOS Kernel

In my experience, most teams don't adopt an RTOS because they want one — they adopt it because the super-loop finally breaks. A bare-metal main loop polling flags set by interrupts works beautifully until you have three things running concurrently: a 1 kHz sensor sampling task, a communication stack that can't block, and a low-priority housekeeping job that writes to flash. Suddenly you are hand-rolling state machines, disabling interrupts for critical sections, and debugging race conditions with a logic analyzer at 2 a.m.

The Breaking Point of the Super-Loop

The super-loop forces you to make everything cooperative. If your UART driver blocks for 5 ms while you wait for a response, your IMU sampling jitters. If you push more work into ISRs to avoid that jitter, your interrupt latency grows and you lose determinism where it matters most. An RTOS solves this by giving you preemptive scheduling and well-tested primitives for communication between contexts. You stop inventing your own scheduler and start using one that has been hammered on by thousands of products.

What an RTOS Kernel Actually Gives You

At its core, both FreeRTOS and Zephyr provide the same fundamentals: threads (called tasks in FreeRTOS), a scheduler that decides who runs next, mechanisms for threads to talk and synchronize, and a tick or timebase for delays and timeouts. They also give you a way to reason about priority, stack usage, and blocking. That last point is critical. In bare metal, every function that waits is a design problem. With an RTOS, blocking is explicit — a thread can call vTaskDelay() or k_sleep() and the kernel will run something else. I've found that this shift from polling to blocking alone cuts firmware complexity in half on projects with more than two real-time concerns. It also makes power management sane, because the idle thread becomes the single place to enter sleep, which both kernels support via tickless idle.

Neither kernel will save you from poor architecture, though. You still need to partition your system into tasks with clear responsibilities, bound their worst-case execution time, and decide what runs in interrupt context versus thread context. For a deeper look at that partitioning problem, Interrupt Handling Best Practices: Priority, Latency and ISR Design is a useful companion to this article.

FreeRTOS Inside Out: Kernel Primitives and Config Reality

FreeRTOS is intentionally small. The kernel itself is three or four C files: tasks.c, queue.c, list.c, and optionally timers.c and event_groups.c. Everything else — TCP/IP, filesystems, AWS IoT libraries — is layered on as separate libraries. That minimalism is why FreeRTOS runs on more than 40 architectures, from tiny Cortex-M0s to RISC-V and even older 8-bit parts. The official FreeRTOS Documentation is the definitive reference for configuration and API behavior.

The FreeRTOSConfig.h Contract

Every FreeRTOS project lives and dies by FreeRTOSConfig.h. This single header defines your system: configTICK_RATE_HZ (typically 1000 Hz for 1 ms resolution, though I often drop to 250 Hz on low-power products to reduce tick overhead), configMAX_PRIORITIES (keep this low — 5 to 7 distinct levels is plenty; more just wastes RAM in the ready lists), configUSE_PREEMPTION, configUSE_TICKLESS_IDLE, and critically your heap implementation. In my experience, heap_4.c with its first-fit and coalescence is the right default for most products. I avoid heap_3 (which just wraps malloc) and I only reach for heap_5 when I have non-contiguous RAM regions.

Memory management in FreeRTOS is explicit. By default, tasks, queues, and semaphores come from the FreeRTOS heap. You can switch to static allocation with configSUPPORT_STATIC_ALLOCATION, which I recommend for any safety-related firmware because it makes your worst-case RAM usage analyzable at link time. This topic deserves its own discussion — see Memory Management in Embedded C: Static Allocation, Pool and Arena Patterns for patterns that map cleanly to FreeRTOS static objects.

Tasks, Queues and Heap Management in Practice

A FreeRTOS task is just a C function with a stack you provide. Queues are the central IPC primitive and they copy data by value, which eliminates a whole class of use-after-free bugs. I lean heavily on queues and direct task notifications for ISR-to-task signaling. Here is a minimal, production-style example creating two tasks and a queue for sensor data:

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

#define SENSOR_STACK_WORDS  256
#define SENSOR_PRIORITY     3

static QueueHandle_t xSensorQueue;
static StackType_t xSensorStack[SENSOR_STACK_WORDS];
static StaticTask_t xSensorTCB;

static void vSensorTask(void *pvParameters) {
    int32_t lSample;
    for (;;) {
        lSample = read_accelerometer_blocking(); // blocks on DRDY via notification
        xQueueSend(xSensorQueue, &lSample, pdMS_TO_TICKS(10));
    }
}

static void vProcessorTask(void *pvParameters) {
    int32_t lReceived;
    for (;;) {
        if (xQueueReceive(xSensorQueue, &lReceived, portMAX_DELAY) == pdPASS) {
            process_sample(lReceived);
        }
    }
}

void app_create_tasks(void) {
    xSensorQueue = xQueueCreateStatic(16, sizeof(int32_t),
                                      ucQueueStorage, &xQueueBuffer);

    xTaskCreateStatic(vSensorTask, "sensor", SENSOR_STACK_WORDS,
                      NULL, SENSOR_PRIORITY, xSensorStack, &xSensorTCB);

    xTaskCreate(vProcessorTask, "proc", 512, NULL, 2, NULL);
    vTaskStartScheduler(); // never returns
}

A few practical lessons from this pattern: size your stacks with high-water mark checking (uxTaskGetStackHighWaterMark()) and leave 20% margin. Never call xQueueSend from an ISR — use xQueueSendFromISR and check pxHigherPriorityTaskWoken to request a yield. And keep queue lengths short; a queue depth of 16 is a buffer, not an architecture.

Zephyr Inside Out: Kconfig, Devicetree and the Device Model

Zephyr takes the opposite approach to FreeRTOS. Where FreeRTOS gives you a kernel and lets you bring your own drivers, Zephyr gives you a full operating system: kernel, device drivers, networking stack, filesystem, Bluetooth, USB, shell, and logging — all integrated behind Kconfig and devicetree. If FreeRTOS feels like building with Lego bricks you source yourself, Zephyr feels like getting a complete kit with an instruction manual. The Zephyr Project Documentation is essential reading because the build system itself is part of the OS.

Kconfig and Devicetree Are the Real Learning Curve

I've found that newcomers struggle not with Zephyr's kernel, but with its build configuration. Kconfig (prj.conf) selects features: CONFIG_HEAP_MEM_POOL_SIZE, CONFIG_BT=y, CONFIG_LOG=y. Devicetree (.dts and overlays) describes hardware: which UART is where, what pins the I2C sensor uses, how much RAM you have. The two systems interact. For example, enabling CONFIG_I2C=y does nothing useful until your devicetree actually declares an I2C node with status = "okay".

Once it clicks, this model is powerful. You stop writing board support packages by hand. Instead, you write a devicetree overlay for your custom board and reuse upstream drivers. On a recent nRF52840 design, I brought up a new sensor board in an afternoon by copying the nrf52840dk_nrf52840.overlay, changing three pin assignments, and letting Zephyr's existing driver handle the rest. That same bring-up in FreeRTOS would have meant porting or writing the I2C driver and integrating it with my build.

Zephyr's Thread and Work Queue Model

Zephyr threads are more configurable than FreeRTOS tasks. You define stack size, priority (with negative numbers being higher priority, and cooperative priorities above preemptive ones), and options at compile time. The work queue system is something I use constantly — it gives you a thread-backed deferred execution context without creating a thread per job.

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>

#define STACK_SIZE 1024
#define SENSOR_PRIO 5

K_THREAD_STACK_DEFINE(sensor_stack, STACK_SIZE);
static struct k_thread sensor_thread;
static struct k_sem sensor_sem;

K_MSGQ_DEFINE(sensor_msgq, sizeof(struct sensor_value), 16, 4);

void sensor_thread_entry(void *p1, void *p2, void *p3) {
    const struct device *dev = DEVICE_DT_GET(DT_NODELABEL(accel0));
    struct sensor_value val;

    if (!device_is_ready(dev)) {
        printk("sensor not ready\n");
        return;
    }

    while (1) {
        k_sem_take(&sensor_sem, K_FOREVER);
        sensor_sample_fetch(dev);
        sensor_channel_get(dev, SENSOR_CHAN_ACCEL_XYZ, &val);
        k_msgq_put(&sensor_msgq, &val, K_NO_WAIT);
    }
}

int main(void) {
    k_sem_init(&sensor_sem, 0, 1);

    k_thread_create(&sensor_thread, sensor_stack, STACK_SIZE,
                    sensor_thread_entry, NULL, NULL, NULL,
                    SENSOR_PRIO, 0, K_NO_WAIT);

    // Triggered from ISR or timer
    // k_sem_give(&sensor_sem);
    return 0;
}

Note the driver model: DEVICE_DT_GET(DT_NODELABEL(accel0)) returns a device pointer generated from devicetree, and device_is_ready() is your runtime check. In production, I keep a K_TIMER_DEFINE or GPIO callback that gives the semaphore, keeping the ISR short. The message queue k_msgq is analogous to a FreeRTOS queue but integrates with Zephyr's pollable objects — you can k_poll() on multiple queues, semaphores, and FIFOs simultaneously, which is cleaner than FreeRTOS queue sets.

How FreeRTOS and Zephyr Handle Scheduling, IPC and Synchronization

Both kernels are priority-based preemptive schedulers with round-robin for equal-priority threads, but the details affect real designs.

Scheduler Behaviour and Tick Configuration

FreeRTOS uses a fixed tick rate and a single ready list per priority. Context switch time on a Cortex-M4 at 80 MHz is typically 1-2 µs without FPU stacking, 3-4 µs with it. Zephyr offers the same tick-based scheduling but also supports tickless operation more seamlessly — when no timers are pending, it programs a one-shot hardware timer and sleeps until the next expiry. I've measured 30-40% lower idle current on Zephyr tickless versus FreeRTOS at 1000 Hz on the same STM32L4 board, simply because the kernel wakes less often.

Priority inversion is handled similarly: both offer priority inheritance mutexes (xSemaphoreCreateMutex in FreeRTOS, k_mutex in Zephyr). In my experience, you should use mutexes only for short critical sections protecting shared hardware or data, and use message passing for longer transactions. A common mistake I see is protecting a whole SPI transaction chain with a mutex when a dedicated driver thread with a message queue would be simpler and avoid holding a mutex for milliseconds.

IPC Choices That Shape Your Architecture

FreeRTOS centers on queues, semaphores, event groups, stream buffers, and direct task notifications. Task notifications are the fastest ISR-to-task signal — 45-60 cycles on Cortex-M — and I use them for high-rate interrupts. Event groups let you wait for any combination of bits, useful for synchronizing on multiple events like Wi-Fi connected + MQTT subscribed.

Zephyr provides a richer set: message queues, FIFOs (which pass pointers rather than copying, useful for larger buffers), LIFO, pipes, semaphores, mutexes, barriers, and poll. The k_fifo is particularly elegant for zero-copy passing of dynamically allocated buffers — the sender allocates, puts the pointer in the FIFO, the receiver processes and frees it. This pairs well with memory slabs (k_mem_slab) for deterministic allocation. If you have been juggling FreeRTOS queue copies of large structures, Zephyr's FIFO + slab pattern will feel like a relief.

For timing, both offer one-shot and periodic software timers that run in a dedicated timer thread/task. I've found Zephyr's k_timer with expiry and stop callbacks more predictable than FreeRTOS timers when you need to start/stop rapidly, because the API separates init from start.

Footprint, Toolchain and Hardware Porting on Cortex-M and RISC-V

Footprint is where the minimalist versus integrated philosophy shows up in the linker map.

What You'll Actually Ship in Flash and RAM

A minimal FreeRTOS kernel with one task and one queue on Cortex-M4 (GCC -Os) is roughly 4-6 KB flash and under 1 KB RAM for kernel structures plus your task stacks. I've shipped products where the entire firmware including kernel was under 32 KB flash because FreeRTOS let me include only what I needed. Zephyr's minimal footprint is larger — a basic blinky with kernel + UART is around 25-35 KB flash on the same MCU because you pull in the device model, logging, and initialization code even if you don't use it. Pruning is possible via prj.conf (CONFIG_LOG=n, CONFIG_PRINTK=n), but you won't beat FreeRTOS for the absolute smallest builds.

The crossover happens as your product grows. Once you add a network stack, filesystem, or Bluetooth, Zephyr's integrated components often result in smaller or comparable size to pulling in third-party stacks for FreeRTOS, because Zephyr's subsystems are co-optimized and configured together. A Zephyr build with BLE peripheral, GATT services, and a sensor driver was about 140 KB flash in my last nRF52 project; the equivalent FreeRTOS + NimBLE build was 155 KB with more manual integration work.

Porting and Debugging Experience

Porting FreeRTOS is straightforward: you provide port.c for context switching and interrupt handling, and implement vPortSetupTimerInterrupt. Most Cortex-M and RISC-V ports already exist upstream. Zephyr porting means writing devicetree bindings and driver implementations conforming to its driver API, then letting the build system generate initialization. The upfront effort is higher, but the payoff is that every future board using the same SoC reuses your work.

Toolchain-wise, FreeRTOS builds with anything — Make, CMake, Keil, IAR. Zephyr mandates CMake + west + its toolchain manager. I prefer Zephyr's west workflow now because it pins module versions and handles multi-repo dependencies, but it is a heavier lift for teams used to a simple Makefile. For debugging, both expose thread awareness to GDB via plugins, and both work with standard probes. If you are setting up your debug probe and trace workflow for either kernel, Embedded Debugging with JTAG and SWD: Tools, Techniques and Workflows covers the openOCD and GDB patterns that apply to both.

Selecting FreeRTOS or Zephyr for Production: Licensing, Ecosystem and Longevity

This is the decision I get asked about most, and in my experience there is no universal winner — only a better fit for your constraints.

When FreeRTOS Is the Pragmatic Choice

Choose FreeRTOS when your team is small, your hardware is constrained, you need broad MCU vendor support, or you already have a mature code base. The MIT license is as permissive as it gets, which simplifies legal review. Vendor support is extensive — NXP, ST, TI, and Microchip all ship FreeRTOS examples and sometimes pre-integrate it in their SDKs. If you need Functional Safety, there is SafeRTOS (a certified derivative) and FreeRTOS still offers a clear path to certification because the kernel is small and auditable.

When Zephyr Pays Off

Choose Zephyr when you anticipate hardware diversity (multiple boards sharing one codebase via devicetree), you need integrated connectivity (BLE, 802.15.4, Wi-Fi, CAN, USB), or you value long-term maintenance over initial simplicity. Zephyr is governed by the Linux Foundation with LTS releases every year, and its driver ecosystem is growing fast. I have migrated a product line from nRF52 to STM32WB by changing only the devicetree overlay and two Kconfig options — that portability is hard to replicate with FreeRTOS without significant refactoring.

Criterion FreeRTOS Zephyr
License MIT, no copyleft obligations Apache 2.0, permissive with patent grant
Typical Kernel Footprint 4–10 KB flash, ~500 B + stacks RAM 20–40 KB flash minimal, ~2–5 KB RAM base
Scheduling Model Preemptive priority + round-robin, tick-based Preemptive priority + round-robin, tickless optimized
Configuration FreeRTOSConfig.h macros, heap_4/heap_5 Kconfig + devicetree, west build
Driver / Stack Integration Bring your own, vendor SDKs vary widely Integrated device model, BLE, net, FS, USB in-tree
Supported Toolchains Any (GCC, Clang, IAR, Keil), simple Makefile/CMake CMake + west required, GCC/Clang only
Best Fit Small footprint, 8–32 bit MCUs, fastest time to first boot Connected products, multi-board fleets, long-term code reuse

My practical rule: if your firmware roadmap is primarily sensor reading and control loops with no wireless stack, FreeRTOS will get you to production faster with less build system friction. If your roadmap includes Bluetooth, Thread, or a family of hardware variants over three years, invest the two weeks to learn Zephyr's Kconfig/devicetree flow — I've found that investment pays back on the second board spin. In both cases, prototype your worst-case latency early: measure interrupt-to-thread latency under load and validate your priority assignment before your architecture solidifies.

Frequently Asked Questions

Can I run both FreeRTOS and Zephyr on the same microcontroller family?

Yes, and this is common. Both kernels support Cortex-M0/M3/M4/M7/M33 and RISC-V 32-bit cores. The deciding factor is rarely core support but peripheral and SDK support. Check whether your vendor's HAL or low-level drivers already integrate with one kernel — ST's STM32Cube can generate FreeRTOS scaffolding, while Nordic's nRF Connect SDK is Zephyr-based. I've run FreeRTOS on an nRF52840 and Zephyr on an STM32F4; both work, but you will do more integration work going against the vendor's primary ecosystem.

How much RAM should I allocate per thread or task?

Start with 512–1024 bytes for simple tasks and 1024–2048 bytes for stacks that call into networking or filesystem code, then measure. Both kernels let you check stack high-water marks at runtime — FreeRTOS with uxTaskGetStackHighWaterMark() and Zephyr with k_thread_stack_space_get(). In my experience, integer-only control tasks often settle around 384–512 bytes after optimization, while a BLE or MQTT thread rarely fits below 1200 bytes. Always leave at least 15–20% headroom and guard against stack overflow with configCHECK_FOR_STACK_OVERFLOW in FreeRTOS or CONFIG_HW_STACK_PROTECTION in Zephyr.

Is Zephyr harder to learn than FreeRTOS for a beginner?

Initially, yes. FreeRTOS lets you compile two files and toggle an LED in an afternoon with a simple Makefile. Zephyr requires installing west, learning Kconfig and devicetree concepts, and understanding its build and flashing workflow. I've found that a motivated beginner reaches productivity in FreeRTOS in about a week, versus two to three weeks for Zephyr. The return on that investment appears later when you add drivers or migrate hardware — Zephyr's abstraction saves time that FreeRTOS would spend on manual driver integration.

Which RTOS is better for ultra-low-power battery devices?

Both can be very low power, but Zephyr has a more complete tickless idle and power management subsystem out of the box, including device power states and system power management hooks. FreeRTOS supports tickless idle via configUSE_TICKLESS_IDLE, but you typically write the sleep entry and peripheral gating yourself. On a recent STM32L4 sensor node, Zephyr's PM subsystem achieved ~2.8 µA sleep with RTC wake, while my hand-tuned FreeRTOS tickless build reached ~3.4 µA on the same hardware. If battery life is measured in years, Zephyr's integrated PM is an advantage; if you have aggressive sleep needs and a tiny kernel, either can be tuned to excellent results with careful driver work.

Related Articles

References & Standards: FreeRTOS Documentation · Zephyr Project Documentation · MQTT Specification