The Essence of Task Switching in FreeRTOS

In embedded development, multi-task concurrency relies on task switching. This article focuses on FreeRTOS, starting from the concept of task switching, breaking down its “trigger → request → execute” process, analyzing core function source code and functionality, helping developers understand the logic of hardware-software co-design, and mastering practical methods for optimizing task scheduling and troubleshooting exceptions in projects.

01

The Concept of Task Switching

In embedded development, “multi-task concurrency” is the key technology that allows a single CPU to handle multiple tasks simultaneously — for example, collecting sensor data while displaying an interface and responding to button operations. However, the CPU is essentially “single-threaded” and can only execute one task at a time; this “concurrency” is actually achieved through task switching.

The essence of task switching is to save the current task’s “execution context” (CPU register values, program counter, stack pointer, etc., collectively referred to as “context”) to that task’s dedicated stack at the appropriate moment (for example, system timer, task voluntarily yielding), and then restore the “execution context” from the next task’s stack, allowing the CPU to seamlessly continue executing the next task. It’s like “saving document progress” at work, so you can continue from where you left off the next time you open it. FreeRTOS uses this “save – restore” mechanism to allow multiple tasks to “take turns” on the CPU.

For FreeRTOS, the core design of task switching is “hardware-software collaboration”: utilizing hardware features (such as the PendSV interrupt of the ARM Cortex-M architecture and automatic register stacking) to ensure switching efficiency, while using software logic (scheduler, task control block) to manage switching timing and task priority, ultimately achieving low-latency, high-reliability multi-task scheduling.

02

The Task Switching Process in FreeRTOS

Task switching in FreeRTOS does not occur randomly; it follows a fixed process of “trigger → request → execute”, with all operations centered around “safety” and “efficiency” — for example, using a low-priority PendSV interrupt to avoid switching that interrupts high-priority tasks, and using the scheduler to ensure that “the highest priority task is executed first”.

1. Three Trigger Scenarios for Task Switching

In practical projects, task switching is mainly triggered by the following three scenarios, covering the vast majority of embedded application needs:

  • Task voluntarily yields: After reaching a certain point in execution, the task actively calls taskYIELD() to give up the CPU (for example, when it does not need to continuously occupy resources in a loop);

  • System timer trigger: The SysTick timer periodically interrupts (usually every 1ms to 10ms), triggering time slice rotation or checking if delayed tasks are due;

  • Interrupt wakes up high-priority tasks: After processing external interrupts (such as buttons, sensors), it wakes up higher-priority ready tasks, requiring immediate switching.

2. Complete Process of Task Switching

Taking the ARM Cortex-M architecture (the most commonly used architecture in embedded systems) as an example, the complete process of task switching in FreeRTOS V9.0.0 (hereinafter referred to as FreeRTOS) is as follows:

The Essence of Task Switching in FreeRTOS

Trigger phase: Regardless of the scenario, the switch is ultimately triggered by “setting the PendSV interrupt pending bit” — PendSV is a low-priority interrupt specifically designed for operating systems in the ARM architecture, ensuring that switching occurs after all high-priority tasks/interrupts have been processed, avoiding interference with critical logic;

Context saving phase: Upon entering the PendSV interrupt, the CPU automatically pushes the R0-R3, R12, LR, PC, and xPSR registers onto the current task stack, while the remaining R4-R11 are pushed manually by assembly code, and finally updates the stack pointer of the current task control block (TCB);

Scheduling decision phase: Calls vTaskSwitchContext() to traverse the ready task list, find the highest priority ready task, and point the global pointer pxCurrentTCB to the new task’s TCB;

Context restoration phase: Retrieves the stack pointer from the new task’s TCB, manually restores the R4-R11 registers, updates the stack pointer (PSP), and when exiting the interrupt, the CPU automatically restores the R0-R3 registers, allowing the new task to continue executing from where it was interrupted.

03

Core Functions of Task Switching

FreeRTOS’s task switching functions are divided into two categories: “architecture-dependent” and “architecture-independent”: architecture-dependent functions (such as xPortPendSVHandler()) are stored in the portable directory due to differences in CPU architecture (such as Cortex-M3/M4) and compilers (such as GCC/RVDS), while architecture-independent functions (such as vTaskSwitchContext()) are uniformly stored in tasks.c for cross-platform reuse.

