Quanta ControlQuantaControl
Back to Blog
·Quanta Control

Embedded RTOS Scheduling Philosophy

RTOSLinuxFreeRTOSZephyrRTICEmbassyRustembeddedscheduling

When choosing an embedded platform, most people look at hardware specs first — clock speed, memory, peripherals. But what really shapes the development experience and system reliability is often something deeper: how the scheduler is designed.

This article does one thing: compares the scheduling models of five systems — Linux (CFS + PREEMPT_RT), FreeRTOS, Zephyr, RTIC, and Embassy — side by side. Not to declare a winner, but to understand what assumptions each model makes, what they cost, and where they fit.


Linux: A Scheduler Built for Throughput

Linux's Completely Fair Scheduler (CFS) is designed on the premise that there are many tasks and each deserves a fair share of CPU time. Its scheduling decision doesn't ask "who is most urgent?" but "who has received the least CPU time so far?" This is classic throughput-oriented design — perfectly correct for desktops and servers, but in a control scenario, fairness and low latency are at odds. CFS chooses fairness in that trade-off, which happens to be the opposite of what control applications need.

The interrupt path latency is non-deterministic. Linux splits interrupt handling into two layers: the hardirq does the bare minimum register operations (acknowledge the interrupt, copy data to a buffer) and returns. Before returning, it checks for pending softirqs and executes them in place if any exist — softirqs do not involve a context switch; they run in the current interrupt context or in the ksoftirqd kernel thread. The latency from a pin toggling to a userspace task being woken up distributes across three stages: hardirq response latency → softirq / workqueue queuing and execution latency → scheduler selecting the target task. Each stage has different sources of unpredictability, and together they form the worst-case uncertainty.

Kernel non-preemptible sections are another problem. A critical section holding a spinlock cannot be interrupted by any higher-priority task — even if that higher-priority task is your motor control loop that needs a response within 50 µs. How long is the critical section? It depends on the kernel version, compile options, and runtime lock contention.

The PREEMPT_RT patch does one important engineering thing: it replaces kernel spinlocks with preemptible rt_mutexes and turns interrupt handlers into preemptible kernel threads. This brings worst-case scheduling latency from the millisecond range down to tens of microseconds. Beckhoff's TwinCAT industrial control system runs on PREEMPT_RT-patched Windows/Linux, proving this path is viable in production. But note: PREEMPT_RT solves the kernel latency problem, not the scheduling policy problem. CFS still targets fairness and throughput, not "guarantee the worst-case response time of the highest-priority task." For most control scenarios, tens of microseconds of worst-case latency is sufficient. For scenarios requiring sub-microsecond response (high-speed motor vector control, RF pulse timing), the real-time portion must be offloaded to an FPGA or dedicated MCU.

One conceptual point worth clarifying: real-time is not a question of "how fast," but of "does missing a deadline count as failure." Hard real-time means missing a deadline is a system-level fault — a motor runs away, a valve fails to close, a flight attitude correction is late. Soft real-time means average response time is short enough; occasional misses degrade user experience but don't cause catastrophe. Linux + PREEMPT_RT can achieve soft real-time and even approach hard real-time latency levels, but its semantics are still "best-effort" — there is no formal deadline-miss detection mechanism and no WCET analysis toolchain support. If your system truly cannot tolerate a single missed deadline, you need to prove it starting from the scheduling model.

So, for scenarios that genuinely need deterministic latency, the natural next step is to look at schedulers designed for hard real-time.


FreeRTOS: Simple, but All Guarantees Are Runtime

FreeRTOS's scheduler is the polar opposite of Linux — it is designed for worst-case analyzability from the ground up. Fixed-priority preemptive scheduling, analyzable under Rate-Monotonic scheduling theory, with O(1) scheduling decisions (finding the highest-priority task in the ready list). No CFS fairness calculations, no dynamic priority adjustments. Whoever has the highest priority runs.

This simplicity has real engineering value. Context switch overhead is deterministic (tens to hundreds of CPU cycles, architecture-dependent), and per-tick scheduling decision time is nearly constant.

But FreeRTOS's weakness is not in the scheduler itself — it's that none of the conventions the scheduler depends on have compile-time guarantees.

