field guide
FreeRTOS Task Control Block (TCB)
The FreeRTOS Task Control Block - the per-task struct the scheduler uses to track, save, and restore a task, and how it relates to the task’s separate stack.
Every FreeRTOS task has one TCB — a struct allocated at xTaskCreate() time that the scheduler uses to track, save, and restore the task. The TCB and the task’s stack are separate pvPortMalloc calls. The TCB lives in the kernel’s internal linked lists (Ready list, Delayed list, etc.); the stack is wherever malloc found room.
Key fields
typedef struct tskTaskControlBlock {
volatile StackType_t *pxTopOfStack; // where to resume — points into the stack
ListItem_t xStateListItem; // node in Ready/Blocked/Suspended list
ListItem_t xEventListItem; // node in event notification list
UBaseType_t uxPriority; // task priority
StackType_t *pxStack; // bottom of allocated stack
char pcTaskName[configMAX_TASK_NAME_LEN];
#if (configUSE_TASK_NOTIFICATIONS == 1)
volatile uint32_t ulNotifiedValue; // the notification value — zero-cost semaphore
volatile uint8_t ucNotifyState; // notification pending flag
#endif
// ... conditional fields for mutex inheritance, stats, MPU, etc.
} tskTCB;The ulNotifiedValue field is what ulTaskNotifyTake reads and decrements. It exists in every TCB whether you use notifications or not — there is zero memory cost to enabling them, and that’s the entire reason they beat semaphores.
Context switch mechanics
A FreeRTOS context switch (on Cortex-M / Xtensa / RISC-V) follows the same pattern regardless of architecture:
- Save: The current task’s CPU registers are pushed onto its own stack by the hardware exception entry sequence (on Xtensa/ESP32 this happens in the
_frxt_dispatchassembly trampoline). - Update TCB:
pxTopOfStackis updated to point where the registers were saved. - Pick next task: The scheduler walks the Ready task list by priority — first task at the highest priority that isn’t empty. The choice is because FreeRTOS uses a bitmap of ready priorities, not a linear scan.
- Restore: The picked task’s
pxTopOfStacktells the CPU where to pop its saved registers. Execution resumes at the instruction after the last context switch — the task never knows it was suspended.
Run → xSwitch → save registers to current stack
→ update current TCB.pxTopOfStack
→ walk Ready list, pick next
→ load next TCB.pxTopOfStack into SP
→ restore registers from next stack
→ return → next task resumes
The entire sequence takes ~1-3 µs on ESP32 at 240 MHz, dominated by register save/restore.
What lives in the lists
FreeRTOS never searches for a task — every task is always in exactly one scheduler list:
| List | Contains | Transition |
|---|---|---|
| Ready (one per priority) | Tasks that can run immediately | vTaskResume, xTaskNotifyGive |
| Delayed | Tasks waiting on vTaskDelay, vTaskDelayUntil |
Tick interrupt decrements counter |
| Blocked | Tasks waiting on a queue, semaphore, notification | xQueueReceive, ulTaskNotifyTake |
| Suspended | Tasks explicitly paused | vTaskSuspend |
| Pending Ready (ISR-safe) | Tasks unblocked from ISR context | portYIELD_FROM_ISR at end of ISR |
When the scheduler looks for the next task to run, it scans the Ready lists from highest priority downward, picks the first thread-ready task, and restores its register context from the TCB’s pxTopOfStack.
ESP32 practical footprint
| Item | Size | Notes |
|---|---|---|
sizeof(tskTCB) |
~84–108 bytes | Varies with config options enabled |
| Default stack | 3072 bytes | configMINIMAL_STACK_SIZE on ESP32 |
ulTaskNotifyTake / ulNotifiedValue |
0 bytes extra | Reuses existing TCB field |
On a drone with 4-5 tasks (control, telemetry, radio, LED, watchdog), the TCB overhead is ~500 bytes total. The stacks are where the RAM goes — ~16 KB for the control task alone if it does printf over UART.
Relation to other kernel objects
TCB (per task, always exists)
├── embedded: ulNotifiedValue → ulTaskNotifyTake (zero-cost, single task)
├── embedded: xEventListItem → xQueueReceive (costs queue object)
├── references: xMutexHolder → xSemaphoreTake (costs semaphore object)
└── references: xTimerHandle → xTimerStart (costs timer object)
Task notifications sit on the TCB directly — no separate object, no allocation, no cleanup on deletion. That’s the entire reason to reach for them over a queue or semaphore when you only have one consumer task.