On this page
- Starting Point
- What the First Task Should Prove
- A Minimal Task Function
- Creating the Task
- Starting the Scheduler
- What Happens to main()
- Adding a Second Task
- Verifying Task Switching
- Stack Size and Priority Choices
- What Not To Add Yet
- Common First Task Failures
- Checklist Before Debugging Scheduler Bring-Up
- Next Steps
The previous articles prepared the FreeRTOS build, configuration, tick, and Cortex-M exception wiring. The project now has enough scheduler plumbing to try the first real runtime test: create a task and start the scheduler.
This article keeps that test deliberately small.
The first task should prove that the scheduler can start, that task stack setup works, that the tick advances time, and that a task can delay and run again. It should not prove UART logging, queues, semaphores, ISR-to-task signaling, display refresh, or a full application architecture.
Those pieces come later. First, make one task run.
Starting Point
At this point, the project should have:
- FreeRTOS kernel source files in the CMake build.
- The Cortex-M4F GCC portable layer selected.
- A project-owned
FreeRTOSConfig.h. - A selected heap implementation such as
heap_4.c. SysTickconnected to the FreeRTOS tick path.PendSVandSVCconnected to the FreeRTOS port handlers.- Basic STM32 initialization still happening before the scheduler starts.
The startup path still begins the same way as a normal STM32 firmware project. Reset runs startup code. Startup code prepares the C runtime. main() runs. The difference is that main() will create RTOS objects and then hand control to the scheduler.
What the First Task Should Prove
The first task should answer a narrow question:
Can FreeRTOS start a task and return to that task repeatedly after a delay?
A useful first task often toggles an LED. That gives a visible sign that the scheduler is running and that vTaskDelay() returns after the expected amount of time.
If an LED is not available or not initialized yet, a debugger-only task can increment a volatile counter instead. The important behavior is the same: the task runs, blocks for a delay, and later runs again.
Avoid doing too much in the first task. A task that logs over UART, reads a sensor, updates a display, and waits on a queue creates too many possible failure points. If it fails, you will not know whether the scheduler is broken or the application code is broken.
A Minimal Task Function
A FreeRTOS task is a function with this shape:
static void blink_task(void *argument)
{
(void)argument;
for (;;)
{
board_led_toggle();
vTaskDelay(pdMS_TO_TICKS(500));
}
}
The function receives a void * argument. That lets the same task function receive project-specific context later. The first task does not need it, so the argument is explicitly ignored.
The task function should not return. It normally contains an infinite loop. If a task really needs to exit, it should delete itself with vTaskDelete(NULL), but that is not needed for first bring-up.
vTaskDelay() blocks the task for a number of ticks. The helper pdMS_TO_TICKS(500) converts milliseconds to ticks using configTICK_RATE_HZ.
That line proves two pieces at once: the task can block, and the scheduler tick is advancing.
Creating the Task
Tasks are created before the scheduler starts:
BaseType_t created = xTaskCreate(
blink_task,
"blink",
256,
NULL,
1,
NULL);
configASSERT(created == pdPASS);
The arguments are:
- The task function.
- A short task name for debugging.
- The task stack size in words, not bytes.
- The task argument.
- The task priority.
- An optional task handle output.
For a simple blink task, a stack size of 256 words is a reasonable starting point on a 32-bit Cortex-M target. That is not a universal value. It is a bring-up value for a small task.
The priority 1 is also deliberate. The idle task uses priority 0, so priority 1 lets the blink task run above idle while keeping the example simple.
Always check the return value from xTaskCreate(). If task creation fails, the scheduler may start with no useful task to run. Common causes include insufficient FreeRTOS heap, stack sizes that are too large for the configured heap, or dynamic allocation being disabled.
Starting the Scheduler
After the task is created, start the scheduler:
vTaskStartScheduler();
At that point, the FreeRTOS port starts the first task. In a working bring-up, normal application execution does not continue past this call.
A minimal main() can look like this:
int main(void)
{
HAL_Init();
SystemClock_Config();
board_init();
BaseType_t created = xTaskCreate(
blink_task,
"blink",
256,
NULL,
1,
NULL);
configASSERT(created == pdPASS);
vTaskStartScheduler();
for (;;)
{
}
}
The infinite loop after vTaskStartScheduler() is not normal application behavior. It is a failure trap. If execution reaches that loop, the scheduler did not start successfully or returned unexpectedly.
Set a breakpoint there during bring-up. You want to know immediately if the scheduler startup path fails.
What Happens to main()
Before FreeRTOS, main() usually owns the application loop:
while (1)
{
app_run();
}
After FreeRTOS starts, tasks own the ongoing application behavior. main() becomes the startup and scheduler handoff function.
That is a design shift. Do not keep a normal application loop running after vTaskStartScheduler(). It should not run in a successful RTOS system.
Initialization that must happen once before tasks start still belongs before scheduler startup. Behavior that should happen repeatedly belongs in tasks.
Adding a Second Task
Once one task runs, a second task can prove that scheduling is actually switching between independent contexts.
For example:
static volatile uint32_t heartbeat_count;
static void heartbeat_task(void *argument)
{
(void)argument;
for (;;)
{
heartbeat_count++;
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
Create it with the same low priority:
configASSERT(xTaskCreate(
heartbeat_task,
"heart",
256,
NULL,
1,
NULL) == pdPASS);
With time slicing enabled, two ready tasks at the same priority can share CPU time. In this example, both tasks spend most of their time blocked in vTaskDelay(), so the scheduler wakes each one when its delay expires.
This is still not a full application. It is a scheduler test.
Verifying Task Switching
Use simple observations first.
If the blink task toggles an LED every 500 ms, the LED should change state at that rate. If the heartbeat task increments a volatile counter every second, the counter should increase while the debugger is attached.
In a debugger, set breakpoints inside both task loops. You should see execution reach both tasks over time. You can also watch the tick count if your FreeRTOS configuration exposes the relevant API:
TickType_t now = xTaskGetTickCount();
Do not rely only on source-level stepping through scheduler internals. Context switching can be confusing in a debugger because the CPU moves between exception handlers and task stacks. Start with simple proof that tasks are running at all.
Stack Size and Priority Choices
The first task stack sizes are intentionally conservative.
Remember that FreeRTOS stack sizes are usually specified in words. On a 32-bit Cortex-M target, 256 words means 1024 bytes.
Small tasks that toggle an LED or increment a counter do not need much stack. Tasks that call printf, perform HAL operations, use large local buffers, or call deep driver stacks need more.
Priorities should also stay simple. For first bring-up:
- Use priority
1for simple application tasks. - Let the idle task stay at priority
0. - Avoid creating many priority levels before there is a scheduling reason.
The goal is to prove scheduler operation, not design the final task priority model.
What Not To Add Yet
Do not add queues yet.
Do not send data from interrupts to tasks yet.
Do not move every peripheral driver into its own task yet.
Do not add complex logging from multiple tasks yet.
Those are useful patterns, but they create new failure modes. First, keep the scheduler baseline small and known-good. Once two simple tasks can run and delay correctly, the project has a much better foundation for debugging richer behavior.
Common First Task Failures
One common failure is xTaskCreate() returning something other than pdPASS. That usually points to heap configuration, dynamic allocation settings, or stack sizes that are too large for the configured FreeRTOS heap.
Another failure is reaching the loop after vTaskStartScheduler(). That often means the scheduler could not start, commonly because there was not enough heap to create internal scheduler structures or the idle task.
A third failure is landing in a default handler. That points back to exception wiring. Check SVC_Handler, PendSV_Handler, and SysTick_Handler before blaming task code.
A fourth failure is a task running once and never running again. That often points to a missing or incorrect tick path. If vTaskDelay() blocks the task forever, verify SysTick and the FreeRTOS tick handler path.
A fifth failure is an assert during scheduler startup or interrupt activity. Do not disable the assert. Inspect it. It may be catching an interrupt priority or API usage problem.
Checklist Before Debugging Scheduler Bring-Up
Before debugging deeper, confirm:
- At least one task is created successfully.
configASSERT(created == pdPASS)is present after task creation.vTaskStartScheduler()is followed by a failure trap loop.- The task function never returns.
- Task stack sizes are specified in words, not bytes.
- The FreeRTOS heap is large enough for the created tasks.
SysTickreaches the FreeRTOS tick handler.SVCandPendSVreach the FreeRTOS port handlers.
If all of those are true, debugging becomes much more focused.
Next Steps
The next article focuses on debugging FreeRTOS scheduler bring-up.
Now that the project creates tasks and starts the scheduler, failures become more concrete. The next step is to diagnose the common places bring-up breaks: missing handler wiring, failed task allocation, bad stack assumptions, wrong interrupt priorities, tick problems, and assertions that point to configuration mistakes.