FreeRTOS Task Scheduling: Priorities, Preemption and Time Slicing

When I first moved from bare-metal superloops to FreeRTOS on an STM32F4 project, I assumed task scheduling was just about setting a priority number and letting the kernel sort it out. That assumption cost me a week of debugging missed sensor deadlines. FreeRTOS scheduling looks simple on the surface — highest priority ready task runs — but the interaction between priorities, preemption, and time slicing determines whether your system meets its real-time constraints or silently fails under load. Understanding exactly how the scheduler makes decisions is what separates a prototype that works on the bench from a product that behaves correctly in the field.

How FreeRTOS Decides Which Task Runs Next

The FreeRTOS scheduler is fundamentally a prioritized, preemptive scheduler with optional time slicing. At any given moment, the kernel maintains lists of tasks that are in the Ready state, grouped by priority. When the scheduler needs to select a task — on a tick interrupt, when a task blocks, or when an interrupt makes a higher-priority task ready — it scans those ready lists from the highest priority down and picks the first task it finds.

This is not a round-robin system by default. If a priority 3 task is ready, no priority 2 or priority 1 task will run, regardless of how long they have been waiting. In my experience, this strict priority model is exactly what you want for hard real-time control, but it forces you to think carefully about what "priority" actually means in your application. It is not about importance in a business sense; it is about urgency and deadline.

The Ready List and the Scheduler's Selection Logic

Internally, FreeRTOS keeps an array of ready lists, one per priority level. The constant configMAX_PRIORITIES in your FreeRTOSConfig.h defines the size of that array. If you set it to 5, you get priorities 0 through 4. The scheduler uses a bitmap or a simple loop (depending on the port) to find the highest non-empty list in O(1) or O(n) time. On Cortex-M ports, this is optimized with the CLZ (Count Leading Zeros) instruction, so the selection itself adds negligible jitter.

What matters for you is that insertion into the ready list is FIFO within a priority level. When two tasks share the same priority, the one that has been in the ready list the longest is the one that runs first. This ordering becomes critical once we enable time slicing.

Task States That Feed the Scheduler

A task can only be selected if it is in the Ready state. Tasks in Blocked, Suspended, or Running states are not in the ready lists. A task enters Blocked when it calls vTaskDelay(), xQueueReceive() with a timeout, xSemaphoreTake(), or any other blocking API. It leaves Blocked when the delay expires or the event it was waiting for occurs. I have found that most scheduling bugs beginners see are not scheduler bugs at all, but tasks that never actually block — they poll in a tight loop and starve everything else at the same or lower priority.

The scheduler runs whenever a task voluntarily blocks or yields, and on every tick interrupt if preemption is enabled. If you disable preemption, the scheduler only runs when a task explicitly blocks or yields, which completely changes system responsiveness. The FreeRTOS Documentation describes this as the difference between preemptive and cooperative scheduling, and you should read the configuration section closely before choosing.

Assigning Priorities That Reflect Real-Time Urgency

Priority assignment is the most consequential design decision you make in a FreeRTOS application. The classic framework is Rate Monotonic Scheduling: tasks with shorter periods (higher rates) get higher priorities. A 1 kHz control loop should be higher priority than a 10 Hz telemetry task. This analytically guarantees schedulability under certain load conditions, but in practice, most IoT products need a more nuanced approach.

In my experience, you should assign priorities based on the worst-case consequence of missing a deadline, not just frequency. On a motor controller I worked on, the current-control loop ran every 125 microseconds and was priority 4 (the top). The position loop ran every 1 millisecond at priority 3. A safety monitor that checked for overcurrent and stalled rotors ran at priority 5 — even higher than the control loop — because even though it only needed to run every 10 milliseconds, when it did need to run, it had to preempt everything immediately to cut power. That kind of aperiodic, event-driven safety task often deserves the highest priority.

Mapping Deadlines and Hardware Events to Priority Numbers

