Creating multiple tasks is the point where FreeRTOS starts to feel like a real multitasking system rather than a single blinking demo. One task can prove that the scheduler works, but multiple tasks show what an RTOS is actually for.
Instead of placing all application logic inside one long loop, you can split the program into separate units of work, each with its own purpose, timing, and priority.
This change is important because it affects how you design embedded software. In a bare-metal superloop, every job shares one main execution path. In FreeRTOS, each task becomes its own execution context with its own stack, its own state, and its own scheduling behavior.
That does not mean everything runs at the same time in a literal sense on a single-core microcontroller. It means the scheduler switches between tasks so the system behaves like multiple activities are progressing independently.
This article explains how to create multiple tasks in FreeRTOS, how they are structured, how the scheduler handles them, and what beginner mistakes to avoid. The goal is not just to show extra xTaskCreate() calls, but to explain what changes when more than one task exists in the system.
What changes when you go from one task to many
With one task, the idea is simple. You create the task, start the scheduler, and let that task run. Once you move to multiple tasks, the scheduler has real choices to make. It must decide which ready task runs first, when another task should get CPU time, and how priorities affect execution.
That means multiple tasks introduce new questions:
Which task should have the highest priority?
Should tasks delay or block when idle?
How should tasks exchange data?
What happens if one task never stops running?
How do you keep the design understandable?
These questions are where FreeRTOS design begins.
The basic structure of multiple tasks
A multi-task FreeRTOS project usually has this pattern:
- hardware initialization
- task creation for each application job
- scheduler startup
- task functions that each loop forever
A small example looks like this:
#include "FreeRTOS.h"
#include "task.h"
void LedTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
toggle_led();
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void PrintTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
serial_print("System running\n");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
int main(void)
{
hardware_init();
xTaskCreate(LedTask, "LED", 256, NULL, 1, NULL);
xTaskCreate(PrintTask, "PRINT", 256, NULL, 1, NULL);
vTaskStartScheduler();
for (;;)
{
}
}
This is the simplest form of a multi-task application. One task blinks an LED. The other prints a message. Each task has its own loop, and each task delays to give the scheduler room to run other work.
Each task is independent
One of the most important ideas for beginners is that each task should be thought of as an independent activity. It is not just a helper function called from somewhere else. Once the task is created and the scheduler is running, the task becomes a managed execution context.
That means each task has:
- its own function
- its own stack
- its own priority
- its own blocked or ready state
- its own timing behavior
This is why multiple tasks are so useful. You can separate different responsibilities into cleaner units.
For example:
- one task handles sensor reading
- one task updates a display
- one task manages serial communication
- one task logs status information
That is much easier to reason about than one giant loop trying to do everything in sequence.
A practical example with three tasks
Suppose you are building a simple embedded monitoring device. It needs to:
- sample a sensor every 100 milliseconds
- update a display every 250 milliseconds
- write a log message every second
A clean FreeRTOS design might look like this:
#include "FreeRTOS.h"
#include "task.h"
void SensorTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
read_sensor();
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void DisplayTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
update_display();
vTaskDelay(pdMS_TO_TICKS(250));
}
}
void LoggerTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
write_log();
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
int main(void)
{
hardware_init();
xTaskCreate(SensorTask, "SENSOR", 256, NULL, 2, NULL);
xTaskCreate(DisplayTask, "DISPLAY", 256, NULL, 1, NULL);
xTaskCreate(LoggerTask, "LOGGER", 256, NULL, 1, NULL);
vTaskStartScheduler();
for (;;)
{
}
}
This example is useful because it shows how different jobs can be split naturally by purpose and timing.
Why tasks usually contain an infinite loop
A task normally contains a for (;;) loop because it is expected to stay alive and continue doing work over time. If a task function runs to the end and returns, that is usually a problem.
For example, this is usually wrong for a beginner task:
void BadTask(void *pvParameters)
{
serial_print("Task started\n");
}
This task does one thing and exits. That is generally not how application tasks are meant to be structured.
A better version is:
void GoodTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
serial_print("Task running\n");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
The task now behaves like a proper recurring activity.
Why vTaskDelay() matters in multi-task systems
A common beginner mistake is creating multiple tasks but not allowing them to block or delay. If a higher-priority task runs forever without waiting, it can prevent lower-priority tasks from running.
For example:
void FastTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
process_data();
}
}
If FastTask has a higher priority than the others and never blocks, it can dominate the CPU.
That is why many first FreeRTOS examples use vTaskDelay(). It lets a task go into the blocked state for a period of time, making room for other tasks.
This is not just a convenience. It is part of healthy RTOS design. Tasks should usually wait when they have nothing useful to do.
Multiple tasks with different priorities
Once you create several tasks, priorities start to matter more.
A task with a higher priority will run before a lower-priority task if both are ready. That means you should not assign priorities randomly.
A sensible rule is to give higher priority to tasks that need faster response and lower priority to background work.
For example:
xTaskCreate(SensorTask, "SENSOR", 256, NULL, 3, NULL);
xTaskCreate(DisplayTask, "DISPLAY", 256, NULL, 2, NULL);
xTaskCreate(LoggerTask, "LOGGER", 256, NULL, 1, NULL);
This makes sense if sensor sampling is the most timing-sensitive part of the system.
But priorities should be used carefully. A high priority should not mean “this task feels important.” It should mean “this task must run promptly when ready.”
Equal-priority tasks
Tasks can also share the same priority. This is common when their urgency is similar.
For example:
xTaskCreate(LedTask, "LED", 256, NULL, 1, NULL);
xTaskCreate(PrintTask, "PRINT", 256, NULL, 1, NULL);
If both tasks are ready and have the same priority, the scheduler can switch between them according to its configuration and timing behavior.
This can work well when tasks are peers and neither one needs to outrank the other.
However, equal priority does not mean identical behavior. The way tasks delay, block, or consume CPU still affects how often they run.
Passing different parameters to multiple tasks
When creating multiple tasks, you can also pass different parameters into each one. This is useful when several tasks use the same function but need different configuration data.
Example:
#include "FreeRTOS.h"
#include "task.h"
typedef struct
{
const char *name;
uint32_t delayMs;
} TaskConfig;
void MessageTask(void *pvParameters)
{
TaskConfig *config = (TaskConfig *) pvParameters;
for (;;)
{
serial_print(config->name);
serial_print("\n");
vTaskDelay(pdMS_TO_TICKS(config->delayMs));
}
}
static TaskConfig config1 = { "Task A", 500 };
static TaskConfig config2 = { "Task B", 1000 };
int main(void)
{
hardware_init();
xTaskCreate(MessageTask, "TASKA", 256, &config1, 1, NULL);
xTaskCreate(MessageTask, "TASKB", 256, &config2, 1, NULL);
vTaskStartScheduler();
for (;;)
{
}
}
This is a useful pattern because it avoids duplicating task code when only the settings differ.
Using task handles with multiple tasks
When there are several tasks, handles become more useful. A task handle lets you refer to a task later, for example to suspend it, resume it, or send a notification.
Example:
TaskHandle_t sensorTaskHandle = NULL;
TaskHandle_t loggerTaskHandle = NULL;
xTaskCreate(SensorTask, "SENSOR", 256, NULL, 2, &sensorTaskHandle);
xTaskCreate(LoggerTask, "LOGGER", 256, NULL, 1, &loggerTaskHandle);
Even if a first multi-task project does not use handles actively, it is worth understanding that they exist because task management becomes more important as systems grow.
A realistic design pattern: one task per responsibility
A good beginner design approach is to assign one clear responsibility to each task.
For example:
SensorTaskreads measurementsCommsTaskhandles communicationUiTaskupdates display or LEDsLogTaskhandles diagnostics
This makes the design easier to understand, test, and expand.
A weak design is to create multiple tasks with overlapping responsibilities and unclear boundaries. That often leads to confusion, shared-data problems, and tangled scheduling behavior.
The best multi-task designs are usually clear and intentional.
Multiple tasks do not remove the need for communication
Once you split an application into multiple tasks, those tasks often need to exchange data. For example, a sensor task may produce data that a display task should show.
That means task communication becomes part of the design. At first, beginners may try to use global variables for everything. That can work in small cases, but it quickly becomes risky if several tasks access shared data at the same time.
Later, queues, semaphores, mutexes, and task notifications become important. But even before using them, it is good to understand that multiple tasks naturally introduce communication needs.
Example: creating two tasks that share a simple flag
A simple example might use a shared variable:
volatile int sensorValue = 0;
void SensorTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
sensorValue = read_sensor();
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void DisplayTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
show_value(sensorValue);
vTaskDelay(pdMS_TO_TICKS(200));
}
}
This can work as a very basic demo, but it is also the point where you begin to see why safer communication methods are useful in larger systems.
Dynamic vs static creation for multiple tasks
A multi-task FreeRTOS system can create tasks dynamically or statically.
Dynamic creation uses xTaskCreate(). This is the most common beginner path because it is simpler.
Static creation uses xTaskCreateStatic(). This requires you to provide memory for each task stack and control block yourself.
For a first article on creating multiple tasks, dynamic creation is usually easier to understand because you can focus on task structure and scheduling first.
Example using xTaskCreateStatic
Here is the general idea:
#include "FreeRTOS.h"
#include "task.h"
static StackType_t task1Stack[256];
static StaticTask_t task1Buffer;
static StackType_t task2Stack[256];
static StaticTask_t task2Buffer;
void Task1(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
toggle_led();
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void Task2(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
serial_print("Task2 running\n");
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
int main(void)
{
hardware_init();
xTaskCreateStatic(Task1, "TASK1", 256, NULL, 1, task1Stack, &task1Buffer);
xTaskCreateStatic(Task2, "TASK2", 256, NULL, 1, task2Stack, &task2Buffer);
vTaskStartScheduler();
for (;;)
{
}
}
This version is more explicit about memory, but the scheduling idea stays the same.
A useful beginner test: observable tasks
When first creating multiple tasks, it helps to choose actions that are easy to observe.
Good first examples include:
- blinking LEDs at different rates
- printing different messages over serial
- toggling pins with different timing
- incrementing counters and displaying them
These are useful because you can clearly see whether both tasks are running and whether timing looks reasonable.
For example, two tasks printing different strings at different rates can make scheduler behavior easier to understand than more abstract code.
Common beginner mistakes
One common mistake is creating several tasks but forgetting that all of them need enough stack space. Each task has its own stack. If several tasks are created with unrealistic sizes, the system may become unstable.
Another common mistake is not having enough heap for all dynamically created tasks. Every task created with xTaskCreate() consumes memory.
Another is creating a high-priority task that never blocks, preventing lower-priority tasks from running.
Another is assigning priorities without a clear reason.
Another is putting too much unrelated work into one task while making the other tasks too trivial. That often defeats the purpose of task-based design.
Another is assuming code after vTaskStartScheduler() should continue running normally. In a healthy system, the scheduler should take over.
A practical progression for beginners
A good way to learn multiple tasks is to build up gradually.
First, create one task that blinks an LED.
Next, add a second task that prints to serial.
Then add a third task that does something simple on a different interval.
After that, begin introducing task communication, such as queues.
This gradual approach helps you separate basic scheduler behavior from later complexity.
A simple mental model
A useful mental model is this:
Each task is its own repeating job.
main() creates those jobs and starts the scheduler.
The scheduler decides which ready task runs next.
Tasks should usually block or delay when idle.
Once you think of tasks this way, multi-task FreeRTOS systems become much easier to design.
Conclusion
Creating multiple tasks in FreeRTOS is more than repeating xTaskCreate() several times. It is the start of structuring an embedded application as separate schedulable activities, each with its own purpose, stack, timing, and priority.
The best beginner designs keep each task focused on one responsibility, use sensible priorities, and make tasks block or delay when they have nothing to do. A small system with a sensor task, display task, and logger task already shows the value of this approach clearly. Each part can run according to its own timing, and the scheduler coordinates the work.

