Skip to content

celec-club/Arduino-FreeRTOS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FreeRTOS Starter Tutorial - Task Synchronization with Mutexes

Overview

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.

What You'll Learn

  1. Creating and managing multiple FreeRTOS tasks
  2. Mutex synchronization for shared resource protection
  3. Task delays and timing in FreeRTOS
  4. Core affinity - pinning tasks to specific CPU cores
  5. Race condition prevention in concurrent systems

Hardware Setup

#define LED_PIN 2

Hardware Requirements:

  • ESP32 development board
  • Built-in LED (GPIO 2) or external LED with resistor
  • USB cable for programming and serial monitoring

Architecture

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.

FreeRTOS Mutexes Explained

What is a Mutex?

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;

Why Two Mutexes?

We use separate mutexes for different resources:

  1. serialMutex: Protects the Serial port

    • Prevents garbled/interleaved print statements
    • Ensures atomic message output
  2. ledMutex: Protects the LED GPIO

    • Ensures complete blink cycles aren't interrupted
    • Prevents one task from turning LED on while another turns it off

Mutex Lifecycle

1. Creation

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)

2. Taking (Locking)

xSemaphoreTake(ledMutex, portMAX_DELAY);

Parameters:

  • ledMutex: The mutex to acquire
  • portMAX_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 100ms

3. Giving (Unlocking)

xSemaphoreGive(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.

Mutex vs Semaphore

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

Task Analysis

BlinkTask - Slow Blink

void BlinkTask(void* parameter)

Purpose: Blinks LED slowly (1 second on, 1 second off) while printing status messages.

Step-by-Step Execution

for (;;) {  // Infinite loop - standard for FreeRTOS tasks

Tasks never return - they run forever in an infinite loop.

1. Acquire Mutexes

xSemaphoreTake(ledMutex, portMAX_DELAY);
xSemaphoreTake(serialMutex, portMAX_DELAY);

Locking order:

  1. First lock LED mutex (controls hardware)
  2. 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.

2. LED On Phase

digitalWrite(LED_PIN, HIGH);
Serial.println("BlinkTask: LED ON");
vTaskDelay(1000 / portTICK_PERIOD_MS);  // 1000ms

Protected 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

3. LED Off Phase

digitalWrite(LED_PIN, LOW);
Serial.println("BlinkTask: LED OFF");
vTaskDelay(1000 / portTICK_PERIOD_MS);

Same pattern - turn off, print, delay.

4. Core Identification

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)

5. Release Mutexes

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.

BlinkFastTask - Fast Blink

void BlinkFastTask(void* parameter)

Purpose: Blinks LED rapidly (250ms on, 250ms off).

Key Differences from BlinkTask

vTaskDelay(250 / portTICK_PERIOD_MS);  // 250ms instead of 1000ms

Timing:

  • 4x faster than BlinkTask
  • Creates contention - both tasks want LED frequently
  • Perfect test case for mutex synchronization

Identical Structure

Despite different timing, the structure is identical:

  1. Acquire mutexes (blocking if unavailable)
  2. Control LED + print messages
  3. Delay (yield CPU)
  4. Release mutexes

This demonstrates: Same synchronization pattern works for any task, regardless of timing.

Critical Sections

A critical section is code that must execute atomically (without interruption by other tasks accessing the same resources).

Protected Critical Section

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.

What Happens Without Mutexes?

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.

Application Initialization

Setup Function

void setup() {
  Serial.begin(115200);

Initializes serial communication at 115200 baud for debugging output.

GPIO Configuration

pinMode(LED_PIN, OUTPUT);

Configures GPIO 2 as output for LED control. Must be done before tasks start using it.

Mutex Creation

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
}

Task Creation - BlinkTask

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)
);

Parameter Breakdown

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 parameter argument
  • 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_AFFINITY to let scheduler choose

Task Creation - BlinkFastTask

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.

Setup Completion

Serial.println("Setup complete");

