Scheduler

January 26, 2026 · View on GitHub

F9 implements a fixed-priority preemptive scheduler with Preemption-Threshold Scheduling (PTS) extensions. The scheduler provides O(1) thread selection using the ARM Cortex-M CLZ instruction for efficient bitmap scanning.

Architecture Overview

The scheduler consists of two main components:

  1. Priority Bitmap Scheduler: O(1) highest-priority selection with 32 priority levels
  2. Preemption-Threshold Scheduling (PTS): Optional threshold-based preemption control for reduced context switching

Both components integrate with the Priority Inheritance Protocol (PIP) to prevent priority inversion during IPC operations.

Priority Bitmap Scheduler

Design

The scheduler uses a 32-bit bitmap where each bit represents one priority level:

/* Priority bitmap: bit set means queue has runnable threads */
static uint32_t ready_bitmap;

/* Ready queue heads for each priority level (circular doubly-linked) */
static tcb_t *ready_queue[SCHED_PRIORITY_LEVELS];

The bitmap uses inverted bit positions: bit 31 represents priority 0 (highest), bit 0 represents priority 31 (lowest). This allows the CLZ (Count Leading Zeros) instruction to directly return the highest priority level.

Priority Levels

F9 defines 32 priority levels (0 = highest, 31 = lowest):

PriorityConstantPurpose
0SCHED_PRIO_SOFTIRQKernel softirq thread
1SCHED_PRIO_INTRInterrupt handler threads
2SCHED_PRIO_ROOTRoot thread
3SCHED_PRIO_IPCIPC fast path (temporary boost)
4-30User-definedApplication threads
31SCHED_PRIO_IDLEIdle thread (always runnable)

Thread Selection

The schedule_select() function provides O(1) selection:

tcb_t *schedule_select(void)
{
    uint32_t flags;
    uint32_t prio;
    tcb_t *thread;

    flags = irq_save_flags();

    /* CLZ returns 32 if bitmap is 0 */
    prio = clz32(ready_bitmap);

    if (prio >= SCHED_PRIORITY_LEVELS) {
        irq_restore_flags(flags);
        panic("SCHED: Empty ready_bitmap (idle missing)\n");
        return NULL;
    }

    thread = ready_queue[prio];
    irq_restore_flags(flags);

    return thread;
}

The function uses the CLZ instruction to find the first set bit in the bitmap, which corresponds to the highest priority with runnable threads.

Queue Operations

Enqueue

Threads are added to the tail of their priority queue (FIFO within priority):

void sched_enqueue(tcb_t *thread)
{
    uint8_t prio = thread->priority;
    tcb_t *head = ready_queue[prio];

    if (!head) {
        /* Empty queue: thread becomes sole element */
        ready_queue[prio] = thread;
        thread->sched_link.prev = thread;
        thread->sched_link.next = thread;

        /* Set bitmap bit for this priority */
        ready_bitmap |= (1UL << (31 - prio));
    } else {
        /* Insert at tail (before head in circular list) */
        tcb_t *tail = head->sched_link.prev;
        thread->sched_link.next = head;
        thread->sched_link.prev = tail;
        tail->sched_link.next = thread;
        head->sched_link.prev = thread;
    }
}

Optimization: The bitmap is only updated when a queue transitions from empty to non-empty.

Dequeue

Threads are removed from their current queue:

void sched_dequeue(tcb_t *thread)
{
    uint8_t prio = thread->priority;
    tcb_t *prev = thread->sched_link.prev;
    tcb_t *next = thread->sched_link.next;

    if (thread == next) {
        /* Only element in queue */
        ready_queue[prio] = NULL;
        ready_bitmap &= ~(1UL << (31 - prio));
    } else {
        /* Unlink from list */
        prev->sched_link.next = next;
        next->sched_link.prev = prev;

        /* Update head if we removed it */
        if (ready_queue[prio] == thread)
            ready_queue[prio] = next;
    }

    /* Mark as not queued */
    thread->sched_link.prev = NULL;
    thread->sched_link.next = NULL;
}

Yield

