Embedded HandbookApps
All sections › IV. FreeRTOS
STM32F103, STM32F407 · level intermediate · 16 min read
Open in the interactive handbook

26. Tasks

The task is the one thing you take an operating system for. Everything else — queues, semaphores, timers — serves how tasks interact. This section covers how tasks are built, by what rules they get the CPU, and the four mistakes people make with them most often.

Terms used in the documentation

Term Meaning
task function the function the task runs
task handle a reference to a task
stack depth stack size, in words
time slicing sharing time between tasks of equal priority
round-robin handing out time in turn, in a circle
starvation the task never gets the CPU at all
blocked / suspended waiting / paused
yield give up the CPU voluntarily
high water mark the smallest stack remainder over all time
task control block (TCB) the kernel's record of a task

26.1 What a task looks like

static void worker_task(void *arg)
{
    const uint32_t period = (uint32_t)arg;

    for (;;) {                      /* цикл обязателен */
        do_work();
        vTaskDelay(pdMS_TO_TICKS(period));
    }
}

Three required properties.

It returns void and takes void *. The signature is fixed — the kernel calls the function through a pointer and cannot pass anything else. The argument is often used to serve several tasks with one function: a channel number, a period, the address of a settings structure.

An endless loop inside. A task does not finish — it runs the whole time the device is powered.

⚠️ You must not return from a task function. This is not a matter of style but a requirement: there is nowhere to return to, there is no return address on the task's stack. The kernel catches such a return with a check and halts the program. If a task has to run once and disappear, it must delete itself explicitly:

c static void one_shot(void *arg) { do_the_thing(); vTaskDelete(NULL); /* NULL means "delete myself" */ }

Its own stack. All local variables of the task live in it, and its size is set when the task is created.


26.2 Priorities: the main misunderstanding

The scheduler has one rule, and you have already heard it: the ready task with the highest priority runs. It leads to a conclusion that is often understood the wrong way round.

Tasks of different priority do not share time. Not at all. The higher one runs until it blocks; the lower ones get nothing.

Hence the main beginner's mistake:

static void high_task(void *arg)
{
    for (;;) {
        poll_sensor();          /* и никакого ожидания */
    }
}

A high-priority task without a single blocking call completely stops everything below it. The device looks frozen, although formally it is running: the lower tasks simply never execute. This is called starvation, and the cure is not to lower the priority but to add a wait.

Tasks of the same priority do share time — one tick each, in turn. This is time slicing, and it is enabled by the macro configUSE_TIME_SLICING.

Situation What happens
tasks of different priorities the higher one runs, the lower ones wait
tasks of the same priority one tick each, in turn
the higher one has blocked the CPU goes to the next in seniority
everyone is waiting the idle task runs

How many levels and how to choose them

There are as many levels as configMAX_PRIORITIES sets; numbering starts from zero, and level zero is taken by the idle task.

⚠️ A priority above the maximum. Before clipping it, the kernel checks configASSERT(uxPriority < configMAX_PRIORITIES). With the check enabled — and CubeMX enables it — the chip stops right in it, when the task is created. Without the check, priority 10 with five levels silently becomes 4: two tasks given 10 and 99 end up on the same level and start sharing time, while the code says one of them is more important (experiment — in 26.9).

A sensible priority scheme for a device:

Level For whom
high handling whatever cannot wait
medium the main logic of the device
low display output, the log, the link to the computer
0 the idle task; none of your tasks here

You do not need many levels. Three or four cover almost any device; each extra level is one more ready list in kernel memory and one more decision to make when you add a task.

Is there enough CPU

Priorities decide who goes first, but they do not tell you whether everyone makes it. For periodic tasks this is calculated in advance. Each task has an execution time C and a period T; priorities are assigned by rate: the more often a task runs, the higher it is.

Utilization is the sum of C / T over all tasks. The Liu–Layland bound: if utilization is no more than n·(2^(1/n) − 1), all deadlines are guaranteed; for three tasks this is 77.98%. The bound is sufficient but not necessary: above it a set may meet its deadlines or may not — then you calculate the response time of each task: its own C plus everything the higher ones manage to preempt it with, R = C + Σ ⌈R / Tj⌉ · Cj, until the value stops changing.

