How Do You Create and Schedule Tasks in FreeRTOS?
Last updated 30 June 2026 · 12 min read
Direct Answer
FreeRTOS tasks are created with xTaskCreate(), which takes a task function pointer, a name for debugging, a stack size in words, an optional parameter, a priority level (0 = lowest, configMAX_PRIORITIES − 1 = highest), and an optional task handle. After all startup tasks are created, call vTaskStartScheduler() once to hand control to the FreeRTOS scheduler — it will then always run the highest-priority task that is in the Ready state, preempting any lower-priority task immediately.
Detailed Explanation
FreeRTOS is the most widely deployed open-source RTOS in embedded development. It runs on ARM Cortex-M (STM32, nRF52, Raspberry Pi Pico), Xtensa (ESP32), RISC-V, and dozens of other architectures. The core abstraction is the task — an independent function with its own stack that the FreeRTOS scheduler multiplexes on a single CPU core.
For context on when to choose FreeRTOS over bare-metal, see Bare-Metal vs RTOS: Which Should You Use for Your Firmware? and What Is an RTOS?.
What Is a FreeRTOS Task?
A FreeRTOS task is a C function that looks like an infinite loop. It has its own stack (allocated when the task is created), its own priority, and its own state in the scheduler. Unlike a bare-metal super-loop where a single while(1) runs everything sequentially, FreeRTOS tasks appear to run concurrently — the scheduler switches between them based on priority and blocking state.
A minimal task function:
void vSensorTask(void *pvParameters) {
/* One-time initialisation here */
for (;;) {
/* Periodic work here */
vTaskDelay(pdMS_TO_TICKS(10)); /* Block for 10 ms, yield the CPU */
}
/* Task functions must never return. If this task needs to exit: */
vTaskDelete(NULL);
}
The function signature must be void task_name(void *pvParameters) — a void return type with a void-pointer parameter. A task function that returns causes undefined behaviour (typically a hard fault); always loop forever or explicitly delete the task before the function ends.
Creating a Task with xTaskCreate()
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, /* Pointer to the task function */
const char *pcName, /* Debug name (max configMAX_TASK_NAME_LEN chars) */
uint16_t usStackDepth, /* Stack size in WORDS (not bytes) */
void *pvParameters, /* Parameter passed to the task (can be NULL) */
UBaseType_t uxPriority, /* Priority: 0 = lowest, configMAX_PRIORITIES-1 = highest */
TaskHandle_t *pxCreatedTask /* Output: task handle (NULL if not needed) */
);
/* Returns: pdPASS on success, errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY on failure */
A practical example creating two tasks at different priorities:
TaskHandle_t xSensorHandle = NULL;
TaskHandle_t xCommsHandle = NULL;
int main(void) {
/* Board and peripheral initialisation */
HAL_Init();
SystemClock_Config();
BaseType_t result;
result = xTaskCreate(
vSensorTask, /* Task function */
"Sensor", /* Debug name */
256, /* Stack: 256 words = 1024 bytes on Cortex-M */
NULL, /* No parameter */
tskIDLE_PRIORITY + 3, /* Higher priority — responds to sensor interrupts */
&xSensorHandle /* Store handle for later use (e.g. notifications) */
);
configASSERT(result == pdPASS);
result = xTaskCreate(
vCommsTask,
"Comms",
512, /* 512 words = 2048 bytes — more stack for protocol buffers */
NULL,
tskIDLE_PRIORITY + 2,
&xCommsHandle
);
configASSERT(result == pdPASS);
vTaskStartScheduler(); /* Hand control to FreeRTOS — should never return */
for (;;); /* Reached only if heap too small for idle task */
}
Critical detail — usStackDepth is in words, not bytes. On 32-bit Cortex-M, one word = 4 bytes, so usStackDepth = 256 allocates 1024 bytes of stack. usStackDepth = 512 allocates 2048 bytes. This is a common source of confusion when reading stack sizes from existing code.
xTaskCreate() allocates both the stack and the Task Control Block (TCB) from the FreeRTOS heap. If configTOTAL_HEAP_SIZE is too small, the function returns errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY. Always check the return value with configASSERT() during development.
Task Priorities and Preemption
FreeRTOS priorities run from 0 (lowest, tskIDLE_PRIORITY) to configMAX_PRIORITIES − 1 (highest). The scheduler always runs the highest-priority task currently in the Ready state. When a high-priority task becomes ready (e.g. it receives a queue item or a notification), it immediately preempts any lower-priority task that is running.
Practical guidelines:
| Priority level | Typical use |
|---|---|
tskIDLE_PRIORITY (0) | Idle task only — never assign user tasks this priority |
tskIDLE_PRIORITY + 1 | Background housekeeping, logging, non-critical LED blinking |
tskIDLE_PRIORITY + 2 | Standard I/O tasks — UART comms, display updates, non-time-critical sensing |
tskIDLE_PRIORITY + 3 | Data processing — sensor fusion, protocol parsing, medium-latency response |
tskIDLE_PRIORITY + 4 | Time-critical tasks — hard real-time control loops, safety monitoring |
Keep configMAX_PRIORITIES (set in FreeRTOSConfig.h) to the minimum needed — typically 5 for most embedded designs. Each additional priority level adds a small amount of RAM overhead.
Tasks of equal priority round-robin using time-slicing when configUSE_TIME_SLICING is 1 (the default): each task runs for one tick period before yielding the CPU to the next ready task at the same priority. If equal-priority tasks are doing unrelated work, this is fine. If they share a resource, use a mutex to prevent concurrent access.
Starting the Scheduler
vTaskStartScheduler();
This single call transfers control to the FreeRTOS kernel. It creates the idle task, configures the SysTick peripheral as the tick source (on Cortex-M, at configTICK_RATE_HZ — typically 1000 Hz for a 1 ms tick), and starts the first task.
vTaskStartScheduler() must not be called before all initial tasks are created. It should never return under normal operation. If it returns, the heap was too small to allocate the idle task's stack and TCB — increase configTOTAL_HEAP_SIZE in FreeRTOSConfig.h. Any code written after vTaskStartScheduler() in main() is for fault indication only.
Stack Sizing and Overflow Detection
Choosing the right stack size is one of the most consequential decisions in FreeRTOS firmware. Too small and the task corrupts adjacent memory; too large and you exhaust the heap.
Starting points for usStackDepth (in words on 32-bit Cortex-M):
| Task type | Typical stack |
|---|---|
| Simple control loop, no function calls | 128–256 words (512 B – 1 KB) |
| I2C/SPI peripheral driver | 256–512 words (1–2 KB) |
Task calling printf() or sprintf() | 512–1024 words (2–4 KB) |
| Task running a TCP/IP or TLS stack | 1024–4096 words (4–16 KB) |
configMINIMAL_STACK_SIZE (idle task minimum) | ~128 words — never use directly for user tasks |
Never use configMINIMAL_STACK_SIZE as a starting point for user tasks. It is the absolute minimum for the idle task, which does almost nothing. A real task that calls any non-trivial function will overflow a configMINIMAL_STACK_SIZE stack.
Enable stack overflow detection in FreeRTOSConfig.h:
#define configCHECK_FOR_STACK_OVERFLOW 2
Method 1 (= 1) checks the stack pointer at every context switch. Method 2 (= 2) additionally fills the stack with a known canary pattern (0xA5) at task creation and verifies the last 16 bytes haven't changed at each switch — slower but catches gradual stack corruption. Implement the hook:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
/* pcTaskName: the name string passed to xTaskCreate() */
(void)xTask;
(void)pcTaskName;
configASSERT(0); /* Halt in debug; flash an error LED in production */
}
Measure actual stack usage with uxTaskGetStackHighWaterMark():
Enable INCLUDE_uxTaskGetStackHighWaterMark = 1 in FreeRTOSConfig.h, then call this inside each task during development to read the minimum free stack (in words) since the task was created:
void vSensorTask(void *pvParameters) {
for (;;) {
/* ... task work ... */
UBaseType_t uxHighWaterMark = uxTaskGetStackHighWaterMark(NULL);
/* Log uxHighWaterMark via printf or SWO trace */
/* If uxHighWaterMark < 20 words, the stack is dangerously full */
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
Exercise all code paths (nominal, error recovery, interrupt-driven) before trusting the high-water mark reading. Add a safety margin of at least 20–30 words above the measured minimum when setting the final usStackDepth.
A task-aware debugger (STM32CubeIDE with FreeRTOS awareness, or SEGGER Ozone with a J-Link probe) shows all task states, priorities, and stack watermarks in a live debug session — without needing uxTaskGetStackHighWaterMark() calls in the firmware. See How Do You Debug Embedded Firmware? for how to configure a task-aware debugger alongside JTAG/SWD and trace tools during RTOS bring-up.
Task States and Blocking
A FreeRTOS task is always in one of four states:
- Running — the task currently executing on the CPU. Only one task is Running at a time on a single-core MCU.
- Ready — eligible to run but waiting for the CPU because an equal- or higher-priority task is currently Running.
- Blocked — waiting for a time delay (
vTaskDelay) or an event (queue receive, semaphore, notification). A Blocked task does not consume CPU time. - Suspended — explicitly paused via
vTaskSuspend(). Does not transition to Ready untilvTaskResume()is called. Rarely needed — prefer blocking on a semaphore or notification instead.
The most important state for CPU efficiency is Blocked. A well-designed FreeRTOS application keeps most tasks Blocked most of the time, consuming zero CPU while waiting — letting the CPU run lower-priority tasks or enter the idle task to sleep.
Delaying a task:
/* Relative delay: block for at least 100 ms from now.
Jitter accumulates over time if the task misses a deadline. */
vTaskDelay(pdMS_TO_TICKS(100));
/* Absolute periodic delay: execute every 10 ms, compensating for
the time spent in the task body. Use for fixed-period control loops. */
TickType_t xLastWakeTime = xTaskGetTickCount();
for (;;) {
/* Do work here */
vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(10));
}
Use vTaskDelayUntil() for any task that must execute at a fixed rate (sensor sampling, motor control, communications polling). Use vTaskDelay() when only a minimum delay matters.
Interrupts and FreeRTOS
FreeRTOS imposes a rule on Cortex-M: only interrupts at or below configMAX_SYSCALL_INTERRUPT_PRIORITY (set in FreeRTOSConfig.h) may call FreeRTOS API functions. Interrupts above this threshold (lower number on Cortex-M, since NVIC uses inverted priority) are fully preemptive and must never call any FreeRTOS function.
ISR-safe API variants (suffixed FromISR) are used inside compliant interrupt handlers:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xQueueSendFromISR(xUartRxQueue, &rxByte, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); /* Request context switch if needed */
}
See STM32 NVIC Interrupt Priority Configuration for how configMAX_SYSCALL_INTERRUPT_PRIORITY maps to STM32 NVIC priority group settings.
Platform Notes
STM32 + CubeMX: STM32CubeMX generates FreeRTOS configuration and initial task creation code via the CMSIS-RTOS v2 wrapper (osThreadNew() wraps xTaskCreate()). Always review the generated FreeRTOSConfig.h — in particular configTOTAL_HEAP_SIZE, configTICK_RATE_HZ, and configMAX_PRIORITIES — before writing task code. CubeMX defaults are conservative and may need adjustment for real applications. See STM32 peripheral configuration with CubeMX for the broader CubeMX workflow.
ESP32 (ESP-IDF): ESP-IDF is built on a dual-core FreeRTOS port. Both cores run the same FreeRTOS scheduler. Use xTaskCreatePinnedToCore() to pin a task to a specific core:
xTaskCreatePinnedToCore(
vSensorTask,
"Sensor",
2048, /* Stack in BYTES on ESP32 (not words — ESP-IDF difference) */
NULL,
5,
&xSensorHandle,
1 /* Core: 0 = PRO_CPU, 1 = APP_CPU, tskNO_AFFINITY = either */
);
Note that ESP-IDF's xTaskCreate() and xTaskCreatePinnedToCore() take stack size in bytes, not words — the opposite convention from the standard Cortex-M FreeRTOS port. This is a common source of porting bugs when moving code between STM32 and ESP32.
Design Considerations
Stack allocation dominates heap usage. On a device with 128 KB RAM and several tasks each needing 1–2 KB of stack, it is easy to exhaust the heap before the application starts. Map out total stack requirements early: number of tasks × average stack × overhead (TCB, queues, semaphores) against configTOTAL_HEAP_SIZE. On MCUs with tightly constrained RAM, xTaskCreateStatic() and static memory allocation (configSUPPORT_STATIC_ALLOCATION = 1) give deterministic memory usage and eliminate heap fragmentation entirely.
For priority design, assign priorities based on timing requirements, not task importance. A UART receive task that must capture bytes with microsecond precision gets a high priority — a logging task that compresses data every second gets a low one. The scheduler handles importance (through priorities) and fairness (through time-slicing) separately.
Stack sizing in production should be validated under worst-case load, not just nominal operation. Enable configCHECK_FOR_STACK_OVERFLOW throughout development and only remove it (if ever) after thorough watermark validation. Many production crashes blamed on "intermittent hardware issues" trace back to stack overflow under peak load.
If you're building a product that requires a reliable FreeRTOS task architecture — particularly across multiple MCU families or with strict real-time requirements — Zeus Design's firmware team designs and implements embedded RTOS firmware for STM32, ESP32, nRF, and custom platforms.
See How Does the Memory Map Work in an Embedded Microcontroller? for how FreeRTOS stacks, the TCB heap, and your application data all coexist in the MCU address space.
Common Mistakes
1. Stack too small for tasks that call printf()
printf() and sprintf() pull in significant library code and use substantial stack — typically 2–4 KB (512–1024 words) when including floating-point formatting. A task with usStackDepth = 128 will hard-fault the first time it calls printf(). Either increase the stack or use a lightweight alternative (ITM_SendChar() via SWO, a ring-buffer logger, or snprintf() with a pre-allocated buffer in a higher-stack task).
2. Forgetting to call vTaskStartScheduler()
Tasks created by xTaskCreate() are never executed unless vTaskStartScheduler() is called. Omitting it means main() falls through to a while(1) with all tasks permanently in the Ready state. This mistake is easy to make when adding FreeRTOS to an existing bare-metal project.
3. Task function that returns
A task function that falls off the end of its function (return without vTaskDelete(NULL)) causes undefined behaviour — typically a hard fault. All task functions must loop forever or explicitly delete themselves with vTaskDelete(NULL) before the function returns.
4. Using configMINIMAL_STACK_SIZE for user tasks
configMINIMAL_STACK_SIZE is the minimum stack for the idle task, defined as a small constant (typically 128 words on Cortex-M). Passing it to xTaskCreate() for a user task almost guarantees a stack overflow the first time the task does any real work.
5. Undifferentiated priorities Creating all tasks at the same priority results in purely time-sliced scheduling with no preemption on priority differences. If one task must respond to an event before another, they need different priorities. Conversely, creating too many distinct priorities (more than 4–5 levels) makes the system hard to reason about — most embedded designs need only 3–4 priority tiers.
6. Priority inversion without a mutex
When a low-priority task holds a binary semaphore that a high-priority task is waiting on, the high-priority task is blocked indefinitely if medium-priority tasks keep running. Use xSemaphoreCreateMutex() instead of a binary semaphore for resource guarding — FreeRTOS mutexes implement priority inheritance, temporarily boosting the holding task's priority to prevent priority inversion. For the full inter-task communication picture — queues, semaphores, mutexes, task notifications, and event groups — see How Do FreeRTOS Queues, Semaphores, and Mutexes Work?.
Frequently Asked Questions
- What is the difference between xTaskCreate() and xTaskCreateStatic()?
- xTaskCreate() allocates the task's stack and TCB (Task Control Block) dynamically from the FreeRTOS heap using pvPortMalloc(). xTaskCreateStatic() accepts caller-supplied buffers for both the stack and the TCB, so no heap allocation occurs. xTaskCreateStatic() is preferred in safety-critical systems or when configSUPPORT_DYNAMIC_ALLOCATION is disabled. Both functions create an identical task; the difference is only in memory ownership. Most embedded designs using FreeRTOS use xTaskCreate() with a suitably sized heap (configTOTAL_HEAP_SIZE in FreeRTOSConfig.h).
- How do I choose the right FreeRTOS task priority?
- Assign priority based on deadline sensitivity, not importance. Tasks that must respond to hardware events within microseconds (e.g. a high-frequency sensor interrupt handler task) get the highest priority. Tasks that process data or communicate over slow interfaces get lower priority. A common four-level scheme: tskIDLE_PRIORITY + 1 for background housekeeping, + 2 for slow I/O tasks, + 3 for data processing tasks, + 4 for time-critical real-time tasks. Avoid assigning more than one task per priority level unless time-slicing is the intended behaviour. Never assign user tasks priority 0 (idle task level) — the idle task must run periodically to reclaim memory from deleted tasks.
- How do I debug a FreeRTOS stack overflow?
- Enable configCHECK_FOR_STACK_OVERFLOW in FreeRTOSConfig.h (set to 2 for the most thorough checking) and implement the hook: void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName). The hook receives the overflowing task's handle and name string — log the name, assert, or toggle a GPIO before halting. During development, call uxTaskGetStackHighWaterMark(NULL) inside each task to read the minimum remaining free stack (in words) since the task started. If the watermark is less than ~20 words, the stack is dangerously close to overflow — increase usStackDepth in xTaskCreate(). Stack overflows are most common in tasks that call printf/sprintf (which require 2–4 KB of stack), use large local arrays, or call deeply nested functions.
- What happens if I forget to call vTaskStartScheduler()?
- If vTaskStartScheduler() is never called, no FreeRTOS tasks execute. The code after xTaskCreate() calls continues to run sequentially (i.e. reaches the for(;;) at the end of main() and loops there forever) while all created tasks remain permanently in the Ready state, never scheduled. If vTaskStartScheduler() is called but returns, it means there was insufficient heap memory to create the idle task — check that configTOTAL_HEAP_SIZE is large enough to hold the idle task's TCB and stack (minimum configMINIMAL_STACK_SIZE words of stack plus sizeof(StaticTask_t) bytes for the TCB).
References
Related Questions
How Do FreeRTOS Queues, Semaphores, and Mutexes Work?
How to use FreeRTOS queues, semaphores, and mutexes for inter-task communication — including ISR-safe variants, task notifications, and event groups.
What Is an RTOS (Real-Time Operating System)?
An RTOS is a lightweight operating system that gives embedded firmware deterministic task scheduling. Learn how RTOSes work and when you actually need one.
Bare-Metal vs RTOS: Which Should You Use for Your Firmware?
Bare-metal firmware and RTOS suit different embedded projects. Learn the trade-offs — timing, RAM overhead, complexity — and how to choose.
What Are Interrupts in Embedded Systems and How Do They Work?
Interrupts let a microcontroller respond to hardware events instantly without polling. Learn how ISRs, NVIC priority, and interrupt latency work.
How Do You Configure STM32 NVIC Interrupt Priorities?
Learn how to configure STM32 NVIC interrupt priorities using HAL, priority grouping, and the FreeRTOS configMAX_SYSCALL_INTERRUPT_PRIORITY constraint.
How Does the Memory Map Work in an Embedded Microcontroller?
The Cortex-M memory map assigns flash, RAM, and peripherals to fixed address regions. Covers STM32 layout, volatile keyword, and how linker scripts map to it.