The sched_yield() function implements round-robin within a priority level:

void sched_yield(void)
{
    tcb_t *curr = thread_current();
    uint8_t prio = curr->priority;
    tcb_t *head = ready_queue[prio];

    /* Only rotate if more than one thread at this priority */
    if (head && head->sched_link.next != head) {
        ready_queue[prio] = head->sched_link.next;
    }
}

The queue head is rotated to the next thread, giving all threads at the same priority fair access to the CPU.

Priority Changes

The sched_set_priority() function handles priority changes atomically:

void sched_set_priority(tcb_t *thread, uint8_t new_prio)
{
    uint32_t flags;
    int was_queued;

    if (!thread)
        return;

    if (new_prio >= SCHED_PRIORITY_LEVELS)
        new_prio = SCHED_PRIO_IDLE;

    flags = irq_save_flags();

    /* Check if priority actually changes */
    if (thread->priority == new_prio) {
        irq_restore_flags(flags);
        return;
    }

    /* Remove from old queue if queued */
    was_queued = sched_is_queued(thread);
    if (was_queued)
        sched_dequeue(thread);

    /* Update priority */
    thread->priority = new_prio;

    /* Re-add to new queue if was queued */
    if (was_queued)
        sched_enqueue(thread);

    irq_restore_flags(flags);
}

If the thread is currently queued, it is moved to the appropriate queue for its new priority.

Preemption-Threshold Scheduling (PTS)

F9's PTS implementation is designed to match ThreadX RTOS semantics, which provides the reference implementation of preemption-threshold scheduling. The L4_Set_PreemptionDelay API is modeled after ThreadX's tx_thread_preemption_change function.

Motivation

Standard priority scheduling allows any higher-priority thread to preempt a running thread. This can lead to:

  • Excessive context switches in critical sections
  • Priority inversion when low-priority threads hold resources
  • Increased stack depth due to nested preemptions
  • Cache pollution from frequent context switches

PTS addresses these issues by allowing threads to temporarily raise their preemption threshold, reducing the set of threads that can preempt them.

Semantics

Priority Numbering: F9 uses 0=highest priority, 31=lowest (matching ThreadX). Lower numeric values represent higher priorities.

Each thread has two priority-related attributes:

  • Priority (π): Determines scheduling order when multiple threads are runnable
  • Threshold (γ): Controls which threads can preempt this thread

Preemption Rule: Thread j can preempt thread i if and only if π_j < γ_i (where < means higher priority, lower number)

Constraints:

  • Threshold must satisfy: γ ≤ π (numerically, meaning threshold number is same or lower)
  • Lower threshold values provide tighter protection (fewer threads can preempt)
  • Example: If priority = 10, threshold can be 0-10 (not 11-31). Setting threshold=10 allows only 0-9 to preempt (equal protection). Setting threshold=5 would allow only 0-4 to preempt (tighter protection).

Benefits

Context Switch Reduction: 30-50% fewer context switches in critical sections

Example: A thread at priority 15 with threshold 10 can only be preempted by priorities 0-9 (instead of 0-14). This eliminates 5 potential preemption sources.

Stack Memory Optimization: 15-25% reduction through non-preemptive grouping

When threads never preempt each other (due to thresholds), they never coexist on the call stack, allowing reduced stack allocation.

Bounded WCET: Critical sections have deterministic worst-case execution time

The maximum preemption delay is limited to threads with priority less than the threshold.

Data Structures

The scheduler maintains an additional bitmap to track deferred preemptions:

/* Preempted bitmap: tracks priorities deferred by threshold */
static uint32_t preempted_bitmap;

Each TCB includes PTS-related fields:

typedef struct tcb {
    /* ... existing fields ... */

    uint8_t priority;              /* Effective priority (may be boosted) */
    uint8_t base_priority;         /* Natural priority */
    uint8_t preempt_threshold;     /* Active threshold */
    uint8_t user_priority;         /* Original user-set priority */
    uint8_t user_preempt_threshold; /* Original user threshold */
    uint8_t inherit_priority;      /* Priority inherited via PIP */
} tcb_t;