The classic example is priority inversion. In short: a high-priority task H gets blocked by an unrelated medium-priority task M — because the lock H needs is held by low-priority task L, and M steals the CPU, preventing L from releasing the lock. FreeRTOS's mutex (xSemaphoreCreateMutex) includes built-in Priority Inheritance (PIP) to bound the inversion duration, but this depends on the developer choosing the right API — if you accidentally use a binary semaphore (xSemaphoreCreateBinary) for mutual exclusion, there is no PIP, and the compiler won't warn you. A detailed timeline walkthrough of priority inversion and how PIP fixes it is in the appendix at the end.

Tick-based scheduling introduces granularity issues as well. FreeRTOS's tick frequency is configurable via configTICK_RATE_HZ, with a typical value of 1 kHz (1 ms resolution), and can be raised to 10 kHz (0.1 ms) or higher. The cost is interrupt frequency scaling proportionally — 10 kHz means 10,000 tick interrupts per second, repeatedly waking the MCU from sleep in low-power scenarios. FreeRTOS supports tickless idle mode (configUSE_TICKLESS_IDLE), which stops the tick interrupt during idle periods, letting the MCU enter deep sleep and dynamically compensating the tick count upon wake-up by a peripheral interrupt. This is practical for low-power designs, but it does not eliminate tick overhead under load. vTaskDelay(1) has an actual delay anywhere between 0 and 1 tick period (depending on the call's phase relative to the tick interrupt), and this uncertainty remains an extra complication for precise timing (e.g., sensor sample synchronization).

At a deeper level: inter-task data sharing, stack usage estimation, priority assignment — all of these depend on the engineer's tacit knowledge and runtime debugging to verify. The compiler remains silent.


Zephyr: Most Capable, but Heaviest Cognitive Load

Zephyr's scheduler is far more flexible than FreeRTOS's. It supports three configurable scheduling policies: preemptive, cooperative, and Earliest Deadline First (EDF).

EDF is the theoretically optimal dynamic-priority scheduling algorithm in real-time systems theory (C. L. Liu & Layland, 1973) — task priority dynamically increases as the deadline approaches, and it guarantees all deadlines are met provided total CPU utilization does not exceed 100%. Zephyr's EDF implementation supports CONFIG_SCHED_DEADLINE; threads can carry deadline metadata, and the scheduler dynamically computes priority from it. This is simply impossible in FreeRTOS — FreeRTOS only has static priorities, fixed once assigned.

Beyond EDF, Zephyr's preemptive scheduling supports time slicing, where threads at the same priority rotate execution with a configurable granularity; thread priority can be changed dynamically at runtime; and it provides CONFIG_THREAD_RUNTIME_STATS and SystemView integration for runtime scheduling analysis. The scheduler type is configured in Kconfig — the same kernel can serve different project requirements with different strategies. In cooperative mode, threads must explicitly call k_yield() or k_sleep() to yield the CPU.

As capable as the scheduler is, Zephyr's barrier to entry is high. Getting an application running on a new MCU platform requires understanding DeviceTree, configuring Kconfig, and learning the west build system. For a "just make an LED blink" project, this overhead is disproportionate.

Zephyr is best understood as an attempt to provide Linux-grade functionality on top of a real-time operating system — protocol stacks, filesystem, device driver model, all included. On first encounter it is genuinely impressive: check boxes in Kconfig, describe hardware in DeviceTree, it feels like configuring a Linux kernel. But when considering production, the concern arises: the system is too complex. SystemView and runtime stats exist, but when a latency issue sits on a path that crosses multiple driver layers and protocol stack calls, pinpointing the root cause is far harder than with FreeRTOS. Zephyr provides a lot of data, but the distance from data to conclusion is not short.


RTIC: Scheduling Correctness Guaranteed by the Compiler

RTIC takes a fundamentally different path from the three systems above. It is not a traditional RTOS — there is no scheduler, no threads, no concept of dynamically created tasks. All "tasks" are interrupt handlers, and a task's "priority" is the hardware interrupt priority. Scheduling correctness is jointly guaranteed by the hardware interrupt controller and Rust's type system.

Core mechanism: every task is assigned a static interrupt priority at compile time. When a higher-priority interrupt fires, the hardware automatically preempts the currently executing lower-priority interrupt — no software scheduler involvement needed. The task-switch latency path is: NVIC interrupt entry (minimum ~12 CPU cycles on Cortex-M) → register stacking (caller-saved registers) → if floating-point is in use, lazy FPU state preservation can add hundreds of nanoseconds. Additionally, RTIC's lock() internally raises BASEPRI or disables interrupts to ensure critical-section mutual exclusion, which also introduces brief masking latency. Taken together, a Cortex-M7 @400 MHz without FPU has a floor around 100 ns; worst-case figures in real projects are typically higher. But even with conservative estimates, it is still far below the context-switch overhead of any software scheduler.