Three sets — a sensor (1 ms every 5 ms), a controller (3 ms every 20 ms) and a display with different redraw lengths, period 50 ms:

Display Utilization Bound 77.98% Display response Deadline 50 ms
10 ms 55% passed 17 ms meets it
25 ms 85% not passed 39 ms meets it
32 ms 99% not passed 52 ms misses it

A direct simulation of a preemptive scheduler — all tasks start at once, which is the worst case — gave the same response times and a missed deadline in the third set. The sensor responds in 1 ms in all three, the controller in 4 ms: a lower task has no effect on the higher ones.

The "Tasks" tab in the app's calculators does the same calculation for your own set of tasks: utilization, the bound and the response time of each task.


26.3 Blocking: what counts as waiting

A task gives up the CPU only if it has called a kernel function that blocks it. Nothing else counts as waiting.

Blocks Does not block
vTaskDelay, xTaskDelayUntil an empty for loop
xQueueReceive with a timeout while (!flag) { }
xSemaphoreTake with a timeout polling a peripheral register in a loop
ulTaskNotifyTake with a timeout a loop with __NOP() inside

The difference is fundamental. The first column moves the task into the blocked state, and the kernel removes it from the ready list. The second leaves the task running: it simply wastes its time and does not let anyone below it run.

The only way to give up the CPU without waiting for an event:

taskYIELD();        /* отдать остаток кванта равным по приоритету */

But even that does not help against starvation: you can only yield to tasks of the same priority or higher.

Five states

State How to get there How to leave
running the scheduler picked it preempted or blocked
ready created or done waiting the scheduler picked it
blocked called a blocking function an event or a timeout
suspended vTaskSuspend only vTaskResume
deleted vTaskDelete no way

The five states of a FreeRTOS task and the calls that move it between them

⚠️ A task waiting with no time limit sits in the same kernel list as suspended ones. eTaskGetState tells them apart and returns eBlocked for it; eSuspended comes only after vTaskSuspend. But the vTaskList report and uxTaskGetSystemState show as suspended a task that waits forever for a notification or a stream buffer (which waits through notifications) — this is confusing when debugging. An infinite wait on a queue or a semaphore is shown correctly.


26.4 🔧 A periodic task: two versions

The most common kind of task does something with a constant period. The obvious solution turns out to be wrong.

/* Неверно: период уплывает */
for (;;) {
    do_work();                              /* занимает 3 мс */
    vTaskDelay(pdMS_TO_TICKS(10));
}

vTaskDelay counts from the moment of the call. So the period is not 10 ms but 13: ten milliseconds of waiting plus three of work. And that is optimistic — as soon as the task is preempted, the period grows further.

The numbers show how much this matters. In one minute with a period of 10 ms there should be 6000 activations. With an actual period of 13 ms there will be 4615. 1385 activations lost in one minute — almost a quarter.

/* Верно: период точный */
TickType_t last = xTaskGetTickCount();

for (;;) {
    do_work();
    xTaskDelayUntil(&last, pdMS_TO_TICKS(10));
}

xTaskDelayUntil counts from the previous wake-up, not from the call. The task's working time is subtracted from the wait automatically, and the period stays exact however long the task computes — as long as it fits within the period.

vTaskDelay xTaskDelayUntil
counts from the moment of the call the previous wake-up
period delay + work exactly as set
error accumulates yes no
used for pauses, retries periodic measurement, polling, control

⚠️ Do not touch the reference variable by hand. xTaskDelayUntil updates it itself on every call. It is initialized once, before the loop, with xTaskGetTickCount(). Assigning anything to it inside the loop breaks the whole point of the function.

There is a second subtlety: if the work took longer than the period, the function does not wait at all and returns a flag saying the task did not sleep. A device in this mode is running at its limit, and you should know about it — checking the return value gives you a free overload signal.


26.5 The idle task

The kernel creates it itself when the scheduler starts. Its priority is zero; it runs when everyone else is waiting.

Three things it does:

Frees the memory of tasks that deleted themselves. A task cannot return the stack it is running on, so vTaskDelete(NULL) only puts it in the deletion queue, and the control block and stack are returned to the heap by the idle task. For another task, vTaskDelete frees the memory at once.

Calls the idle hook, if it is enabled. This is usually where you put CPU sleep (section 10) — the device draws current only when it is working.

