Skip to content

RTOS Basics with ESP-IDF LAB


What you will learn

By the end, you should be able to:

  • Create multiple FreeRTOS tasks in ESP-IDF.
  • Use blocking correctly (vTaskDelay, waiting on a queue/mutex) so tasks don’t hog the CPU.
  • Pass data between tasks using a queue (producer/consumer pattern).
  • Protect shared resources using a mutex (avoid race conditions).

Setup

  1. Create a new ESP-IDF project (or use your template).
  2. Put the lab code into main/main.c.
  3. Build/flash/monitor using your normal workflow, e.g.:
  4. idf.py build flash monitor

Hardware note: LED pin

The built-in LED GPIO differs by board.
In the examples below we use:

#define LED_GPIO GPIO_NUM_2

If your board’s LED is not GPIO2, change it to match your wiring.


Lab 1 — Two tasks, delays, priorities

Goal

Create two tasks:

  • blink_task: toggles an LED every 300 ms
  • hello_task: prints a message every 1 second

What to watch for

  • Both tasks run (interleave).
  • Changing priority can change which task runs “more” or “first”.
  • If you remove a delay from a task, it may hog the CPU.

Syntax used

  • TAG is just a label string used by ESP-IDF logging so you can see where the message came from.
  • vTaskDelay() is a FreeRTOS function that suspends the calling task for a specified number of system ticks, placing it into a blocked state. It is a non-blocking (to the CPU) delay, allowing other tasks to run.
  • pdMS_TO_TICKS(n) converts milliseconds to system ticks, which is the unit FreeRTOS uses for timing.
    vTaskDelay(pdMS_TO_TICKS(300));
    
  • ESP_LOGI(const char* tag, const char* format, ...) is an ESP-IDF logging function that logs an informational message. The tag parameter helps identify the source of the log message, and the format parameter allows for formatted output similar to printf, the ... are variadic argumentsVariables to be replaced into the format string, matching the format specifiers.
  • ESP_LOGW(const char* tag, const char* format, ...)Used when something unexpected happens but the program can continue (like queue full) so it's a warning.
    ESP_LOGI(TAG, "Produced %d", value);
    ESP_LOGW(TAG, "Queue full, dropped %d", value);
    
    xTaskCreate() creates a new FreeRTOS task. It takes the following parameters:
    BaseType_t xTaskCreate(
        TaskFunction_t pvTaskCode,  //  A pointer to the function that implements the task.
        const char * const pcName,  //  A descriptive name for the task, primarily used for debugging purposes
        const uint32_t usStackDepth,//  The size of the stack allocated for the task, specified in words, not bytes.
        void *pvParameters,         //   A value, typically a pointer to a variable or structure, that is passed as a parameter to the created task's function.
        UBaseType_t uxPriority,     //  The priority at which the task should run, with 0 being the lowest priority. 
        TaskHandle_t *pxCreatedTask //  Used to pass back a handle by which the created task can be referenced and manipulated by other API
    );
    
    /* EXAMPLE */
    
    xTaskCreate(blink_task, "blink_task", 2048, NULL, 5, NULL);
    

Code (Lab 1)

Copy into main/main.c:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define LED_GPIO GPIO_NUM_2   // CHANGE for your board

static const char *TAG = "LAB1";

static void blink_task(void *pvParameters)
{
    gpio_reset_pin(LED_GPIO);
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);

    while (1) {
        gpio_set_level(LED_GPIO, 1);
        vTaskDelay(pdMS_TO_TICKS(300));
        gpio_set_level(LED_GPIO, 0);
        vTaskDelay(pdMS_TO_TICKS(300));
    }
}

