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
- Create a new ESP-IDF project (or use your template).
- Put the lab code into
main/main.c. - Build/flash/monitor using your normal workflow, e.g.:
idf.py build flash monitor
Hardware note: LED pin
The built-in LED GPIO differs by board.
In the examples below we use:
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
TAGis 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.ESP_LOGI(const char* tag, const char* format, ...)is an ESP-IDF logging function that logs an informational message. Thetagparameter helps identify the source of the log message, and theformatparameter allows for formatted output similar toprintf, 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.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
- Priority experiment: change
hello_taskpriority from5to2. - Does behavior change? Why might it (or might it not)?
- Starvation demo: temporarily remove
vTaskDelay(...)fromhello_task. - What happens to blinking?
- 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.xQueueSend(q_numbers, &value, pdMS_TO_TICKS(50))sends an item to the back of the queue. Whereq_numbersis the handle of the queue(which queue),&valueis a pointer to the item to be sent, andpdMS_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. Whereq_numbersis the handle of the queue(which queue),&rxis a pointer to the variable where the received item will be stored, andpdMS_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
- Make the producer faster: change producer delay
200ms → 20ms. - When do you see “Queue full”?
- Increase the queue length
5 → 20. - What changes?
- Make the consumer “slow”: after a successful receive, add:
- 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 toportMAX_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
- Remove the mutex again. Do you ever see weird behavior?
- Change priorities: TaskA priority
6, TaskB priority4. - What do you expect and why?
- 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
- Heartbeat + work task
- Add a third task that prints “alive” every 2 seconds.
- Queue with struct
- Send a struct:
{int id; int value;} - Mutex around a shared peripheral
- Make two tasks write to the same log message format (simulate “shared UART resource”) and guard it with a mutex.