Series / Porting FreeRTOS to STM32 From Scratch / Debugging the FreeRTOS Scheduler Bring-Up

Debugging the FreeRTOS Scheduler Bring-Up

Debug common FreeRTOS scheduler bring-up failures on STM32, including failed task creation, default handler traps, missing ticks, stack problems, priority mistakes, and assertions.

On this page

The previous article created the first FreeRTOS tasks on STM32 and started the scheduler. If everything was wired correctly, a simple task ran, delayed, and ran again.

This article is for the cases where that does not happen.

FreeRTOS scheduler bring-up failures are usually not mysterious once you separate them by where execution stops. A task creation failure points in one direction. A default handler trap points somewhere else. A task that runs once and never runs again usually points at the tick path. An assertion is often the most useful clue in the whole system.

The goal is to debug the scheduler baseline before adding queues, ISR-to-task signaling, or larger application behavior.

Starting Point

The project should have a minimal task setup like this:

static void blink_task(void *argument)
{
    (void)argument;

    for (;;)
    {
        board_led_toggle();
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

And main() should create the task and start the scheduler:

int main(void)
{
    HAL_Init();
    SystemClock_Config();

    board_init();

    configASSERT(xTaskCreate(blink_task, "blink", 256, NULL, 1, NULL) == pdPASS);

    vTaskStartScheduler();

    for (;;)
    {
    }
}

That final loop is a failure trap. In a working FreeRTOS system, execution should not continue there after the scheduler starts.

What Scheduler Bring-Up Means

Scheduler bring-up means proving the minimum runtime path works:

  • A task can be created.
  • The scheduler can start.
  • The first task can run.
  • The tick interrupt advances kernel time.
  • A delayed task can become ready again.
  • Context switching does not fall into a default handler or fault.

This is not general application debugging yet. Do not debug UART protocols, display drivers, sensor reads, queue design, or task architecture until the basic scheduler path is stable.

Keep the test small enough that each symptom maps to a small set of possible causes.

Failure 1: xTaskCreate() Fails

If xTaskCreate() does not return pdPASS, the scheduler has not even started yet.

Common causes include:

  • configSUPPORT_DYNAMIC_ALLOCATION is disabled.
  • No heap implementation is linked.
  • More than one heap implementation is linked.
  • configTOTAL_HEAP_SIZE is too small.
  • The requested task stack is too large for the configured FreeRTOS heap.

Keep the task creation check explicit:

BaseType_t created = xTaskCreate(
    blink_task,
    "blink",
    256,
    NULL,
    1,
    NULL);

configASSERT(created == pdPASS);

Do not ignore the return value. If task creation fails and the scheduler starts anyway, the failure becomes less obvious.

For first bring-up, reduce the number of tasks to one and use a modest stack size. If the task is tiny and creation still fails, inspect the heap configuration before changing scheduler code.

Failure 2: vTaskStartScheduler() Returns

In a working first bring-up, vTaskStartScheduler() should not return.

If execution reaches the loop after it, common causes include:

  • The idle task could not be created.
  • The timer task could not be created, if software timers are enabled.
  • The FreeRTOS heap is too small.
  • Static allocation is required by the configuration but the required callbacks are missing.
  • A port setup assertion or configuration issue prevented startup.

Put a breakpoint on the failure trap:

vTaskStartScheduler();

for (;;)
{
    /* Scheduler failed to start. */
}

If that breakpoint hits, do not start debugging the task body. The task did not run under scheduler control. Go back to allocation, heap, configuration, and scheduler startup settings.

Failure 3: You Land in Default_Handler

A default handler trap often means an exception fired but the vector table did not route it to the intended handler.

For FreeRTOS Cortex-M bring-up, check these handlers first:

SVC_Handler
PendSV_Handler
SysTick_Handler

They must reach the corresponding FreeRTOS port handlers for the selected port, commonly:

vPortSVCHandler
xPortPendSVHandler
xPortSysTickHandler

A common failure is that startup code still provides weak default handlers and the project never overrides or maps them.

Set breakpoints on both the startup names and the FreeRTOS port names. Start the scheduler and observe which breakpoint is hit. If Default_Handler is hit, identify the active exception. On Cortex-M, debugger views of system control registers can help show which exception is active.

Do not treat a default handler as random. It is usually a direct clue that a vector-table symbol is wrong, missing, or still weak.

Failure 4: The Task Runs Once and Never Again

If a task reaches its first vTaskDelay() and never runs again, suspect the tick path.

For example:

for (;;)
{
    board_led_toggle();
    vTaskDelay(pdMS_TO_TICKS(500));
}

If the LED toggles once and stops, the task probably blocked successfully. The problem is that kernel time is not advancing enough to unblock it.

Check:

  • Is SysTick enabled?
  • Does SysTick_Handler run?
  • Does SysTick_Handler call the FreeRTOS tick handler path?
  • Is configTICK_RATE_HZ reasonable?
  • Does configCPU_CLOCK_HZ match the clock used by SysTick?
  • Is the tick interrupt priority compatible with FreeRTOS?

Set a breakpoint in SysTick_Handler. If it never hits, the timer is not firing or the vector is wrong. If it hits but the task never wakes, inspect whether the FreeRTOS tick handler is actually called.

Failure 5: configASSERT() Stops the Firmware

An assertion is not an annoyance to remove.

During RTOS bring-up, configASSERT() often catches the real bug close to where it happens. It may stop on a bad interrupt priority, invalid API usage, failed allocation, or configuration mismatch.

A simple assert loop is useful:

#define configASSERT(x) \
    if ((x) == 0) { taskDISABLE_INTERRUPTS(); for (;;) { } }

When the assert loop is hit, pause the debugger and inspect the call stack. The caller is often more important than the loop itself.

Do not disable assertions to make the firmware continue. Continuing after a failed kernel assumption can corrupt scheduler state and produce a much harder failure later.

If the task runs repeatedly but the timing is wrong, the scheduler is probably alive but the time base is wrong.

Check the relationship between:

#define configCPU_CLOCK_HZ (SystemCoreClock)
#define configTICK_RATE_HZ ((TickType_t)1000)

and the actual clock used by SysTick.

If SystemCoreClock is stale, or if the clock tree is different from what the project assumes, pdMS_TO_TICKS(500) will not correspond to the expected real time.

Also check whether SysTick is being reconfigured by HAL code, startup code, or generated code after FreeRTOS has configured it. If two pieces of code configure the same timer, the last one wins.

Failure 7: HardFault or Stack Problems

Stack problems can show up as hard faults, corrupted variables, strange task behavior, or failures far away from the code that caused them.

For first bring-up, reduce stack risk:

  • Keep task functions tiny.
  • Avoid printf inside early tasks.
  • Avoid large local arrays.
  • Avoid deep driver call chains.
  • Use conservative task stack sizes.

If stack overflow checking is enabled in FreeRTOSConfig.h, provide the hook:

void vApplicationStackOverflowHook(TaskHandle_t task, char *name)
{
    (void)task;
    (void)name;

    taskDISABLE_INTERRUPTS();
    for (;;)
    {
    }
}

If malloc failure checking is enabled, provide the allocation failure hook:

void vApplicationMallocFailedHook(void)
{
    taskDISABLE_INTERRUPTS();
    for (;;)
    {
    }
}

These hooks are not a substitute for understanding memory usage, but they turn silent failures into obvious breakpoints.

Useful Breakpoints

For first scheduler debugging, use a small breakpoint set:

xTaskCreate
vTaskStartScheduler
SVC_Handler
PendSV_Handler
SysTick_Handler
Default_Handler
HardFault_Handler
vApplicationMallocFailedHook
vApplicationStackOverflowHook

You do not need all of these enabled forever. Use them to narrow the failure.

If task creation fails, focus on heap and allocation. If default handler is hit, focus on vector wiring. If SysTick_Handler never runs, focus on tick setup. If the task runs once and blocks forever, focus on kernel tick flow.

Good debugging is mostly classification.

Useful Watch Expressions

Depending on your debugger and FreeRTOS configuration, useful watch expressions include:

created
SystemCoreClock
xTaskGetTickCount()
heartbeat_count

A volatile heartbeat counter inside a task can be more reliable than stepping through scheduler code:

static volatile uint32_t heartbeat_count;

static void heartbeat_task(void *argument)
{
    (void)argument;

    for (;;)
    {
        heartbeat_count++;
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

If the counter increases, the task is running repeatedly. If it increments once, the task started but probably never woke from delay. If it never increments, the scheduler may not have reached the task.

A Debugging Order That Saves Time

Use this order before changing code randomly:

  1. Confirm the firmware reaches main().
  2. Confirm hardware and board initialization return.
  3. Confirm xTaskCreate() returns pdPASS.
  4. Confirm vTaskStartScheduler() does not return.
  5. Confirm SVC_Handler reaches the FreeRTOS SVC path.
  6. Confirm SysTick_Handler fires repeatedly.
  7. Confirm PendSV_Handler reaches the FreeRTOS PendSV path.
  8. Confirm the first task runs.
  9. Confirm the first task runs again after vTaskDelay().

This order follows the actual bring-up path. It avoids debugging task behavior before the scheduler is even running.

What Not To Debug Yet

Do not debug queue behavior yet.

Do not debug ISR-to-task signaling yet.

Do not debug task priority architecture yet.

Do not move peripheral drivers into multiple tasks yet.

Those topics matter, but they assume the scheduler baseline works. If the first task cannot run and delay correctly, adding more RTOS features only makes the problem harder to isolate.

Keep the test small until the scheduler path is boring.

Next Steps

The next article uses queues and delays without breaking timing.

Once the scheduler can start and basic tasks can run, the next useful step is controlled communication between tasks and time-based behavior. That introduces blocking, wakeups, queue capacity, and timing boundaries, but it should be built on top of a scheduler baseline that has already been debugged.