Resource protection is even more radical. RTIC requires all shared resources to be accessed through lock(), and the compiler checks at compile time:

  • What is the highest priority at which each resource is accessed
  • Whether any task attempts to access a resource at a priority higher than that resource's ceiling (this is the compile-time equivalent of priority inversion)

If there is a problem, compilation fails. No runtime debugging of priority inversion, no manual Priority Inheritance configuration — the compiler's type checking is your proof of scheduling correctness.

The cost: RTIC's programming model is highly constrained. All tasks must be interrupt-context async operations, there is no dynamic task creation, and WCET (Worst-Case Execution Time) analysis must be done by the engineer. This is not a "do everything" framework — it is a "guaranteed correctness under given constraints" framework.


Embassy: Trading Preemption for Simplicity

Embassy chose the opposite direction from RTIC: give up preemption entirely and adopt cooperative async scheduling.

The executor is single-threaded (per core), and tasks exist as Rust Futures. Tasks yield only at .await points, and the executor polls all ready tasks. No preemption means no context switches (registers are never saved/restored), and shared state does not need a mutex — data you modify between two .await points is absolutely invisible to other tasks in its intermediate states. Priority inversion, in the preemptive sense, is eliminated at the root — but cooperative scheduling has its own equivalent: a task that goes too long without .await can starve every other task. This is essentially a "cooperative priority inversion," where the trigger condition shifts from lock contention to CPU non-release.

The correctness reasoning of this model is extremely simple: your code is only interrupted where you wrote .await. Everywhere else is atomic.

The trade-off is direct: if a task goes too long without .await (e.g., a tight computation loop), all other tasks on the same executor starve. The fix is to manually insert yield_now().await to break long computations into smaller chunks, or use spawn_blocking to offload compute-heavy work to a separate thread (if the MCU has multiple cores). In pure control scenarios this is rarely a problem — compute-heavy work should be offloaded to an FPGA, DSP, or application processor anyway — but it does limit Embassy's suitability for mixed-workload scenarios.

Embassy's other key advantage is embedded-hal's cross-platform abstraction. Driver code is implemented against traits; switching MCU models requires only changing the HAL implementation, with application logic untouched. Combined with Rust's native async/await, unit testing on the host requires no mock framework — simply swap in a host executor that implements the same embedded-hal traits. This may be Embassy's most important engineering advantage in real projects, even if it appears unrelated to scheduling.


The Inescapable Language Layer: C's Explicit Scheduling vs. Rust's Native Async

At this point, one dimension cannot be avoided: C has no language-level async/await support. FreeRTOS and Zephyr's scheduling models can only be explicit — task creation, switching, and synchronization are all managed through API calls (xTaskCreate, vTaskDelay, k_sem_take, etc.). The scheduler and your business logic sit at two different abstraction layers, and their interaction relies entirely on runtime conventions. When the compiler cannot help, conventions can only be upheld by human diligence — code review, experience, and extensive debugging.

Rust bakes async/await into the language. Embassy's executor is not a standalone kernel that you "call APIs to manage" — it is the standard Rust async execution environment, adapted for embedded. Scheduling points and business logic live at the same abstraction layer: .await means both "I'm waiting for this peripheral" and "I yield execution here." This unification is hard to achieve with C's API-level approach.

Of course, synchronous vs. asynchronous programming is a large topic in its own right — it touches I/O models, concurrency primitives, debugging and observability, and even team technology stack choices. This article stays focused on the scheduler layer. We'll cover Rust async's design philosophy and engineering practice on embedded in a separate piece.


Scheduling Model Comparison