static void hello_task(void *pvParameters)
{
    int n = 0;
    while (1) {
        ESP_LOGI(TAG, "hello_task says hi, n=%d", n++);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void app_main(void)
{
    ESP_LOGI(TAG, "Starting Lab 1 (two tasks)");

    // Stack size in ESP-IDF FreeRTOS is in BYTES
    xTaskCreate(blink_task, "blink_task", 2048, NULL, 5, NULL);
    xTaskCreate(hello_task, "hello_task", 2048, NULL, 5, NULL);
}

Exercises

  1. Priority experiment: change hello_task priority from 5 to 2.
  2. Does behavior change? Why might it (or might it not)?
  3. Starvation demo: temporarily remove vTaskDelay(...) from hello_task.
  4. What happens to blinking?
  5. Put the delay back and explain in one sentence why blocking helps.

Lab 2 — Queue: producer/consumer

Goal

Use a queue to pass integers from a producer task to a consumer task.

Why it matters: - Queues are a clean way to pass data without sharing global variables.

Note

Remember MUTEX means Mutual EXclusion (protect shared resources), while QUEUE is for passing data between tasks.It operates like a binary flag, indicating whether the resource is locked (0) or unlocked (1).

Syntax

  • xQueueCreate(num_items, item_size) creates a new queue. Where num_items is the maximum number of items the queue can hold, and item_size is the size (in bytes) of each item.
    q_numbers = xQueueCreate(5, sizeof(int));
    
  • xQueueSend(q_numbers, &value, pdMS_TO_TICKS(50)) sends an item to the back of the queue. Where q_numbers is the handle of the queue(which queue), &value is a pointer to the item to be sent, and pdMS_TO_TICKS(50) is the maximum time to wait if the queue is full. It returns pdPASS if successful.
  • xQueueReceive(q_numbers, &rx, pdMS_TO_TICKS(1000)); receives an item from the front of the queue. Where q_numbers is the handle of the queue(which queue), &rx is a pointer to the variable where the received item will be stored, and pdMS_TO_TICKS(1000) is the maximum time to wait if the queue is empty. It returns pdPASS if successful.

Code (Lab 2)

Replace main/main.c with:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"

static const char *TAG = "LAB2";
static QueueHandle_t q_numbers;

static void producer_task(void *pvParameters)
{
    int value = 0;

    while (1) {
        value++;

        // Send to queue; wait up to 50ms if full
        if (xQueueSend(q_numbers, &value, pdMS_TO_TICKS(50)) == pdPASS) {
            ESP_LOGI(TAG, "Produced %d", value);
        } else {
            ESP_LOGW(TAG, "Queue full, dropped %d", value);
        }

        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

static void consumer_task(void *pvParameters)
{
    int rx = 0;

    while (1) {
        // Wait up to 1000ms for data
        if (xQueueReceive(q_numbers, &rx, pdMS_TO_TICKS(1000)) == pdPASS) {
            ESP_LOGI(TAG, "Consumed %d", rx);
        } else {
            ESP_LOGW(TAG, "No data in 1s");
        }
    }
}

void app_main(void)
{
    ESP_LOGI(TAG, "Starting Lab 2 (queue)");

    q_numbers = xQueueCreate(5, sizeof(int)); // length 5
    if (q_numbers == NULL) {
        ESP_LOGE(TAG, "Queue create failed");
        return;
    }

    xTaskCreate(producer_task, "producer_task", 2048, NULL, 5, NULL);
    xTaskCreate(consumer_task, "consumer_task", 2048, NULL, 5, NULL);
}

Exercises

  1. Make the producer faster: change producer delay 200ms → 20ms.
  2. When do you see “Queue full”?
  3. Increase the queue length 5 → 20.
  4. What changes?
  5. Make the consumer “slow”: after a successful receive, add:
    vTaskDelay(pdMS_TO_TICKS(300));
    
  6. What pattern is happening now (buffering / backlog)?

Lab 3 — Mutex: protect a shared resource

Goal

See a race condition happen with a shared counter, then fix it with a mutex.

Syntax

  • xSemaphoreCreateMutex() creates a new mutex. It returns a handle to the created mutex.
  • xSemaphoreTake(mutex, portMAX_DELAY) attempts to take (lock) the mutex. If the mutex is already taken by another task, the calling task will block indefinitely (due to portMAX_DELAY) until the mutex becomes available.
  • xSemaphoreGive(mutex) releases (unlocks) the mutex, allowing other tasks to take it.

Part A — Race demo (no mutex)

Replace main/main.c with:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"

static const char *TAG = "LAB3A";

static volatile int shared_counter = 0;

static void increment_task(void *pvParameters)
{
    const char *name = (const char *)pvParameters;

    while (1) {
        // NOT safe: read-modify-write without protection
        int local = shared_counter;
        local++;
        shared_counter = local;

        if ((shared_counter % 1000) == 0) {
            ESP_LOGI(TAG, "%s sees counter=%d", name, shared_counter);
        }

        vTaskDelay(pdMS_TO_TICKS(1));
    }
}

void app_main(void)
{
    ESP_LOGI(TAG, "Starting Lab 3A (race demo)");

    xTaskCreate(increment_task, "incA", 2048, "TaskA", 5, NULL);
    xTaskCreate(increment_task, "incB", 2048, "TaskB", 5, NULL);
}

why can the counter be wrong?


Part B — Fix with a mutex

Replace with:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"

static const char *TAG = "LAB3B";

static volatile int shared_counter = 0;
static SemaphoreHandle_t counter_mutex;

static void increment_task(void *pvParameters)
{
    const char *name = (const char *)pvParameters;

    while (1) {
        xSemaphoreTake(counter_mutex, portMAX_DELAY);

        int local = shared_counter;
        local++;
        shared_counter = local;

        xSemaphoreGive(counter_mutex);

        if ((shared_counter % 1000) == 0) {
            ESP_LOGI(TAG, "%s sees counter=%d", name, shared_counter);
        }

        vTaskDelay(pdMS_TO_TICKS(1));
    }
}

void app_main(void)
{
    ESP_LOGI(TAG, "Starting Lab 3B (mutex fix)");

    counter_mutex = xSemaphoreCreateMutex();
    if (counter_mutex == NULL) {
        ESP_LOGE(TAG, "Mutex create failed");
        return;
    }

    xTaskCreate(increment_task, "incA", 2048, "TaskA", 5, NULL);
    xTaskCreate(increment_task, "incB", 2048, "TaskB", 5, NULL);
}

Exercises

  1. Remove the mutex again. Do you ever see weird behavior?
  2. Change priorities: TaskA priority 6, TaskB priority 4.
  3. What do you expect and why?
  4. In one sentence: what does a mutex “guarantee”?

Wrap-up: what to use when

Rule of thumb

  • Periodic work (blink, poll, print): use a task + vTaskDelay(pdMS_TO_TICKS(x))
  • Data passing between tasks: use a queue
  • Shared resource (shared variable, shared peripheral access): use a mutex

Extra mini-challenges

  1. Heartbeat + work task
  2. Add a third task that prints “alive” every 2 seconds.
  3. Queue with struct
  4. Send a struct: {int id; int value;}
  5. Mutex around a shared peripheral
  6. Make two tasks write to the same log message format (simulate “shared UART resource”) and guard it with a mutex.