Start by listing every task, its period or minimum inter-arrival time if it is event-driven, its worst-case execution time (WCET), and its deadline. Measure WCET with a GPIO toggle and a logic analyzer, not by guessing. I toggle a pin at task entry and exit and capture with a Saleae — it is the only honest way to know. Then apply this rule: if two tasks' deadlines could conflict, the one with the shorter deadline gets the higher priority. This is Deadline Monotonic assignment and it works better than pure rate-based assignment when deadlines are shorter than periods.

Avoid using priority 0 for anything except the Idle task. FreeRTOS reserves priority 0 for Idle, and if you put application tasks there, they will share time with the idle task's cleanup of deleted tasks and low-power hook. I keep priority 0 unused and start application tasks at 1. At the other end, be careful with the highest priority you configure. If configMAX_PRIORITIES is 6, the highest available is 5. Do not create a task at 5 unless it is truly the most urgent in the system and its WCET is very short.

How Many Priority Levels Do You Actually Need?

You do not need 32 priority levels for a typical sensor node. I have found that 5 to 7 levels are enough for most IoT firmware: 1 for background/logging, 2 for network stack, 3 for application logic, 4 for real-time control or audio, 5 for safety-critical monitors. More levels add RAM (each level is a list head) and make the system harder to reason about. Set configMAX_PRIORITIES to the minimum you need. If you find yourself wanting 15 levels, it is usually a sign you are using priority to fix an architecture problem that should be solved with proper blocking and event design.

// Creating tasks with meaningful priorities
// configMAX_PRIORITIES is 6 in this project (0-5 available)

#define PRIO_SAFETY_MONITOR   5
#define PRIO_MOTOR_CONTROL    4
#define PRIO_SENSOR_ACQ       3
#define PRIO_MQTT_PUBLISH     2
#define PRIO_LOGGING          1

void app_create_tasks(void)
{
    xTaskCreate(vSafetyMonitorTask, "Safety", 256, NULL, PRIO_SAFETY_MONITOR, NULL);
    xTaskCreate(vMotorControlTask, "MotorCtrl", 512, NULL, PRIO_MOTOR_CONTROL, NULL);
    xTaskCreate(vSensorTask, "Sensor", 512, NULL, PRIO_SENSOR_ACQ, NULL);
    xTaskCreate(vMqttTask, "MQTT", 1024, NULL, PRIO_MQTT_PUBLISH, NULL);
    xTaskCreate(vLoggingTask, "Log", 512, NULL, PRIO_LOGGING, NULL);
}

Notice the stack sizes are not arbitrary. Tasks that call printf or handle TLS need far more stack than a simple control loop. Undersized stacks cause hard faults that look like scheduling bugs. If you are not already using static allocation and stack overflow detection, read RTOS Memory Management: Static Allocation, Pools and Heap Strategies before you go further — memory configuration and scheduling reliability are tightly coupled.

Preemption in Action: When a High-Priority Task Wakes Up

Preemption is what makes FreeRTOS responsive. When configUSE_PREEMPTION is set to 1 (which it is in almost every real product), the kernel will preempt a running lower-priority task as soon as a higher-priority task becomes Ready. This happens not only on the SysTick, but immediately when an interrupt service routine (ISR) makes a task ready.

Consider this sequence: a low-priority logging task is running and an ADC conversion complete interrupt fires. The ISR calls xSemaphoreGiveFromISR() to unblock a high-priority sensor task that was waiting for that data. At the end of the ISR, the port-specific yield macro (portYIELD_FROM_ISR()) checks if a higher-priority task is now ready. If it is, the ISR returns not to the interrupted logging task, but directly to the sensor task. The logging task is moved back to Ready and will resume only when the sensor task blocks again. This entire context switch takes a few microseconds on a Cortex-M4 at 168 MHz.

I have seen teams disable preemption to "reduce jitter" and then wonder why their UART receive task overflows. Without preemption, that logging task would continue running until it voluntarily blocked or yielded, even though the sensor task was already ready and waiting. Latency becomes a function of how long low-priority tasks take to cooperate, which is unpredictable.

Interrupt-Driven Preemption Flow