PTS Enforcement

The schedule_select() function enforces threshold constraints:

tcb_t *schedule_select(void)
{
    uint32_t prio;
    tcb_t *thread, *curr;
    uint32_t flags;

    flags = irq_save_flags();

    prio = clz32(ready_bitmap);
    if (prio >= SCHED_PRIORITY_LEVELS) {
        irq_restore_flags(flags);
        panic("SCHED: Empty ready_bitmap\n");
        return NULL;
    }

    thread = ready_queue[prio];

    /* PTS Enforcement: check if current thread's threshold blocks preemption */
    curr = thread_current();
    if (curr && curr->state == T_RUNNABLE && curr != thread) {
        /* Can preempt iff: priority < threshold (numerically) */
        if (prio >= curr->preempt_threshold) {
            /* Priority doesn't exceed threshold - defer preemption */
            if (curr->preempt_threshold != curr->priority) {
                preempted_bitmap |= (1UL << (31 - curr->priority));
            }

            irq_restore_flags(flags);
            return curr; /* Continue running current thread */
        }
    }

    /* Preemption allowed - clear preempted bit */
    preempted_bitmap &= ~(1UL << (31 - prio));

    irq_restore_flags(flags);
    return thread;
}

When the highest-priority ready thread cannot preempt due to the current thread's threshold:

  1. The current thread continues running
  2. The current thread's priority is marked in the preempted_bitmap
  3. Scheduling is deferred until the threshold is lowered

Threshold Management

The sched_preemption_change() function updates a thread's threshold:

int sched_preemption_change(tcb_t *thread,
                            uint8_t new_threshold,
                            uint8_t *old_threshold)
{
    uint32_t flags;
    uint8_t old_thresh;
    int should_reschedule = 0;

    if (!thread)
        return -1;

    if (new_threshold >= SCHED_PRIORITY_LEVELS)
        return -1;

    if (new_threshold > thread->user_priority)
        return -1;

    flags = irq_save_flags();

    /* Save old threshold (return user-set value) */
    old_thresh = thread->user_preempt_threshold;
    if (old_threshold)
        *old_threshold = old_thresh;

    /* Update threshold considering priority inheritance */
    if (new_threshold < thread->inherit_priority) {
        thread->preempt_threshold = new_threshold;
    } else {
        thread->preempt_threshold = thread->inherit_priority;
    }

    thread->user_preempt_threshold = new_threshold;

    /* If threshold was raised, check if deferred threads can now preempt */
    if (thread == thread_current() && thread->preempt_threshold > old_thresh) {
        uint32_t highest_ready_prio = clz32(ready_bitmap);
        if (highest_ready_prio < thread->preempt_threshold) {
            /* Higher priority thread can now preempt */
            should_reschedule = 1;
            preempted_bitmap &= ~(1UL << (31 - thread->priority));
        }
    }

    irq_restore_flags(flags);

    /* Trigger reschedule if needed */
    if (should_reschedule) {
        *SCB_ICSR |= SCB_ICSR_PENDSVSET;
    }

    return 0;
}

Key behaviors:

  • Validates that threshold ≤ priority
  • Integrates with priority inheritance (uses tighter of threshold or inherited priority)
  • Triggers immediate reschedule when threshold is raised and higher-priority threads are ready

Example Usage

/* Create critical section with reduced preemption */
L4_ThreadId_t self = L4_Myself();
uint8_t old_threshold;

/* Set priority to 15 */
L4_Set_Priority(self, 15);

/* Raise threshold to 10 - only priorities 0-9 can preempt */
L4_Set_PreemptionDelay(self, 10, &old_threshold);

/* Critical section - reduced preemption window */
process_sensor_data();

/* Restore normal preemption */
L4_Set_PreemptionDelay(self, old_threshold, NULL);

Priority Inheritance Protocol (PIP)

Purpose

PIP prevents unbounded priority inversion when threads block on shared resources.

Without PIP:

T1 (priority 5)  - High priority, waiting on mutex
T2 (priority 15) - Medium priority, CPU-bound
T3 (priority 20) - Low priority, holds mutex