1. Trigger functions: Initiate switch requests

1. taskYIELD(): The “entry macro” for voluntary task switching

This is the upper-level interface for tasks to actively request a switch, essentially a macro definition that ultimately calls the lower-level switch trigger function;

When to call: When the task code needs to voluntarily yield the CPU (for example, waiting for an external signal in a loop, not wanting to occupy resources);

Absolute path: FreeRTOS/Source/include/task.h;

// Macro definition, adapted for different architectures, under Cortex-M architecture it ultimately triggers the PendSV interrupt#define taskYIELD() portYIELD()// Corresponding portYIELD() definition in port.h (for Cortex-M as an example)#define portYIELD() {     /* Write PendSV interrupt pending bit, trigger interrupt */     portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;     /* Memory barrier: ensure instruction execution is complete, avoid compiler reordering */     __dsb( portSY_FULL_READ_WRITE );     __isb( portSY_FULL_READ_WRITE ); }

2. xPortSysTickHandler(): System timer trigger switch

SysTick timer interrupt service function, updates the system time base (xTickCount), checks if tasks are due, and triggers PendSV when a switch is needed;

When to call: SysTick timer periodic interrupt (for example, once every 1ms, initialized by vPortSetupTimerInterrupt());

Absolute path: FreeRTOS/Source/portable/[compiler]/[architecture]/port.c (example: GCC/ARM_CM3/port.c);

void xPortSysTickHandler( void ){    /* Enter critical section: protect time base update, avoid being interrupted by other interrupts */    vPortRaiseBASEPRI();    {        /* xTaskIncrementTick(): update time base, check if delayed tasks are due           returns pdTRUE indicating a task switch is needed */        if( xTaskIncrementTick() != pdFALSE )        {            /* Trigger PendSV interrupt, execute switch */            portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;        }    }    /* Exit critical section */    vPortClearBASEPRIFromISR();}

3. portYIELD_FROM_ISR(): Trigger switch in interrupt

This is a dedicated switch trigger function in the interrupt service routine (ISR), avoiding direct switching in the ISR to ensure safety;

When to call: After waking up a high-priority task in the ISR (for example, a button interrupt waking up the interface update task);

Absolute path: FreeRTOS/FreeRTOS/Source/portable/GCC/ARM_CM3/portmacro.h;

#define portEND_SWITCHING_ISR( xSwitchRequired ) if( xSwitchRequired != pdFALSE ) portYIELD()#define portYIELD_FROM_ISR( x ) portEND_SWITCHING_ISR( x )

2. Execution functions: Complete context switching

4. xPortPendSVHandler(): The “execution core” of switching

PendSV interrupt service function, written in assembly, responsible for saving the current task context, calling the scheduler, and restoring the new task context;

When to call: After the PendSV interrupt is triggered (initiated by the aforementioned trigger functions);

Absolute path: FreeRTOS/Source/portable/[compiler]/[architecture]/portasm.s (example: GCC/ARM_CM3/portasm.s);