The correct ISR pattern is critical for preemption to work. You must use the FromISR variant of APIs and handle the xHigherPriorityTaskWoken flag. If you forget portYIELD_FROM_ISR(), the high-priority task will still become Ready, but it will not run until the next tick interrupt — adding up to 1/configTICK_RATE_HZ seconds of latency. At 1000 Hz that is 1 ms, which is enough to miss a control deadline.

void DMA1_Channel1_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    // Clear interrupt flag
    if (DMA_GetITStatus(DMA1_IT_TC1))
    {
        DMA_ClearITPendingBit(DMA1_IT_TC1);

        // Unblock the sensor task waiting for this data
        xSemaphoreGiveFromISR(xDmaCompleteSemaphore, &xHigherPriorityTaskWoken);

        // Force context switch if sensor task is higher priority
        // than the interrupted task
        portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }
}

void vSensorTask(void *pvParameters)
{
    for (;;)
    {
        // Block until DMA interrupt gives the semaphore
        if (xSemaphoreTake(xDmaCompleteSemaphore, portMAX_DELAY) == pdTRUE)
        {
            process_sensor_buffer();
            // After processing, this task blocks again on the next Take,
            // allowing lower priority tasks to run
        }
    }
}

This pattern is non-negotiable for low-latency I/O. If you are debugging with a trace tool and see that your ISR unblocks a task but the task does not run until the next tick, you almost certainly missed the yield call. Tools like SEGGER SystemView make this visible immediately — something I cover in more depth alongside Real-Time Debugging: Trace Tools, Logic Analyzers and JTAG Techniques.

Time Slicing Among Equal-Priority Tasks and the SysTick

Time slicing, controlled by configUSE_TIME_SLICING, only matters when you have more than one Ready task at the same priority. When enabled (the default when preemption is enabled), the scheduler will switch between equal-priority tasks on every tick interrupt. Each task gets a slice equal to one tick period before being moved to the back of its ready list.

For example, if you have three tasks at priority 2 and configTICK_RATE_HZ is 1000 (1 ms tick), and all three are Ready, task A runs for 1 ms, then on the tick, B runs for 1 ms, then C for 1 ms, then back to A. If task A blocks on a queue during its slice, it is removed from Ready and B starts immediately — you do not waste the rest of the slice. Without time slicing, task A would run until it blocked or yielded, and B and C would starve even though they share the same priority, which is rarely what you intend when you deliberately assign the same priority.

In my experience, time slicing is useful for background work where fairness matters more than determinism — like handling two independent network sockets or blinking multiple LEDs. For control tasks, I avoid putting them at the same priority precisely because time slicing adds jitter. If you need deterministic ordering among critical tasks, give them distinct priorities.

Enabling configUSE_TIME_SLICING and the Tick Rate Tradeoff

Time slicing requires preemption and a tick interrupt. If configUSE_PREEMPTION is 0, configUSE_TIME_SLICING does nothing. The tick rate itself is a tradeoff. A faster tick (1000 Hz) gives finer time slicing and more accurate delays, but increases overhead — each tick is an interrupt that saves context, increments the tick count, checks delayed task lists, and possibly switches tasks. On a 64 MHz Cortex-M0, a 1000 Hz tick can consume 2-3% CPU even with no application tasks.

For low-power IoT nodes, I often drop to 250 Hz or use tickless idle (configUSE_TICKLESS_IDLE). That reduces time slicing granularity to 4 ms, which is fine for non-real-time tasks but not for a 1 kHz loop. In that case, the control loop should not rely on vTaskDelay(1) for its period; it should use xTaskDelayUntil() or, better, be driven by a hardware timer interrupt that unblocks it, making it independent of the tick rate entirely. I have found that decoupling hard real-time tasks from the kernel tick is the single best way to improve determinism.

// Using xTaskDelayUntil for precise periodic execution
// Avoids drift that accumulates with vTaskDelay

void vPeriodicTask(void *pvParameters)
{
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xPeriod = pdMS_TO_TICKS(10); // 10 ms, 100 Hz

    for (;;)
    {
        // Do fixed amount of work
        read_sensors_and_update_state();

        // Block until next period. Even if work took 2ms, this
        // still wakes up exactly 10ms after last wake.
        xTaskDelayUntil(&xLastWakeTime, xPeriod);
    }
}

