Chapter 10: Introduction to Concurrency and RTOS
A super loop works until it doesn't. When your system has multiple tasks with different timing requirements—a motor controller running at 10 kHz, a UI updating at 10 Hz, and a network stack handling packets with variable timing—a single loop becomes unwieldy. This is where a Real-Time Operating System (RTOS) enters. An RTOS provides multitasking: the illusion that multiple tasks run simultaneously, with deterministic scheduling and inter-task communication.
What Is an RTOS?
An RTOS is a lightweight operating system designed for embedded systems. Unlike desktop OSes (Linux, Windows) that optimize for throughput and fairness, an RTOS optimizes for determinism: tasks must meet their deadlines consistently.
Key differences from desktop OSes:
| Feature | Desktop OS | RTOS |
|---|---|---|
| Scheduling | Fairness (everyone gets CPU time) | Priority-based (highest-priority task runs) |
| Latency | Variable (milliseconds to seconds) | Bounded (microseconds) |
| Memory | Virtual memory, paging | Flat memory, no protection (usually) |
| Processes | Isolated, protected | Shared address space |
| Interrupts | Complex, layered | Direct, minimal overhead |
| Footprint | Hundreds of MB | 2-20 KB |
Popular RTOSes for embedded systems: FreeRTOS, Zephyr, ThreadX, RT-Thread, and embOS. FreeRTOS is the most widely used, especially on ARM Cortex-M.
Misconception: "An RTOS is a mini-Linux. I can run multiple programs and use threads like on desktop."
Reality: An RTOS provides tasks (threads) but not process isolation. All tasks share one address space. A bad pointer in one task corrupts memory in another. The RTOS provides scheduling and synchronization, not protection.
Tasks: The Basic Unit of Concurrency
A task (also called a thread) is an independent execution context with its own stack and its own program flow. Tasks are scheduled by the RTOS kernel based on priority.
Creating Tasks in FreeRTOS
#include "FreeRTOS.h"
#include "task.h"
void motor_control_task(void *params) {
(void)params;
uint32_t period_ticks = pdMS_TO_TICKS(1); // 1 ms period
while (1) {
// Read sensor
float position = read_encoder();
// Compute control output
float output = pid_compute(position);
// Apply output
set_pwm(output);
// Wait until next period
vTaskDelayUntil(&last_wake_time, period_ticks);
}
}
void uart_task(void *params) {
(void)params;
while (1) {
// Wait for data (blocking)
uint8_t byte;
if (xQueueReceive(uart_rx_queue, &byte, portMAX_DELAY)) {
process_byte(byte);
}
}
}
int main(void) {
// Initialize hardware...
// Create tasks
xTaskCreate(motor_control_task, "Motor", 512, NULL, 3, NULL);
xTaskCreate(uart_task, "UART", 256, NULL, 2, NULL);
xTaskCreate(display_task, "Display", 1024, NULL, 1, NULL);
// Start scheduler (never returns)
vTaskStartScheduler();
// Should never reach here
while (1) {}
}
Each task has:
- Stack size: In words (ARM) or bytes depending on RTOS. Must be sufficient for the task's worst-case stack usage.
- Priority: Higher number = higher priority in FreeRTOS (0 to configMAX_PRIORITIES-1).
- Entry function: The task's main loop.
- Parameters: Optional data passed to the task.
Task States
A task can be in one of several states:
Ready → Running → Blocked → Ready
↑ ↓
Suspended ← Suspended
- Ready: Waiting for CPU time
- Running: Currently executing
- Blocked: Waiting for a queue, semaphore, or delay
- Suspended: Explicitly suspended (not in FreeRTOS by default)
The scheduler always runs the highest-priority Ready task. If two tasks have the same priority, they time-slice (round-robin).
Scheduling: Priority-Based Preemption
The RTOS scheduler is the core of the system. It runs whenever:
- A task yields or blocks
- An interrupt wakes a higher-priority task
- A timer tick occurs (configurable, typically 1 ms)
The scheduler selects the highest-priority Ready task and switches to it. This is called context switching: saving the current task's registers and stack, and restoring the next task's.
// High-priority task: runs immediately when ready
void high_priority_task(void *params) {
while (1) {
// This task runs whenever it is Ready
// If it blocks (vTaskDelay, queue receive), lower tasks run
vTaskDelay(pdMS_TO_TICKS(10)); // Block for 10 ms
}
}
// Low-priority task: only runs when high-priority task is Blocked
void low_priority_task(void *params) {
while (1) {
// This runs in the "gaps" when high-priority task is blocked
}
}
Priority Inversion
A subtle problem occurs when a low-priority task holds a resource needed by a high-priority task:
// Low-priority task acquires mutex
// Medium-priority task preempts low-priority task
// High-priority task tries to acquire mutex, but it's held by low-priority
// High-priority task blocks, medium-priority runs indefinitely
// Low-priority never gets CPU to release the mutex
// Result: high-priority task starves, system misses deadlines
Solutions include priority inheritance (FreeRTOS supports this) and careful priority assignment.
Inter-Task Communication
Tasks need to exchange data and coordinate. The RTOS provides synchronization primitives.
Queues: Data Transfer Between Tasks
Queues are the primary data-passing mechanism. They are thread-safe and blocking:
// Create a queue for UART data
QueueHandle_t uart_rx_queue = xQueueCreate(64, sizeof(uint8_t));
// Producer: ISR or task
void uart_isr(void) {
uint8_t byte = UART->DR;
xQueueSendFromISR(uart_rx_queue, &byte, NULL);
}
// Consumer: task
void uart_task(void *params) {
uint8_t byte;
while (1) {
if (xQueueReceive(uart_rx_queue, &byte, portMAX_DELAY)) {
process_byte(byte);
}
}
}
Queues can hold any data type, including structs:
typedef struct {
uint16_t sensor_id;
uint32_t timestamp;
float value;
} sensor_reading_t;
QueueHandle_t sensor_queue = xQueueCreate(10, sizeof(sensor_reading_t));
// Producer
sensor_reading_t reading = { .sensor_id = 1, .timestamp = now(), .value = 23.5 };
xQueueSend(sensor_queue, &reading, 0);
// Consumer
sensor_reading_t received;
if (xQueueReceive(sensor_queue, &received, pdMS_TO_TICKS(100))) {
// Process received data
}
Semaphores: Signaling Between Tasks
Semaphores are counters used for signaling. A binary semaphore signals an event; a counting semaphore counts occurrences.
// Binary semaphore: signals when data is ready
SemaphoreHandle_t data_ready = xSemaphoreCreateBinary();
// Producer
void producer_task(void *params) {
while (1) {
produce_data();
xSemaphoreGive(data_ready); // Signal: data is ready
}
}
// Consumer
void consumer_task(void *params) {
while (1) {
if (xSemaphoreTake(data_ready, portMAX_DELAY)) {
consume_data(); // Woken when producer signals
}
}
}
Mutexes: Mutual Exclusion
Mutexes protect shared resources. Unlike semaphores, mutexes have ownership: only the task that acquired the mutex can release it.
// Shared resource
static uint8_t shared_buffer[256];
static SemaphoreHandle_t buffer_mutex;
void write_shared_buffer(uint8_t *data, uint16_t len) {
if (xSemaphoreTake(buffer_mutex, pdMS_TO_TICKS(100))) {
// Critical section
memcpy(shared_buffer, data, len);
xSemaphoreGive(buffer_mutex);
} else {
// Timeout: couldn't acquire mutex
}
}
ISRs and the RTOS
Chapter 5 covered bare-metal ISRs. With an RTOS, ISRs interact with the kernel:
// ISR wakes a task
void uart_isr(void) {
BaseType_t higher_priority_woken = pdFALSE;
uint8_t byte = UART->DR;
xQueueSendFromISR(uart_rx_queue, &byte, &higher_priority_woken);
// If a higher-priority task was woken, request context switch
portYIELD_FROM_ISR(higher_priority_woken);
}
Key rules for ISRs with an RTOS:
- Use
...FromISRvariants of API functions (e.g.,xQueueSendFromISR, notxQueueSend) - Pass
&higher_priority_wokento determine if a context switch is needed - Call
portYIELD_FROM_ISR()at the end if needed - Keep ISRs short; defer processing to tasks
Memory Management in an RTOS
An RTOS typically provides its own memory management for kernel objects. FreeRTOS offers several heap implementations:
- heap_1: No freeing. Simple, deterministic.
- heap_2: Allows freeing but no coalescing. Fragmentation possible.
- heap_3: Wraps standard
malloc/free. Non-deterministic. - heap_4: Allows freeing with coalescing. Best general choice.
- heap_5: Like heap_4 but supports multiple memory regions.
The RTOS heap is separate from the application heap. Tasks, queues, and semaphores are allocated from the RTOS heap at runtime. Application data should still use static allocation (Chapter 6) or memory pools.
Timing and Delays with an RTOS
The vTaskDelay and vTaskDelayUntil functions provide time-based blocking:
// Delay for 100 ms
vTaskDelay(pdMS_TO_TICKS(100));
// Precise periodic task
TickType_t last_wake_time = xTaskGetTickCount();
while (1) {
// Do work...
// Wait until exactly one period after last wake
vTaskDelayUntil(&last_wake_time, pdMS_TO_TICKS(10));
}
vTaskDelayUntil is preferred for periodic tasks because it accounts for execution time. If the task takes 2 ms and the period is 10 ms, vTaskDelayUntil waits 8 ms; vTaskDelay(10) would wait 10 ms, making the actual period 12 ms.
A Complete RTOS Example
Here is a complete system: a sensor reader, a data processor, and a display updater.
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
// Shared queues
static QueueHandle_t sensor_queue;
static QueueHandle_t processed_queue;
// Shared flag (protected by mutex)
static float current_temperature = 0.0f;
static SemaphoreHandle_t temp_mutex;
typedef struct {
uint32_t timestamp;
float raw_value;
} sensor_data_t;
typedef struct {
uint32_t timestamp;
float temperature_celsius;
} processed_data_t;
void sensor_task(void *params) {
sensor_data_t data;
while (1) {
// Read sensor (blocking)
data.raw_value = read_temperature_sensor();
data.timestamp = xTaskGetTickCount();
// Send to processing task
if (xQueueSend(sensor_queue, &data, pdMS_TO_TICKS(10)) != pdPASS) {
// Queue full: drop sample
}
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(100)); // 10 Hz
}
}
void processing_task(void *params) {
sensor_data_t input;
processed_data_t output;
while (1) {
if (xQueueReceive(sensor_queue, &input, portMAX_DELAY)) {
// Process data
output.temperature_celsius = (input.raw_value * 3.3f / 4096.0f) * 100.0f;
output.timestamp = input.timestamp;
// Update shared variable
if (xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(10))) {
current_temperature = output.temperature_celsius;
xSemaphoreGive(temp_mutex);
}
// Send to display
xQueueSend(processed_queue, &output, 0);
}
}
}
void display_task(void *params) {
processed_data_t data;
while (1) {
if (xQueueReceive(processed_queue, &data, pdMS_TO_TICKS(100))) {
display_temperature(data.temperature_celsius);
}
// Display also updates at a fixed rate even without new data
}
}
int main(void) {
// Initialize hardware...
// Create queues
sensor_queue = xQueueCreate(10, sizeof(sensor_data_t));
processed_queue = xQueueCreate(5, sizeof(processed_data_t));
// Create mutex
temp_mutex = xSemaphoreCreateMutex();
// Create tasks (priority: processing > sensor > display)
xTaskCreate(sensor_task, "Sensor", 256, NULL, 2, NULL);
xTaskCreate(processing_task, "Processing", 512, NULL, 3, NULL);
xTaskCreate(display_task, "Display", 512, NULL, 1, NULL);
// Start scheduler
vTaskStartScheduler();
while (1) {}
}
When to Use an RTOS (and When Not To)
Use an RTOS when:
- Multiple tasks with different timing requirements
- Blocking operations (UART, I2C, network) that shouldn't stall other work
- Complex state machines that are easier to express as tasks
- Need for inter-task communication (queues, semaphores)
- Third-party middleware (TCP/IP, USB, file systems) expects an RTOS
Stick with a super loop when:
- Simple, single-purpose firmware
- Limited RAM (RTOS overhead: 2-10 KB for kernel, plus task stacks)
- Hard real-time requirements that are easier to meet with interrupts
- Certification constraints (some safety standards have simpler certification for single-threaded code)
- Power-sensitive applications (RTOS tick wakes the CPU periodically)
What to Actually Learn vs. Look Up
Look up (as needed):
- Specific FreeRTOS API function signatures
- RTOS configuration options (FreeRTOSConfig.h)
- Vendor-specific RTOS ports
- Advanced features (event groups, task notifications, stream buffers)
Deeply understand:
- Task states and priority-based scheduling
- How queues provide thread-safe data transfer
- The difference between semaphores and mutexes
- Priority inversion and how to avoid it
- How ISRs interact with the RTOS scheduler
- Stack sizing for tasks
- The trade-offs of RTOS vs. super loop
Key Takeaways
- An RTOS provides multitasking, not protection. All tasks share one address space.
- Priority-based scheduling is deterministic. The highest-priority ready task always runs.
- Queues are the primary inter-task communication mechanism. They are thread-safe and blocking.
- Semaphores signal events; mutexes protect resources. Mutexes have ownership; semaphores do not.
- ISRs use special API variants.
...FromISRfunctions andportYIELD_FROM_ISR. - Task stacks must be sized carefully. Too small = stack overflow; too large = wasted RAM.
- An RTOS adds overhead. Consider whether a super loop suffices.
This completes the course. You now have the foundation to navigate embedded C codebases, write clean drivers, handle interrupts safely, manage memory without an OS, structure modular firmware, debug on real hardware, and scale to RTOS-based systems. The next step is practice: pick a real project, apply these patterns, and learn the specifics of your target MCU.