Introduction
Have you ever wondered how your phone can play music, receive messages, and allow you to browse the web smoothly at the same time? One of the key contributors behind this is the multitasking operating system. In the embedded world, the Real-Time Operating System (RTOS) is the magician that enables this “simultaneous” processing of multiple tasks, allowing your lab workstation to execute several tasks concurrently.
Today, we will delve into the core magic of RTOS—multitasking and scheduling mechanisms—to see how it makes a single-core CPU appear to be a multitasking expert.
The Illusion of Concurrency: The “Shadow Clone Technique” of Single-Core CPUs
First, we need to clarify a key concept:conventional single-core processors can physically execute only one instruction at a time. The so-called “simultaneous execution of multiple tasks” is actually an illusion created by rapid context switching. RTOS creates the illusion that each task is monopolizing the CPU by quickly pausing and resuming multiple tasks, similar to the principle of animation:rapidly switching static images → the illusion of continuous motion.
Comparison of Execution Modes:

(█ indicates execution, ░ indicates pause)
The Brain Center: Scheduler and Scheduling Policies
-
Scheduler: Determines “who runs next”.
-
Context Switching: The process of resuming/pausing tasks.
-
Scheduling Policy
-
Desktop OS: Emphasizes “fairness”.
-
RTOS: Emphasizes “determinism” (critical tasks must be completed within a specified time).
For example, in FreeRTOS:
<span>configUSE_PREEMPTION=1</span>(preemptive), with the same priority<span>configUSE_TIME_SLICING=1</span>(time-slicing), high-priority tasks can preempt at any time.
Two Major Opportunities for Task Switching
When will the scheduler interrupt the current task and switch to another? There are mainly two situations:
1) Implicit Switching (Passive Preemption)
Tasks are unaware when the kernel forcibly switches them out. It’s like you are working diligently when suddenly a more urgent phone call comes in.
-
Event-Driven: For example, responding to external interrupts (button presses, data arrival), high-priority tasks become ready, and the kernel will immediately preempt the current task to execute it.
-
Time Slice Expiration: Even without external events, each task usually has a “time slice”. Once it runs out, the kernel will forcibly switch to allow other tasks of the same priority to run, preventing one task from monopolizing the CPU.
a) Event-Driven Preemption—High-Priority Task Ready
// High-Priority Task: Data Processing Task void vDataProcessTask(void* pvParameters) { for (;;) { xSemaphoreTake(xDataReadySemaphore, portMAX_DELAY); // Wait for data to be ready printf("Data Processing Task: Processing data...\n"); } } // UART Receive Interrupt Service Routine void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; volatile uint8_t data = USART1->DR; // Read to clear the register xSemaphoreGiveFromISR(xDataReadySemaphore, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // Switch immediately before exiting the interrupt }Execution Flow: Low-Priority Task Running → Receive Interrupt → Wake Up High-Priority Task → Immediate Preemption.
b) Time Slice Expiration Preemption—Fair Round Robin for Same Priority
void vTaskA(void* pvParameters) { for(;;) { printf("A\n"); /* Non-blocking */ } } void vTaskB(void* pvParameters) { for(;;) { printf("B\n"); /* Non-blocking */ } }Flow: A→A→A→Time Slice Expiration→B→B→B→Time Slice Expiration→A→…
2) Explicit Switching (Voluntary Yield)
Tasksvoluntarily call an API to yield the CPU, just like you finish a phase of work and voluntarily give the opportunity to others or take a break.
a) Yield—Voluntarily Give Up Remaining Time Slice
void vCalculationTask(void* pvParameters) {
for (;;) {
for (int i = 0; i < 1000; ++i) {
doSomeCalculation(i); // Calculate a small segment
}
taskYIELD(); // Actively yield the CPU (only affects same priority)
}
}
Some Scenarios Encountered in Laboratory Automation Equipment Development
Long-running programs can be split (Laboratory Liquid Workstation: Algorithm Planning/CSV Generation/Log Compression): Break large calculations into smaller chunks, and after each chunk, <span><span>taskYIELD()</span></span>, to avoid starvationof same-priority UI/TCP/485 tasks.
Insert taskYIELD in batch processing loops (Filter: Traverse liquid sensors for quick health checks): Insert <span><span>taskYIELD()</span></span> in the code every 2-3 channels checked, allowing same-level communication or interface refresh to “cut in” for a round.
Incorrect Usage: Using <span><span>taskYIELD()</span></span> when “waiting for data”
for (;;) {
if (!uart_has_byte()) {
taskYIELD(); // Thinking it's "waiting", but actually continues immediately (may spin)
continue;
}
handle_byte();
}
Correct Usage: Blocking wait for data
for (;;) {
uint8_t ch;
xQueueReceive(uartRxQ, &ch, portMAX_DELAY); // Truly "wait until continue"
handle_byte(ch);
}
Incorrect Usage: Trying to give low-priority tasks a chance
// High-Priority Task
for (;;) {
do_some_cpu_work();
taskYIELD(); // Low priority still cannot run (you are still the highest priority and ready)
}
Correct Usage: Briefly blocking or periodically delaying
for (;;) {
do_some_cpu_work();
vTaskDelay(1); // Current task temporarily not ready, low priority has a chance
}
Note:
<span>taskYIELD()</span>does not indicate waiting for events/times, and cannot yield across priorities; lower priority tasks want to run? The scheduler will not select them unless you block/delay yourself, temporarily disappearing from the “ready” list. If you need to wait, please use blocking/events.
b) Sleep/Delay—Timed Blocking, Run Again at the Set Time
void vHeartbeatTask(void* pvParameters) {
for (;;) {
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
vTaskDelay(pdMS_TO_TICKS(1000)); // Delay 1 second
}
}
The core is just two sentences:
1. No “ready” signal: Fixed wait for a short while (give a safety margin based on actual conditions).
2. With “ready” signal: Wait until the signal arrives to continue, and wait an additional 10–30 ms after the signal arrives to create a small “debounce” window for stability.
Without a “ready” signal, fixed periodic waiting (recommended <span><span>vTaskDelayUntil()</span></span> to prevent drift)
Device: Pump Speed/Temperature Control PID
Requirement: Sample and control every 10ms cycle
void vPumpPidTask(void* arg) {
const TickType_t T = pdMS_TO_TICKS(10);
TickType_t next = xTaskGetTickCount();
for (;;) {
float q = read_flow();
pid_update(q, setpoint);
apply_pwm();
vTaskDelayUntil(&next, T); // Stabilize 10 ms cycle
}
}
With “ready” event (switch debounce wait):
// Task Side
set_valve_async(port);
if (xSemaphoreTake(xValveDoneSem, pdMS_TO_TICKS(200)) == pdTRUE) {
vTaskDelay(pdMS_TO_TICKS(20)); // Wait 20ms after ready (debounce)
} else {
// Timeout handling/alarm
}
// Interrupt Side (e.g., limit switch ready)
void EXTIx_IRQHandler(void) {
BaseType_t hp = pdFALSE;
xSemaphoreGiveFromISR(xValveDoneSem, &hp);
portYIELD_FROM_ISR(hp);
}
Note: During waiting, the task will block and not occupy the CPU. However, its task stack is still retained (for example, each task 1–2KB). If you have many concurrent “waiting for 3 seconds” tasks, many stacks will be occupied. If you are concerned about “stack occupation during waiting”, you can delegate “do something after 3 seconds” to software timers, return immediately (without letting any task sleep and occupy the stack). Once 3 seconds are up, the timer service task will call your callback.
A Vivid Execution Flow
Let’s combine a scenario to see how scheduling occurs:
# Legend: █=RUN ░=BLOCK ↯=SWITCH
# Events: t2=interrupt t4=lock t6=task3 blocked t10=unlock
Timeline (t=1..10)
TIME = | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |10 |
TASK2 = | ░ | █ | █ | █ | ↯ | ░ | ░ | █ | █ | ░ | # High Priority (Network/IO)
TASK1 = | █ | ↯ | ░ | ░ | ░ | █ | █ | ░ | ░ | ░ | # Medium Priority (Log/Algorithm)
TASK3 = | ░ | ░ | ░ | ░ | █ | ░ | ░ | ░ | ░ | █ | # Low Priority (Background/Batch)
EVENTS = | | 中 | | 锁 | | 阻 | | | | 解 |
-
Moment 1: Task 1 is executing (processing logs).
-
Moment 2: The kernel receives an interrupt and finds that Task 2 (processing network data) is ready with data that has arrived, and its priority is higher.
-
Moment 3: The kernel forcibly switches out Task 1, switching in Task 2.
-
Moment 4: Task 2 is executing and has locked a certain SPI peripheral.
-
Moment 5: Task 2’s time slice expires, and the kernel switches it out, switching in a lower-priority Task 3.
-
Moment 6: Task 3 also wants to use that SPI peripheral, finds it locked, and thus actively blocks itself, entering the wait queue.
-
Moment 7: The kernel again switches in Task 1 to continue execution.
-
Moment 8: After a while, the kernel schedules Task 2 again.
-
Moment 9: Task 2 completes its operation and unlocks the SPI peripheral.
-
Moment 10: This unlock event immediately wakes up the blocked Task 3, and the kernel quickly schedules it to start execution.
Throughout the process, all three tasks “feel” like they are running continuously, while the CPU efficiently completes all work amidst their competition.
Conclusion
The multitasking scheduling mechanism of RTOS is like a highly skilled conductor, allowing each instrument (task) to sound at precise moments, ultimately weaving into a harmonious symphony.
Understanding preemption and yielding, priority and state transitions is the cornerstone of mastering RTOS programming. With this foundation, you can design embedded systems that are both efficient and responsive in real-time, ensuring that every second of CPU time works hard for you, rather than “slacking off”.