Do not use a busy loop with a counter for delays. It blocks the CPU, prevents lower-priority tasks from running, and breaks entirely if you change clock speeds. Every delay should be a blocking call that lets the scheduler run other work.

Configuring the Scheduler in FreeRTOSConfig.h for Your Hardware

Your FreeRTOSConfig.h is not boilerplate to be copied from a demo. The four scheduler-related macros directly determine system behavior, and choosing them incorrectly leads to bugs that only appear under load or temperature changes.

Configuration When to Enable (1) When to Disable (0) Impact on Behavior
configUSE_PREEMPTION Almost always; required for real-time response Only for very simple cooperative systems or formal verification Controls whether high-priority Ready tasks preempt immediately or only on yield/block
configUSE_TIME_SLICING When multiple tasks share a priority and need fairness When equal-priority tasks should run FIFO to completion Round-robin at tick rate; ignored if preemption is off
configUSE_TICKLESS_IDLE Battery-powered devices sleeping for seconds Systems with fast tick-dependent control loops Stops SysTick during idle to enter deep sleep; wakes on interrupt
configIDLE_SHOULD_YIELD Default (1) when idle and other priority-0 tasks share Set 0 to give idle task more CPU at priority 0 Determines if Idle yields to other priority-0 tasks when time slicing

One parameter beginners often misconfigure is configTICK_RATE_HZ. The common demo value of 1000 Hz is not mandatory. If you have a Cortex-M3 at 72 MHz running on battery, 100 Hz may be sufficient and will cut tick overhead by 10x. Conversely, if you need vTaskDelay() with 1 ms resolution, you need 1000 Hz. Validate this with power measurements, not assumptions. I have shipped products where moving from 1000 Hz to 200 Hz with tickless idle extended battery life by 14% with no functional change.

Another critical detail is configMAX_PRIORITIES. This is not the maximum priority value; it is the count of levels. Setting it to 32 when you only use 5 wastes RAM for 27 empty list heads and makes priority analysis harder. Keep it tight. And remember configKERNEL_INTERRUPT_PRIORITY and configMAX_SYSCALL_INTERRUPT_PRIORITY on ARM — if your peripheral interrupt priority is numerically lower (higher logical priority) than configMAX_SYSCALL_INTERRUPT_PRIORITY, you cannot call FreeRTOS FromISR APIs from it safely. I have debugged hard faults caused by exactly this misconfiguration.

Avoiding Common Scheduling Pitfalls: Starvation, Priority Inversion and Jitter

The scheduler does exactly what you tell it to. When the system misbehaves, it is usually because priorities tell it to do the wrong thing. The three failure modes I see most often are starvation, unbounded priority inversion, and excessive jitter.

Starvation is straightforward: a high-priority task that never blocks will starve everything below it. This often happens with a task written as while(1) { poll_sensor(); } without any vTaskDelay or blocking queue call. The fix is to restructure it to block. Even taskYIELD() is not a real fix — it only helps if there are other tasks at the same priority with time slicing enabled. For tasks that must poll rapidly, I block on a notification or semaphore with a short timeout, so the task still wakes frequently but allows lower-priority work to run when data is not ready.

Jitter is variation in when a periodic task actually runs. If your control task is priority 3 and a priority 4 logging task occasionally runs for 800 microseconds to flush to flash, your control task's start time will jitter by up to 800 microseconds. The solution is to keep high-priority tasks short and to move long operations like flash writes or JSON formatting to a lower-priority task, communicating via queues. I also raise the priority of the periodic task above the logging task, or use a hardware timer interrupt to unblock it with minimal latency.

Priority Inversion and What the Scheduler Cannot Fix Alone