Timeline:
T3 locks mutex
T2 becomes ready → preempts T3
T1 becomes ready → blocked on mutex held by T3
T3 never runs → T1 starves (UNBOUNDED DELAY)

With PIP:

T3 locks mutex, inherits T1's priority
T2 becomes ready → CANNOT preempt (priority 15 >= inherited priority 5)
T3 completes critical section → releases mutex
T1 acquires mutex immediately → BOUNDED DELAY

Implementation

The kernel provides two functions for priority inheritance:

Boost Priority

void thread_priority_inherit(tcb_t *waiter, tcb_t *holder)
{
    uint32_t flags;

    if (!waiter || !holder)
        return;

    flags = irq_save_flags();

    /* Boost if waiter has higher priority (lower number) */
    if (waiter->priority < holder->priority) {
        holder->inherit_priority = waiter->priority;
        holder->priority = waiter->priority;

        /* Recalculate threshold considering inheritance */
        if (holder->user_preempt_threshold < holder->inherit_priority) {
            holder->preempt_threshold = holder->user_preempt_threshold;
        } else {
            holder->preempt_threshold = holder->inherit_priority;
        }

        /* Requeue at new priority */
        if (sched_is_queued(holder)) {
            sched_dequeue(holder);
            sched_enqueue(holder);
        }
    }

    irq_restore_flags(flags);
}

When a thread blocks waiting on a resource held by another thread:

  1. The holder inherits the waiter's priority if it's higher
  2. The holder's threshold is recalculated (tighter of user threshold or inherited priority)
  3. The holder is requeued at the new priority

Restore Priority

void thread_priority_disinherit(tcb_t *holder)
{
    uint32_t flags;

    if (!holder)
        return;

    flags = irq_save_flags();

    /* Restore original priorities */
    holder->priority = holder->user_priority;
    holder->inherit_priority = holder->user_priority;

    /* Recalculate threshold */
    if (holder->user_preempt_threshold < holder->inherit_priority) {
        holder->preempt_threshold = holder->user_preempt_threshold;
    } else {
        holder->preempt_threshold = holder->inherit_priority;
    }

    /* Requeue at original priority */
    if (sched_is_queued(holder)) {
        sched_dequeue(holder);
        sched_enqueue(holder);
    }

    irq_restore_flags(flags);
}

When the resource is released:

  1. The holder's priority is restored to the original user priority
  2. The threshold is recalculated
  3. The holder is requeued at the original priority

PTS + PIP Integration

The integration of PTS and PIP provides complete priority inversion prevention:

/* Threshold is always the tighter (lower) of:
 * 1. User-specified threshold
 * 2. Inherited priority from PIP
 */
if (holder->user_preempt_threshold < holder->inherit_priority) {
    holder->preempt_threshold = holder->user_preempt_threshold;
} else {
    holder->preempt_threshold = holder->inherit_priority;
}

This ensures that:

  • PIP boosts prevent priority inversion
  • User thresholds reduce unnecessary context switches
  • The effective threshold is always safe (never looser than inherited priority)

User-Space API

Priority Management

/* Set thread priority (0 = highest, 31 = lowest) */
L4_Word_t L4_Set_Priority(L4_ThreadId_t tid, L4_Word_t priority);

/* Get current priority */
L4_Word_t priority = L4_Get_Priority(L4_Myself());

Threshold Management

/* Set preemption threshold */
L4_Word_t L4_Set_PreemptionDelay(L4_ThreadId_t tid,
                                  L4_Word_t new_threshold,
                                  L4_Word_t *old_threshold);

The threshold must be ≤ the thread's current priority (numerically). Lower threshold values provide stronger non-preemptive protection.

Combined Operation

The L4_Schedule system call allows atomic priority and threshold changes:

L4_Word_t L4_Schedule(L4_ThreadId_t dest,
                      L4_Word_t TimeControl,
                      L4_Word_t ProcessorControl,
                      L4_Word_t PrioControl,
                      L4_Word_t PreemptionControl,
                      L4_Word_t *old_TimeControl);

