02 · Your First Tasks¶
A task is FreeRTOS's unit of concurrency: an ordinary C function with its
own stack and priority, running an infinite loop, scheduled independently of
every other task. In this module you create your first tasks with
xTaskCreate, blink two LEDs at unrelated rates with none of the superloop's
timing entanglement, learn the difference between vTaskDelay and
vTaskDelayUntil, and meet the four states every task lives in.
Anatomy of a task function¶
A task function has a fixed shape:
void myTask(void *pvParameters) { // must take void*, return void
// one-time setup for this task can go here
for (;;) { // tasks run forever...
// do work
vTaskDelay(pdMS_TO_TICKS(100)); // ...but must block, not busy-wait
}
// if a task ever must exit, it deletes itself:
// vTaskDelete(NULL); — a task function must NEVER simply return
}
Two hard rules:
- Never return from a task function. If a task is finished, call
vTaskDelete(NULL). Returning corrupts the scheduler (the ESP32 port aborts with an error). - Every loop iteration must block somewhere (
vTaskDelay, queue receive, semaphore take...). A task that never blocks starves everything at lower priority — more on that in the next module.
xTaskCreate, parameter by parameter¶
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // the task function
const char *pcName, // debug name, e.g. "blink1"
uint32_t usStackDepth, // stack size (BYTES on ESP32 — see note)
void *pvParameters, // void* handed to the function
UBaseType_t uxPriority, // 0 = lowest; higher number = higher priority
TaskHandle_t *pxCreatedTask // out: handle, or NULL if not needed
);
pcName— purely for humans: shows up in debuggers, crash dumps, andvTaskList()output. Keep it short (default limit 16 chars).usStackDepth— how much stack this task gets, forever. Too small → stack overflow and a crash; too large → wasted RAM. Module 9 shows how to measure the right size. Starting points on ESP32: 1024-2048 for a simple loop, 4096 if the task usesSerial/String/printf.pvParameters— an arbitrary pointer passed to the task. Lets one function serve many tasks (see below). The pointed-to data must outlive the task — pass a pointer to astatic/global, never to a local that dies.uxPriority— 0 (lowest, shared with the idle task) up toconfigMAX_PRIORITIES - 1(25 on ESP32). The Arduinoloop()task runs at priority 1.pxCreatedTask— receives aTaskHandle_tyou can use later to suspend, resume, notify, or delete the task. PassNULLif you'll never need it.- Return value —
pdPASSon success, orerrCOULD_NOT_ALLOCATE_REQUIRED_MEMORYif there wasn't enough heap for the stack + task control block. Check it.
ESP32 quirk: stack depth is in bytes
In vanilla FreeRTOS usStackDepth is in words (×4 bytes on 32-bit
MCUs). The ESP32 port changed it to bytes. xTaskCreate(..., 2048,
...) means 2048 bytes on ESP32 but 8192 bytes on, say, an STM32. Keep
this in mind when reading generic FreeRTOS examples.
Two blinking LEDs, zero timing entanglement¶
Wire two LEDs (with resistors) to GPIO 25 and GPIO 26 in Wokwi, or just watch the serial log.
// Two independent blinkers — ESP32 Arduino (FreeRTOS built in, no includes needed)
const uint8_t LED1 = 25;
const uint8_t LED2 = 26;
void blink1(void *pvParameters) {
pinMode(LED1, OUTPUT);
for (;;) {
digitalWrite(LED1, !digitalRead(LED1));
vTaskDelay(pdMS_TO_TICKS(250)); // 2 Hz
}
}
void blink2(void *pvParameters) {
pinMode(LED2, OUTPUT);
for (;;) {
digitalWrite(LED2, !digitalRead(LED2));
vTaskDelay(pdMS_TO_TICKS(700)); // ~0.7 Hz — totally unrelated rate
}
}
void setup() {
Serial.begin(115200);
xTaskCreate(blink1, "blink1", 1024, NULL, 1, NULL);
xTaskCreate(blink2, "blink2", 1024, NULL, 1, NULL);
Serial.println("Tasks created — scheduler was already running.");
}
void loop() {
// The loop() task keeps running too — it's just another task (priority 1).
vTaskDelay(pdMS_TO_TICKS(2000));
Serial.printf("uptime: %lu s\n", millis() / 1000);
}
Change one delay and the other blinker is completely unaffected — that's the
entire point. On a plain Arduino you'd have needed two millis() state
machines; here each task is three honest lines.
No vTaskStartScheduler()?
Generic FreeRTOS programs create tasks in main() and then call
vTaskStartScheduler(). On ESP32 Arduino the scheduler is already
running before setup() is called (setup/loop live inside a task named
loopTask), so you just create tasks and they start immediately.
One function, many tasks — using pvParameters¶
struct BlinkSpec {
uint8_t pin;
uint32_t periodMs;
};
// static: must outlive the tasks that receive pointers to them
static BlinkSpec spec1 = {25, 250};
static BlinkSpec spec2 = {26, 700};
void blinker(void *pvParameters) {
BlinkSpec *spec = (BlinkSpec *)pvParameters; // cast back from void*
pinMode(spec->pin, OUTPUT);
for (;;) {
digitalWrite(spec->pin, !digitalRead(spec->pin));
vTaskDelay(pdMS_TO_TICKS(spec->periodMs));
}
}
void setup() {
xTaskCreate(blinker, "blink25", 1024, &spec1, 1, NULL);
xTaskCreate(blinker, "blink26", 1024, &spec2, 1, NULL);
}
void loop() { vTaskDelay(portMAX_DELAY); } // nothing to do here
vTaskDelay vs vTaskDelayUntil¶
vTaskDelay(n) blocks for n ticks from now. If the loop body itself takes
time, the period drifts:
vTaskDelay(100 ms), body takes 7 ms:
run(7) + delay(100) + run(7) + delay(100)... → actual period 107 ms, drifting
vTaskDelayUntil blocks until an absolute tick count, giving a fixed
period with no drift — the right tool for sampling sensors, control loops,
or anything that must run at a precise rate:
void sampler(void *pvParameters) {
TickType_t lastWake = xTaskGetTickCount(); // initialize ONCE
const TickType_t period = pdMS_TO_TICKS(100); // exactly 10 Hz
for (;;) {
vTaskDelayUntil(&lastWake, period); // wakes at lastWake + period
int raw = analogRead(34);
Serial.printf("[%lu ms] sample=%d\n", millis(), raw);
}
}
lastWake is updated by the call itself, so the wake-up times form an exact
grid: t₀+100, t₀+200, t₀+300... regardless of how long the body takes (as
long as it takes less than one period).
Also useful: vTaskDelay(pdMS_TO_TICKS(x)) converts milliseconds to ticks —
never hardcode tick counts, because the tick rate is configurable (1000 Hz on
ESP32 Arduino, often 100 Hz elsewhere).
The four task states¶
Every task is always in exactly one state:
┌───────────┐
scheduler │ Running │ the one task per core actually executing
picks it ───►│ │───┐ preempted / time slice over
└───────────┘ │
▲ ▼
event occurs ┌───────────┐ ┌───────────┐
or timeout │ Blocked │◄─┤ Ready │ wants CPU, waiting its turn
┌───────►│ │ └───────────┘
│ └───────────┘
vTaskDelay, │ vTaskSuspend() ┌────────────┐
queue wait, └────────────────────►│ Suspended │ invisible to the
semaphore... │ │ scheduler until
└────────────┘ vTaskResume()
- Running — executing right now (one task per core; the ESP32 has two cores — next module).
- Ready — able to run, waiting for the CPU (a higher-priority or equal-priority task is running).
- Blocked — waiting for time (
vTaskDelay) or an event (queue, semaphore, notification) with a timeout. Blocked tasks consume zero CPU — this is why blocking beats busy-waiting. - Suspended — explicitly parked with
vTaskSuspend(handle); won't run again untilvTaskResume(handle). No timeout involved.
vTaskDelay is therefore not "wasting time" like delay() busy-waiting —
it's telling the scheduler "wake me in 250 ms; give the CPU to someone else."
Cheat sheet¶
| API | Purpose |
|---|---|
xTaskCreate(fn, name, stack, param, prio, &handle) |
Create a task (stack in bytes on ESP32); returns pdPASS on success |
vTaskDelay(pdMS_TO_TICKS(ms)) |
Block for a relative time (task uses no CPU while blocked) |
vTaskDelayUntil(&lastWake, period) |
Block until an absolute time — drift-free fixed periods |
pdMS_TO_TICKS(ms) |
Convert milliseconds → ticks portably |
vTaskDelete(NULL) |
Delete the calling task (never just return) |
vTaskSuspend(h) / vTaskResume(h) |
Park / unpark a task by handle |
xTaskGetTickCount() |
Current tick count (like millis() in ticks) |
| Task states | Running · Ready · Blocked (waiting, zero CPU) · Suspended |
How It Actually Works¶
xTaskCreate does three concrete things: it pvPortMallocs a stack buffer of
the size you requested (in words, tracked in bytes internally) plus a TCB
struct, it pre-fills the stack with a guard pattern (so
uxTaskGetStackHighWaterMark can later measure how deep it was actually
used), and it writes a synthetic initial stack frame — register values as
if the task had just been interrupted — with the program counter pointing at
your task function and the link register pointing at a trap that catches a
task falling off the end of for(;;). The task is then inserted into the
Ready list for its priority; nothing runs yet until the scheduler is invoked.
The four states (Running/Ready/Blocked/Suspended) are literally which linked
list a TCB's xStateListItem sits on — vTaskDelay doesn't "wait," it moves
the TCB from Ready onto the Delayed task list, sorted by wake tick, and
removes it from Ready so the scheduler never considers it until the tick
handler notices its time has come and moves it back. vTaskDelayUntil
differs by computing the next wake time from a stored last-wake value
rather than "now," which is why it doesn't accumulate drift the way chained
vTaskDelay calls do when the task itself takes variable time to run.
Exercise¶
Build a three-task sketch in Wokwi:
heartbeat— toggles GPIO 25 every 500 ms withvTaskDelay.sampler— printsanalogRead(34)at exactly 4 Hz usingvTaskDelayUntil(verify the timestamps land on a 250 ms grid usingmillis()in the printout).reporter— every 3 s prints how many samples have been taken so far (share the count through a globalvolatile uint32_tfor now — modules 4 and 5 will show why that's naive and what to use instead).
Then deliberately break it: change sampler to use vTaskDelay(250) and add
delay(30) inside its loop body to simulate slow work — watch the timestamps
drift, then restore vTaskDelayUntil and watch them snap back to the grid.