Does nothing if neither is required.

⚠️ If the idle task does not get the CPU, the memory of tasks that deleted themselves is not returned. A device in which tasks are created and deleted while the lower priority levels are always busy will slowly lose heap. The symptom is running out of memory after hours of operation with code that looks correct.

The idle hook must be fast and must never block. The idle task must always stay ready — freeing memory depends on it.

The macro configIDLE_SHOULD_YIELD controls whether the idle task yields its time slice to tasks of the same — that is, zero — priority. It matters only if you have created your own tasks with priority zero, which you should not do.


26.6 Scheduler modes

Two macros give three behaviours.

configUSE_PREEMPTION configUSE_TIME_SLICING Result
1 1 preemption with time slicing — the normal mode
1 0 preemption without slicing: equal tasks do not alternate
0 — cooperative: switching only on taskYIELD or when a task blocks

The normal mode is the first row, and there is no reason to change it without cause.

The cooperative mode is sometimes chosen for predictability: switching happens only where you allowed it, and races between tasks almost never occur. The price: any task can stop the system by forgetting to yield, and the reaction to events is limited by the length of the longest uninterrupted stretch. In essence it is a superloop with more convenient notation.


26.7 The stack: how much to give and how to check

There is no theoretical answer to this question. A task's stack is used by local variables, nested calls and, on Cortex‑M, also the interrupt frame that the processor pushes onto the current task's stack.

The practice is: give it with a margin, then measure and trim.

UBaseType_t words = uxTaskGetStackHighWaterMark(handle);
uint32_t    bytes = words * sizeof(StackType_t);

The function returns the smallest remainder over the whole life of the task, and returns it in words.

⚠️ The value is in words, not bytes. A remainder of 20 means 80 bytes of free stack, not 20. A factor-of-four error is especially nasty here: the margin seems critical when it is acceptable — or the other way round.

A remainder of at least a quarter of the allocation is considered healthy. Zero means the stack was exhausted — and, most likely, with consequences already.

The overflow hook catches trouble at the moment it happens, not after the fact:

void vApplicationStackOverflowHook(TaskHandle_t task, char *name)
{
    (void)task;
    for (;;) { }        /* имя виновной задачи лежит в name */
}

It is enabled by the macro configCHECK_FOR_STACK_OVERFLOW. Value 1 compares the stack pointer with the limit at each switch, value 2 additionally checks that the tail of the stack still holds a known pattern. The second method is more reliable and slightly slower; use it during development.

⚠️ No check catches an overflow instantly. Both fire on a task switch, that is, after someone else's memory has already been corrupted. The hook names the culprit but does not undo the damage — which is why the margin matters more than the diagnostics. Only hardware catches it instantly: on Cortex-M33 (L5, U5, H5) the FreeRTOS port writes the task's stack limit into the PSPLIM register, and going past it raises an exception at once; on M3/M4 the same is done with an MPU guard region below the stack.

A separate mine is printf and everything to do with floating point. One harmless line of formatted output can eat several hundred bytes of stack at once; a task with a minimal stack falls over right on it.


26.8 Static or dynamic

Returning to section 25 as it applies to tasks:

/* динамически: память из кучи, может не получиться */
if (xTaskCreate(worker, "worker", 256, NULL, 2, &h) != pdPASS) {
    /* не хватило кучи */
}

/* статически: память ваша, не получиться не может */
static StaticTask_t tcb;
static StackType_t  stack[256];

h = xTaskCreateStatic(worker, "worker", 256, NULL, 2, stack, &tcb);

For tasks the difference is especially noticeable: their stacks are the largest item of memory use in a device. With static allocation the total is visible in the memory map right after the build, and the question "will it fit" is closed before the first flashing.


26.9 🔧 Experiment: who gets the CPU

Everything said about priorities is easy to check: give tasks a load and ask the kernel how much CPU each one got. That is what task run-time accounting is for — configGENERATE_RUN_TIME_STATS: the kernel itself accumulates, in the control block of each task, how long that task has run.

/* работа без единого ожидания */
void busy_task(void *arg)
{
    volatile uint32_t *n = arg;
    for (;;)
        (*n)++;
}