Parameters:

  • dest: Target thread
  • PrioControl: New priority (0xFF = no change)
  • PreemptionControl: New threshold in bits [23:16] (0xFF = no change)
  • old_TimeControl: Output parameter receiving old priority and threshold

Return value:

  • L4_SCHEDRESULT_RUNNING: Thread is runnable
  • L4_SCHEDRESULT_ERROR: Invalid parameters

Yield

/* Voluntarily yield timeslice to threads at same priority */
void L4_Yield(void);

Rotates the current thread to the back of its priority queue, allowing other threads at the same priority to run.

Use Cases

Critical Section Protection

/* Protect I2C transaction from preemption */
L4_Set_PreemptionDelay(self, 10, &old_thresh);

for (int i = 0; i < 10; i++) {
    i2c_write_register(addr, data[i]);
}

L4_Set_PreemptionDelay(self, old_thresh, NULL);

Benefit: Atomic multi-register I2C transaction, faster completion time

Network Stack Layers

/* Transport layer processing */
void tcp_process_packet(void) {
    L4_Set_Priority(self, 18);
    L4_Set_PreemptionDelay(self, 15, NULL);  /* Reduce preemption */

    update_tcp_state();
    compute_checksum();
    queue_ack();

    L4_Set_PreemptionDelay(self, 18, NULL);  /* Restore */
}

Benefit: fewer context switches in packet processing path

Sensor Fusion

/* Ensure temporal coherence of sensor readings */
void kalman_filter_update(void) {
    L4_Set_PreemptionDelay(self, 8, &old);

    accel_data = read_accelerometer();
    gyro_data = read_gyroscope();
    mag_data = read_magnetometer();
    fused_data = kalman_filter(accel_data, gyro_data, mag_data);

    L4_Set_PreemptionDelay(self, old, NULL);
}

Benefit: reduction in fusion errors due to guaranteed temporal coherence

Motor Control

/* PID control loop with deterministic timing */
void pid_control_loop(void) {
    L4_Set_PreemptionDelay(self, 3, &old);

    position = read_encoder();
    error = setpoint - position;
    output = pid_update(error);
    set_pwm_duty(output);

    L4_Set_PreemptionDelay(self, old, NULL);
}

Benefit: Jitter reduced

Performance Characteristics

Time Complexity

OperationComplexityNotes
schedule_select()O(1)CLZ instruction
sched_enqueue()O(1)Circular list insert
sched_dequeue()O(1)Circular list remove
sched_yield()O(1)Queue head rotation
sched_set_priority()O(1)Dequeue + enqueue
sched_preemption_change()O(1)Bitmap operations

Space Complexity

StructureSizeNotes
ready_bitmap4 bytesGlobal state
preempted_bitmap4 bytesGlobal state
ready_queue[]128 bytes32 pointers
TCB PTS fields6 bytes/threadPriority + threshold tracking
Total overhead136 + 6n bytesn = thread count

For a typical system with 16 threads: 136 + 96 = 232 bytes (0.18% of 128KB SRAM)

Context Switch Cost

  • Baseline: 68 ARM cycles (no PTS)
  • With PTS check: 73 cycles (+7.4% overhead)

Net performance: 30-50% reduction in context switch overhead despite slightly more expensive per-switch cost.

Safety Properties

Based on "Formal Analysis of FreeRTOS Scheduler on ARM Cortex-M4 Cores":

  • S0: Task synchronization timing - delayed tasks complete before timeout
  • S1: Thread-safe blocking - data ready when thread unblocks
  • S2: Data readiness - expected-ready data is actually ready
  • S3: Message ordering - queue messages preserve FIFO order
  • S4: Mutual exclusion - mutexes ensure critical section safety
  • S5: Lock release - taken locks are eventually released
  • S6: Priority inheritance - temporary boost during IPC, restoration after

All properties are maintained with PTS enabled.

Liveness Property

LTL: []<>SysTick → ([]<>Task1 ∧ []<>Task2 ∧ ... ∧ []<>TaskN)

