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

24. How FreeRTOS is organized

The kernel is a couple of dozen files that are compiled together with your program. This section is about which of them you need, why the functions have such strange names and what exactly has to be done for all of it to start on a board.

The material of the section is based on version 10.5.1. The differences between close versions are small, but names and feature sets do change from version to version, so it is worth knowing yours: it is in task.h under the name tskKERNEL_VERSION_NUMBER.

Terms used in the documentation

Term Meaning
kernel source the kernel's source files
port layer the layer that adapts the kernel to a CPU
heap implementation the memory allocation scheme
handle a reference to a kernel object
hook function a user function the kernel calls
idle hook / tick hook a hook called from the idle task / from the tick
stack depth the stack size (in words)
Hungarian notation type prefixes in names
wrapper a layer on top of the kernel API

24.1 What the kernel consists of

The files fall into three groups, and confusing them is the source of most of the trouble at the first build.

The portable part — shared by all processors, from eight-bit ones to servers. This is the kernel proper:

File What is inside Size
tasks.c scheduler, tasks, states, tick 218 KB
queue.c queues, semaphores, mutexes 123 KB
list.c linked lists on which everything else rests 10 KB
timers.c software timers 49 KB
event_groups.c event groups 31 KB
stream_buffer.c stream buffers and message buffers 61 KB
croutine.c co-routines — an obsolete mechanism, not used 16 KB

The FreeRTOS kernel: the portable part with file sizes, the port layer, one heap scheme out of five and FreeRTOSConfig.h

The first three are mandatory. The rest are added as needed: if you do not need software timers, timers.c need not be compiled, and one and a half kilobytes of firmware stay free.

Note how modest list.c is. Ten kilobytes of source are doubly linked lists sorted by value, and on them are built all the ready queues, all the wait lists and all the time handling. The scheduler does not "search" for a task — it takes the first one from the list of the required priority, and so the selection time does not depend on the number of tasks.

The port layer — the binding to a specific processor. Only two files:

File What it does
port.c starting the scheduler, the context switch, the tick handler
portmacro.h types, critical sections, word size

This is where the nineteen PendSV instructions from section 23 live. Each processor family has its own port, and they are in the portable directory.

⚠️ Cortex‑M3 and Cortex‑M4 are different ports, and you must not swap them. The ARM_CM4F directory takes the floating-point unit into account: on a context switch its registers are saved as well. The Cortex‑M3 port on an F407 with the FPU enabled builds without a single warning, but it does not know about the extended exception frame: it returns into the new task with the EXC_RETURN code of the previous one, the kernel pops 26 words from the stack instead of 8 or the other way round, and the task's stack slips by 18 words. In a run in QEMU (Cortex‑M4F, FreeRTOS 10.5.1, two tasks using float) the device fell into HardFault soon after the first preemption of such a task, and if main used the FPU before the scheduler was started, right at startup. If automatic FPU state saving is off (FPCCR.ASPEN = 0), there is no crash, but the numbers get corrupted with no pattern at all: in the same run roughly every two-hundredth calculation came out wrong. Neither looks like a build error.

A separate subtlety for the Arm Compiler 6 compiler (armclang, Keil MDK): you have to take the port from the GCC directory, not RVDS. The latter is written for the old armcc version 5 and simply does not compile because of the __forceinline keyword.

The memory allocation scheme — exactly one file out of five to choose from, heap_1.c … heap_5.c. Section 25 is devoted entirely to them.

And your FreeRTOSConfig.h — the only kernel file that you write yourself. It is a set of several dozen macros: the tick rate, the number of priorities, the heap size, which features are enabled. It is in every project, and it is also the first place to look when something does not work.


24.2 Naming conventions

Names in FreeRTOS look frightening until you learn the rule. And the rule is simple: the first letters tell you the type.

The return type — a function prefix

Prefix What it returns Example
v nothing (void) vTaskDelay
x BaseType_t or a handle xTaskCreate, xQueueSend
ux UBaseType_t — unsigned uxTaskPriorityGet
pv a pointer to void pvPortMalloc
pc a pointer to a character pcTaskGetName
e an enumeration value eTaskGetState

Scope — the second prefix

Prefix Where it is defined
prv a file-internal function, not visible outside
x, v with no prefix part of the public API

Hence prvIdleTask is an internal function and vTaskDelay is a public one.