/* 2 мс работы, 8 мс ожидания — пятая часть процессора */
void duty_task(void *arg)
{
    volatile uint32_t *n = arg;
    for (;;) {
        TickType_t t0 = xTaskGetTickCount();
        while (xTaskGetTickCount() - t0 < pdMS_TO_TICKS(2))
            (*n)++;
        vTaskDelay(pdMS_TO_TICKS(8));
    }
}

/* сколько процессора задача получила с момента создания — по счётчику ядра */
configRUN_TIME_COUNTER_TYPE task_runtime(TaskHandle_t h)
{
    TaskStatus_t st;
    vTaskGetInfo(h, &st, pdFALSE, eRunning);
    return st.ulRunTimeCounter;
}

The counter needs a time source — the macro portGET_RUN_TIME_COUNTER_VALUE(). On the chip this is DWT->CYCCNT or a free-running timer (section 73), and it must tick much more often than the system tick: time is handed out at every task switch, and a counter that ticks less often than the switches hands it out at random. That is exactly what happened with the port's own counter for the PC — it ticks once every 10 ms, and the shares of equal tasks jumped from run to run. So the experiment uses a counter in microseconds.

The counter is limited from above too. A 32-bit CYCCNT at 72 MHz overflows every 59.7 s, and the kernel accumulates task time in 32 bits and does not notice the overflow — after a minute of operation the shares become garbage. So you take a timer at 10–100 kHz (at 100 kHz overflow comes after 11.9 h) or accumulate time in 64 bits: configRUN_TIME_COUNTER_TYPE = uint64_t, and the upper half on top of CYCCNT is counted in software.

The experiment runs on the real kernel 10.5.1 with the POSIX port: tasks are threads of the computer, the tick is 1 ms. A supervisor with the highest priority creates the scenario's tasks, sleeps for a second, stops them and asks the kernel for the counters.

Scenario Task priorities CPU shares
three equal, no waiting 1, 1, 1 33, 33, 33%
higher one with no waiting 2, 1, 1 100, 0, 0%
higher one works 2 ms out of 10 2, 1, 1 20, 40, 40%
priorities 10 and 99 with five levels 4, 4 after clipping 50, 50%

The idle task got 0% in all four: there was always enough work.

There are four conclusions. Equal tasks share time evenly — that is time slicing by tick. A higher task with no waiting takes everything: the lower ones and idle do not get a single tick — starvation (26.2) in its pure form. As soon as the higher one waits 8 ms out of 10, it takes its fifth, and the lower ones share the rest evenly. And priorities 10 and 99 turned out to be the same fourth level: the configASSERT check fired on both (here it only recorded the hit and let go), after which the kernel clipped the priorities, and the tasks split the time in half.

The third scenario tick by tick — as a trace, the kind trace tools draw:

Scheduler trace: the higher task runs 2 ms out of 10, two lower ones share the rest tick by tick


26.10 Pitfalls

The device hangs, but not completely. A high-priority task does not block. Look for an endless loop with no wait.

Two tasks with different priorities behave as equals. Priorities of at least configMAX_PRIORITIES are clipped to the maximum — this happens when configASSERT is disabled.

The chip stops when a task is created. configASSERT fired on a priority of at least configMAX_PRIORITIES.

The task period is not the one you set. vTaskDelay was used instead of xTaskDelayUntil; the period equals the delay plus the working time.

The program stops right after a task starts. The task returned from its function. You need an endless loop or vTaskDelete(NULL).

A task stopped running after vTaskSuspend. Only vTaskResume brings it out of suspension; no event will wake it.

vTaskList shows a working task as suspended. It waits forever for a notification or a stream buffer, and the report only recognizes waiting on queues and semaphores; eTaskGetState returns eBlocked for it.

Memory melts away as tasks are created and deleted. The tasks delete themselves, and the idle task does not get the CPU and cannot free their memory.

The stack runs out on printf. Formatted output costs hundreds of bytes of stack. Either a margin, or no printf in tasks.

The stack margin looks four times smaller than it really is. The function returns words, not bytes.

Task shares in the run-time statistics jump around. The counter ticks less often than tasks switch; you need a source much faster than the system tick (26.9).


26.11 Summary

Next: section 27 "Queues" — how tasks pass data to each other and why a queue copies rather than references.

Open in the interactive handbook