Priority inversion happens when a low-priority task holds a resource needed by a high-priority task, and a medium-priority task preempts the low-priority holder, indirectly blocking the high-priority task. FreeRTOS mutexes with priority inheritance (xSemaphoreCreateMutex()) solve the bounded inversion case: when a high-priority task blocks on a mutex held by a low-priority task, the low-priority task temporarily inherits the high priority until it releases the mutex. This prevents medium-priority tasks from preempting it.

This does not work with binary semaphores, counting semaphores, or queues — only mutexes. If you use a semaphore to protect a shared I2C bus, you do not get inheritance. I have seen a medium-priority MQTT task starve a high-priority sensor task for 50 milliseconds because of this exact mistake. Use mutexes for mutual exclusion, semaphores for signaling. That distinction is essential, and the full synchronization picture is worth studying in RTOS Synchronization: Mutexes, Semaphores and Event Groups. For long-term stability, also consider how a stuck high-priority task can trigger watchdogs — a topic detailed in Watchdog Timers: Building Reliable Embedded Systems That Self-Recover.

To keep jitter measurable, enable run-time stats (configGENERATE_RUN_TIME_STATS and configUSE_TRACE_FACILITY) and review with a trace. Check that your highest-priority periodic task's inter-arrival time stays within your required tolerance. If you see spikes, look at what higher-priority ISR or task was running just before each spike. In my experience, once you trace the actual schedule for five minutes of real load, the root cause of most deadline misses becomes obvious.

Finally, remember that scheduling configuration is hardware-specific. A Cortex-M0+ and a RISC-V RV32IMAC handle interrupt priority and context switching differently, and tickless idle behaves differently across vendors. Always validate your FreeRTOSConfig.h against the port documentation for your exact MCU family and test scheduling behavior under worst-case CPU load, not just typical load. The scheduler is deterministic, but your system's use of it determines whether that determinism helps or hurts.

Frequently Asked Questions

What happens if two tasks have the same priority?

When preemption and time slicing are enabled, FreeRTOS runs equal-priority tasks in round-robin order on each tick interrupt. The scheduler keeps them in a FIFO ready list for that priority level; the task at the head runs, and on the next tick it is moved to the tail so the next task at that level runs. If time slicing is disabled, the first task to run at that priority will continue until it blocks or yields, and other tasks at the same priority will not run until that happens. If you need strict ordering between important tasks, assign distinct priorities instead of relying on time slicing.

Should I enable or disable configUSE_PREEMPTION?

Enable it (set to 1) for almost every IoT and embedded product. Preemption ensures a high-priority task that becomes ready — for example, after an ISR gives a semaphore — runs immediately rather than waiting for the current task to cooperate. Disabling preemption creates a cooperative scheduler where tasks must manually yield or block before any other task can run. That can simplify reasoning for tiny systems but causes unpredictable latency and is not suitable when you have timing deadlines, network stacks, or safety monitors. Keep preemption enabled and use blocking calls correctly.

Does increasing configTICK_RATE_HZ make scheduling more accurate?

It makes delays and time slicing more granular, but at a cost. A rate of 1000 Hz gives 1 ms resolution for vTaskDelay and time slicing slices, while 100 Hz gives 10 ms resolution. Higher rates increase CPU overhead because each tick is an interrupt that must run the scheduler, and they consume more power, which matters for battery devices. In my experience, 1000 Hz is good for development and for systems with fast control loops, but many production sensor nodes can drop to 200-250 Hz or use tickless idle and drive fast loops from a dedicated hardware timer instead of the kernel tick. Measure tick overhead on your hardware before deciding.

How do I debug a task that appears to starve lower-priority tasks?

First confirm the high-priority task is actually blocking. Add a run-time stats trace or toggle a GPIO when the task blocks and ensure it does. If the task loops without calling vTaskDelay, xQueueReceive, xSemaphoreTake, xTaskNotifyWait, or similar blocking APIs, it will starve everything below it when preemption is enabled. Second, check for priority inversion: a low-priority task holding a mutex needed by a high-priority task can cause the medium-priority task to run unexpectedly. Use xSemaphoreCreateMutex with priority inheritance for mutual exclusion, not a binary semaphore. A trace tool that shows which task runs and when will identify the culprit in minutes.

Related Articles

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