Comparing Global Variables and Queues in FreeRTOS for Arduino and ESP32

When developing multitasking applications with FreeRTOS on ESP32 using the Arduino framework, you often need to share data between tasks. Two common approaches are using global variables and FreeRTOS queues (xQueue). Let's explore the similarities, differences, and potential risks of each method.

Global Variables

Global variables are simple to use and provide quick access to shared data across tasks.

Example:

volatile int sharedCounter = 0;

void task1(void *pvParameters) {
  while (1) {
    sharedCounter++;
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void task2(void *pvParameters) {
  while (1) {
    Serial.println(sharedCounter);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void setup() {
  Serial.begin(115200);
  xTaskCreate(task1, "Task 1", 1000, NULL, 1, NULL);
  xTaskCreate(task2, "Task 2", 1000, NULL, 1, NULL);
}

void loop() {}

Advantages:

  1. Easy to implement
  2. Low overhead
  3. Quick access

Risks:

  1. Race conditions: Multiple tasks accessing the variable simultaneously can lead to data corruption.
  2. Lack of synchronization: No built-in mechanism to ensure data consistency or notify tasks of changes.
  3. Difficult to debug: Issues related to shared data access are hard to trace.

FreeRTOS Queues (xQueue)

Queues provide a structured and thread-safe way to pass data between tasks.

Example:

QueueHandle_t dataQueue;

void task1(void *pvParameters) {
  int counter = 0;
  while (1) {
    counter++;
    xQueueSend(dataQueue, &counter, portMAX_DELAY);
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void task2(void *pvParameters) {
  int receivedValue;
  while (1) {
    if (xQueueReceive(dataQueue, &receivedValue, portMAX_DELAY) == pdTRUE) {
      Serial.println(receivedValue);
    }
  }
}

void setup() {
  Serial.begin(115200);
  dataQueue = xQueueCreate(5, sizeof(int));
  xTaskCreate(task1, "Task 1", 1000, NULL, 1, NULL);
  xTaskCreate(task2, "Task 2", 1000, NULL, 1, NULL);
}

void loop() {}

Advantages:

  1. Thread-safe: Built-in synchronization prevents race conditions.
  2. Structured communication: Clear sender-receiver model.
  3. Flow control: Can block tasks when the queue is full or empty.
  4. Debugging: Easier to track data flow between tasks.

Risks:

  1. Memory usage: Queues consume more memory than simple variables.
  2. Potential for deadlocks: Improper use of blocking calls can lead to system hangs.
  3. Learning curve: Requires understanding of FreeRTOS queue APIs.

Understanding xQueueReceive

Let's take a closer look at this crucial part of the queue example:

if (xQueueReceive(dataQueue, &receivedValue, portMAX_DELAY) == pdTRUE) {
    Serial.println(receivedValue);
}

This code snippet demonstrates how to receive data from a FreeRTOS queue. Let's break it down:

  1. xQueueReceive() function:

    • This is a FreeRTOS API function used to read/receive an item from a queue.
    • It takes three parameters:
      a. dataQueue: The handle of the queue to receive from.
      b. &receivedValue: A pointer to the variable where the received item will be stored.
      c. portMAX_DELAY: The maximum time to block waiting for an item to be available.
  2. portMAX_DELAY:

    • This is a special value that tells the function to wait indefinitely until an item is available in the queue.
    • It's useful when you want the task to block and wait for data without a timeout.
  3. Return value:

    • xQueueReceive() returns pdTRUE if an item was successfully received from the queue.
    • It returns pdFALSE if the queue was empty and portMAX_DELAY was not used.
  4. The if statement:

    • This checks if the queue receive operation was successful.
    • If pdTRUE is returned, it means an item was received and stored in receivedValue.
  5. Serial.println(receivedValue):

    • If an item was successfully received, this line prints the value to the serial monitor.

This structure ensures that the task only processes data when it's actually available in the queue. If the queue is empty, the task will block (wait) until data becomes available, thanks to the portMAX_DELAY parameter.

Alternative: Non-blocking receive

If you don't want the task to block, you can use a timeout value instead of portMAX_DELAY:

const TickType_t xTicksToWait = pdMS_TO_TICKS(100);  // Wait for 100 ms

if (xQueueReceive(dataQueue, &receivedValue, xTicksToWait) == pdTRUE) {
    Serial.println(receivedValue);
} else {
    Serial.println("No data received within timeout period");
}

In this version, the task will wait for up to 100 milliseconds for data to become available. If no data arrives within that time, it will continue execution, allowing the task to perform other operations or check again later.

Using xQueueReceive() in this way provides a robust method for inter-task communication, ensuring that data is only processed when it's available and allowing for flexible handling of scenarios where data might not be immediately present.

Comparison

  1. Data Access:

    • Global variables: Direct access, but prone to race conditions.
    • xQueue: Controlled access through send/receive operations, preventing race conditions.
  2. Synchronization:

    • Global variables: Require external synchronization (e.g., mutexes).
    • xQueue: Built-in synchronization and optional blocking behavior.
  3. Data Flow:

    • Global variables: No inherent structure for data flow between tasks.
    • xQueue: Clear producer-consumer model with FIFO ordering.
  4. Memory Usage:

    • Global variables: Minimal memory overhead.
    • xQueue: Additional memory required for queue management.
  5. Scalability:

    • Global variables: Can become messy with many shared variables.
    • xQueue: Easier to manage multiple data streams between tasks.

Conclusion

While global variables offer simplicity and low overhead, they come with significant risks in multitasking environments. FreeRTOS queues provide a more robust solution for inter-task communication, albeit with a slight increase in complexity and memory usage.

For simple projects or when performance is critical, global variables might be sufficient if proper synchronization is implemented. However, for more complex applications or when reliability is paramount, using xQueue is generally the safer and more maintainable choice.

Remember, the best approach depends on your specific project requirements, the complexity of your task interactions, and your comfort level with FreeRTOS concepts.

Would you like me to elaborate on any specific aspect of this comparison?