Linux (PREEMPT_RT)FreeRTOSZephyrRTICEmbassy
Scheduling TypeDynamic priority (CFS)Fixed-priority preemptiveConfigurable (preemptive/cooperative/EDF)Hardware interrupt-drivenCooperative async
Worst-Case Latency~30 µs~1 µs (tick resolution)~1 µs~100 ns–1 µs (interrupt + FPU)Depends on longest .await gap
Priority InversionKernel PIP (rt_mutex)Manual PIP configManual PIP configCompile-time detectionNo preemptive inversion (starvation risk)
Context SwitchingComplex (kernel/user mode)Tick-driven, O(1)O(1) or O(log n) (EDF)No software schedulerNone (Future state restore only)
Correctness GuaranteeRuntimeRuntimeRuntimeCompile-timeCompile-time
Trade-off CostRequires MMU, > 8 MB RAMNo compile-time protection in CVery high configuration complexityTasks must fit interrupt modelCompute-heavy tasks starve others
Memory Footprint> 8 MB (OS + app)~10–100 KB (kernel + app)~50–500 KB (kernel + subsystems)~few KB (no kernel overhead)~few KB (no kernel overhead)
Certification ReadinessNone (Linux as a whole lacks functional safety cert)Yes (SafeRTOS, IEC 61508/DO-178C)In progress (IEC 61508 SIL3 pre-cert 2023)None (community project)None (community project)
Best FitComplex systems, networking, UITraditional embeddedFull protocol stack requirementsHard real-time, verifiable correctnessAsync control, multiple peripherals

Same Operation, Three Ways

At this point, it's better to just look at code. Below is the same scenario — two tasks contending for a shared counter — written for FreeRTOS, RTIC, and Embassy respectively. The scheduling philosophy difference lands directly in the code.

FreeRTOS (C)

// Must use xSemaphoreCreateMutex (built-in PIP), not binary semaphore
SemaphoreHandle_t mutex = xSemaphoreCreateMutex();
int counter = 0;
 
void task_A(void *pv) {
    if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {
        counter++;          // Forget to take the mutex? Compiler won't tell you
        xSemaphoreGive(mutex);
    }
}
 
void task_B(void *pv) {
    if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {
        counter++;          // Used binary semaphore instead of mutex? No PIP — nobody warns you
        xSemaphoreGive(mutex);
    }
}

The key issue: mutex create-take-give pairing relies on human discipline; PIP activation depends on choosing the right API; any omission is a runtime bug.

RTIC (Rust)

#[rtic::app(device = stm32f4)]
mod app {
    #[shared]
    struct Shared {
        counter: u32,   // No manual mutex wrapping needed — RTIC manages it
    }
 
    #[task(priority = 2, shared = [counter])]
    fn task_a(mut cx: task_a::Context) {
        cx.shared.counter.lock(|c| {
            *c += 1;    // lock() closure is the critical section — compiler enforces it
        });             // Try accessing without lock() → compile error
    }
 
    #[task(priority = 1, shared = [counter])]
    fn task_b(mut cx: task_b::Context) {
        cx.shared.counter.lock(|c| {
            *c += 1;    // If task_b's priority exceeds counter's ceiling → compile error
        });
    }
}

The key difference: forgetting lock()compile error; priority misconfiguration causing potential inversion → compile error.

Embassy (Rust)

#[embassy_executor::task]
async fn task_a(counter: &'static Mutex<NoopRawMutex, u32>) {
    let mut c = counter.lock().await;  // async mutex — yields the executor at .await
    *c += 1;
    // mutex guard auto-released here
}
 
// Under cooperative scheduling, code between .await points is naturally atomic
// Mutex-free access to non-preemptible data is safe
async fn task_b(flag: &'static AtomicBool) {
    flag.store(true, Ordering::SeqCst);
    Timer::after_millis(10).await;  // only possible switch point
    flag.store(false, Ordering::SeqCst);
}

The key difference: under cooperative scheduling, most shared state doesn't need a mutex; where protection is needed, the async mutex model matches standard Rust async idioms.


What This Comparison Is Really Saying

Five scheduling models. The question is not "which is better" — it's "which provides the right guarantees for your scenario."

If you need a system that can run a TLS network stack and render a touchscreen UI, Linux is correct — millisecond-scale scheduling overhead does not affect your product at all.

If you need a system where you must prove "the worst-case motor control loop response time does not exceed 500 ns," RTIC's interrupt-driven model provides latency closest to the hardware limit and verifiable correctness.

If you need multi-peripheral async control (SPI, I2C, UART running concurrently without blocking each other), Embassy's cooperative async model simplifies all state management — .await is the only concurrency point you need to think about.