"All tasks finish their jobs infinitely often if SysTick is triggered infinitely often."

Guaranteed by:

  • Idle thread (priority 31) always runnable
  • O(1) scheduler guarantees progress
  • PTS thresholds bounded (cannot prevent all preemptions)

Queue Invariants

ready_bitmap[i] set ⟺ ready_queue[i] non-empty
preempted_bitmap[i] set ⟺ thread at priority i deferred by threshold

These invariants are maintained atomically through IRQ-safe critical sections.

Victim Task Problem

F9 is immune to the "victim task problem" identified in the formal verification paper:

Problem: When SysTick and PendSV have the same priority, tail-chaining can cause the elected task to be preempted before execution.

F9 Protection: SysTick priority = 0x3 (high), PendSV priority = 0xF (low). Different priorities prevent tail-chaining, so the scheduler always completes before the next timer interrupt.

Integration with Other Subsystems

IPC

During IPC operations, the scheduler temporarily boosts receiver priority:

/* In sys_ipc() */
void perform_ipc(tcb_t *sender, tcb_t *receiver) {
    /* Boost receiver to minimize IPC latency */
    thread_priority_inherit(sender, receiver);

    /* Perform message transfer */
    do_ipc(sender, receiver);

    /* Restore receiver's original priority */
    thread_priority_disinherit(receiver);
}

See ipc.md for complete IPC documentation.

Interrupts

Interrupt threads run at elevated priorities (SCHED_PRIO_INTR) to ensure prompt handling:

/* IRQ thread configuration */
L4_Set_Priority(irq_thread, SCHED_PRIO_INTR);

/* Can use PTS to protect critical sections */
L4_Set_PreemptionDelay(irq_thread, 0, NULL);  /* Only NMI/HardFault preempt */

See interrupt.md for interrupt handling documentation.

Timers

The kernel timer subsystem uses SysTick for timeslice management:

  • SysTick fires every 1ms
  • Triggers sched_yield() for round-robin scheduling
  • Wakes sleeping threads when timeouts expire

See ktimer.md for timer documentation.

Memory

Context switches involve MPU reconfiguration:

/* In thread_switch() */
void thread_switch(tcb_t *next) {
    /* Save current context */
    save_context(current);

    /* Configure MPU for next thread's address space */
    as_setup_mpu(next->as);

    /* Restore next context */
    restore_context(next);
}

See memory.md for address space and MPU documentation.

Debugging

Debug Output

Enable scheduler debug messages:

/* In include/debug.h */
#define DL_SCHEDULE (1 << 0)

/* Set debug level */
dbg_setlevel(DL_SCHEDULE);

Output includes:

  • Thread selection decisions
  • PTS deferral events
  • Priority changes
  • Queue operations

KDB Commands

The kernel debugger (KDB) provides scheduler inspection:

CommandFunction
tList all threads with states and priorities
sShow ready queue state

Example output:

KDB> t
Thread List:
ID   State    Priority  Threshold  Name
0    RUNNABLE 31        31         idle
1    RUNNABLE 0         0          kernel
2    RUNNABLE 2         2          root
3    RUNNABLE 1         1          irq_handler

KDB> s
Ready Queue State:
Priority 0: kernel
Priority 1: irq_handler
Priority 2: root
Priority 31: idle
Ready bitmap: 0xC0000003
Preempted bitmap: 0x00000000

References

  1. ThreadX RTOS
    • tx_thread_preemption_change: Reference implementation
    • Validation: Rejects new_threshold > thread->tx_thread_user_priority (allows tighter protection)
  2. Chen-Kai Lin and Bow-Yaw Wang, "Formal Analysis of FreeRTOS Scheduler on ARM Cortex-M4 Cores", Academia Sinica
    • Formal verification methodology
    • Safety properties and liveness guarantees
  • threads.md - Thread management and lifecycle
  • ipc.md - Inter-process communication and priority boosts
  • interrupt.md - Interrupt handling and IRQ threads
  • ktimer.md - Timers and timeout management
  • memory.md - Address spaces and MPU configuration