__asm void xPortPendSVHandler( void ){    extern pxCurrentTCB;       // Global pointer: points to the current task's TCB    extern vTaskSwitchContext; // Core scheduler function    PRESERVE8                   // Ensure stack alignment (Cortex-M requires 8-byte alignment)    /* 1. Get the current task's stack pointer (PSP: process stack pointer, tasks use PSP, interrupts use MSP) */    mrs r0, psp    isb                         // Instruction synchronization barrier: ensure instruction execution order    /* 2. Save the current task's R4-R11 registers to the stack (CPU automatically saves R0-R3, etc.) */    ldr r3, =pxCurrentTCB       // r3 = &pxCurrentTCB    ldr r2, [r3]                // r2 = pxCurrentTCB (current task TCB)    stmdb r0!, {r4-r11}         // R4-R11 pushed to stack, stack pointer r0 decremented    str r0, [r2]                // Update TCB's stack pointer: TCB->pxTopOfStack = r0    /* 3. Call the scheduler: select the next task to run */    stmdb sp!, {r3, r14}        // Temporarily save r3 and r14 to main stack (MSP)    bl vTaskSwitchContext       // Call the scheduler, update pxCurrentTCB    ldmia sp!, {r3, r14}        // Restore r3 and r14    /* 4. Restore the new task's R4-R11 registers */    ldr r1, [r3]                // r1 = new task's TCB (pxCurrentTCB has been updated)    ldr r0, [r1]                // r0 = new task's stack pointer    ldmia r0!, {r4-r11}         // R4-R11 popped from stack, stack pointer r0 incremented    msr psp, r0                 // Update PSP to new task's stack pointer    isb    /* 5. Exit interrupt: CPU automatically restores R0-R3, R12, LR, PC, xPSR */    bx r14    nop                         // NOP instruction to ensure instruction integrity}

3. Scheduling functions: Decide “who runs next”

5. vTaskSwitchContext(): Core of the scheduler

Traverses the ready task list, selects the highest priority ready task, and updates the pxCurrentTCB pointer;

When to call: Called in xPortPendSVHandler(), it is the “decision-making phase” of the switch;

Absolute path: FreeRTOS/Source/tasks.c;

void vTaskSwitchContext( void ){    /* Check if the scheduler is suspended (e.g., after calling vTaskSuspendAll()) */    if( uxSchedulerSuspended != ( UBaseType_t ) pdFALSE )    {        /* Mark that a switch is needed when the scheduler is suspended, to be executed after resuming */        xYieldPending = pdTRUE;    }    else    {        xYieldPending = pdFALSE;        /* Stack overflow check (optional, enabled by configCHECK_FOR_STACK_OVERFLOW) */        taskCHECK_FOR_STACK_OVERFLOW();        /* Core: select the highest priority ready task */        taskSELECT_HIGHEST_PRIORITY_TASK();        /* Track task switching (optional, for debugging) */        traceTASK_SWITCHED_IN();    }}

5.1 taskSELECT_HIGHEST_PRIORITY_TASK(): Priority selection macro

This is a macro called internally in vTaskSwitchContext() to quickly find the highest priority non-empty ready list;

When to call: When the scheduler selects tasks;

Absolute path: FreeRTOS/Source/tasks.c;

#define taskSELECT_HIGHEST_PRIORITY_TASK() {     UBaseType_t uxTopPriority;     /* Start from the highest priority, find the first non-empty ready list */     for( uxTopPriority = uxTopReadyPriority;          listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) != pdFALSE;          uxTopPriority-- )     {         configASSERT( uxTopPriority ); /* Ensure priority is not less than 0 */     }     /* Update pxCurrentTCB to the new task's TCB */     listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) );     /* Update highest ready priority (optimize for next search) */     uxTopReadyPriority = uxTopPriority; }

04

Conclusion

By dissecting the task switching in FreeRTOS, we can see the “delicate design” behind it, and these ideas are also applicable to other real-time operating systems (RTOS), providing important guidance for actual project development.

For developers, mastering the principles of task switching in FreeRTOS allows for quick application in actual projects through the following methods:

  • Active switching: Use taskYIELD() in non-urgent tasks to avoid occupying the CPU;

  • Timed switching: Use vTaskDelay() to delay tasks, triggering SysTick switching;

  • Interrupt switching: Use portYIELD_FROM_ISR() in ISR to wake up high-priority tasks;

  • Debugging and locating: Check the pxCurrentTCB pointer and task stack to troubleshoot switching anomalies.

Previous articles:

Introduction to a domestic low-power series MCU

The difference between firmware and software in embedded development

The role of the embedded “clock tree”

MCU registers and library functions, how should you choose?

AI smart rings: small size, great technological energy

Detailed explanation of motor principles and classifications

Introduction to the selection and layout of peripheral components in switch power supply (DCDC) design

Bare-metal programming vs. RTOS programming

Development board “battle royale”: which of the five popular development boards from Feilin, Youzan, Tianmai, etc. is your “favorite”?

Calculating the stack size for FreeRTOS tasks

Leave a Comment