FreeRTOS powers billions of IoT devices worldwide — from simple temperature sensors to industrial control systems. It’s the most widely deployed embedded RTOS and is the default operating environment for ESP32, many Nordic Semiconductor designs, and a huge range of ARM Cortex-M products. Despite this ubiquity, many firmware engineers who encounter FreeRTOS for the first time find its concepts intimidating: tasks, schedulers, context switching, queues, semaphores, mutexes — the vocabulary is unfamiliar, and the consequences of misuse (race conditions, priority inversion, deadlocks) can be severe. This guide cuts through the complexity with practical examples drawn from real IoT use cases, giving you a solid foundation for using FreeRTOS effectively.
Why Use FreeRTOS? The Problem it Solves
Before diving into FreeRTOS specifics, understand what problem it solves. Consider an IoT device that must:
- Sample a temperature sensor every 500ms
- Maintain a BLE connection and process received commands
- Publish sensor readings to MQTT when connected to Wi-Fi
- Check battery voltage every 5 minutes and blink an LED warning when low
- Download and apply OTA firmware updates when notified
Without an RTOS, you’d implement this as a superloop with careful timing:
void main() {
while(1) {
if (500ms_timer_elapsed()) { read_temperature(); }
if (ble_event_pending()) { process_ble_event(); }
if (mqtt_message_pending()) { process_mqtt(); }
if (5min_timer_elapsed()) { check_battery(); }
// OTA check somewhere in here...
}
}
This works for simple cases but breaks down as complexity grows:
- How do you handle a long MQTT reconnection attempt without blocking the BLE handler?
- How do you ensure the temperature reading is never delayed more than 10ms regardless of what else is happening?
- How do you safely share a buffer between the sensor sampling code and the MQTT publishing code?
FreeRTOS solves these problems by letting you write each of these concerns as an independent task (a function that appears to run concurrently), with the RTOS scheduler handling the multiplexing.
FreeRTOS Core Concept: The Scheduler
The FreeRTOS scheduler is the heart of the RTOS. It’s a preemptive priority-based scheduler: at any moment, it runs the highest-priority task that is in the Ready state.
Task states:
- Running: Currently executing on the CPU (only one task can be Running at a time)
- Ready: Not blocked, waiting to run — will run when it becomes the highest-priority ready task
- Blocked: Waiting for something: a timer, a queue message, a semaphore, a mutex
- Suspended: Explicitly paused by the application (not waiting for anything specific)
The scheduler runs periodically (driven by the SysTick interrupt, typically at 1ms intervals with configTICK_RATE_HZ = 1000) and recalculates which task should be running.
Context switching: When the scheduler decides a different task should run (because a higher-priority task became ready, or the current task blocked), it performs a context switch: it saves the current task’s CPU registers (the “context”) to the task’s stack, then loads the saved context of the next task to run. From each task’s perspective, it appears to be running on a dedicated CPU — the context switch is transparent.
Creating Your First FreeRTOS Tasks
The basic task structure:
// Task function: must be a void function taking a void* parameter
void temperature_sensor_task(void *pvParameters) {
TickType_t xLastWakeTime = xTaskGetTickCount();
const TickType_t xPeriod = pdMS_TO_TICKS(500); // 500ms period
for (;;) { // Task functions must never return; use infinite loop
// Read sensor
float temperature = read_bme280_temperature();
// Post to queue for MQTT task to publish
xQueueSend(temperature_queue, &temperature, portMAX_DELAY);
// Wait until next period (accurate 500ms interval, accounting for execution time)
vTaskDelayUntil(&xLastWakeTime, xPeriod);
}
}
// Create the task (typically in main() before starting the scheduler)
void app_main(void) {
// Create queue (10 float values capacity)
temperature_queue = xQueueCreate(10, sizeof(float));
// Create temperature task: function, name, stack depth (words), params, priority, handle
xTaskCreate(temperature_sensor_task,
"TempSensor",
2048, // Stack size in bytes (or words, check your port)
NULL, // Parameters to pass (NULL here)
3, // Priority (higher number = higher priority)
NULL); // Task handle (NULL if not needed)
// Create other tasks...
vTaskStartScheduler(); // Hand control to FreeRTOS (never returns)
}
Key points:
- Task functions must never return. If you ever reach the end of a task function, the behavior is undefined (often a crash). Use
for(;;)orwhile(1). vTaskDelayUntil()creates a periodic task with accurate timing, compensating for the task’s own execution time. Use this instead ofvTaskDelay()when timing accuracy matters.- Stack size must be sufficient for all local variables, the maximum call depth, and any libraries used within the task. Start with 2048 bytes and monitor the stack high-water mark.
Queues: The Right Way to Share Data Between Tasks
Queues are thread-safe FIFO buffers for passing data between tasks and ISRs. They’re the preferred mechanism for inter-task communication in FreeRTOS.
IoT example: temperature sensor task sends data to MQTT publishing task
// Queue handle (global so both tasks can access it)
QueueHandle_t sensor_data_queue;
typedef struct {
float temperature;
float humidity;
uint32_t timestamp;
} SensorReading_t;
// Sender (temperature task)
void sensor_task(void *pvParameters) {
SensorReading_t reading;
for (;;) {
// Read sensors
reading.temperature = bme280_read_temperature();
reading.humidity = bme280_read_humidity();
reading.timestamp = xTaskGetTickCount();
// Send to queue; wait up to 100ms if queue is full
if (xQueueSend(sensor_data_queue, &reading, pdMS_TO_TICKS(100)) != pdTRUE) {
// Queue full — MQTT task not keeping up; log warning
log_warning("Sensor queue full, reading dropped");
}
vTaskDelay(pdMS_TO_TICKS(1000)); // 1 second interval
}
}
// Receiver (MQTT publishing task)
void mqtt_task(void *pvParameters) {
SensorReading_t received_reading;
for (;;) {
// Wait for data; block indefinitely until data arrives
if (xQueueReceive(sensor_data_queue, &received_reading, portMAX_DELAY) == pdTRUE) {
// Publish to MQTT
publish_temperature(received_reading.temperature);
publish_humidity(received_reading.humidity);
}
}
}
Why queues instead of global variables? A shared global variable accessed from two tasks without protection creates a race condition. If the sensor task writes to the global while the MQTT task reads from it, you may read a partially-written value (e.g., the old high byte and new low byte of a 32-bit integer). Queues handle the copying and synchronization internally — no race condition possible.

