When I first started working with microcontrollers, I assumed “multitasking” meant something like what my laptop does — dozens of programs running at once, switching between browser tabs and music players without a hitch. Embedded multitasking looks similar on the surface, but the mechanics underneath are completely different, and honestly, more interesting. In this article I want to walk you through exactly how a tiny chip with a single CPU core convinces the outside world that it’s doing several things “at the same time,” from the simplest bare-metal tricks to full-blown RTOS scheduling.
What “Multitasking” Really Means on a Microcontroller
Let’s get one myth out of the way immediately: on a single-core microcontroller, true parallelism doesn’t exist. At any given instant, the CPU is executing exactly one instruction. What we call multitasking is actually time-slicing — the processor switches between tasks so quickly (microseconds to milliseconds) that, from a human’s perspective, or even from the perspective of many electromechanical processes, everything appears simultaneous.
I like to compare it to a chef running a small kitchen alone. He’s not literally chopping vegetables and stirring soup at the exact same moment — he’s rapidly switching attention between multiple dishes, checking on each one just often enough that nothing burns. That’s precisely what a microcontroller’s scheduler does with tasks.
The Building Blocks of Embedded Architecture
Before I go into the multitasking mechanisms themselves, it helps to understand the hardware stage on which all this happens.
graph TD
A[Microcontroller Core - CPU] --> B[Flash Memory - Program Storage]
A --> C[SRAM - Data/Stack/Heap]
A --> D[Peripheral Bus]
D --> E[Timers]
D --> F[UART/SPI/I2C]
D --> G[GPIO]
D --> H[ADC/DAC]
A --> I[Interrupt Controller - NVIC]
I --> A
The CPU core executes instructions from flash, using SRAM as its scratchpad for variables, stacks, and heaps. The interrupt controller (on ARM Cortex-M chips this is the NVIC — Nested Vectored Interrupt Controller) is the single most important piece of hardware that makes multitasking possible, because it lets the CPU be “interrupted” out of whatever it’s doing to respond to something urgent, then resume later.
The Four Main Approaches to Embedded Multitasking
1. Superloop (Bare-Metal Round Robin)
This is the simplest and most common structure in beginner embedded projects. You write one big while(1) loop, and inside it you call a sequence of functions, each handling one “task” briefly before moving to the next.
int main(void) {
system_init();
uart_init();
adc_init();
led_init();
while (1) {
read_sensor_task();
update_display_task();
check_button_task();
blink_led_task();
}
}
The catch: every function must return quickly. If read_sensor_task() blocks for 500ms waiting on a slow sensor, everything else stalls for 500ms too. I ran into this constantly early on — a single delay_ms(1000) call anywhere in the loop would freeze my “multitasking” system completely. The fix is to write every task as a small state machine that does a tiny bit of work and returns immediately, checking timers to know when to advance to its next state.
2. Interrupt-Driven Multitasking
Interrupts let time-critical work happen immediately, regardless of what the main loop is doing. A UART receive interrupt, a timer overflow, or a button press can all pause the main loop mid-instruction, run a short Interrupt Service Routine (ISR), and return control right where it left off.
volatile uint8_t new_data_flag = 0;
volatile uint16_t adc_value = 0;
void ADC_IRQHandler(void) {
adc_value = ADC1->DR; // grab the converted value
new_data_flag = 1; // signal main loop
ADC1->SR &= ~ADC_SR_EOC; // clear interrupt flag
}
int main(void) {
system_init();
while (1) {
if (new_data_flag) {
process_adc_value(adc_value);
new_data_flag = 0;
}
do_other_work();
}
}
This pattern — ISR sets a flag, main loop checks the flag — is one of the most common ways I structure interrupt-driven firmware. It keeps ISRs short (which is important, since long ISRs block other interrupts) while still letting the main loop pick up urgent work quickly.
sequenceDiagram
participant Main as Main Loop
participant NVIC as Interrupt Controller
participant ISR as ADC ISR
Main->>Main: do_other_work()
NVIC-->>ISR: ADC conversion complete
ISR->>ISR: Save context
ISR->>ISR: Read ADC->DR, set flag
ISR->>NVIC: Restore context
NVIC-->>Main: Resume exactly where interrupted
Main->>Main: Notices flag, processes value
3. Cooperative Multitasking
In cooperative multitasking, several tasks exist as separate functions, but each task voluntarily “yields” control back to a scheduler when it’s done with its current chunk of work or when it needs to wait for something. No task is ever forcibly interrupted by another task — hence “cooperative.”
typedef void (*task_func_t)(void);
typedef struct {
task_func_t func;
uint32_t period_ms;
uint32_t last_run;
} task_t;
task_t task_list[] = {
{blink_led_task, 500, 0},
{read_sensor_task, 100, 0},
{uart_send_task, 200, 0},
};
void scheduler_run(void) {
uint32_t now = get_tick_ms();
for (int i = 0; i < 3; i++) {
if (now - task_list[i].last_run >= task_list[i].period_ms) {
task_list[i].func();
task_list[i].last_run = now;
}
}
}
int main(void) {
system_init();
while (1) {
scheduler_run();
}
}
This is a lightweight, timer-based cooperative scheduler — I’ve used this exact pattern on small AVR and STM32 projects where pulling in a full RTOS felt like overkill. It gives you clean, periodic task execution without the memory overhead of separate stacks for each task.
The downside is the same as the superloop: if one task misbehaves and never yields (say, it gets stuck in an infinite loop waiting on a sensor), the entire system hangs.
4. Preemptive Multitasking with an RTOS
This is the “real” multitasking most people picture, and it’s what a Real-Time Operating System like FreeRTOS, Zephyr, or ThreadX provides. Here, each task gets its own stack, and a scheduler — usually driven by a hardware timer (SysTick on ARM Cortex-M) — forcibly switches between tasks at fixed time intervals (a “tick”), regardless of whether a task wants to give up control or not.
graph LR
subgraph "RTOS Preemptive Scheduling"
T1[Task 1: Sensor Read - Priority 2]
T2[Task 2: Display Update - Priority 1]
T3[Task 3: Comms - Priority 3]
end
Sched[RTOS Scheduler] -->|SysTick interrupt every 1ms| T1
Sched --> T2
Sched --> T3
T3 -->|Highest priority ready| CPU[CPU Core]
Here’s a minimal FreeRTOS example creating two tasks:
#include "FreeRTOS.h"
#include "task.h"
void vTaskBlinkLED(void *pvParameters) {
for (;;) {
GPIO_Toggle(LED_PIN);
vTaskDelay(pdMS_TO_TICKS(500)); // yields CPU to other tasks
}
}
void vTaskReadSensor(void *pvParameters) {
for (;;) {
uint16_t value = adc_read();
process(value);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
int main(void) {
system_init();
xTaskCreate(vTaskBlinkLED, "LED", 128, NULL, 1, NULL);
xTaskCreate(vTaskReadSensor, "Sensor", 256, NULL, 2, NULL);
vTaskStartScheduler(); // hands control to the RTOS forever
while (1) {} // never reached
}
Each task here has its own priority, its own stack, and behaves as if it has the CPU entirely to itself. Under the hood, the RTOS kernel is doing context switching — saving all the CPU registers of the currently running task onto that task’s stack, then loading the registers of the next task to run.
The Context Switch, Step by Step
Context switching is the real mechanism behind preemptive multitasking, so it’s worth walking through carefully.
sequenceDiagram
participant Task1
participant SysTick as SysTick Timer
participant Kernel as RTOS Kernel
participant Task2
Task1->>Task1: Executing instructions
SysTick-->>Kernel: Tick interrupt fires
Kernel->>Task1: Save CPU registers to Task1's stack
Kernel->>Kernel: Scheduler picks next ready task
Kernel->>Task2: Load CPU registers from Task2's stack
Kernel->>Task2: Resume execution
- A hardware timer (SysTick) fires an interrupt at a fixed interval, commonly every 1ms.
- The kernel’s tick handler saves the current task’s context (program counter, stack pointer, general-purpose registers) onto that task’s own private stack.
- The scheduler algorithm (round-robin, priority-based, or a hybrid) decides which task should run next.
- The kernel loads the saved context of the newly selected task from its stack.
- Execution resumes in the new task exactly where it left off last time.
This entire sequence typically takes only a handful of microseconds on a modern Cortex-M processor, which is why it feels instantaneous.
Timing Diagram: Task Execution Over Time
gantt
dateFormat X
axisFormat %L ms
title RTOS Task Execution Timeline (illustrative, ms)
section Task 1 (High Priority)
Running :active, t1a, 0, 2
Running :active, t1b, 5, 2
section Task 2 (Medium Priority)
Running :active, t2a, 2, 3
section Task 3 (Low Priority)
Running :active, t3a, 7, 3
Notice how the high-priority task can preempt lower-priority ones whenever it becomes ready, while lower-priority tasks only run in the gaps.
Comparing the Four Approaches
| Approach | Determinism | Memory Overhead | Complexity | Best For |
|---|---|---|---|---|
| Superloop | Low | None | Very Low | Tiny, simple firmware |
| Interrupt-driven | Medium-High | Low | Low-Medium | Time-sensitive I/O handling |
| Cooperative scheduler | Medium | Low | Medium | Small multi-task systems without RTOS |
| Preemptive RTOS | High | Higher (per-task stacks) | Medium-High | Complex, real-time, multi-peripheral systems |
Memory Considerations
Every RTOS task needs its own stack, carved out of SRAM. On a resource-constrained MCU with, say, 20KB of RAM, if you create ten tasks each with a 512-byte stack, you’ve already consumed 5KB just for stacks — before accounting for the kernel’s own overhead, queues, semaphores, and your actual application data. This is one of the real trade-offs of preemptive multitasking: you get responsiveness and clean task isolation, but you pay for it in RAM. On very small AVR-class microcontrollers with just a couple of KB of RAM, a full RTOS often isn’t practical, and a cooperative scheduler or superloop is the more realistic choice.
Interrupt Priorities and Nested Interrupts
On ARM Cortex-M cores, the NVIC supports interrupt priority levels, and higher-priority interrupts can preempt lower-priority ones — this is called nesting. This matters for multitasking because your RTOS tick interrupt itself has a priority, and if you’re not careful about how you assign priorities to peripheral interrupts (UART, ADC, external GPIO), you can end up with a UART ISR blocking the RTOS tick for too long, causing scheduling jitter.
NVIC_SetPriority(SysTick_IRQn, 0); // Highest priority - keep scheduling accurate
NVIC_SetPriority(USART1_IRQn, 1);
NVIC_SetPriority(ADC1_IRQn, 2);
NVIC_SetPriority(EXTI0_IRQn, 3);
Real-World Example: A Weather Station
Let me tie this together with an example I’ve actually built. A small IoT weather station needs to:
- Sample a temperature/humidity sensor every second
- Read a light sensor via ADC every 500ms
- Publish readings over UART/Wi-Fi (ESP32) every 10 seconds
- Respond instantly to a button press to force an immediate reading
- Blink a status LED every second, unless in an error state
With a superloop, this is a headache to keep responsive — a slow Wi-Fi publish would delay the button response. With FreeRTOS on an ESP32, this becomes five independent tasks and one interrupt, each handling its own timing without stepping on the others:
xTaskCreate(vTempHumidityTask, "TempHum", 2048, NULL, 2, NULL);
xTaskCreate(vLightSensorTask, "Light", 1024, NULL, 2, NULL);
xTaskCreate(vPublishTask, "Publish", 4096, NULL, 1, NULL);
xTaskCreate(vLedStatusTask, "LED", 512, NULL, 1, NULL);
// Button handled via GPIO interrupt + a binary semaphore given to a task
This is a genuinely common pattern in production IoT firmware — sensor tasks, a comms task, and a UI/status task, all coordinated through queues and semaphores rather than shared global flags.
Inter-Task Communication
Multitasking wouldn’t be very useful if tasks couldn’t safely share data. RTOSes provide several primitives for this:
- Queues — pass data (like sensor readings) from one task to another safely.
- Semaphores — signal that an event has occurred (e.g., ISR signals a task that data is ready).
- Mutexes — protect a shared resource (like an I2C bus) from being accessed by two tasks simultaneously.
- Event groups — let a task wait on multiple conditions at once.
QueueHandle_t sensorQueue = xQueueCreate(10, sizeof(float));
void vSensorTask(void *pv) {
float reading;
for (;;) {
reading = read_temperature();
xQueueSend(sensorQueue, &reading, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void vLoggerTask(void *pv) {
float value;
for (;;) {
if (xQueueReceive(sensorQueue, &value, portMAX_DELAY)) {
log_to_flash(value);
}
}
}
Without a mutex protecting a shared I2C bus, two tasks trying to talk to two different sensors on the same bus at the same time will corrupt each other’s transactions — I learned this the hard way early on, chasing a bug where sensor readings occasionally came back garbled. Wrapping the I2C calls in xSemaphoreTake()/xSemaphoreGive() fixed it completely.
Power Management and Multitasking
RTOSes also tie into power management nicely. When no task has work to do, the idle task runs, and many RTOS ports let you hook into the idle task to put the MCU into a low-power sleep mode until the next interrupt or tick, waking automatically for the next scheduled task. This is critical for battery-powered IoT devices, where you might spend 99% of the time asleep and only wake briefly to sample a sensor or transmit data.
Debugging Multitasked Firmware
A few practices that have saved me significant time:
- Use a logic analyzer or oscilloscope to toggle a GPIO pin at the start/end of ISRs and tasks, so you can visually see timing on real hardware.
- Enable stack overflow checking in FreeRTOS (
configCHECK_FOR_STACK_OVERFLOW) — undersized task stacks are a very common source of mysterious crashes. - Use RTOS-aware debugging in tools like SEGGER J-Link/Ozone or STM32CubeIDE, which can show you each task’s state (Running, Ready, Blocked, Suspended) live.
- Watch for priority inversion, where a low-priority task holding a mutex blocks a high-priority task — FreeRTOS’s priority inheritance mutexes help mitigate this.
Security Considerations
Multitasking systems widen the attack surface a bit compared to simple superloops: a compromised or buggy task could corrupt another task’s memory if there’s no memory protection. On Cortex-M cores with an MPU (Memory Protection Unit), you can isolate task memory regions so one task can’t accidentally (or maliciously) write into another’s stack or the kernel’s data structures. This is increasingly relevant for connected/IoT devices, where a vulnerability in your comms task shouldn’t be able to compromise your safety-critical control task.
Frequently Asked Questions
Does every embedded system need an RTOS to multitask? No. Plenty of production firmware runs entirely on superloops or simple cooperative schedulers, especially for small, well-defined tasks. An RTOS becomes valuable once you have several independent timing requirements, need clean priority handling, or want to simplify a growing codebase.
Can two tasks really run “at the same time” on a single-core MCU? Not literally — it’s time-slicing. On multi-core MCUs (like dual-core ESP32 or STM32H7 dual-core parts), true parallel execution across cores is possible, and the RTOS or bare-metal code must handle two cores each running their own scheduler or task set.
What’s the difference between a task and an interrupt? An interrupt is triggered by hardware and runs an ISR immediately, outside the normal task scheduling — it always preempts tasks. A task is scheduled cooperatively or preemptively by the RTOS kernel according to priority and timing, not directly by hardware events (though it can be woken by one, e.g., via a semaphore given inside an ISR).
How many tasks can I create? Practically, this is limited by available RAM (each task needs its own stack) rather than any hard software limit. On a chip with 64KB–512KB of RAM, tens of tasks is common; on a chip with 2KB–8KB of RAM, you’ll want to stick to just a few, or avoid a full RTOS altogether.
Is FreeRTOS the only option? No — Zephyr RTOS, ThreadX (Azure RTOS), embOS, RT-Thread, and mbed OS are all widely used alternatives, each with different licensing, footprint, and ecosystem trade-offs.
Summary
Multitasking on an embedded system is fundamentally an illusion created by fast switching, not true parallel execution on a single core. That illusion can be built at several levels of sophistication: a simple superloop that cycles through tasks, interrupts that handle urgent events immediately, a cooperative scheduler that lets tasks yield voluntarily, or a full preemptive RTOS that forcibly time-slices tasks based on priority using hardware timer interrupts and context switching. The right choice depends entirely on your system’s complexity, timing requirements, and available RAM — and in my experience, it’s worth starting with the simplest approach that solves your problem, then reaching for an RTOS once your task list and timing requirements genuinely demand it.
References and Further Reading
- ARM Cortex-M Programming Guide to Memory Barrier Instructions — developer.arm.com
- FreeRTOS Official Documentation — freertos.org/Documentation/RTOS_book.html
- STMicroelectronics STM32 Reference Manuals — st.com (search by part number, e.g., RM0090 for STM32F4)
- Espressif ESP32 Technical Reference Manual — espressif.com/en/support/documents/technical-documents
- Atmel/Microchip AVR Instruction Set Manual — microchip.com
- Arduino Official Documentation — docs.arduino.cc
- Zephyr Project Documentation — docs.zephyrproject.org