[ESP32] Data Synchronization Mechanisms in FreeRTOS: Ensuring Safe Multitasking

In multitasking environments like those created with FreeRTOS, ensuring safe access to shared resources is crucial. FreeRTOS provides several synchronization mechanisms to help developers manage concurrent access to data and resources. Let's explore these mechanisms and understand how they can be used in your ESP32 Arduino projects.

1. Semaphores

Semaphores are one of the most fundamental synchronization primitives in FreeRTOS. They can be used for various purposes, including mutual exclusion and signaling between tasks.

Binary Semaphores

Binary semaphores have two states: taken or available. They're often used for mutual exclusion (mutex) or task synchronization.

Example:

SemaphoreHandle_t mutex;

void task1(void *pvParameters) {
    while(1) {
        if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {
            // Critical section
            Serial.println("Task 1 accessing shared resource");
            xSemaphoreGive(mutex);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void task2(void *pvParameters) {
    while(1) {
        if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {
            // Critical section
            Serial.println("Task 2 accessing shared resource");
            xSemaphoreGive(mutex);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void setup() {
    Serial.begin(115200);
    mutex = xSemaphoreCreateBinary();
    xSemaphoreGive(mutex);  // Make it available
    
    xTaskCreate(task1, "Task 1", 1000, NULL, 1, NULL);
    xTaskCreate(task2, "Task 2", 1000, NULL, 1, NULL);
}

Counting Semaphores

Counting semaphores can have more than two states and are often used to manage a finite pool of resources.

Example:

SemaphoreHandle_t resourceSemaphore;
const int MAX_RESOURCES = 3;

void resourceUser(void *pvParameters) {
    while(1) {
        if (xSemaphoreTake(resourceSemaphore, portMAX_DELAY) == pdTRUE) {
            Serial.println("Resource acquired");
            vTaskDelay(pdMS_TO_TICKS(1000));  // Simulate using the resource
            xSemaphoreGive(resourceSemaphore);
            Serial.println("Resource released");
        }
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void setup() {
    Serial.begin(115200);
    resourceSemaphore = xSemaphoreCreateCounting(MAX_RESOURCES, MAX_RESOURCES);
    
    for (int i = 0; i < 5; i++) {
        xTaskCreate(resourceUser, "Resource User", 1000, NULL, 1, NULL);
    }
}

2. Mutexes

Mutexes are similar to binary semaphores but include a priority inheritance mechanism to prevent priority inversion.

Example:

SemaphoreHandle_t mutex;

void highPriorityTask(void *pvParameters) {
    while(1) {
        Serial.println("High priority task waiting for mutex");
        if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {
            Serial.println("High priority task got mutex");
            vTaskDelay(pdMS_TO_TICKS(500));
            xSemaphoreGive(mutex);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void lowPriorityTask(void *pvParameters) {
    while(1) {
        if (xSemaphoreTake(mutex, portMAX_DELAY) == pdTRUE) {
            Serial.println("Low priority task got mutex");
            vTaskDelay(pdMS_TO_TICKS(2000));  // Simulate long operation
            xSemaphoreGive(mutex);
        }
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void setup() {
    Serial.begin(115200);
    mutex = xSemaphoreCreateMutex();
    
    xTaskCreate(highPriorityTask, "High Priority", 1000, NULL, 3, NULL);
    xTaskCreate(lowPriorityTask, "Low Priority", 1000, NULL, 1, NULL);
}

3. Task Notifications

Task notifications provide a lightweight and fast alternative to binary semaphores for task-to-task signaling.

Example:

TaskHandle_t receiverTaskHandle = NULL;

void senderTask(void *pvParameters) {
    while(1) {
        vTaskDelay(pdMS_TO_TICKS(2000));
        xTaskNotify(receiverTaskHandle, 0, eIncrement);
        Serial.println("Notification sent");
    }
}

void receiverTask(void *pvParameters) {
    uint32_t notificationValue;
    while(1) {
        if (xTaskNotifyWait(0, 0, &notificationValue, portMAX_DELAY) == pdTRUE) {
            Serial.printf("Notification received. Value: %u\n", notificationValue);
        }
    }
}

void setup() {
    Serial.begin(115200);
    
    xTaskCreate(receiverTask, "Receiver", 1000, NULL, 1, &receiverTaskHandle);
    xTaskCreate(senderTask, "Sender", 1000, NULL, 2, NULL);
}

4. Event Groups

Event groups allow multiple tasks to wait for one or more events to occur. They're useful for synchronizing multiple tasks based on complex conditions.

Example:

#define BIT_0 (1 << 0)
#define BIT_1 (1 << 1)

EventGroupHandle_t eventGroup;

void taskSetBits(void *pvParameters) {
    while(1) {
        vTaskDelay(pdMS_TO_TICKS(2000));
        xEventGroupSetBits(eventGroup, BIT_0 | BIT_1);
        Serial.println("Bits set");
    }
}

void taskWaitBits(void *pvParameters) {
    while(1) {
        EventBits_t bits = xEventGroupWaitBits(eventGroup,
                                               BIT_0 | BIT_1,
                                               pdTRUE,  // Clear bits after return
                                               pdTRUE,  // Wait for all bits
                                               portMAX_DELAY);
        Serial.printf("Bits received: %u\n", bits);
    }
}

void setup() {
    Serial.begin(115200);
    eventGroup = xEventGroupCreate();
    
    xTaskCreate(taskSetBits, "Set Bits", 1000, NULL, 1, NULL);
    xTaskCreate(taskWaitBits, "Wait Bits", 1000, NULL, 2, NULL);
}

Conclusion

FreeRTOS provides a rich set of synchronization mechanisms to handle various multitasking scenarios. Choosing the right mechanism depends on your specific use case:

  • Use semaphores for general-purpose synchronization and resource management.
  • Prefer mutexes when dealing with shared resources to avoid priority inversion.
  • Consider task notifications for simple, fast task-to-task signaling.
  • Use event groups when you need to synchronize based on multiple conditions.

Remember, while these mechanisms help prevent race conditions and ensure data integrity, they should be used judiciously to avoid unnecessarily complex code or potential deadlocks. Always design your multitasking application with careful consideration of task interactions and shared resource access patterns.

By mastering these synchronization tools, you can create robust and efficient multitasking applications on your ESP32 using FreeRTOS and the Arduino framework.