Prefixes in variable names

ul — unsigned long, us — short, uc — byte, p — pointer, x — BaseType_t or a kernel structure. Hence ulValue, pxTask, pcName.

Prefixes in macro names

Prefix Where the macro comes from
config from your FreeRTOSConfig.h
INCLUDE_ from the same place, enables an individual function
port from the port layer
task, queue from the corresponding module
pd, err return values

The INCLUDE_ rule deserves a separate word: these macros do not configure behaviour, they add or remove code. Forget INCLUDE_vTaskDelay — and you get a link error on a function that is definitely in the sources.

Types

The kernel does not use int and long directly: their width differs from processor to processor, and portability is the main requirement for the kernel. It has its own names instead:

Type What it is On Cortex‑M
BaseType_t the natural word of the processor, signed 4 bytes
UBaseType_t the same, unsigned 4 bytes
TickType_t the tick counter 4 bytes (or 2, see below)
StackType_t a stack element 4 bytes
TaskHandle_t a task handle a pointer

TickType_t is controlled by the configUSE_16_BIT_TICKS macro. On eight- and sixteen-bit processors it is set to one for speed, on Cortex‑M never: you save two bytes and get the counter overflowing after 65 seconds instead of 49 days.

Starting with kernel 11 the macro is called configTICK_TYPE_WIDTH_IN_BITS and takes TICK_TYPE_WIDTH_16_BITS, TICK_TYPE_WIDTH_32_BITS or TICK_TYPE_WIDTH_64_BITS. The kernel still understands the old name for compatibility: configUSE_16_BIT_TICKS 0 gives the same four bytes there. But you cannot define both at once — the build stops with "Only one of configUSE_16_BIT_TICKS and configTICK_TYPE_WIDTH_IN_BITS must be defined". Defining neither is not allowed either: "Missing definition".

Return codes

pdPASS is success, pdFAIL is failure, and they are also pdTRUE and pdFALSE.

⚠️ A trap: errQUEUE_EMPTY and errQUEUE_FULL are the same value, and it equals pdFAIL. Both are zero. You cannot tell from the return code what exactly happened — whether the queue was empty or full. The meaning is determined by which function you called, not by what it returned.


24.3 Converting time

All kernel functions take time in ticks, not in milliseconds. A macro does the conversion:

vTaskDelay(pdMS_TO_TICKS(250));     /* правильно */
vTaskDelay(250);                    /* тоже работает — но только при 1 кГц */

The second variant works by accident: with configTICK_RATE_HZ = 1000 a tick equals a millisecond, and the numbers coincide. Change the tick rate — and all the delays in the program drift silently, without a single warning.

⚠️ The macro uses integer division and rounds down — all the way to zero. With a tick of 1 kHz pdMS_TO_TICKS(5) gives 5, but with a tick of 100 Hz the same five milliseconds turn into zero ticks. And vTaskDelay(0) does not wait at all: the task simply yields the CPU and returns at once. Instead of a pause you get a loop that eats all the free time. The symptom is "the device became sluggish after I lowered the tick rate".

The other edge is overflow: in 10.5.1 the macro first multiplies by the tick rate in 32 bits, and at 1 kHz anything longer than 4294967 ms (71.6 minutes) is silently truncated (since version 11 the multiplication is done in 64 bits). Pauses of an hour are computed by hand — (TickType_t)(ms / 1000u * configTICK_RATE_HZ) — or you sleep in a loop a minute at a time.

Hence the practical rule: a tick rate of 1000 Hz and pdMS_TO_TICKS everywhere. The first spares you rounding, the second spares you depending on the first.

Task trace: vTaskDelay(pdMS_TO_TICKS(5)) with a 1000 Hz and a 100 Hz tick


24.4 What the kernel needs to start

Five steps. Skipping any of them gives its own characteristic failure.

Step 1. Give the kernel three exceptions

The kernel takes SVC, PendSV and SysTick for itself. Internally it names their handlers in its own way, so in the configuration the names are mapped to the names from the vector table:

#define vPortSVCHandler     SVC_Handler
#define xPortPendSVHandler  PendSV_Handler
#define xPortSysTickHandler SysTick_Handler

⚠️ The most common error of the first build. The handler template that the configurator puts into the project already contains empty SVC_Handler, PendSV_Handler and SysTick_Handler. Together with the kernel you get two definitions of the same name, and the linker says it plainly:

Error: L6200E: Symbol SVC_Handler multiply defined (by port.o and it.o). Error: L6200E: Symbol PendSV_Handler multiply defined (by port.o and it.o). Error: L6200E: Symbol SysTick_Handler multiply defined (by port.o and it.o).

The cure is to delete the three templates from the handlers file, not to remove the name mapping from the configuration. This error is honest and clear; it is worse when the mapping was forgotten altogether: then everything builds, but the tasks do not switch, because the empty templates are called.

If your own code also needs a system tick (for example, the delays of the HAL library), it is taken from another timer — there are enough free ones on the board.

Step 2. Set up interrupt priorities

A requirement which, if not met, causes the most mysterious failures.

All significant priority bits must be preemption bits. On Cortex‑M the priority is split into two parts — preemption and subpriority, and the split point is set by the PRIGROUP field. The kernel needs no subpriority left at all.

STM32 has four significant bits — the upper ones out of eight. So any PRIGROUP from 0 to 3 will do: with them the split falls on the unused low bits, and all four significant bits end up as preemption bits. This is exactly what the kernel checks — that PRIGROUP is no coarser than three.

NVIC_SetPriorityGrouping(0);     /* годится на любом Cortex-M */
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);   /* то же самое: PRIGROUP = 3 */

Both lines do the job. The first is universal, the second is more familiar in projects from the configurator; the only confusion is that the "four" in the name means the number of preemption bits, not the field value.

Interrupts that call the kernel must not have a higher priority than the threshold. The threshold is set in the configuration:

#define configPRIO_BITS                               4      /* у F1 и F4 */
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY  5
#define configMAX_SYSCALL_INTERRUPT_PRIORITY (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))

The kernel reads only the third line — the same threshold shifted into the upper bits (0x50), for the BASEPRI register; the "library" 5 is for NVIC_SetPriority, which does the shift itself. A "library" line alone, without the shifted one, gives the kernel nothing: the build stops on an unknown name. The full set, with the priority of the kernel itself, is in section 33.1.

The meaning: interrupts with priority 5 and lower (that is, numerically larger) can be masked by the kernel for a while, and calling functions ending in FromISR from them is allowed. Interrupts with priority 0…4 are never masked — they are fast, but you must not call the kernel from them. This is covered in detail in section 28.

⚠️ The default priority is zero, and it is always wrong. An interrupt that was not given a priority explicitly gets zero, that is, the highest. Calling any kernel function from such a handler is an error that stops the program immediately when configASSERT is enabled, and when it is disabled corrupts the kernel's internal structures and leads to a failure after an hour of operation.

Step 3. Enable configASSERT

One line that saves days:

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

The kernel checks itself in several dozen places: whether the interrupt priority is chosen correctly, whether an ordinary function was called from a handler, whether a list is corrupted. Without configASSERT these checks do nothing, and the error shows up later and somewhere else.

Stopping in an endless loop looks crude, but it is ideal for debugging: you attach the debugger, look at the call stack and see at once which check failed.

Step 4. Provide the hooks

Two functions that the kernel calls when things go wrong, if the configuration has configCHECK_FOR_STACK_OVERFLOW 2 (section 26.7) and configUSE_MALLOC_FAILED_HOOK 1. Enabled the macro but did not write the function — a link error; wrote it but did not enable the macro — the hook stays silent:

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

void vApplicationMallocFailedHook(void)
{
    for (;;) { }        /* кончилась куча ядра */
}

Both are about errors that otherwise show up as random hangs. The name argument in the second line is the most valuable: it directly names the guilty task.

Step 5. Create the tasks and start the scheduler

int main(void)
{
    clock_init();
    gpio_init();                    /* периферия — до планировщика */

    if (xTaskCreate(worker, "worker", 128, NULL, 2, NULL) != pdPASS) {
        for (;;) { }                /* не хватило кучи */
    }

    vTaskStartScheduler();
    for (;;) { }                    /* сюда попадают, если не создалась
                                       задача простоя */
}

The xTaskCreate arguments:

Argument Meaning
function the task body; you must not return from it
name for the debugger only, has no effect on operation
stack depth in words, not in bytes
argument passed to the task, often NULL
priority larger means more important, zero is taken by the idle task
handle a reference is stored here; NULL if you do not need it