But there is a deeper trend worth noting: RTIC and Embassy's correctness guarantees come from compile time, while FreeRTOS and Zephyr's come from runtime debugging. This is not a language choice issue (though Rust enables them) — it's a difference in scheduling model design philosophy. When scheduling correctness is verifiable by the compiler, what it means to "choose an RTOS" changes. You no longer need to be the senior engineer who catches priority inversion in code review — the compiler does it for you. More importantly, it changes the definition of what counts as a "bug": in FreeRTOS, forgetting xSemaphoreTake is a bug — a runtime bug that requires a debugger to locate; in RTIC, forgetting lock() is a compile error — it simply does not exist at runtime.

Of course, all of the above discussion is just one slice. Every real-world project's technology choice depends on a large amount of context — team experience, existing codebase, supply chain constraints, certification requirements — dimensions a single article cannot cover. The ambition of this article is modest: to lay five scheduling philosophies side by side, giving the reader one more coordinate to think with when making their choice.


Appendix: Priority Inversion and Priority Inheritance

The FreeRTOS section above only gave a summary description of priority inversion. Here, we walk through the full timeline to make the problem concrete, then show how Priority Inheritance fixes it.

Setup

Three tasks, with priorities H > M > L. A single mutex shared by H and L (both use it to protect the same resource). M does not touch the mutex at all.

SemaphoreHandle_t mutex;
 
void task_L(void *pv) {
    xSemaphoreTake(mutex, portMAX_DELAY);  // ① L acquires the lock
    // ... critical section work ...
    xSemaphoreGive(mutex);                 // ④ L releases the lock
}
 
void task_M(void *pv) {
    // M does its own thing, never touches the mutex
    // e.g., continuously processing sensor data, running CRC checks
    while (1) {
        process_sensor_data();  // time-consuming work, no mutex involved
    }
}
 
void task_H(void *pv) {
    xSemaphoreTake(mutex, portMAX_DELAY);  // ② H also needs the lock, blocks
    // ... critical section work ...
    xSemaphoreGive(mutex);
}

The Disaster Timeline

Moment ① — L runs first (a low-priority task can initially get the CPU when the system boots). L successfully Takes the mutex, enters the critical section, and has not yet released it.

Moment ② — H becomes ready (e.g., an interrupt fires). H has the highest priority, so the kernel preempts L and runs H. H calls xSemaphoreTake(mutex, ...), finds the lock held by L, and enters the blocked state, waiting for the lock. The kernel resumes L — L is the only ready task that holds the lock.

Moment ③ — M becomes ready (e.g., a timer event). This is the critical turning point. M has medium priority > low priority, so the kernel preempts L and runs M. M does not touch the mutex and can execute indefinitely.

Result: H is waiting on the lock (blocked). L holds the lock but cannot get the CPU (M occupies it). M occupies the CPU but has no resource competition with H at all. H is blocked indefinitely by M — even though M's priority is much lower than H's. This is priority inversion.

Why It's Counterintuitive

Intuitively, high-priority H should at most wait for low-priority L to finish its critical section — a short delay. But a medium-priority M slips in — it doesn't touch the lock, yet it steals L's CPU — making H's wait time effectively infinite, entirely dependent on how long M runs. H's real-time behavior is completely destroyed.

How Priority Inheritance Fixes It

FreeRTOS's xSemaphoreCreateMutex() includes built-in Priority Inheritance (PIP). The mechanism is simple:

When a high-priority task H attempts to acquire a lock held by a low-priority task L and blocks, the kernel temporarily raises L's priority to match H's.

Going back to the timeline: at Moment ②, when H blocks on the mutex, the kernel temporarily elevates L's priority to H's level. By Moment ③, when M becomes ready, L's priority is already "high" (equal to H's), so M cannot preempt L. L smoothly finishes the critical section, releases the lock, and reverts to its original low priority. H immediately acquires the lock and runs.

The inversion is now bounded to at most the duration of L's critical section — no longer unbounded.

The Trap

PIP only works if you use the right API. xSemaphoreCreateMutex() returns a mutex with PIP; xSemaphoreCreateBinary() returns a binary semaphore without it. The two are completely indistinguishable at the C language level — both are SemaphoreHandle_t, zero compile-time checking. As the project grows and a code review misses it, the result is a runtime bug that is nearly impossible to reproduce.


References: Embassy · RTIC · FreeRTOS Task Scheduling · FreeRTOS Mutexes & Priority Inversion · Zephyr Scheduling · Linux PREEMPT_RT