Semaphores: Signaling and Synchronization
Binary semaphores are used to signal between tasks or between an ISR and a task. Think of a binary semaphore as a flag that can be set (given) and cleared (taken).
IoT example: ISR signals a task when a button is pressed
SemaphoreHandle_t button_semaphore;
// ISR — runs at interrupt priority, must be minimal
void EXTI4_IRQHandler(void) { // Button GPIO interrupt
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// Give semaphore from ISR — use ISR-safe version
xSemaphoreGiveFromISR(button_semaphore, &xHigherPriorityTaskWoken);
// If a higher-priority task was woken, yield to it immediately
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// Task that handles button presses
void button_handler_task(void *pvParameters) {
for (;;) {
// Block here until semaphore is given (button is pressed)
if (xSemaphoreTake(button_semaphore, portMAX_DELAY) == pdTRUE) {
handle_button_press();
}
}
}
// Initialization
button_semaphore = xSemaphoreCreateBinary();
Counting semaphores work like a counter — they can be given multiple times and each take decrements the count. Used to track resource availability (e.g., “how many items are in this pool?”).
Mutexes: Protecting Shared Resources
Mutexes (mutual exclusion semaphores) protect shared resources from concurrent access. Unlike binary semaphores, mutexes include priority inheritance — when a high-priority task blocks waiting for a mutex held by a low-priority task, the low-priority task temporarily inherits the high-priority task’s priority, preventing it from being preempted by medium-priority tasks. This avoids priority inversion.
IoT example: protecting shared SPI bus used by multiple tasks
MutexHandle_t spi_bus_mutex;
// Task 1: reads a sensor over SPI
void sensor_task_1(void *pvParameters) {
for (;;) {
// Acquire SPI bus mutex before using the bus
if (xSemaphoreTake(spi_bus_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
// We have exclusive access to SPI
spi_cs_low(SENSOR_1_CS);
spi_transfer(sensor_command, response, len);
spi_cs_high(SENSOR_1_CS);
// Always release the mutex, even if an error occurred
xSemaphoreGive(spi_bus_mutex);
} else {
log_error("Could not acquire SPI bus");
}
vTaskDelay(pdMS_TO_TICKS(500));
}
}
Critical rule: Always call xSemaphoreGive() after xSemaphoreTake() — a task that takes a mutex and never gives it back will deadlock any other task that tries to take the same mutex. Structure your code so the give always executes, even on error paths.
Configuring FreeRTOS for Your IoT Application
FreeRTOS is configured through a FreeRTOSConfig.h file. Key parameters:
// FreeRTOSConfig.h — important settings for IoT firmware
#define configCPU_CLOCK_HZ 80000000 // MCU clock frequency
#define configTICK_RATE_HZ 1000 // Tick rate (1ms tick)
#define configMAX_PRIORITIES 8 // Number of priority levels
#define configMINIMAL_STACK_SIZE 128 // Minimum task stack (words)
#define configTOTAL_HEAP_SIZE (32 * 1024) // Total FreeRTOS heap (bytes)
// Use heap_4 for general IoT use (fragmentation-resistant)
// Use heap_1 if you never free tasks/queues after creation (lowest overhead)
#define configUSE_HEAP 4
// Enable stack overflow detection (uses configSTACK_DEPTH_TYPE callback)
#define configCHECK_FOR_STACK_OVERFLOW 2
// Enable runtime stats (for profiling)
#define configGENERATE_RUN_TIME_STATS 0 // Enable in development
// Enable assertions (catch misuse of FreeRTOS API)
#define configASSERT(x) if((x) == 0) { taskDISABLE_INTERRUPTS(); for(;;); }
IoT Task Architecture Example
For a typical sensor+connectivity IoT device, a clean FreeRTOS task architecture might look like:
| Task | Priority | Stack | Role |
|---|---|---|---|
| Sensor sampling | 4 (high) | 2KB | Reads sensors at fixed interval |
| Connectivity mgr | 3 | 4KB | Manages Wi-Fi/BLE connection state |
| MQTT publisher | 2 | 4KB | Publishes readings, handles responses |
| OTA updater | 1 | 8KB | Monitors for and applies updates |
| Logging | 1 (low) | 2KB | Buffers and outputs debug logs |
This structure ensures sensor sampling is never delayed by connectivity or OTA operations (which can be time-consuming), while still giving connectivity management priority over background tasks.
The FreeRTOS documentation on task priorities provides additional guidance on priority assignment.
Common FreeRTOS Mistakes to Avoid
Calling blocking API from ISR: Never call xQueueSend() from an ISR — use xQueueSendFromISR(). The same applies to all semaphore and mutex operations. The FromISR variants are interrupt-safe.
Inadequate stack size: The default suggestion of 2048 bytes is a starting point. Tasks that use printf, TLS libraries, or deep call chains need much more. Use uxTaskGetStackHighWaterMark() to check the minimum free stack during development.
Using vTaskDelay() for precise timing: vTaskDelay(500ms) delays 500ms from when the delay is called. If the task took 20ms to execute before the delay, the period is 520ms. Use vTaskDelayUntil() for accurate periodic timing.
Forgetting to give a mutex: Structure mutex usage with a single acquisition point and a single release point. In C, this often means a goto cleanup or wrapper function pattern.
For complete FreeRTOS reference documentation, the FreeRTOS book by Richard Barry is authoritative and free to download. UABit’s firmware development team uses FreeRTOS daily in production IoT products across a range of industries.
Conclusion
FreeRTOS provides the concurrency model that makes complex IoT firmware manageable. Tasks allow you to write each concern independently and let the scheduler handle the interleaving. Queues provide safe inter-task communication. Semaphores enable event signaling. Mutexes protect shared resources. Together, these four primitives handle the vast majority of coordination problems in IoT firmware.
Start simple: create two tasks communicating through a queue, run them on your hardware, and observe how the scheduler manages them. Add complexity incrementally. The learning investment in FreeRTOS pays off enormously in the clarity and reliability of IoT firmware that uses it correctly.
Further reading:
- FreeRTOS official documentation — comprehensive RTOS reference
- FreeRTOS book (free PDF) — the definitive FreeRTOS guide
- Embedded.com FreeRTOS tutorials — practical RTOS programming articles
- ESP-IDF FreeRTOS documentation — ESP32-specific FreeRTOS guide
- Nordic Academy FreeRTOS course — free RTOS course for nRF devices
IoT & AIoT Weekly
Get the best IoT development content delivered weekly. No noise, just signal.