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 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_CM4Fdirectory 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 theEXC_RETURNcode 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 ifmainused 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_EMPTYanderrQUEUE_FULLare the same value, and it equalspdFAIL. 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. AndvTaskDelay(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.
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_HandlerandSysTick_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
configASSERTis 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.
128means 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 withvApplicationGetTimerTaskMemory. 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
- The kernel is three mandatory files (
tasks.c,queue.c,list.c), two files of the processor port, one memory scheme file and yourFreeRTOSConfig.h. - The ports for Cortex‑M3 and Cortex‑M4 differ; swapping them does not give a build
error, but on an F407 with the FPU it ends in HardFault, and with
ASPENoff — in corrupted floating-point calculations. - For Arm Compiler 6 the port is taken from the
GCCdirectory, notRVDS. - Prefixes in names tell you the type:
vreturns nothing,x—BaseType_t,ux— unsigned,prv— an internal function. errQUEUE_EMPTY,errQUEUE_FULLandpdFAILare the same value; you cannot tell the cause from the return code.- Time is in ticks everywhere;
pdMS_TO_TICKSrounds down and with a low tick rate can turn a delay into zero. - Startup needs five things: give the kernel three exceptions, set up
interrupt grouping and priorities, enable
configASSERT, provide the hooks, create the tasks. - The stack depth is given in words: 128 means 512 bytes.
- Checking the result of
xTaskCreateis mandatory. - Static creation cannot fail and shows memory use right after the build — it is preferable for permanent objects.
- CMSIS-RTOS is a wrapper over the same kernel with its own priority scale; mixing it with direct calls in one program is not a good idea.
Next: section 25 "Memory management" — five heap schemes, how they differ and why the choice between them matters more than it seems.