⚠️ The stack depth is given in words. 128 means 512 bytes, not 128. A factor-of-four error is equally unpleasant in both directions: too small gives a stack overflow, too large gives a silent memory use four times greater than planned.

Checking the result of xTaskCreate is mandatory. The function returns pdPASS or an out-of-memory error, and the second case on a chip with 20 KB is entirely real. A task that "was not created" gives no sign of itself: the device simply does not do part of its work.


24.5 Two ways to create an object

Every kernel object — a task, a queue, a semaphore — has two forms of creation.

Dynamic takes memory from the kernel heap:

xTaskCreate(worker, "worker", 128, NULL, 2, NULL);

Static requires the program to provide the memory:

static StaticTask_t worker_tcb;
static StackType_t  worker_stack[128];

xTaskCreateStatic(worker, "worker", 128, NULL, 2, worker_stack, &worker_tcb);

The difference runs deeper than it seems. The static variant cannot fail: the memory is allocated by the linker, and its availability is checked at build time. The dynamic one can return a failure at the most inconvenient moment — and must be checked.

Dynamic Static
memory from the kernel heap from your own memory
can fail to be created yes no
memory use is visible only at run time in the memory map after the build
requires heap_*.c nothing
convenience higher lower

The practice is this: everything that lives from power-up to power-down is created statically. Dynamic creation is left for objects that appear and disappear during operation — and a device usually has none of those at all. Then dynamic creation is switched off — configSUPPORT_DYNAMIC_ALLOCATION 0 — and the heap file is not added to the project (25.6), and a whole class of errors disappears. If a library takes memory from the kernel by itself, you still need a heap.

⚠️ Once you enable static allocation, you must give the kernel memory for the idle task. The kernel requests it with the function vApplicationGetIdleTaskMemory, and with software timers enabled also with vApplicationGetTimerTaskMemory. Without them the program will not link. The error looks like an unresolved reference to a function you never called, and it is confusing the first time.


24.6 The CMSIS-RTOS wrapper

A project created by the configurator usually contains not xTaskCreate but osThreadNew. This is CMSIS-RTOS — a standard interface invented so that application code would not depend on a particular kernel.

osThreadNew(worker, NULL, &worker_attributes);    /* обёртка   */
xTaskCreate(worker, "worker", 128, NULL, 2, NULL); /* напрямую */

In the end both lines do the same thing: the wrapper calls the kernel function.

CMSIS-RTOS Direct
portability to another kernel yes no
access to all features not to all to all
clarity of error messages worse better
configurator support generates it itself edited by hand

A sensible approach is to know both and not mix them in one file. It is the direct interface that is worth learning: the wrapper is described in kernel terms, and without understanding the kernel its documentation is hard to read. And in a finished project from the configurator it is simpler to keep what it generated.

A notable difference is in priorities: CMSIS-RTOS has its own scale with its own names (osPriorityNormal and the like), and it does not match the FreeRTOS numbers. Mix the two approaches in one program, and you get tasks whose priorities do not match your intentions.


24.7 Pitfalls

It does not build: SVC_Handler is defined twice. The handler templates remained in the interrupts file. Delete all three.

It builds, but the tasks do not switch. The handler names are not mapped: the kernel calls its own functions, and the vectors lead to the empty templates.

Unresolved reference to vApplicationGetIdleTaskMemory. Static allocation is enabled — write both memory-providing functions.

Unresolved reference to a function that is in the sources. The corresponding INCLUDE_ is not enabled.

The program stops inside port.c. configASSERT fired. Look at the call stack: most often it is a wrong priority of the interrupt from which a kernel function was called.

It works on the F103 but on the F407 falls into HardFault or corrupts numbers. The ARM_CM3 port was taken instead of ARM_CM4F: it does not know about the extended exception frame and does not save the floating-point registers on a switch.

__forceinline does not compile. The RVDS port was taken instead of GCC when building with Arm Compiler 6.

The delays became three times shorter. configTICK_RATE_HZ was changed, and the delays in the code are written as numbers instead of pdMS_TO_TICKS.

The device suddenly got 4 KB heavier. Software timers, event groups and stream buffers are enabled, of which only one is used.


24.8 Summary

Next: section 25 "Memory management" — five heap schemes, how they differ and why the choice between them matters more than it seems.

Open in the interactive handbook