This project is an introduction to FreeRTOS on ESP32, demonstrating fundamental concepts of multitasking and resource synchronization. Two tasks compete to blink an LED at different rates while using mutexes to prevent resource conflicts.
- Creating and managing multiple FreeRTOS tasks
- Mutex synchronization for shared resource protection
- Task delays and timing in FreeRTOS
- Core affinity - pinning tasks to specific CPU cores
- Race condition prevention in concurrent systems
#define LED_PIN 2Hardware Requirements:
- ESP32 development board
- Built-in LED (GPIO 2) or external LED with resistor
- USB cable for programming and serial monitoring
Task 1: BlinkTask Task 2: BlinkFastTask
(1 second intervals) (250ms intervals)
↓ ↓
[Mutex] [Mutex]
↓ ↓
Shared Resources:
- LED (GPIO 2)
- Serial Port
The Problem: Both tasks want to control the same LED and print to the same serial port. Without synchronization, this causes:
- Garbled serial output (interleaved messages)
- Unpredictable LED behavior
- Data corruption
The Solution: Mutexes ensure only one task accesses shared resources at a time.
A mutex (Mutual Exclusion) is a synchronization primitive that ensures only one task can access a shared resource at a time. Think of it as a key to a locked room - only the holder can enter.
SemaphoreHandle_t serialMutex;
SemaphoreHandle_t ledMutex;We use separate mutexes for different resources:
-
serialMutex: Protects the Serial port- Prevents garbled/interleaved print statements
- Ensures atomic message output
-
ledMutex: Protects the LED GPIO- Ensures complete blink cycles aren't interrupted
- Prevents one task from turning LED on while another turns it off
serialMutex = xSemaphoreCreateMutex();
ledMutex = xSemaphoreCreateMutex();What happens:
- Allocates memory for mutex control structure
- Initializes mutex in "available" state
- Returns handle for future operations
Returns:
- Valid handle on success
- NULL if insufficient memory (should check in production code)
xSemaphoreTake(ledMutex, portMAX_DELAY);Parameters:
ledMutex: The mutex to acquireportMAX_DELAY: Wait forever if mutex is held by another task
Behavior:
- If mutex is available → acquire it immediately, proceed
- If mutex is held → block (sleep) until it becomes available
- Prevents two tasks from entering critical section simultaneously
Alternative timeout:
xSemaphoreTake(ledMutex, pdMS_TO_TICKS(100)); // Wait max 100msxSemaphoreGive(serialMutex);
xSemaphoreGive(ledMutex);What happens:
- Releases the mutex
- Wakes up any task waiting for it (highest priority first)
- Must be called by the same task that took it
Critical Rule: Always release mutexes in reverse order of acquisition to prevent deadlock.
While we use xSemaphoreCreateMutex(), mutexes have special properties:
| Feature | Mutex | Binary Semaphore |
|---|---|---|
| Ownership | Yes - only owner can release | No |
| Priority Inheritance | Yes - prevents priority inversion | No |
| Recursive | Can be (with recursive mutex) | No |
| Use Case | Protecting resources | Signaling between tasks |
void BlinkTask(void* parameter)Purpose: Blinks LED slowly (1 second on, 1 second off) while printing status messages.
for (;;) { // Infinite loop - standard for FreeRTOS tasksTasks never return - they run forever in an infinite loop.
xSemaphoreTake(ledMutex, portMAX_DELAY);
xSemaphoreTake(serialMutex, portMAX_DELAY);Locking order:
- First lock LED mutex (controls hardware)
- Then lock serial mutex (controls output)
Why this matters: Consistent locking order prevents deadlock. If one task locks A then B, and another locks B then A, they can deadlock.
digitalWrite(LED_PIN, HIGH);
Serial.println("BlinkTask: LED ON");
vTaskDelay(1000 / portTICK_PERIOD_MS); // 1000msProtected operations:
- Turn LED on (hardware access)
- Print message (serial access)
- Delay for 1 second
vTaskDelay() Explained:
- Suspends the task for specified ticks
- Releases CPU to other tasks (cooperative multitasking)
portTICK_PERIOD_MS: Converts milliseconds to FreeRTOS ticks- Formula:
delay_ms / portTICK_PERIOD_MS
digitalWrite(LED_PIN, LOW);
Serial.println("BlinkTask: LED OFF");
vTaskDelay(1000 / portTICK_PERIOD_MS);Same pattern - turn off, print, delay.
Serial.print("BlinkTask running on core ");
Serial.println(xPortGetCoreID());xPortGetCoreID(): Returns 0 or 1, identifying which CPU core is executing this code.
ESP32 has two cores:
- Core 0: Usually runs WiFi/Bluetooth stack
- Core 1: User tasks (we pin our tasks here)
xSemaphoreGive(serialMutex);
xSemaphoreGive(ledMutex);Release order: Reverse of acquisition (LIFO - Last In, First Out)
- Unlock serial first
- Unlock LED second
Critical: After releasing, other waiting tasks can proceed.
void BlinkFastTask(void* parameter)Purpose: Blinks LED rapidly (250ms on, 250ms off).
vTaskDelay(250 / portTICK_PERIOD_MS); // 250ms instead of 1000msTiming:
- 4x faster than BlinkTask
- Creates contention - both tasks want LED frequently
- Perfect test case for mutex synchronization
Despite different timing, the structure is identical:
- Acquire mutexes (blocking if unavailable)
- Control LED + print messages
- Delay (yield CPU)
- Release mutexes
This demonstrates: Same synchronization pattern works for any task, regardless of timing.
A critical section is code that must execute atomically (without interruption by other tasks accessing the same resources).
xSemaphoreTake(ledMutex, portMAX_DELAY); // ← Enter critical section
xSemaphoreTake(serialMutex, portMAX_DELAY);
// ... operate on LED and Serial ...
xSemaphoreGive(serialMutex); // ← Exit critical section
xSemaphoreGive(ledMutex);Inside: Safe to access LED and Serial - no other task can interfere. Outside: Other tasks can run concurrently.
Scenario: Both tasks print at the same time:
Task 1: "BlinkTask: L"
Task 2: "BlinkFastTask: LED "
Task 1: "ED ON"
Task 2: "ON"
Result: BlinkTask: LBlinkFastTask: LED ED ONON (gibberish!)
With mutexes: Complete messages print atomically.
void setup() {
Serial.begin(115200);Initializes serial communication at 115200 baud for debugging output.
pinMode(LED_PIN, OUTPUT);Configures GPIO 2 as output for LED control. Must be done before tasks start using it.
serialMutex = xSemaphoreCreateMutex();
ledMutex = xSemaphoreCreateMutex();Critical timing: Create mutexes BEFORE creating tasks. If tasks run before mutexes exist, they'll crash trying to take NULL handles.
Production tip: Check for NULL:
if (!serialMutex || !ledMutex) {
Serial.println("Mutex creation failed!");
while(1) {} // Halt
}xTaskCreatePinnedToCore(
BlinkTask, // Task function pointer
"BlinkTask", // Human-readable name (max 16 chars)
10000, // Stack size in bytes
NULL, // Parameters to pass (none)
1, // Priority (0 = lowest, higher number = higher priority)
&BlinkTaskHandle, // Handle to task (for suspend/resume/delete)
1 // Core ID (0 or 1)
);1. Task Function:
- Must have signature:
void TaskName(void* parameter) - Never returns (infinite loop inside)
2. Task Name:
- For debugging and monitoring
- Visible in tools like SystemView or FreeRTOS task lists
3. Stack Size (10000 bytes):
- Memory allocated for task's local variables and call stack
- 10KB is generous for this simple task
- Too small → stack overflow (watchdog reset)
- Too large → wasted RAM
4. Parameters (NULL):
- Data passed to task via
parameterargument - Could pass config struct, pointers, etc.
- NULL means no data passed
5. Priority (1):
- Both tasks have same priority → time-sliced fairly
- Higher priority tasks preempt lower ones
- Idle task has priority 0
6. Task Handle (&BlinkTaskHandle):
- Allows controlling task after creation
- Can use for:
vTaskSuspend(),vTaskResume(),vTaskDelete() - NULL if you don't need control
7. Core ID (1):
- Pin task to specific core (ESP32-specific)
- Core 0: WiFi stack usually runs here
- Core 1: User tasks (cleaner separation)
- Use
tskNO_AFFINITYto let scheduler choose
xTaskCreatePinnedToCore(BlinkFastTask, "BlinkFastTask", 10000, NULL, 1, &BlinkFastTaskHandle, 1);Same priority (1): Tasks share CPU equally. When one blocks (delay or waiting for mutex), the other runs.
Same core (1): Both run on Core 1, but scheduler switches between them.
Serial.println("Setup complete");Confirms initialization finished. Tasks are now running independently.
void loop() {
// Empty because FreeRTOS scheduler runs the task
}Why empty?
- FreeRTOS tasks handle all work
loop()still exists (Arduino framework requirement)- Runs as a task at priority 1
- Since it's empty, just yields CPU to other tasks
Time 0ms:
- setup() runs on Core 1
- Serial initialized
- GPIO configured
- Mutexes created
- Tasks created (enter ready state)
- Scheduler starts
Time 10ms:
- BlinkTask starts (arbitrary - depends on scheduler)
- Takes ledMutex ✓
- Takes serialMutex ✓
- Turns LED ON
- Prints "BlinkTask: LED ON"
- Enters vTaskDelay(1000ms)
- Releases CPU
Time 15ms:
- BlinkFastTask starts
- Attempts to take ledMutex → BLOCKED (BlinkTask holds it)
- Enters sleep state
Time 1010ms:
- BlinkTask wakes from delay
- Turns LED OFF
- Prints "BlinkTask: LED OFF"
- Enters vTaskDelay(1000ms)
Time 1015ms:
- BlinkTask wakes from delay
- Prints core info
- Releases serialMutex ✓
- Releases ledMutex ✓
Time 1016ms:
- BlinkFastTask wakes (mutex now available!)
- Takes ledMutex ✓
- Takes serialMutex ✓
- Begins fast blinking...
Scenario: BlinkFastTask tries to blink while BlinkTask holds mutexes.
BlinkTask: [Takes Mutex]----[Holds]----[Releases]
↓
BlinkFastTask: [Waiting]→[Takes Mutex]
Result: BlinkFastTask blocks (sleeps) until BlinkTask releases. No busy-waiting, no CPU waste.
Both tasks have priority 1, so FreeRTOS uses time-slicing:
Core 1: [BlinkTask]--[BlinkFastTask]--[BlinkTask]--[BlinkFastTask]--...
└─ Quantum ─┘ └── Quantum ──┘ └─ Quantum ─┘
Quantum: Time slice duration (typically 1-10ms, configurable in FreeRTOSConfig.h)
FreeRTOS is preemptive:
- Higher priority tasks interrupt lower ones
- Same priority tasks cooperate (yield via delays)
In this project:
- Tasks cooperate via
vTaskDelay() - When delaying, task explicitly yields
- Scheduler picks next ready task
Scenario: Low-priority task holds mutex, high-priority task needs it.
Problem: High-priority task blocked by low-priority (priority inversion)
Solution: FreeRTOS mutexes support priority inheritance:
- Low-priority task temporarily inherits high-priority
- Finishes critical section quickly
- Drops back to original priority
Our project: Same priority, so this doesn't apply. But important for complex systems.
Problem:
// Task A:
xSemaphoreTake(mutex1, portMAX_DELAY);
xSemaphoreTake(mutex2, portMAX_DELAY);
// Task B:
xSemaphoreTake(mutex2, portMAX_DELAY); // ← Opposite order!
xSemaphoreTake(mutex1, portMAX_DELAY);Result: Both tasks block forever waiting for each other.
Solution: Always acquire mutexes in the same order.
xSemaphoreTake(mutex, portMAX_DELAY);
if (error) return; // ← BUG: Mutex never released!
xSemaphoreGive(mutex);Solution: Use early returns carefully, or use RAII patterns (C++ guard classes).
Symptom: Random crashes, watchdog resets, corrupt data
Cause: Stack size too small for local variables + function calls
Solution:
xTaskCreate(..., 10000, ...); // Increase from 2048 to 10000Debug tip: Enable stack overflow detection in FreeRTOSConfig.h:
#define configCHECK_FOR_STACK_OVERFLOW 2Scenario:
- Task A (priority 3) takes mutex
- Task B (priority 1) waits for mutex
- Task C (priority 2) runs, preventing A from finishing
Solution: Use mutexes (not binary semaphores) - they have priority inheritance.
Setup complete
BlinkTask: LED ON
BlinkTask: LED OFF
BlinkTask running on core 1
BlinkFastTask: LED ON
BlinkFastTask: LED OFF
BlinkFastTask running on core 1
BlinkFastTask: LED ON
BlinkFastTask: LED OFF
BlinkFastTask running on core 1
BlinkTask: LED ON
...
Pattern:
- Clean, non-interleaved messages (thanks to mutexes)
- BlinkTask completes full cycle (2 seconds)
- BlinkFastTask gets multiple cycles between BlinkTask runs
- Both run on core 1 (as configured)
Visual pattern:
- Irregular blinking (two different rates competing)
- Sometimes fast (250ms), sometimes slow (1000ms)
- Never "stuck" on or off (mutex prevents interference)
Add to loop() for runtime stats:
void loop() {
static unsigned long lastPrint = 0;
if (millis() - lastPrint > 5000) {
Serial.printf("BlinkTask high water mark: %d\n",
uxTaskGetStackHighWaterMark(BlinkTaskHandle));
Serial.printf("BlinkFastTask high water mark: %d\n",
uxTaskGetStackHighWaterMark(BlinkFastTaskHandle));
lastPrint = millis();
}
vTaskDelay(100 / portTICK_PERIOD_MS);
}High water mark: Minimum free stack (lower = closer to overflow).
char taskList[512];
vTaskList(taskList);
Serial.println(taskList);Output:
Name State Prio Stack Num
BlinkTask B 1 9234 3
BlinkFastTask B 1 9156 4
- State: R=Running, B=Blocked, S=Suspended
- Stack: Free stack space remaining
xTaskCreatePinnedToCore(BlinkTask, ..., 2, ...); // Higher
xTaskCreatePinnedToCore(BlinkFastTask, ..., 1, ...); // LowerResult: BlinkTask dominates, BlinkFastTask rarely runs.
Comment out mutex operations:
// xSemaphoreTake(ledMutex, portMAX_DELAY);Result: Garbled serial output, unpredictable LED.
void BlinkSlowTask(void* parameter) {
// 5-second blink cycle
}Result: Three-way contention for LED and serial.
xTaskCreatePinnedToCore(BlinkTask, ..., 0); // Core 0
xTaskCreatePinnedToCore(BlinkFastTask, ..., 1); // Core 1Result: True parallelism, but still need mutexes for shared resources.
// BlinkTask:
xSemaphoreTake(ledMutex, portMAX_DELAY);
xSemaphoreTake(serialMutex, portMAX_DELAY);
// BlinkFastTask:
xSemaphoreTake(serialMutex, portMAX_DELAY); // Reversed!
xSemaphoreTake(ledMutex, portMAX_DELAY);Result: System hangs (educational demonstration).
Each task is a separate thread of execution with its own stack and context. Tasks don't call each other - they communicate via synchronization primitives.
Shared resources MUST be protected. Even simple operations like Serial.println() need protection when used from multiple tasks.
vTaskDelay() and blocking mutex operations are efficient - they yield CPU, allowing other tasks to run. No busy-waiting needed.
Always acquire/release mutexes in the same order. Always release what you acquire. These patterns prevent bugs.
ESP32's dual cores allow true parallelism, but shared resources still need synchronization.
After mastering this project, explore:
- Queues - Pass data between tasks without shared variables
- Semaphores - Signal events between tasks
- Event Groups - Synchronize on multiple conditions
- Timers - Periodic callbacks without dedicated tasks
- Direct-to-Task Notifications - Lightweight signaling
- Check: Serial monitor baud rate (115200)
- Check: USB cable connected properly
- Check: Correct COM port selected
- Cause: Stack overflow or infinite loop without delays
- Solution: Increase stack size or add delays
- Check:
LED_PINdefinition matches your hardware - Check: LED wired correctly (anode to GPIO, cathode to GND via resistor)
- Check: Mutexes are actually being used
- Check: Mutexes successfully created (not NULL)
This starter project demonstrates the foundation of FreeRTOS multitasking:
- Concurrency: Multiple tasks running independently
- Synchronization: Mutexes preventing resource conflicts
- Cooperation: Tasks yielding CPU via delays
- Robustness: Protected critical sections
Understanding these concepts prepares you for building complex real-time embedded systems with dozens of concurrent tasks, all safely sharing resources.
Author: ibrahim khadraoui & Claude Sonnet 4.5
Platform: ESP32 with PlatformIO
Framework: Arduino + FreeRTOS
License: Open Source