Unit I - ESP32 Advanced

48. FreeRTOS Tasks and Dual-Core Basics

Build an alive monitor: a snappy status LED in loop while a worker task samples slowly on another core.

Estimated time 3 hours

Learning outcomes

  • State that Arduino-ESP32 runs on FreeRTOS
  • Pin a worker task and keep loop responsive
  • Use vTaskDelay in the worker instead of blocking loop
  • Explain why a slow sample must not freeze a status blink
  • Name race conditions and queues as the next tools

Parts and preparation

ESP32 DevKit (dual-core classic preferred), USB cable, LED on GPIO 2, potentiometer on GPIO 34 for sketch 2.

Before power: inspect wiring, confirm supply voltage and ensure all connected circuits share GND.

FreeRTOS Tasks and Dual-Core Basics instructional connection diagram

Project: Alive monitor

Devices need a heartbeat people can see even while slow work runs (sampling, SD writes, maths). If sampling uses delay(2000) inside loop, the status LED freezes and looks dead.

Sketch 1 shows two cores printing and blinking. Sketch 2 is the product pattern: worker task samples ADC every 2 s; loop keeps a snappy alive blink.

Core 0 worker task and core 1 loop LED blink on dual-core ESP32
Slow work in a task; alive blink stays in loop.

Arduino on FreeRTOS

setup()/loop() usually run on core 1. Protocol stacks often use core 0. xTaskCreatePinnedToCore adds workers. Prefer millis in one loop when that is enough; add a task when slow work would block the UI or networking.

ItemCourse default
Core 1setup / loop
Core 0Stacks + optional workers
vTaskDelayWait inside a task without busy-spin
StackStart 2048; raise if reboot

Shared data: races, then queues

Sketch 2 writes latestSample from the worker and reads it from loop for Serial - acceptable for a teaching int on ESP32 for this lab, but torn data is possible for larger structs. Production code should use a queue or mutex. Learn the names now.

When not to add a task

Do not spawn a task per LED. handleClient / client.loop / ArduinoOTA.handle often share one millis-driven loop.

Wiring and safe build sequence

  1. GPIO 2 LED for alive blink
  2. Sketch 2: pot on GPIO 34 (3V3 / wiper / GND)
  3. Serial 115200
Power rule: switch off before moving wires. Arduino I/O pins are control signals; high-current loads require a driver and suitable external supply.

Worked sketch 1: Two cores visible

Download .ino sketch

What this sketch is for: See FreeRTOS concurrency: a pinned print task and a blinking loop on another core.

#if CONFIG_FREERTOS_UNICORE
#define ARDUINO_RUNNING_CORE 0
#else
#define ARDUINO_RUNNING_CORE 1
#endif

const int ledPin = 2;

void printTask(void *param) {
  (void)param;
  for (;;) {
    Serial.print("printTask on core ");
    Serial.println(xPortGetCoreID());
    vTaskDelay(pdMS_TO_TICKS(1000));
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);

  BaseType_t ok = xTaskCreatePinnedToCore(
    printTask, "printTask", 2048, NULL, 1, NULL, 0
  );
  if (ok != pdPASS) Serial.println("Failed to create printTask");

  Serial.print("loop will run on core ");
  Serial.println(ARDUINO_RUNNING_CORE);
}

void loop() {
  digitalWrite(ledPin, HIGH);
  delay(250);
  digitalWrite(ledPin, LOW);
  delay(250);
}

How the code works

  1. Pinned to core 0 on dual-core chips.
  2. xPortGetCoreID() shows which core printed.
  3. Unicore boards may show core 0 for both - still valid FreeRTOS practice.

Worked sketch 2: Alive LED + slow ADC worker

Download .ino sketch

What this sketch is for: Product pattern: worker samples the pot every 2 s with vTaskDelay; loop blinks fast so the node still looks alive. Compare with putting delay(2000) in loop. Match the breadboard layout below before upload.

Breadboard layout for ESP32 alive monitor: potentiometer on GPIO 34 and status LED on GPIO 2
Breadboard for sketch 2: pot outer legs to 3V3 and GND, wiper to GPIO 34; alive LED with series resistor on GPIO 2 to GND. Click to enlarge.
#if CONFIG_FREERTOS_UNICORE
#define ARDUINO_RUNNING_CORE 0
#else
#define ARDUINO_RUNNING_CORE 1
#endif

const int ledPin = 2;
const int sensePin = 34;
volatile int latestSample = 0;

void sampleTask(void *param) {
  (void)param;
  for (;;) {
    latestSample = analogRead(sensePin);
    Serial.print("sample ");
    Serial.print(latestSample);
    Serial.print(" on core ");
    Serial.println(xPortGetCoreID());
    vTaskDelay(pdMS_TO_TICKS(2000));
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(ledPin, OUTPUT);

  xTaskCreatePinnedToCore(sampleTask, "sampleTask", 2048, NULL, 1, NULL, 0);
  Serial.println("Alive monitor: fast blink + slow samples");
}

void loop() {
  digitalWrite(ledPin, HIGH);
  delay(120);
  digitalWrite(ledPin, LOW);
  delay(120);
}

How the code works

  1. Slow sampling lives in sampleTask - loop never waits 2 seconds.
  2. latestSample is a simple shared int for teaching; use a queue in larger projects.
  3. If you move delay(2000) into loop, the alive LED freezes - that is the lesson.
  4. Build from the breadboard photo: pot on GPIO 34, alive LED on GPIO 2.

Test and record evidence

Expected result: Sketch 1: LED blinks while core prints appear about once per second. Sketch 2: LED keeps a fast alive blink while ADC samples print only every ~2 s.

Practical evidence checklist

Common faults and checks
  • One core ID only: normal on unicore C-series.
  • Reboot under load: increase task stack size.
  • ADC stuck: pot wiring on GPIO 34.
Extension challenge: Pass sample values through a FreeRTOS queue to loop instead of a volatile global, and print only when a new value arrives.

Check your understanding

Q1. What problem does the alive monitor solve?

Show answer

Keep a visible heartbeat while slow work runs elsewhere.

Q2. What OS sits under Arduino-ESP32?

Show answer

FreeRTOS.

Q3. Which API creates a pinned task?

Show answer

xTaskCreatePinnedToCore.

Q4. Why use vTaskDelay in the worker?

Show answer

Yield to the scheduler instead of busy-waiting.

Q5. What goes wrong if delay(2000) is in loop?

Show answer

The status LED freezes for two seconds each sample.

Q6. What is a race condition risk?

Show answer

Unsynchronised shared data between tasks.

Q7. Name a safer hand-off than a raw global

Show answer

A FreeRTOS queue or mutex.

Q8. When should you skip adding a task?

Show answer

When one millis-driven loop already stays responsive.