Confirms initialization finished. Tasks are now running independently.

Loop Function

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

Execution Flow Timeline

Startup Sequence

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...

Mutex Contention

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.

Task Scheduling

Time-Slicing (Round-Robin)

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)

Cooperative vs Preemptive

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

Priority Inheritance

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.

Common Pitfalls and Solutions

1. Deadlock

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.

2. Forgetting to Release

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).

3. Stack Overflow

Symptom: Random crashes, watchdog resets, corrupt data

Cause: Stack size too small for local variables + function calls

Solution:

xTaskCreate(..., 10000, ...);  // Increase from 2048 to 10000

Debug tip: Enable stack overflow detection in FreeRTOSConfig.h:

#define configCHECK_FOR_STACK_OVERFLOW 2

4. Priority Inversion

Scenario:

  • 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.

Output Interpretation

Expected Serial Output

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)

LED Behavior

Visual pattern:

  • Irregular blinking (two different rates competing)
  • Sometimes fast (250ms), sometimes slow (1000ms)
  • Never "stuck" on or off (mutex prevents interference)

Monitoring and Debugging

Task Statistics

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).

FreeRTOS Task List

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

Experimentation Ideas

1. Change Priorities

xTaskCreatePinnedToCore(BlinkTask, ..., 2, ...);      // Higher
xTaskCreatePinnedToCore(BlinkFastTask, ..., 1, ...);  // Lower

Result: BlinkTask dominates, BlinkFastTask rarely runs.

2. Remove Mutexes

Comment out mutex operations:

// xSemaphoreTake(ledMutex, portMAX_DELAY);

Result: Garbled serial output, unpredictable LED.

3. Add Third Task

void BlinkSlowTask(void* parameter) {
  // 5-second blink cycle
}

Result: Three-way contention for LED and serial.

4. Use Different Cores

xTaskCreatePinnedToCore(BlinkTask, ..., 0);      // Core 0
xTaskCreatePinnedToCore(BlinkFastTask, ..., 1);  // Core 1

Result: True parallelism, but still need mutexes for shared resources.

5. Introduce Deadlock

// BlinkTask:
xSemaphoreTake(ledMutex, portMAX_DELAY);
xSemaphoreTake(serialMutex, portMAX_DELAY);

// BlinkFastTask:
xSemaphoreTake(serialMutex, portMAX_DELAY);  // Reversed!
xSemaphoreTake(ledMutex, portMAX_DELAY);

Result: System hangs (educational demonstration).

Key Takeaways

1. Task Independence

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.

2. Mutex Protection

Shared resources MUST be protected. Even simple operations like Serial.println() need protection when used from multiple tasks.

3. Blocking is Good

vTaskDelay() and blocking mutex operations are efficient - they yield CPU, allowing other tasks to run. No busy-waiting needed.

4. Consistent Patterns

Always acquire/release mutexes in the same order. Always release what you acquire. These patterns prevent bugs.

5. Core Affinity

ESP32's dual cores allow true parallelism, but shared resources still need synchronization.

Next Steps

After mastering this project, explore:

  1. Queues - Pass data between tasks without shared variables
  2. Semaphores - Signal events between tasks
  3. Event Groups - Synchronize on multiple conditions
  4. Timers - Periodic callbacks without dedicated tasks
  5. Direct-to-Task Notifications - Lightweight signaling

Troubleshooting

No Output

  • Check: Serial monitor baud rate (115200)
  • Check: USB cable connected properly
  • Check: Correct COM port selected

Watchdog Reset

  • Cause: Stack overflow or infinite loop without delays
  • Solution: Increase stack size or add delays

LED Not Blinking

  • Check: LED_PIN definition matches your hardware
  • Check: LED wired correctly (anode to GPIO, cathode to GND via resistor)

Garbled Output

  • Check: Mutexes are actually being used
  • Check: Mutexes successfully created (not NULL)

Conclusion

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

About

An Arduino FreeRTOS repository with tutorials and example codes

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages