RTOS Preemptive Priority Task Switching in C

This article explains the mechanics of preemptive priority-based context switching within a real-time operating system (RTOS) written in C. It covers how an RTOS detects the need for a switch, evaluates task priorities, preserves CPU state, and resumes execution of a higher-priority thread. By exploring the interaction between high-level C logic and low-level assembly routines, you will understand how microcontrollers transition between tasks deterministically.

Core Data Structures: The TCB and Stack

At the center of an RTOS scheduler is the Task Control Block (TCB). Implemented as a C struct, the TCB stores the state of a thread:

typedef struct TCB {
    void *stack_ptr;           // Current top of stack pointer
    uint8_t priority;          // Task priority level
    struct TCB *next;          // Link to next TCB in ready list
    /* Additional task metadata */
} TCB_t;

Each task possesses its own dedicated stack allocated in RAM. When a task is executing, the CPU's stack pointer (SP) points somewhere within that task's private stack area. When a task is idle or preempted, its stack_ptr field inside its TCB holds the exact address of its most recently saved CPU register frame.

The Trigger for Preemption

Preemption occurs whenever an event causes a task with a higher priority than the currently running task to enter the READY state. This happens primarily through two mechanisms:

  1. Hardware Timer Interrupt (SysTick): A periodic hardware interrupt fires at fixed intervals (e.g., every 1 ms). The interrupt service routine (ISR) increments the system tick, updates delay counters, and evaluates whether a sleeping high-priority task has awakened.
  2. Asynchronous Events / API Calls: An ISR or the running task itself posts a semaphore, sends a message to a queue, or yields. If this action unblocks a higher-priority task, a context switch is requested immediately.

The Scheduling Decision in C

When a rescheduling event is triggered, the scheduler identifies the highest-priority ready thread. Real-time kernels often maintain an array of ready lists indexed by priority or use a priority bitmap combined with a hardware instruction like Count Leading Zeros (CLZ) to achieve \(O(1)\) lookup time:

TCB_t *volatile current_tcb;
TCB_t *volatile next_tcb;

void schedule(void) {
    uint8_t highest_priority = get_highest_priority();
    next_tcb = ready_list[highest_priority];

    if (next_tcb != current_tcb) {
        trigger_context_switch(); 
    }
}

The function trigger_context_switch() does not immediately swap registers in pure C. Instead, it asserts a software interrupt (such as the PendSV interrupt on ARM Cortex-M processors) to ensure the actual switch occurs at the lowest interrupt priority, preventing nested interrupt conflicts.

The Mechanics of Context Switching

Because C cannot manipulate core CPU registers directly, the context switch is performed by an assembly wrapper that coordinates with the C scheduler. The switch follows a defined sequence:

  1. Saving Hardware Context: The CPU enters the context-switch exception handler. Modern architectures (like ARM Cortex-M) automatically push a subset of registers (such as R0-R3, R12, LR, PC, and xPSR) onto the current task's stack.
  2. Saving Software Context: The assembly handler manually pushes the remaining general-purpose registers (such as R4-R11) onto the current task's stack.
  3. Updating the Outgoing TCB: The CPU stack pointer is read and written into current_tcb->stack_ptr.
  4. Selecting the New Task: The pointer is updated: current_tcb = next_tcb;.
  5. Loading the Incoming TCB: The CPU stack pointer register is updated with the address stored in next_tcb->stack_ptr.
  6. Restoring Software Context: The remaining registers (R4-R11) of the new task are popped from its stack.
  7. Exception Return: The assembly handler executes an exception return instruction. The CPU hardware automatically pops the rest of the register frame (R0-R3, R12, LR, PC, xPSR), restoring the Program Counter to the exact instruction where the new task was previously preempted.

The switch completes deterministically, allowing the preempting task to run until it blocks or is preempted by an even higher-priority task.